Conflicts:
	src/i18n/strings/en_EN.json
	src/i18n/strings/fr.json
	src/i18n/strings/ru.json
This commit is contained in:
Weblate 2017-08-15 14:00:35 +00:00
commit d443368aa3
154 changed files with 5550 additions and 2763 deletions

View file

@ -1,4 +1,4 @@
{ {
"presets": ["react", "es2015", "es2016"], "presets": ["react", "es2015", "es2016"],
"plugins": ["transform-class-properties", "transform-object-rest-spread", "transform-async-to-generator", "transform-runtime", "add-module-exports"] "plugins": ["transform-class-properties", "transform-object-rest-spread", "transform-async-to-bluebird", "transform-runtime", "add-module-exports"]
} }

View file

@ -1,6 +1,5 @@
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. # autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
src/AddThreepid.js
src/async-components/views/dialogs/EncryptedEventDialog.js src/async-components/views/dialogs/EncryptedEventDialog.js
src/autocomplete/AutocompleteProvider.js src/autocomplete/AutocompleteProvider.js
src/autocomplete/Autocompleter.js src/autocomplete/Autocompleter.js
@ -9,8 +8,6 @@ src/autocomplete/DuckDuckGoProvider.js
src/autocomplete/EmojiProvider.js src/autocomplete/EmojiProvider.js
src/autocomplete/RoomProvider.js src/autocomplete/RoomProvider.js
src/autocomplete/UserProvider.js src/autocomplete/UserProvider.js
src/Avatar.js
src/BasePlatform.js
src/CallHandler.js src/CallHandler.js
src/component-index.js src/component-index.js
src/components/structures/ContextualMenu.js src/components/structures/ContextualMenu.js
@ -96,7 +93,6 @@ src/components/views/rooms/MessageComposerInput.js
src/components/views/rooms/MessageComposerInputOld.js src/components/views/rooms/MessageComposerInputOld.js
src/components/views/rooms/PresenceLabel.js src/components/views/rooms/PresenceLabel.js
src/components/views/rooms/ReadReceiptMarker.js src/components/views/rooms/ReadReceiptMarker.js
src/components/views/rooms/RoomHeader.js
src/components/views/rooms/RoomList.js src/components/views/rooms/RoomList.js
src/components/views/rooms/RoomNameEditor.js src/components/views/rooms/RoomNameEditor.js
src/components/views/rooms/RoomPreviewBar.js src/components/views/rooms/RoomPreviewBar.js
@ -115,16 +111,7 @@ src/components/views/settings/ChangePassword.js
src/components/views/settings/DevicesPanel.js src/components/views/settings/DevicesPanel.js
src/components/views/settings/DevicesPanelEntry.js src/components/views/settings/DevicesPanelEntry.js
src/components/views/settings/EnableNotificationsButton.js src/components/views/settings/EnableNotificationsButton.js
src/components/views/voip/CallView.js
src/components/views/voip/IncomingCallBox.js
src/components/views/voip/VideoFeed.js
src/components/views/voip/VideoView.js
src/ContentMessages.js src/ContentMessages.js
src/createRoom.js
src/DateUtils.js
src/email.js
src/Entities.js
src/extend.js
src/HtmlUtils.js src/HtmlUtils.js
src/ImageUtils.js src/ImageUtils.js
src/Invite.js src/Invite.js
@ -135,30 +122,20 @@ src/Markdown.js
src/MatrixClientPeg.js src/MatrixClientPeg.js
src/Modal.js src/Modal.js
src/Notifier.js src/Notifier.js
src/ObjectUtils.js
src/PasswordReset.js
src/PlatformPeg.js src/PlatformPeg.js
src/Presence.js src/Presence.js
src/ratelimitedfunc.js src/ratelimitedfunc.js
src/Resend.js
src/RichText.js src/RichText.js
src/Roles.js src/Roles.js
src/RoomListSorter.js
src/RoomNotifs.js
src/Rooms.js src/Rooms.js
src/ScalarAuthClient.js src/ScalarAuthClient.js
src/ScalarMessaging.js src/ScalarMessaging.js
src/SdkConfig.js
src/Skinner.js
src/SlashCommands.js
src/stores/LifecycleStore.js
src/TabComplete.js src/TabComplete.js
src/TabCompleteEntries.js src/TabCompleteEntries.js
src/TextForEvent.js src/TextForEvent.js
src/Tinter.js src/Tinter.js
src/UiEffects.js src/UiEffects.js
src/Unread.js src/Unread.js
src/UserActivity.js
src/utils/DecryptFile.js src/utils/DecryptFile.js
src/utils/DMRoomMap.js src/utils/DMRoomMap.js
src/utils/FormattingUtils.js src/utils/FormattingUtils.js

6
.flowconfig Normal file
View file

@ -0,0 +1,6 @@
[include]
src/**/*.js
test/**/*.js
[ignore]
node_modules/

View file

@ -22,8 +22,11 @@ git checkout "$curbranch" || git checkout develop
mkdir node_modules mkdir node_modules
npm install npm install
(cd node_modules/matrix-js-sdk && npm install) # use the version of js-sdk we just used in the react-sdk tests
rm -r node_modules/matrix-js-sdk
ln -s "$REACT_SDK_DIR/node_modules/matrix-js-sdk" node_modules/matrix-js-sdk
# ... and, of course, the version of react-sdk we just built
rm -r node_modules/matrix-react-sdk rm -r node_modules/matrix-react-sdk
ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk

View file

@ -1,6 +1,15 @@
# we need trusty for the chrome addon
dist: trusty
# we don't need sudo, so can run in a container, which makes startup much
# quicker.
sudo: false
language: node_js language: node_js
node_js: node_js:
- node # Latest stable version of nodejs. - node # Latest stable version of nodejs.
addons:
chrome: stable
install: install:
- npm install - npm install
- (cd node_modules/matrix-js-sdk && npm install) - (cd node_modules/matrix-js-sdk && npm install)

View file

@ -2,10 +2,9 @@
set -e set -e
export KARMAFLAGS="--no-colors"
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm use 4 nvm use 6
set -x set -x
@ -16,7 +15,7 @@ npm install
(cd node_modules/matrix-js-sdk && npm install) (cd node_modules/matrix-js-sdk && npm install)
# run the mocha tests # run the mocha tests
npm run test npm run test -- --no-colors
# run eslint # run eslint
npm run lintall -- -f checkstyle -o eslint.xml || true npm run lintall -- -f checkstyle -o eslint.xml || true

View file

@ -93,7 +93,18 @@ module.exports = function (config) {
// test results reporter to use // test results reporter to use
// possible values: 'dots', 'progress' // possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter // available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress', 'junit'], reporters: ['logcapture', 'spec', 'junit', 'summary'],
specReporter: {
suppressErrorSummary: false, // do print error summary
suppressFailed: false, // do print information about failed tests
suppressPassed: false, // do print information about passed tests
showSpecTiming: true, // print the time elapsed for each spec
},
client: {
captureLogs: true,
},
// web server port // web server port
port: 9876, port: 9876,
@ -104,7 +115,10 @@ module.exports = function (config) {
// level of logging // level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || // possible values: config.LOG_DISABLE || config.LOG_ERROR ||
// config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO, //
// This is strictly for logs that would be generated by the browser itself and we
// don't want to log about missing images, which are emitted on LOG_WARN.
logLevel: config.LOG_ERROR,
// enable / disable watching file and executing tests whenever any file // enable / disable watching file and executing tests whenever any file
// changes // changes
@ -116,11 +130,25 @@ module.exports = function (config) {
browsers: [ browsers: [
'Chrome', 'Chrome',
//'PhantomJS', //'PhantomJS',
//'ChromeHeadless',
], ],
customLaunchers: {
'ChromeHeadless': {
base: 'Chrome',
flags: [
// See https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md
'--headless',
'--disable-gpu',
// Without a remote debugging port, Google Chrome exits immediately.
'--remote-debugging-port=9222',
],
}
},
// Continuous Integration mode // Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits // if true, Karma captures browsers, runs the tests and exits
singleRun: true, // singleRun: false,
// Concurrency level // Concurrency level
// how many browser should be started simultaneous // how many browser should be started simultaneous

View file

@ -33,28 +33,30 @@
"scripts": { "scripts": {
"reskindex": "node scripts/reskindex.js -h header", "reskindex": "node scripts/reskindex.js -h header",
"reskindex:watch": "node scripts/reskindex.js -h header -w", "reskindex:watch": "node scripts/reskindex.js -h header -w",
"build": "npm run reskindex && babel src -d lib --source-maps", "build": "npm run reskindex && babel src -d lib --source-maps --copy-files",
"build:watch": "babel src -w -d lib --source-maps", "build:watch": "babel src -w -d lib --source-maps --copy-files",
"emoji-data-strip": "node scripts/emoji-data-strip.js",
"start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"", "start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"",
"lint": "eslint src/", "lint": "eslint src/",
"lintall": "eslint src/ test/", "lintall": "eslint src/ test/",
"clean": "rimraf lib", "clean": "rimraf lib",
"prepublish": "npm run build && git rev-parse HEAD > git-revision.txt", "prepublish": "npm run build && git rev-parse HEAD > git-revision.txt",
"test": "karma start $KARMAFLAGS --browsers PhantomJS", "test": "karma start --single-run=true --browsers ChromeHeadless",
"test-multi": "karma start $KARMAFLAGS --single-run=false" "test-multi": "karma start"
}, },
"dependencies": { "dependencies": {
"babel-runtime": "^6.11.6", "babel-runtime": "^6.11.6",
"bluebird": "^3.5.0",
"blueimp-canvas-to-blob": "^3.5.0", "blueimp-canvas-to-blob": "^3.5.0",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"classnames": "^2.1.2", "classnames": "^2.1.2",
"commonmark": "^0.27.0", "commonmark": "^0.27.0",
"counterpart": "^0.18.0", "counterpart": "^0.18.0",
"draft-js": "^0.8.1", "draft-js": "^0.11.0-alpha",
"draft-js-export-html": "^0.5.0", "draft-js-export-html": "^0.6.0",
"draft-js-export-markdown": "^0.2.0", "draft-js-export-markdown": "^0.3.0",
"emojione": "2.2.3", "emojione": "2.2.7",
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"filesize": "3.5.6", "filesize": "3.5.6",
"flux": "2.1.1", "flux": "2.1.1",
@ -64,16 +66,16 @@
"isomorphic-fetch": "^2.2.1", "isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3", "linkifyjs": "^2.1.3",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"matrix-js-sdk": "0.7.13", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"q": "^1.4.1",
"react": "^15.4.0", "react": "^15.4.0",
"react-addons-css-transition-group": "15.3.2", "react-addons-css-transition-group": "15.3.2",
"react-dom": "^15.4.0", "react-dom": "^15.4.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.14.1",
"text-encoding-utf-8": "^1.0.1", "text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0",
"velocity-vector": "vector-im/velocity#059e3b2", "velocity-vector": "vector-im/velocity#059e3b2",
"whatwg-fetch": "^1.0.0" "whatwg-fetch": "^1.0.0"
}, },
@ -83,7 +85,7 @@
"babel-eslint": "^6.1.2", "babel-eslint": "^6.1.2",
"babel-loader": "^6.2.5", "babel-loader": "^6.2.5",
"babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-transform-async-to-generator": "^6.16.0", "babel-plugin-transform-async-to-bluebird": "^1.1.1",
"babel-plugin-transform-class-properties": "^6.16.0", "babel-plugin-transform-class-properties": "^6.16.0",
"babel-plugin-transform-object-rest-spread": "^6.16.0", "babel-plugin-transform-object-rest-spread": "^6.16.0",
"babel-plugin-transform-runtime": "^6.15.0", "babel-plugin-transform-runtime": "^6.15.0",
@ -100,17 +102,19 @@
"eslint-plugin-react": "^6.9.0", "eslint-plugin-react": "^6.9.0",
"expect": "^1.16.0", "expect": "^1.16.0",
"json-loader": "^0.5.3", "json-loader": "^0.5.3",
"karma": "^0.13.22", "karma": "^1.7.0",
"karma-chrome-launcher": "^0.2.3", "karma-chrome-launcher": "^0.2.3",
"karma-cli": "^0.1.2", "karma-cli": "^0.1.2",
"karma-junit-reporter": "^0.4.1", "karma-junit-reporter": "^0.4.1",
"karma-logcapture-reporter": "0.0.1",
"karma-mocha": "^0.2.2", "karma-mocha": "^0.2.2",
"karma-phantomjs-launcher": "^1.0.0",
"karma-sourcemap-loader": "^0.3.7", "karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "^0.0.31",
"karma-summary-reporter": "^1.3.3",
"karma-webpack": "^1.7.0", "karma-webpack": "^1.7.0",
"matrix-react-test-utils": "^0.1.1",
"mocha": "^2.4.5", "mocha": "^2.4.5",
"parallelshell": "^1.2.0", "parallelshell": "^1.2.0",
"phantomjs-prebuilt": "^2.1.7",
"react-addons-test-utils": "^15.4.0", "react-addons-test-utils": "^15.4.0",
"require-json": "0.0.1", "require-json": "0.0.1",
"rimraf": "^2.4.3", "rimraf": "^2.4.3",

View file

@ -0,0 +1,26 @@
#!/usr/bin/env node
const EMOJI_DATA = require('emojione/emoji.json');
const EMOJI_SUPPORTED = Object.keys(require('emojione').emojioneList);
const fs = require('fs');
const output = Object.keys(EMOJI_DATA).map(
(key) => {
const datum = EMOJI_DATA[key];
const newDatum = {
name: datum.name,
shortname: datum.shortname,
category: datum.category,
emoji_order: datum.emoji_order,
};
if (datum.aliases_ascii.length > 0) {
newDatum.aliases_ascii = datum.aliases_ascii;
}
return newDatum;
}
).filter((datum) => {
return EMOJI_SUPPORTED.includes(datum.shortname);
});
// Write to a file in src. Changes should be checked into git. This file is copied by
// babel using --copy-files
fs.writeFileSync('./src/stripped-emoji.json', JSON.stringify(output));

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var MatrixClientPeg = require("./MatrixClientPeg"); import MatrixClientPeg from './MatrixClientPeg';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
/** /**
@ -44,7 +44,7 @@ class AddThreepid {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, function(err) {
if (err.errcode == 'M_THREEPID_IN_USE') { if (err.errcode === 'M_THREEPID_IN_USE') {
err.message = _t('This email address is already in use'); err.message = _t('This email address is already in use');
} else if (err.httpStatus) { } else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
@ -69,7 +69,7 @@ class AddThreepid {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, function(err) {
if (err.errcode == 'M_THREEPID_IN_USE') { if (err.errcode === 'M_THREEPID_IN_USE') {
err.message = _t('This phone number is already in use'); err.message = _t('This phone number is already in use');
} else if (err.httpStatus) { } else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
@ -85,16 +85,15 @@ class AddThreepid {
* the request failed. * the request failed.
*/ */
checkEmailLinkClicked() { checkEmailLinkClicked() {
var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
return MatrixClientPeg.get().addThreePid({ return MatrixClientPeg.get().addThreePid({
sid: this.sessionId, sid: this.sessionId,
client_secret: this.clientSecret, client_secret: this.clientSecret,
id_server: identityServerDomain id_server: identityServerDomain,
}, this.bind).catch(function(err) { }, this.bind).catch(function(err) {
if (err.httpStatus === 401) { if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
} } else if (err.httpStatus) {
else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`; err.message += ` (Status ${err.httpStatus})`;
} }
throw err; throw err;
@ -104,6 +103,7 @@ class AddThreepid {
/** /**
* Takes a phone number verification code as entered by the user and validates * Takes a phone number verification code as entered by the user and validates
* it with the ID server, then if successful, adds the phone number. * it with the ID server, then if successful, adds the phone number.
* @param {string} token phone number verification code as entered by the user
* @return {Promise} Resolves if the phone number was added. Rejects with an object * @return {Promise} Resolves if the phone number was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why * with a "message" property which contains a human-readable message detailing why
* the request failed. * the request failed.
@ -119,7 +119,7 @@ class AddThreepid {
return MatrixClientPeg.get().addThreePid({ return MatrixClientPeg.get().addThreePid({
sid: this.sessionId, sid: this.sessionId,
client_secret: this.clientSecret, client_secret: this.clientSecret,
id_server: identityServerDomain id_server: identityServerDomain,
}, this.bind); }, this.bind);
}); });
} }

View file

@ -15,7 +15,6 @@
*/ */
import { getCurrentLanguage } from './languageHandler'; import { getCurrentLanguage } from './languageHandler';
import MatrixClientPeg from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
@ -31,8 +30,18 @@ const customVariables = {
'User Type': 3, 'User Type': 3,
'Chosen Language': 4, 'Chosen Language': 4,
'Instance': 5, 'Instance': 5,
'RTE: Uses Richtext Mode': 6,
'Homeserver URL': 7,
'Identity Server URL': 8,
}; };
function whitelistRedact(whitelist, str) {
if (whitelist.includes(str)) return str;
return '<redacted>';
}
const whitelistedHSUrls = ["https://matrix.org"];
const whitelistedISUrls = ["https://vector.im"];
class Analytics { class Analytics {
constructor() { constructor() {
@ -76,7 +85,7 @@ class Analytics {
this._paq.push(['trackAllContentImpressions']); this._paq.push(['trackAllContentImpressions']);
this._paq.push(['discardHashTag', false]); this._paq.push(['discardHashTag', false]);
this._paq.push(['enableHeartBeatTimer']); this._paq.push(['enableHeartBeatTimer']);
this._paq.push(['enableLinkTracking', true]); // this._paq.push(['enableLinkTracking', true]);
const platform = PlatformPeg.get(); const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName()); this._setVisitVariable('App Platform', platform.getHumanReadableName());
@ -130,20 +139,20 @@ class Analytics {
this._paq.push(['deleteCookies']); this._paq.push(['deleteCookies']);
} }
login() { // not used currently
const cli = MatrixClientPeg.get();
if (this.disabled || !cli) return;
this._paq.push(['setUserId', `@${cli.getUserIdLocalpart()}:${cli.getDomain()}`]);
}
_setVisitVariable(key, value) { _setVisitVariable(key, value) {
this._paq.push(['setCustomVariable', customVariables[key], key, value, 'visit']); this._paq.push(['setCustomVariable', customVariables[key], key, value, 'visit']);
} }
setGuest(guest) { setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
if (this.disabled) return; if (this.disabled) return;
this._setVisitVariable('User Type', guest ? 'Guest' : 'Logged In'); this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl));
}
setRichtextMode(state) {
if (this.disabled) return;
this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off');
} }
} }

View file

@ -15,18 +15,18 @@ limitations under the License.
*/ */
'use strict'; 'use strict';
var ContentRepo = require("matrix-js-sdk").ContentRepo; import {ContentRepo} from 'matrix-js-sdk';
var MatrixClientPeg = require('./MatrixClientPeg'); import MatrixClientPeg from './MatrixClientPeg';
module.exports = { module.exports = {
avatarUrlForMember: function(member, width, height, resizeMethod) { avatarUrlForMember: function(member, width, height, resizeMethod) {
var url = member.getAvatarUrl( let url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(width * window.devicePixelRatio), Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio),
resizeMethod, resizeMethod,
false, false,
false false,
); );
if (!url) { if (!url) {
// member can be null here currently since on invites, the JS SDK // member can be null here currently since on invites, the JS SDK
@ -38,11 +38,11 @@ module.exports = {
}, },
avatarUrlForUser: function(user, width, height, resizeMethod) { avatarUrlForUser: function(user, width, height, resizeMethod) {
var url = ContentRepo.getHttpUriForMxc( const url = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio), Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio),
resizeMethod resizeMethod,
); );
if (!url || url.length === 0) { if (!url || url.length === 0) {
return null; return null;
@ -51,11 +51,11 @@ module.exports = {
}, },
defaultAvatarUrlForString: function(s) { defaultAvatarUrlForString: function(s) {
var images = ['76cfa6', '50e2c2', 'f4c371']; const images = ['76cfa6', '50e2c2', 'f4c371'];
var total = 0; let total = 0;
for (var i = 0; i < s.length; ++i) { for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i); total += s.charCodeAt(i);
} }
return 'img/' + images[total % images.length] + '.png'; return 'img/' + images[total % images.length] + '.png';
} },
}; };

View file

@ -57,6 +57,7 @@ export default class BasePlatform {
/** /**
* Returns true if the platform supports displaying * Returns true if the platform supports displaying
* notifications, otherwise false. * notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/ */
supportsNotifications(): boolean { supportsNotifications(): boolean {
return false; return false;
@ -65,6 +66,7 @@ export default class BasePlatform {
/** /**
* Returns true if the application currently has permission * Returns true if the application currently has permission
* to display notifications. Otherwise false. * to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/ */
maySendNotifications(): boolean { maySendNotifications(): boolean {
return false; return false;

View file

@ -143,7 +143,7 @@ function _setCallListeners(call) {
pause("ringbackAudio"); pause("ringbackAudio");
play("busyAudio"); play("busyAudio");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
title: _t('Call Timeout'), title: _t('Call Timeout'),
description: _t('The remote side failed to pick up') + '.', description: _t('The remote side failed to pick up') + '.',
}); });
@ -205,7 +205,7 @@ function _onAction(payload) {
_setCallState(undefined, newCall.roomId, "ended"); _setCallState(undefined, newCall.roomId, "ended");
console.log("Can't capture screen: " + screenCapErrorString); console.log("Can't capture screen: " + screenCapErrorString);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'), title: _t('Unable to capture screen'),
description: screenCapErrorString, description: screenCapErrorString,
}); });
@ -225,7 +225,7 @@ function _onAction(payload) {
case 'place_call': case 'place_call':
if (module.exports.getAnyActiveCall()) { if (module.exports.getAnyActiveCall()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Existing Call'), title: _t('Existing Call'),
description: _t('You are already in a call.'), description: _t('You are already in a call.'),
}); });
@ -235,7 +235,7 @@ function _onAction(payload) {
// if the runtime env doesn't do VoIP, whine. // if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'), title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'), description: _t('You cannot place VoIP calls in this browser.'),
}); });
@ -251,7 +251,7 @@ function _onAction(payload) {
var members = room.getJoinedMembers(); var members = room.getJoinedMembers();
if (members.length <= 1) { if (members.length <= 1) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'), description: _t('You cannot place a call with yourself.'),
}); });
return; return;
@ -277,13 +277,13 @@ function _onAction(payload) {
console.log("Place conference call in %s", payload.room_id); console.log("Place conference call in %s", payload.room_id);
if (!ConferenceHandler) { if (!ConferenceHandler) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, {
description: _t('Conference calls are not supported in this client'), description: _t('Conference calls are not supported in this client'),
}); });
} }
else if (!MatrixClientPeg.get().supportsVoip()) { else if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'), title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'), description: _t('You cannot place VoIP calls in this browser.'),
}); });
@ -296,13 +296,13 @@ function _onAction(payload) {
// participant. // participant.
// Therefore we disable conference calling in E2E rooms. // Therefore we disable conference calling in E2E rooms.
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Conference calls unsupported e2e', ErrorDialog, {
description: _t('Conference calls are not supported in encrypted rooms'), description: _t('Conference calls are not supported in encrypted rooms'),
}); });
} }
else { else {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, {
title: _t('Warning!'), title: _t('Warning!'),
description: _t('Conference calling is in development and may not be reliable.'), description: _t('Conference calling is in development and may not be reliable.'),
onFinished: confirm=>{ onFinished: confirm=>{
@ -314,7 +314,7 @@ function _onAction(payload) {
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err); console.error("Conference call failed: " + err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Failed to set up conference call', ErrorDialog, {
title: _t('Failed to set up conference call'), title: _t('Failed to set up conference call'),
description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''), description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''),
}); });

View file

@ -0,0 +1,84 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ContentState, convertToRaw, convertFromRaw} from 'draft-js';
import * as RichText from './RichText';
import Markdown from './Markdown';
import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown';
class HistoryItem {
// Keeping message for backwards-compatibility
message: string;
rawContentState: RawDraftContentState;
format: MessageFormat = 'html';
constructor(contentState: ?ContentState, format: ?MessageFormat) {
this.rawContentState = contentState ? convertToRaw(contentState) : null;
this.format = format;
}
toContentState(outputFormat: MessageFormat): ContentState {
const contentState = convertFromRaw(this.rawContentState);
if (outputFormat === 'markdown') {
if (this.format === 'html') {
return ContentState.createFromText(RichText.stateToMarkdown(contentState));
}
} else {
if (this.format === 'markdown') {
return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML());
}
}
// history item has format === outputFormat
return contentState;
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0;
currentIndex: number = 0;
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let item;
for(; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)),
);
}
this.lastIndex = this.currentIndex;
}
save(contentState: ContentState, format: MessageFormat) {
const item = new HistoryItem(contentState, format);
this.history.push(item);
this.currentIndex = this.lastIndex + 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item));
}
getItem(offset: number, format: MessageFormat): ?ContentState {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1);
const item = this.history[this.currentIndex];
return item ? item.toContentState(format) : null;
}
}

View file

@ -16,7 +16,7 @@ limitations under the License.
'use strict'; 'use strict';
var q = require('q'); import Promise from 'bluebird';
var extend = require('./extend'); var extend = require('./extend');
var dis = require('./dispatcher'); var dis = require('./dispatcher');
var MatrixClientPeg = require('./MatrixClientPeg'); var MatrixClientPeg = require('./MatrixClientPeg');
@ -52,7 +52,7 @@ const MAX_HEIGHT = 600;
* and a thumbnail key. * and a thumbnail key.
*/ */
function createThumbnail(element, inputWidth, inputHeight, mimeType) { function createThumbnail(element, inputWidth, inputHeight, mimeType) {
const deferred = q.defer(); const deferred = Promise.defer();
var targetWidth = inputWidth; var targetWidth = inputWidth;
var targetHeight = inputHeight; var targetHeight = inputHeight;
@ -95,7 +95,7 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) {
* @return {Promise} A promise that resolves with the html image element. * @return {Promise} A promise that resolves with the html image element.
*/ */
function loadImageElement(imageFile) { function loadImageElement(imageFile) {
const deferred = q.defer(); const deferred = Promise.defer();
// Load the file into an html element // Load the file into an html element
const img = document.createElement("img"); const img = document.createElement("img");
@ -154,7 +154,7 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
* @return {Promise} A promise that resolves with the video image element. * @return {Promise} A promise that resolves with the video image element.
*/ */
function loadVideoElement(videoFile) { function loadVideoElement(videoFile) {
const deferred = q.defer(); const deferred = Promise.defer();
// Load the file into an html element // Load the file into an html element
const video = document.createElement("video"); const video = document.createElement("video");
@ -210,7 +210,7 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
* is read. * is read.
*/ */
function readFileAsArrayBuffer(file) { function readFileAsArrayBuffer(file) {
const deferred = q.defer(); const deferred = Promise.defer();
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(e) { reader.onload = function(e) {
deferred.resolve(e.target.result); deferred.resolve(e.target.result);
@ -229,11 +229,13 @@ function readFileAsArrayBuffer(file) {
* @param {MatrixClient} matrixClient The matrix client to upload the file with. * @param {MatrixClient} matrixClient The matrix client to upload the file with.
* @param {String} roomId The ID of the room being uploaded to. * @param {String} roomId The ID of the room being uploaded to.
* @param {File} file The file to upload. * @param {File} file The file to upload.
* @param {Function?} progressHandler optional callback to be called when a chunk of
* data is uploaded.
* @return {Promise} A promise that resolves with an object. * @return {Promise} A promise that resolves with an object.
* If the file is unencrypted then the object will have a "url" key. * If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key. * If the file is encrypted then the object will have a "file" key.
*/ */
function uploadFile(matrixClient, roomId, file) { function uploadFile(matrixClient, roomId, file, progressHandler) {
if (matrixClient.isRoomEncrypted(roomId)) { if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it. // If the room is encrypted then encrypt the file before uploading it.
// First read the file into memory. // First read the file into memory.
@ -245,7 +247,9 @@ function uploadFile(matrixClient, roomId, file) {
const encryptInfo = encryptResult.info; const encryptInfo = encryptResult.info;
// Pass the encrypted data as a Blob to the uploader. // Pass the encrypted data as a Blob to the uploader.
const blob = new Blob([encryptResult.data]); const blob = new Blob([encryptResult.data]);
return matrixClient.uploadContent(blob).then(function(url) { return matrixClient.uploadContent(blob, {
progressHandler: progressHandler,
}).then(function(url) {
// If the attachment is encrypted then bundle the URL along // If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and // with the information needed to decrypt the attachment and
// add it under a file key. // add it under a file key.
@ -257,7 +261,9 @@ function uploadFile(matrixClient, roomId, file) {
}); });
}); });
} else { } else {
const basePromise = matrixClient.uploadContent(file); const basePromise = matrixClient.uploadContent(file, {
progressHandler: progressHandler,
});
const promise1 = basePromise.then(function(url) { const promise1 = basePromise.then(function(url) {
// If the attachment isn't encrypted then include the URL directly. // If the attachment isn't encrypted then include the URL directly.
return {"url": url}; return {"url": url};
@ -288,7 +294,7 @@ class ContentMessages {
content.info.mimetype = file.type; content.info.mimetype = file.type;
} }
const def = q.defer(); const def = Promise.defer();
if (file.type.indexOf('image/') == 0) { if (file.type.indexOf('image/') == 0) {
content.msgtype = 'm.image'; content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then(imageInfo=>{ infoForImageFile(matrixClient, roomId, file).then(imageInfo=>{
@ -326,23 +332,24 @@ class ContentMessages {
dis.dispatch({action: 'upload_started'}); dis.dispatch({action: 'upload_started'});
var error; var error;
function onProgress(ev) {
upload.total = ev.total;
upload.loaded = ev.loaded;
dis.dispatch({action: 'upload_progress', upload: upload});
}
return def.promise.then(function() { return def.promise.then(function() {
// XXX: upload.promise must be the promise that // XXX: upload.promise must be the promise that
// is returned by uploadFile as it has an abort() // is returned by uploadFile as it has an abort()
// method hacked onto it. // method hacked onto it.
upload.promise = uploadFile( upload.promise = uploadFile(
matrixClient, roomId, file matrixClient, roomId, file, onProgress,
); );
return upload.promise.then(function(result) { return upload.promise.then(function(result) {
content.file = result.file; content.file = result.file;
content.url = result.url; content.url = result.url;
}); });
}).progress(function(ev) {
if (ev) {
upload.total = ev.total;
upload.loaded = ev.loaded;
dis.dispatch({action: 'upload_progress', upload: upload});
}
}).then(function(url) { }).then(function(url) {
return matrixClient.sendMessage(roomId, content); return matrixClient.sendMessage(roomId, content);
}, function(err) { }, function(err) {
@ -353,7 +360,7 @@ class ContentMessages {
desc = _t('The file \'%(fileName)s\' exceeds this home server\'s size limit for uploads', {fileName: upload.fileName}); desc = _t('The file \'%(fileName)s\' exceeds this home server\'s size limit for uploads', {fileName: upload.fileName});
} }
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
title: _t('Upload Failed'), title: _t('Upload Failed'),
description: desc, description: desc,
}); });

View file

@ -54,24 +54,25 @@ function pad(n) {
function twelveHourTime(date) { function twelveHourTime(date) {
let hours = date.getHours() % 12; let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes()); const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? 'PM' : 'AM'; const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
hours = pad(hours ? hours : 12); hours = hours ? hours : 12; // convert 0 -> 12
return `${hours}:${minutes}${ampm}`; return `${hours}:${minutes}${ampm}`;
} }
module.exports = { module.exports = {
formatDate: function(date, showTwelveHour=false) { formatDate: function(date, showTwelveHour=false) {
var now = new Date(); const now = new Date();
const days = getDaysArray(); const days = getDaysArray();
const months = getMonthsArray(); const months = getMonthsArray();
if (date.toDateString() === now.toDateString()) { if (date.toDateString() === now.toDateString()) {
return this.formatTime(date); return this.formatTime(date);
} } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
// TODO: use standard date localize function provided in counterpart // TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s %(time)s', {weekDayName: days[date.getDay()], time: this.formatTime(date, showTwelveHour)}); return _t('%(weekDayName)s %(time)s', {
} weekDayName: days[date.getDay()],
else if (now.getFullYear() === date.getFullYear()) { time: this.formatTime(date, showTwelveHour),
});
} else if (now.getFullYear() === date.getFullYear()) {
// TODO: use standard date localize function provided in counterpart // TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', {
weekDayName: days[date.getDay()], weekDayName: days[date.getDay()],

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require('react'); import sdk from './index';
var sdk = require('./index');
function isMatch(query, name, uid) { function isMatch(query, name, uid) {
query = query.toLowerCase(); query = query.toLowerCase();
@ -33,8 +32,8 @@ function isMatch(query, name, uid) {
} }
// split spaces in name and try matching constituent parts // split spaces in name and try matching constituent parts
var parts = name.split(" "); const parts = name.split(" ");
for (var i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
if (parts[i].indexOf(query) === 0) { if (parts[i].indexOf(query) === 0) {
return true; return true;
} }
@ -67,7 +66,7 @@ class Entity {
class MemberEntity extends Entity { class MemberEntity extends Entity {
getJsx() { getJsx() {
var MemberTile = sdk.getComponent("rooms.MemberTile"); const MemberTile = sdk.getComponent("rooms.MemberTile");
return ( return (
<MemberTile key={this.model.userId} member={this.model} /> <MemberTile key={this.model.userId} member={this.model} />
); );
@ -84,6 +83,7 @@ class UserEntity extends Entity {
super(model); super(model);
this.showInviteButton = Boolean(showInviteButton); this.showInviteButton = Boolean(showInviteButton);
this.inviteFn = inviteFn; this.inviteFn = inviteFn;
this.onClick = this.onClick.bind(this);
} }
onClick() { onClick() {
@ -93,15 +93,15 @@ class UserEntity extends Entity {
} }
getJsx() { getJsx() {
var UserTile = sdk.getComponent("rooms.UserTile"); const UserTile = sdk.getComponent("rooms.UserTile");
return ( return (
<UserTile key={this.model.userId} user={this.model} <UserTile key={this.model.userId} user={this.model}
showInviteButton={this.showInviteButton} onClick={this.onClick.bind(this)} /> showInviteButton={this.showInviteButton} onClick={this.onClick} />
); );
} }
matches(queryString) { matches(queryString) {
var name = this.model.displayName || this.model.userId; const name = this.model.displayName || this.model.userId;
return isMatch(queryString, name, this.model.userId); return isMatch(queryString, name, this.model.userId);
} }
} }
@ -109,7 +109,7 @@ class UserEntity extends Entity {
module.exports = { module.exports = {
newEntity: function(jsx, matchFn) { newEntity: function(jsx, matchFn) {
var entity = new Entity(); const entity = new Entity();
entity.getJsx = function() { entity.getJsx = function() {
return jsx; return jsx;
}; };
@ -137,5 +137,5 @@ module.exports = {
return users.map(function(u) { return users.map(function(u) {
return new UserEntity(u, showInviteButton, inviteFn); return new UserEntity(u, showInviteButton, inviteFn);
}); });
} },
}; };

View file

@ -23,6 +23,7 @@ var linkifyMatrix = require('./linkify-matrix');
import escape from 'lodash/escape'; import escape from 'lodash/escape';
import emojione from 'emojione'; import emojione from 'emojione';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientPeg from './MatrixClientPeg';
emojione.imagePathSVG = 'emojione/svg/'; emojione.imagePathSVG = 'emojione/svg/';
// Store PNG path for displaying many flags at once (for increased performance over SVG) // Store PNG path for displaying many flags at once (for increased performance over SVG)
@ -37,7 +38,7 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
* because we want to include emoji shortnames in title text * because we want to include emoji shortnames in title text
*/ */
export function unicodeToImage(str) { export function unicodeToImage(str) {
let replaceWith, unicode, alt; let replaceWith, unicode, alt, short, fname;
const mappedUnicode = emojione.mapUnicodeToShort(); const mappedUnicode = emojione.mapUnicodeToShort();
str = str.replace(emojione.regUnicode, function(unicodeChar) { str = str.replace(emojione.regUnicode, function(unicodeChar) {
@ -49,11 +50,14 @@ export function unicodeToImage(str) {
// get the unicode codepoint from the actual char // get the unicode codepoint from the actual char
unicode = emojione.jsEscapeMap[unicodeChar]; unicode = emojione.jsEscapeMap[unicodeChar];
short = mappedUnicode[unicode];
fname = emojione.emojioneList[short].fname;
// depending on the settings, we'll either add the native unicode as the alt tag, otherwise the shortname // depending on the settings, we'll either add the native unicode as the alt tag, otherwise the shortname
alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode]; alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode];
const title = mappedUnicode[unicode]; const title = mappedUnicode[unicode];
replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${unicode}.svg${emojione.cacheBustParam}"/>`; replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${fname}.svg${emojione.cacheBustParam}"/>`;
return replaceWith; return replaceWith;
} }
}); });
@ -84,7 +88,7 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
} }
export function stripParagraphs(html: string): string { export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div'); const contentDiv = document.createElement('div');
contentDiv.innerHTML = html; contentDiv.innerHTML = html;
@ -96,7 +100,18 @@ export function stripParagraphs(html: string): string {
for (let i=0; i < contentDiv.children.length; i++) { for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i]; const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') { if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML + '<br />'; contentHTML += element.innerHTML;
// Don't add a <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> is
// redundant. This is a workaround for a bug in draft-js-export-html:
// https://github.com/sstur/draft-js-export-html/issues/62
contentHTML += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} else { } else {
const temp = document.createElement('div'); const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true)); temp.appendChild(element.cloneNode(true));
@ -107,33 +122,39 @@ export function stripParagraphs(html: string): string {
return contentHTML; return contentHTML;
} }
var sanitizeHtmlParams = { /*
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
*/
export function sanitizedHtmlNode(insaneHtml) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
const sanitizeHtmlParams = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
'del', // for markdown 'del', // for markdown
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
], ],
allowedAttributes: { allowedAttributes: {
// custom ones first: // custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
// We don't currently allow img itself by default, but this img: ['src', 'width', 'height', 'alt', 'title'],
// would make sense if we did
img: ['src'],
ol: ['start'], ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
}, },
// Lots of these won't come up by default because we don't allow them // Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit // URL schemes we permit
allowedSchemes: ['http', 'https', 'ftp', 'mailto'], allowedSchemes: ['http', 'https', 'ftp', 'mailto'],
// DO NOT USE. sanitize-html allows all URL starting with '//' allowProtocolRelative: false,
// so this will always allow links to whatever scheme the
// host page is served over.
allowedSchemesByTag: {},
transformTags: { // custom to matrix transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs // add blank targets to all hyperlinks except vector URLs
@ -165,6 +186,33 @@ var sanitizeHtmlParams = {
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName: tagName, attribs : attribs }; return { tagName: tagName, attribs : attribs };
}, },
'img': function(tagName, attribs) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
if (!attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}};
}
attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
attribs.src,
attribs.width || 800,
attribs.height || 600,
);
return { tagName: tagName, attribs: attribs };
},
'code': function(tagName, attribs) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
let classes = attribs.class.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-');
});
attribs.class = classes.join(' ');
}
return {
tagName: tagName,
attribs: attribs,
};
},
'*': function(tagName, attribs) { '*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span // Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming // because attributes are stripped after transforming

View file

@ -21,6 +21,7 @@ module.exports = {
ENTER: 13, ENTER: 13,
SHIFT: 16, SHIFT: 16,
ESCAPE: 27, ESCAPE: 27,
SPACE: 32,
PAGE_UP: 33, PAGE_UP: 33,
PAGE_DOWN: 34, PAGE_DOWN: 34,
END: 35, END: 35,
@ -30,7 +31,30 @@ module.exports = {
RIGHT: 39, RIGHT: 39,
DOWN: 40, DOWN: 40,
DELETE: 46, DELETE: 46,
KEY_A: 65,
KEY_B: 66,
KEY_C: 67,
KEY_D: 68, KEY_D: 68,
KEY_E: 69, KEY_E: 69,
KEY_F: 70,
KEY_G: 71,
KEY_H: 72,
KEY_I: 73,
KEY_J: 74,
KEY_K: 75,
KEY_L: 76,
KEY_M: 77, KEY_M: 77,
KEY_N: 78,
KEY_O: 79,
KEY_P: 80,
KEY_Q: 81,
KEY_R: 82,
KEY_S: 83,
KEY_T: 84,
KEY_U: 85,
KEY_V: 86,
KEY_W: 87,
KEY_X: 88,
KEY_Y: 89,
KEY_Z: 90,
}; };

View file

@ -125,7 +125,7 @@ export default class KeyRequestHandler {
}; };
const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog"); const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog");
Modal.createDialog(KeyShareDialog, { Modal.createTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, {
matrixClient: this._matrixClient, matrixClient: this._matrixClient,
userId: userId, userId: userId,
deviceId: deviceId, deviceId: deviceId,

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import q from 'q'; import Promise from 'bluebird';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
@ -116,12 +116,12 @@ export function loadSession(opts) {
*/ */
export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
if (!queryParams.loginToken) { if (!queryParams.loginToken) {
return q(false); return Promise.resolve(false);
} }
if (!queryParams.homeserver) { if (!queryParams.homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use"); console.warn("Cannot log in with token: can't determine HS URL to use");
return q(false); return Promise.resolve(false);
} }
// create a temporary MatrixClient to do the login // create a temporary MatrixClient to do the login
@ -197,7 +197,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
// localStorage (e.g. teamToken, isGuest etc.) // localStorage (e.g. teamToken, isGuest etc.)
function _restoreFromLocalStorage() { function _restoreFromLocalStorage() {
if (!localStorage) { if (!localStorage) {
return q(false); return Promise.resolve(false);
} }
const hsUrl = localStorage.getItem("mx_hs_url"); const hsUrl = localStorage.getItem("mx_hs_url");
const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org'; const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
@ -229,18 +229,18 @@ function _restoreFromLocalStorage() {
} }
} else { } else {
console.log("No previous session found."); console.log("No previous session found.");
return q(false); return Promise.resolve(false);
} }
} }
function _handleRestoreFailure(e) { function _handleRestoreFailure(e) {
console.log("Unable to restore session", e); console.log("Unable to restore session", e);
const def = q.defer(); const def = Promise.defer();
const SessionRestoreErrorDialog = const SessionRestoreErrorDialog =
sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); sdk.getComponent('views.dialogs.SessionRestoreErrorDialog');
Modal.createDialog(SessionRestoreErrorDialog, { Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
error: e.message, error: e.message,
onFinished: (success) => { onFinished: (success) => {
def.resolve(success); def.resolve(success);
@ -309,13 +309,16 @@ async function _doSetLoggedIn(credentials, clearStorage) {
// because `teamPromise` may take some time to resolve, breaking the assumption that // because `teamPromise` may take some time to resolve, breaking the assumption that
// `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms // `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms
// later than MatrixChat might assume. // later than MatrixChat might assume.
dis.dispatch({action: 'on_logging_in'}); //
// we fire it *synchronously* to make sure it fires before on_logged_in.
// (dis.dispatch uses `setTimeout`, which does not guarantee ordering.)
dis.dispatch({action: 'on_logging_in'}, true);
if (clearStorage) { if (clearStorage) {
await _clearStorage(); await _clearStorage();
} }
Analytics.setGuest(credentials.guest); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl);
// Resolves by default // Resolves by default
let teamPromise = Promise.resolve(null); let teamPromise = Promise.resolve(null);
@ -344,6 +347,9 @@ async function _doSetLoggedIn(credentials, clearStorage) {
localStorage.setItem("mx_team_token", body.team_token); localStorage.setItem("mx_team_token", body.team_token);
} }
return body.team_token; return body.team_token;
}, (err) => {
console.warn(`Failed to get team token on login: ${err}` );
return null;
}); });
} }
} else { } else {
@ -354,9 +360,6 @@ async function _doSetLoggedIn(credentials, clearStorage) {
teamPromise.then((teamToken) => { teamPromise.then((teamToken) => {
dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); dis.dispatch({action: 'on_logged_in', teamToken: teamToken});
}, (err) => {
console.warn("Failed to get team token on login", err);
dis.dispatch({action: 'on_logged_in', teamToken: null});
}); });
startMatrixClient(); startMatrixClient();
@ -419,6 +422,8 @@ export function logout() {
* listen for events while a session is logged in. * listen for events while a session is logged in.
*/ */
function startMatrixClient() { function startMatrixClient() {
console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used // dispatch this before starting the matrix client: it's used
// to add listeners for the 'sync' event so otherwise we'd have // to add listeners for the 'sync' event so otherwise we'd have
// a race condition (and we need to dispatch synchronously for this // a race condition (and we need to dispatch synchronously for this

View file

@ -18,7 +18,7 @@ limitations under the License.
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
import { _t } from "./languageHandler"; import { _t } from "./languageHandler";
import q from 'q'; import Promise from 'bluebird';
import url from 'url'; import url from 'url';
export default class Login { export default class Login {
@ -144,7 +144,7 @@ export default class Login {
const client = this._createTemporaryClient(); const client = this._createTemporaryClient();
return client.login('m.login.password', loginParams).then(function(data) { return client.login('m.login.password', loginParams).then(function(data) {
return q({ return Promise.resolve({
homeserverUrl: self._hsUrl, homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl, identityServerUrl: self._isUrl,
userId: data.user_id, userId: data.user_id,
@ -160,7 +160,7 @@ export default class Login {
}); });
return fbClient.login('m.login.password', loginParams).then(function(data) { return fbClient.login('m.login.password', loginParams).then(function(data) {
return q({ return Promise.resolve({
homeserverUrl: self._fallbackHsUrl, homeserverUrl: self._fallbackHsUrl,
identityServerUrl: self._isUrl, identityServerUrl: self._isUrl,
userId: data.user_id, userId: data.user_id,

View file

@ -17,7 +17,7 @@ limitations under the License.
import commonmark from 'commonmark'; import commonmark from 'commonmark';
import escape from 'lodash/escape'; import escape from 'lodash/escape';
const ALLOWED_HTML_TAGS = ['del']; const ALLOWED_HTML_TAGS = ['del', 'u'];
// These types of node are definitely text // These types of node are definitely text
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
@ -55,6 +55,25 @@ function is_multi_line(node) {
return par.firstChild != par.lastChild; return par.firstChild != par.lastChild;
} }
import linkifyMatrix from './linkify-matrix';
import * as linkify from 'linkifyjs';
linkifyMatrix(linkify);
// Thieved from draft-js-export-markdown
function escapeMarkdown(s) {
return s.replace(/[*_`]/g, '\\$&');
}
// Replace URLs, room aliases and user IDs with md-escaped URLs
function linkifyMarkdown(s) {
const links = linkify.find(s);
links.forEach((l) => {
// This may replace several instances of `l.value` at once, but that's OK
s = s.replace(l.value, escapeMarkdown(l.value));
});
return s;
}
/** /**
* Class that wraps commonmark, adding the ability to see whether * Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether * a given message actually uses any markdown syntax or whether
@ -62,7 +81,7 @@ function is_multi_line(node) {
*/ */
export default class Markdown { export default class Markdown {
constructor(input) { constructor(input) {
this.input = input; this.input = linkifyMarkdown(input);
const parser = new commonmark.Parser(); const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input); this.parsed = parser.parse(this.input);

View file

@ -77,22 +77,38 @@ class MatrixClientPeg {
this._createClient(creds); this._createClient(creds);
} }
start() { async start() {
// try to initialise e2e on the new client
try {
// check that we have a version of the js-sdk which includes initCrypto
if (this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto();
}
} catch(e) {
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
console.warn("Unable to initialise e2e: " + e);
}
const opts = utils.deepCopy(this.opts); const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow // the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached"; opts.pendingEventOrdering = "detached";
try {
let promise = this.matrixClient.store.startup(); let promise = this.matrixClient.store.startup();
console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`);
await promise;
} catch(err) {
// log any errors when starting up the database (if one exists) // log any errors when starting up the database (if one exists)
promise.catch((err) => {
console.error(`Error starting matrixclient store: ${err}`); console.error(`Error starting matrixclient store: ${err}`);
}); }
// regardless of errors, start the client. If we did error out, we'll // regardless of errors, start the client. If we did error out, we'll
// just end up doing a full initial /sync. // just end up doing a full initial /sync.
promise.finally(() => {
console.log(`MatrixClientPeg: really starting MatrixClient`);
this.get().startClient(opts); this.get().startClient(opts);
}); console.log(`MatrixClientPeg: MatrixClient started`);
} }
getCredentials(): MatrixClientCreds { getCredentials(): MatrixClientCreds {

View file

@ -103,13 +103,20 @@ class ModalManager {
return container; return container;
} }
createDialog(Element, props, className) { createTrackedDialog(analyticsAction, analyticsInfo, Element, props, className) {
if (props && props.title) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
Analytics.trackEvent('Modal', props.title, 'createDialog'); return this.createDialog(Element, props, className);
} }
createDialog(Element, props, className) {
return this.createDialogAsync((cb) => {cb(Element);}, props, className); return this.createDialogAsync((cb) => {cb(Element);}, props, className);
} }
createTrackedDialogAsync(analyticsId, loader, props, className) {
Analytics.trackEvent('Modal', analyticsId);
return this.createDialogAsync(loader, props, className);
}
/** /**
* Open a modal view. * Open a modal view.
* *

View file

@ -142,7 +142,7 @@ const Notifier = {
? _t('Riot does not have permission to send you notifications - please check your browser settings') ? _t('Riot does not have permission to send you notifications - please check your browser settings')
: _t('Riot was not given permission to send notifications - please try again'); : _t('Riot was not given permission to send notifications - please try again');
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, {
title: _t('Unable to enable Notifications'), title: _t('Unable to enable Notifications'),
description, description,
}); });

View file

@ -23,8 +23,8 @@ limitations under the License.
* { key: $KEY, val: $VALUE, place: "add|del" } * { key: $KEY, val: $VALUE, place: "add|del" }
*/ */
module.exports.getKeyValueArrayDiffs = function(before, after) { module.exports.getKeyValueArrayDiffs = function(before, after) {
var results = []; const results = [];
var delta = {}; const delta = {};
Object.keys(before).forEach(function(beforeKey) { Object.keys(before).forEach(function(beforeKey) {
delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially
delta[beforeKey]--; // keys present in the past have -ve values delta[beforeKey]--; // keys present in the past have -ve values
@ -46,9 +46,9 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
results.push({ place: "del", key: muxedKey, val: beforeVal }); results.push({ place: "del", key: muxedKey, val: beforeVal });
}); });
break; break;
case 0: // A mix of added/removed keys case 0: {// A mix of added/removed keys
// compare old & new vals // compare old & new vals
var itemDelta = {}; const itemDelta = {};
before[muxedKey].forEach(function(beforeVal) { before[muxedKey].forEach(function(beforeVal) {
itemDelta[beforeVal] = itemDelta[beforeVal] || 0; itemDelta[beforeVal] = itemDelta[beforeVal] || 0;
itemDelta[beforeVal]--; itemDelta[beforeVal]--;
@ -68,9 +68,9 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
} }
}); });
break; break;
}
default: default:
console.error("Calculated key delta of " + delta[muxedKey] + console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!");
" - this should never happen!");
break; break;
} }
}); });
@ -79,8 +79,10 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
}; };
/** /**
* Shallow-compare two objects for equality: each key and value must be * Shallow-compare two objects for equality: each key and value must be identical
* identical * @param {Object} objA First object to compare against the second
* @param {Object} objB Second object to compare against the first
* @return {boolean} whether the two objects have same key=values
*/ */
module.exports.shallowEqual = function(objA, objB) { module.exports.shallowEqual = function(objA, objB) {
if (objA === objB) { if (objA === objB) {
@ -92,15 +94,15 @@ module.exports.shallowEqual = function(objA, objB) {
return false; return false;
} }
var keysA = Object.keys(objA); const keysA = Object.keys(objA);
var keysB = Object.keys(objB); const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) { if (keysA.length !== keysB.length) {
return false; return false;
} }
for (var i = 0; i < keysA.length; i++) { for (let i = 0; i < keysA.length; i++) {
var key = keysA[i]; const key = keysA[i];
if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) { if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) {
return false; return false;
} }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -22,4 +23,6 @@ export default {
CreateRoom: "create_room", CreateRoom: "create_room",
RoomDirectory: "room_directory", RoomDirectory: "room_directory",
UserView: "user_view", UserView: "user_view",
GroupView: "group_view",
MyGroups: "my_groups",
}; };

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var Matrix = require("matrix-js-sdk"); import * as Matrix from 'matrix-js-sdk';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
/** /**
@ -34,7 +34,7 @@ class PasswordReset {
constructor(homeserverUrl, identityUrl) { constructor(homeserverUrl, identityUrl) {
this.client = Matrix.createClient({ this.client = Matrix.createClient({
baseUrl: homeserverUrl, baseUrl: homeserverUrl,
idBaseUrl: identityUrl idBaseUrl: identityUrl,
}); });
this.clientSecret = this.client.generateClientSecret(); this.clientSecret = this.client.generateClientSecret();
this.identityServerDomain = identityUrl.split("://")[1]; this.identityServerDomain = identityUrl.split("://")[1];
@ -53,7 +53,7 @@ class PasswordReset {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, function(err) {
if (err.errcode == 'M_THREEPID_NOT_FOUND') { if (err.errcode === 'M_THREEPID_NOT_FOUND') {
err.message = _t('This email address was not found'); err.message = _t('This email address was not found');
} else if (err.httpStatus) { } else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
@ -75,16 +75,15 @@ class PasswordReset {
threepid_creds: { threepid_creds: {
sid: this.sessionId, sid: this.sessionId,
client_secret: this.clientSecret, client_secret: this.clientSecret,
id_server: this.identityServerDomain id_server: this.identityServerDomain,
} },
}, this.password).catch(function(err) { }, this.password).catch(function(err) {
if (err.httpStatus === 401) { if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
} } else if (err.httpStatus === 404) {
else if (err.httpStatus === 404) { err.message =
err.message = _t('Your email address does not appear to be associated with a Matrix ID on this Homeserver.'); _t('Your email address does not appear to be associated with a Matrix ID on this Homeserver.');
} } else if (err.httpStatus) {
else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`; err.message += ` (Status ${err.httpStatus})`;
} }
throw err; throw err;

View file

@ -14,10 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var MatrixClientPeg = require('./MatrixClientPeg'); import MatrixClientPeg from './MatrixClientPeg';
var dis = require('./dispatcher'); import dis from './dispatcher';
var sdk = require('./index');
var Modal = require('./Modal');
import { EventStatus } from 'matrix-js-sdk'; import { EventStatus } from 'matrix-js-sdk';
module.exports = { module.exports = {
@ -37,12 +35,10 @@ module.exports = {
}, },
resend: function(event) { resend: function(event) {
const room = MatrixClientPeg.get().getRoom(event.getRoomId()); const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent( MatrixClientPeg.get().resendEvent(event, room).done(function(res) {
event, room
).done(function(res) {
dis.dispatch({ dis.dispatch({
action: 'message_sent', action: 'message_sent',
event: event event: event,
}); });
}, function(err) { }, function(err) {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
@ -58,7 +54,7 @@ module.exports = {
dis.dispatch({ dis.dispatch({
action: 'message_send_failed', action: 'message_send_failed',
event: event event: event,
}); });
}); });
}, },
@ -66,7 +62,7 @@ module.exports = {
MatrixClientPeg.get().cancelPendingEvent(event); MatrixClientPeg.get().cancelPendingEvent(event);
dis.dispatch({ dis.dispatch({
action: 'message_send_cancelled', action: 'message_send_cancelled',
event: event event: event,
}); });
}, },
}; };

View file

@ -16,6 +16,7 @@ import * as sdk from './index';
import * as emojione from 'emojione'; import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html'; import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter"; import {SelectionRange} from "./autocomplete/Autocompleter";
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
const MARKDOWN_REGEX = { const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
@ -30,10 +31,28 @@ const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export const contentStateToHTML = stateToHTML; const ZWS_CODE = 8203;
const ZWS = String.fromCharCode(ZWS_CODE); // zero width space
export function stateToMarkdown(state) {
return __stateToMarkdown(state)
.replace(
ZWS, // draft-js-export-markdown adds these
''); // this is *not* a zero width space, trust me :)
}
export function HTMLtoContentState(html: string): ContentState { export const contentStateToHTML = (contentState: ContentState) => {
return ContentState.createFromBlockArray(convertFromHTML(html)); return stateToHTML(contentState, {
inlineStyles: {
UNDERLINE: {
element: 'u'
}
}
});
};
export function htmlToContentState(html: string): ContentState {
const blockArray = convertFromHTML(html).contentBlocks;
return ContentState.createFromBlockArray(blockArray);
} }
function unicodeToEmojiUri(str) { function unicodeToEmojiUri(str) {
@ -72,7 +91,7 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb
// Workaround for https://github.com/facebook/draft-js/issues/414 // Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = { let emojiDecorator = {
strategy: (contentBlock, callback) => { strategy: (contentState, contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback); findWithRegex(EMOJI_REGEX, contentBlock, callback);
}, },
component: (props) => { component: (props) => {
@ -95,38 +114,13 @@ let emojiDecorator = {
* Returns a composite decorator which has access to provided scope. * Returns a composite decorator which has access to provided scope.
*/ */
export function getScopedRTDecorators(scope: any): CompositeDecorator { export function getScopedRTDecorators(scope: any): CompositeDecorator {
let MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
let usernameDecorator = {
strategy: (contentBlock, callback) => {
findWithRegex(USERNAME_REGEX, contentBlock, callback);
},
component: (props) => {
let member = scope.room.getMember(props.children[0].props.text);
// unused until we make these decorators immutable (autocomplete needed)
let name = member ? member.name : null;
let avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null;
return <span className="mx_UserPill">{avatar}{props.children}</span>;
}
};
let roomDecorator = {
strategy: (contentBlock, callback) => {
findWithRegex(ROOM_REGEX, contentBlock, callback);
},
component: (props) => {
return <span className="mx_RoomPill">{props.children}</span>;
}
};
// TODO Re-enable usernameDecorator and roomDecorator
return [emojiDecorator]; return [emojiDecorator];
} }
export function getScopedMDDecorators(scope: any): CompositeDecorator { export function getScopedMDDecorators(scope: any): CompositeDecorator {
let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({ (style) => ({
strategy: (contentBlock, callback) => { strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
}, },
component: (props) => ( component: (props) => (
@ -137,7 +131,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
})); }));
markdownDecorators.push({ markdownDecorators.push({
strategy: (contentBlock, callback) => { strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback); return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
}, },
component: (props) => ( component: (props) => (
@ -146,9 +140,9 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
</a> </a>
) )
}); });
markdownDecorators.push(emojiDecorator); // markdownDecorators.push(emojiDecorator);
// TODO Consider renabling "syntax highlighting" when we can do it properly
return markdownDecorators; return [emojiDecorator];
} }
/** /**
@ -208,31 +202,36 @@ export function selectionStateToTextOffsets(selectionState: SelectionState,
export function textOffsetsToSelectionState({start, end}: SelectionRange, export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState { contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty(); let selectionState = SelectionState.createEmpty();
// Subtract block lengths from `start` and `end` until they are less than the current
for (let block of contentBlocks) { // block length (accounting for the NL at the end of each block). Set them to -1 to
let blockLength = block.getLength(); // indicate that the corresponding selection state has been determined.
for (const block of contentBlocks) {
if (start !== -1 && start < blockLength) { const blockLength = block.getLength();
// -1 indicating that the position start position has been found
if (start !== -1) {
if (start < blockLength + 1) {
selectionState = selectionState.merge({ selectionState = selectionState.merge({
anchorKey: block.getKey(), anchorKey: block.getKey(),
anchorOffset: start, anchorOffset: start,
}); });
start = -1; start = -1; // selection state for the start calculated
} else { } else {
start -= blockLength; start -= blockLength + 1; // +1 to account for newline between blocks
} }
}
if (end !== -1 && end <= blockLength) { // -1 indicating that the position end position has been found
if (end !== -1) {
if (end < blockLength + 1) {
selectionState = selectionState.merge({ selectionState = selectionState.merge({
focusKey: block.getKey(), focusKey: block.getKey(),
focusOffset: end, focusOffset: end,
}); });
end = -1; end = -1; // selection state for the end calculated
} else { } else {
end -= blockLength; end -= blockLength + 1; // +1 to account for newline between blocks
}
} }
} }
return selectionState; return selectionState;
} }
@ -249,7 +248,7 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
const existingEntityKey = block.getEntityAt(start); const existingEntityKey = block.getEntityAt(start);
if (existingEntityKey) { if (existingEntityKey) {
// avoid manipulation in case the emoji already has an entity // avoid manipulation in case the emoji already has an entity
const entity = Entity.get(existingEntityKey); const entity = newContentState.getEntity(existingEntityKey);
if (entity && entity.get('type') === 'emoji') { if (entity && entity.get('type') === 'emoji') {
return; return;
} }
@ -259,7 +258,10 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
.set('anchorOffset', start) .set('anchorOffset', start)
.set('focusOffset', end); .set('focusOffset', end);
const emojiText = plainText.substring(start, end); const emojiText = plainText.substring(start, end);
const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText }); newContentState = newContentState.createEntity(
'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }
);
const entityKey = newContentState.getLastCreatedEntityKey();
newContentState = Modifier.replaceText( newContentState = Modifier.replaceText(
newContentState, newContentState,
selection, selection,
@ -286,3 +288,14 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
return editorState; return editorState;
} }
export function hasMultiLineSelection(editorState: EditorState): boolean {
const selectionState = editorState.getSelection();
const anchorKey = selectionState.getAnchorKey();
const currentContent = editorState.getCurrentContent();
const currentContentBlock = currentContent.getBlockForKey(anchorKey);
const start = selectionState.getStartOffset();
const end = selectionState.getEndOffset();
const selectedText = currentContentBlock.getText().slice(start, end);
return selectedText.includes('\n');
}

View file

@ -19,8 +19,7 @@ limitations under the License.
function tsOfNewestEvent(room) { function tsOfNewestEvent(room) {
if (room.timeline.length) { if (room.timeline.length) {
return room.timeline[room.timeline.length - 1].getTs(); return room.timeline[room.timeline.length - 1].getTs();
} } else {
else {
return Number.MAX_SAFE_INTEGER; return Number.MAX_SAFE_INTEGER;
} }
} }
@ -32,5 +31,5 @@ function mostRecentActivityFirst(roomList) {
} }
module.exports = { module.exports = {
mostRecentActivityFirst: mostRecentActivityFirst mostRecentActivityFirst,
}; };

View file

@ -16,7 +16,7 @@ limitations under the License.
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor'; import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import q from 'q'; import Promise from 'bluebird';
export const ALL_MESSAGES_LOUD = 'all_messages_loud'; export const ALL_MESSAGES_LOUD = 'all_messages_loud';
export const ALL_MESSAGES = 'all_messages'; export const ALL_MESSAGES = 'all_messages';
@ -52,7 +52,7 @@ export function getRoomNotifsState(roomId) {
} }
export function setRoomNotifsState(roomId, newState) { export function setRoomNotifsState(roomId, newState) {
if (newState == MUTE) { if (newState === MUTE) {
return setRoomNotifsStateMuted(roomId); return setRoomNotifsStateMuted(roomId);
} else { } else {
return setRoomNotifsStateUnmuted(roomId, newState); return setRoomNotifsStateUnmuted(roomId, newState);
@ -80,14 +80,14 @@ function setRoomNotifsStateMuted(roomId) {
kind: 'event_match', kind: 'event_match',
key: 'room_id', key: 'room_id',
pattern: roomId, pattern: roomId,
} },
], ],
actions: [ actions: [
'dont_notify', 'dont_notify',
] ],
})); }));
return q.all(promises); return Promise.all(promises);
} }
function setRoomNotifsStateUnmuted(roomId, newState) { function setRoomNotifsStateUnmuted(roomId, newState) {
@ -99,16 +99,16 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id)); promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id));
} }
if (newState == 'all_messages') { if (newState === 'all_messages') {
const roomRule = cli.getRoomPushRule('global', roomId); const roomRule = cli.getRoomPushRule('global', roomId);
if (roomRule) { if (roomRule) {
promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id));
} }
} else if (newState == 'mentions_only') { } else if (newState === 'mentions_only') {
promises.push(cli.addPushRule('global', 'room', roomId, { promises.push(cli.addPushRule('global', 'room', roomId, {
actions: [ actions: [
'dont_notify', 'dont_notify',
] ],
})); }));
// https://matrix.org/jira/browse/SPEC-400 // https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
@ -119,14 +119,14 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
{ {
set_tweak: 'sound', set_tweak: 'sound',
value: 'default', value: 'default',
} },
] ],
})); }));
// https://matrix.org/jira/browse/SPEC-400 // https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
} }
return q.all(promises); return Promise.all(promises);
} }
function findOverrideMuteRule(roomId) { function findOverrideMuteRule(roomId) {
@ -145,20 +145,10 @@ function isRuleForRoom(roomId, rule) {
return false; return false;
} }
const cond = rule.conditions[0]; const cond = rule.conditions[0];
if ( return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId);
cond.kind == 'event_match' &&
cond.key == 'room_id' &&
cond.pattern == roomId
) {
return true;
}
return false;
} }
function isMuteRule(rule) { function isMuteRule(rule) {
return ( return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify');
rule.actions.length == 1 &&
rule.actions[0] == 'dont_notify'
);
} }

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import q from 'q'; import Promise from 'bluebird';
/** /**
* Given a room object, return the alias we should use for it, * Given a room object, return the alias we should use for it,
@ -102,7 +102,7 @@ export function guessAndSetDMRoom(room, isDirect) {
*/ */
export function setDMRoom(roomId, userId) { export function setDMRoom(roomId, userId) {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
return q(); return Promise.resolve();
} }
const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var q = require("q"); import Promise from 'bluebird';
var request = require('browser-request'); var request = require('browser-request');
var SdkConfig = require('./SdkConfig'); var SdkConfig = require('./SdkConfig');
@ -39,7 +39,7 @@ class ScalarAuthClient {
// Returns a scalar_token string // Returns a scalar_token string
getScalarToken() { getScalarToken() {
var tok = window.localStorage.getItem("mx_scalar_token"); var tok = window.localStorage.getItem("mx_scalar_token");
if (tok) return q(tok); if (tok) return Promise.resolve(tok);
// No saved token, so do the dance to get one. First, we // No saved token, so do the dance to get one. First, we
// need an openid bearer token from the HS. // need an openid bearer token from the HS.
@ -53,7 +53,7 @@ class ScalarAuthClient {
} }
exchangeForScalarToken(openid_token_object) { exchangeForScalarToken(openid_token_object) {
var defer = q.defer(); var defer = Promise.defer();
var scalar_rest_url = SdkConfig.get().integrations_rest_url; var scalar_rest_url = SdkConfig.get().integrations_rest_url;
request({ request({
@ -76,10 +76,13 @@ class ScalarAuthClient {
return defer.promise; return defer.promise;
} }
getScalarInterfaceUrlForRoom(roomId) { getScalarInterfaceUrlForRoom(roomId, screen) {
var url = SdkConfig.get().integrations_ui_url; var url = SdkConfig.get().integrations_ui_url;
url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
url += "&room_id=" + encodeURIComponent(roomId); url += "&room_id=" + encodeURIComponent(roomId);
if (screen) {
url += '&screen=' + encodeURIComponent(screen);
}
return url; return url;
} }
@ -89,4 +92,3 @@ class ScalarAuthClient {
} }
module.exports = ScalarAuthClient; module.exports = ScalarAuthClient;

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,7 +18,7 @@ limitations under the License.
/* /*
Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed: Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed:
{ {
action: "invite" | "membership_state" | "bot_options" | "set_bot_options", action: "invite" | "membership_state" | "bot_options" | "set_bot_options" | etc... ,
room_id: $ROOM_ID, room_id: $ROOM_ID,
user_id: $USER_ID user_id: $USER_ID
// additional request fields // additional request fields
@ -109,6 +110,99 @@ Example:
response: 78 response: 78
} }
can_send_event
--------------
Check if the client can send the given event into the given room. If the client
is unable to do this, an error response is returned instead of 'response: false'.
Request:
- room_id is the room to do the check in.
- event_type is the event type which will be sent.
- is_state is true if the event to be sent is a state event.
Response:
true
Example:
{
action: "can_send_event",
is_state: false,
event_type: "m.room.message",
room_id: "!foo:bar",
response: true
}
set_widget
----------
Set a new widget in the room. Clobbers based on the ID.
Request:
- `room_id` (String) is the room to set the widget in.
- `widget_id` (String) is the ID of the widget to add (or replace if it already exists).
It can be an arbitrary UTF8 string and is purely for distinguishing between widgets.
- `url` (String) is the URL that clients should load in an iframe to run the widget.
All widgets must have a valid URL. If the URL is `null` (not `undefined`), the
widget will be removed from the room.
- `type` (String) is the type of widget, which is provided as a hint for matrix clients so they
can configure/lay out the widget in different ways. All widgets must have a type.
- `name` (String) is an optional human-readable string about the widget.
- `data` (Object) is some optional data about the widget, and can contain arbitrary key/value pairs.
Response:
{
success: true
}
Example:
{
action: "set_widget",
room_id: "!foo:bar",
widget_id: "abc123",
url: "http://widget.url",
type: "example",
response: {
success: true
}
}
get_widgets
-----------
Get a list of all widgets in the room. The response is an array
of state events.
Request:
- `room_id` (String) is the room to get the widgets in.
Response:
[
{
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
type: "grafana",
url: "https://grafanaurl",
name: "dashboard",
data: {key: "val"}
}
room_id: !foo:bar,
sender: "@alice:localhost"
}
]
Example:
{
action: "get_widgets",
room_id: "!foo:bar",
response: [
{
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
type: "grafana",
url: "https://grafanaurl",
name: "dashboard",
data: {key: "val"}
}
room_id: !foo:bar,
sender: "@alice:localhost"
}
]
}
membership_state AND bot_options membership_state AND bot_options
-------------------------------- --------------------------------
@ -191,6 +285,87 @@ function inviteUser(event, roomId, userId) {
}); });
} }
function setWidget(event, roomId) {
const widgetId = event.data.widget_id;
const widgetType = event.data.type;
const widgetUrl = event.data.url;
const widgetName = event.data.name; // optional
const widgetData = event.data.data; // optional
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
// both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields."));
return;
}
if (widgetUrl !== null) { // if url is null it is being deleted, don't need to check name/type/etc
// check types of fields
if (widgetName !== undefined && typeof widgetName !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'name' must be a string."));
return;
}
if (widgetData !== undefined && !(widgetData instanceof Object)) {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object."));
return;
}
if (typeof widgetType !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string."));
return;
}
if (typeof widgetUrl !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'url' must be a string or null."));
return;
}
}
let content = {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
};
if (widgetUrl === null) { // widget is being deleted
content = {};
}
client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, _t('Failed to send request.'), err);
});
}
function getWidgets(event, roomId) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets");
// Only return widgets which have required fields
let widgetStateEvents = [];
stateEvents.forEach((ev) => {
if (ev.getContent().type && ev.getContent().url) {
widgetStateEvents.push(ev.event); // return the raw event
}
})
sendResponse(event, widgetStateEvents);
}
function setPlumbingState(event, roomId, status) { function setPlumbingState(event, roomId, status) {
if (typeof status !== 'string') { if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string'); throw new Error('Plumbing state status should be a string');
@ -287,6 +462,42 @@ function getMembershipCount(event, roomId) {
sendResponse(event, count); sendResponse(event, count);
} }
function canSendEvent(event, roomId) {
const evType = "" + event.data.event_type; // force stringify
const isState = Boolean(event.data.is_state);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const me = client.credentials.userId;
const member = room.getMember(me);
if (!member || member.membership !== "join") {
sendError(event, _t('You are not in this room.'));
return;
}
let canSend = false;
if (isState) {
canSend = room.currentState.maySendStateEvent(evType, me);
}
else {
canSend = room.currentState.maySendEvent(evType, me);
}
if (!canSend) {
sendError(event, _t('You do not have permission to do that in this room.'));
return;
}
sendResponse(event, true);
}
function returnStateEvent(event, roomId, eventType, stateKey) { function returnStateEvent(event, roomId, eventType, stateKey) {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
@ -332,7 +543,7 @@ const onMessage = function(event) {
// All strings start with the empty string, so for sanity return if the length // All strings start with the empty string, so for sanity return if the length
// of the event origin is 0. // of the event origin is 0.
let url = SdkConfig.get().integrations_ui_url; let url = SdkConfig.get().integrations_ui_url;
if (event.origin.length === 0 || !url.startsWith(event.origin)) { if (event.origin.length === 0 || !url.startsWith(event.origin) || !event.data.action) {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
} }
@ -367,7 +578,7 @@ const onMessage = function(event) {
return; return;
} }
// Getting join rules does not require userId // These APIs don't require userId
if (event.data.action === "join_rules_state") { if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId); getJoinRules(event, roomId);
return; return;
@ -377,6 +588,15 @@ const onMessage = function(event) {
} else if (event.data.action === "get_membership_count") { } else if (event.data.action === "get_membership_count") {
getMembershipCount(event, roomId); getMembershipCount(event, roomId);
return; return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
} else if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} else if (event.data.action === "can_send_event") {
canSendEvent(event, roomId);
return;
} }
if (!userId) { if (!userId) {
@ -409,12 +629,27 @@ const onMessage = function(event) {
}); });
}; };
let listenerCount = 0;
module.exports = { module.exports = {
startListening: function() { startListening: function() {
if (listenerCount === 0) {
window.addEventListener("message", onMessage, false); window.addEventListener("message", onMessage, false);
}
listenerCount += 1;
}, },
stopListening: function() { stopListening: function() {
listenerCount -= 1;
if (listenerCount === 0) {
window.removeEventListener("message", onMessage); window.removeEventListener("message", onMessage);
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"ScalarMessaging: mismatched startListening / stopListening detected." +
" Negative count"
);
console.error(e);
}
}, },
}; };

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var DEFAULTS = { const DEFAULTS = {
// URL to a page we show in an iframe to configure integrations // URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/", integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server // Base URL to the REST interface of the integrations server
@ -30,8 +30,8 @@ class SdkConfig {
} }
static put(cfg) { static put(cfg) {
var defaultKeys = Object.keys(DEFAULTS); const defaultKeys = Object.keys(DEFAULTS);
for (var i = 0; i < defaultKeys.length; ++i) { for (let i = 0; i < defaultKeys.length; ++i) {
if (cfg[defaultKeys[i]] === undefined) { if (cfg[defaultKeys[i]] === undefined) {
cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]]; cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]];
} }

View file

@ -51,19 +51,18 @@ class Skinner {
if (this.components !== null) { if (this.components !== null) {
throw new Error( throw new Error(
"Attempted to load a skin while a skin is already loaded"+ "Attempted to load a skin while a skin is already loaded"+
"If you want to change the active skin, call resetSkin first" "If you want to change the active skin, call resetSkin first");
);
} }
this.components = {}; this.components = {};
var compKeys = Object.keys(skinObject.components); const compKeys = Object.keys(skinObject.components);
for (var i = 0; i < compKeys.length; ++i) { for (let i = 0; i < compKeys.length; ++i) {
var comp = skinObject.components[compKeys[i]]; const comp = skinObject.components[compKeys[i]];
this.addComponent(compKeys[i], comp); this.addComponent(compKeys[i], comp);
} }
} }
addComponent(name, comp) { addComponent(name, comp) {
var slot = name; let slot = name;
if (comp.replaces !== undefined) { if (comp.replaces !== undefined) {
if (comp.replaces.indexOf('.') > -1) { if (comp.replaces.indexOf('.') > -1) {
slot = comp.replaces; slot = comp.replaces;

View file

@ -68,7 +68,7 @@ const commands = {
ddg: new Command("ddg", "<query>", function(roomId, args) { ddg: new Command("ddg", "<query>", function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here. // TODO Don't explain this away, actually show a search UI here.
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'), title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'), description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
}); });
@ -301,27 +301,36 @@ const commands = {
const deviceId = matches[2]; const deviceId = matches[2];
const fingerprint = matches[3]; const fingerprint = matches[3];
const device = MatrixClientPeg.get().getStoredDevice(userId, deviceId); return success(
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => {
if (!device) { if (!device) {
return reject(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`); throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`);
} }
if (device.isVerified()) { if (device.isVerified()) {
if (device.getFingerprint() === fingerprint) { if (device.getFingerprint() === fingerprint) {
return reject(_t(`Device already verified!`)); throw new Error(_t(`Device already verified!`));
} else { } else {
return reject(_t(`WARNING: Device already verified, but keys do NOT MATCH!`)); throw new Error(_t(`WARNING: Device already verified, but keys do NOT MATCH!`));
} }
} }
if (device.getFingerprint() === fingerprint) { if (device.getFingerprint() !== fingerprint) {
MatrixClientPeg.get().setDeviceVerified( const fprint = device.getFingerprint();
userId, deviceId, true, throw new Error(
); _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key' +
' "%(fingerprint)s". This could mean your communications are being intercepted!',
{deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint}));
}
// Tell the user we verified everything! return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, {
title: _t("Verified key"), title: _t("Verified key"),
description: ( description: (
<div> <div>
@ -336,19 +345,10 @@ const commands = {
), ),
hasCancelButton: false, hasCancelButton: false,
}); });
}),
return success();
} else {
const fprint = device.getFingerprint();
return reject(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key' +
' "%(fingerprint)s". This could mean your communications are being intercepted!',
{deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint})
); );
} }
} }
}
return reject(this.getUsage()); return reject(this.getUsage());
}), }),
}; };

View file

@ -1,391 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Entry, MemberEntry, CommandEntry } from './TabCompleteEntries';
import SlashCommands from './SlashCommands';
import MatrixClientPeg from './MatrixClientPeg';
const DELAY_TIME_MS = 1000;
const KEY_TAB = 9;
const KEY_SHIFT = 16;
const KEY_WINDOWS = 91;
// NB: DO NOT USE \b its "words" are roman alphabet only!
//
// Capturing group containing the start
// of line or a whitespace char
// \_______________ __________Capturing group of 0 or more non-whitespace chars
// _|__ _|_ followed by the end of line
// / \/ \
const MATCH_REGEX = /(^|\s)(\S*)$/;
class TabComplete {
constructor(opts) {
opts.allowLooping = opts.allowLooping || false;
opts.autoEnterTabComplete = opts.autoEnterTabComplete || false;
opts.onClickCompletes = opts.onClickCompletes || false;
this.opts = opts;
this.completing = false;
this.list = []; // full set of tab-completable things
this.matchedList = []; // subset of completable things to loop over
this.currentIndex = 0; // index in matchedList currently
this.originalText = null; // original input text when tab was first hit
this.textArea = opts.textArea; // DOMElement
this.isFirstWord = false; // true if you tab-complete on the first word
this.enterTabCompleteTimerId = null;
this.inPassiveMode = false;
// Map tracking ordering of the room members.
// userId: integer, highest comes first.
this.memberTabOrder = {};
// monotonically increasing counter used for tracking ordering of members
this.memberOrderSeq = 0;
}
/**
* Call this when a a UI element representing a tab complete entry has been clicked
* @param {entry} The entry that was clicked
*/
onEntryClick(entry) {
if (this.opts.onClickCompletes) {
this.completeTo(entry);
}
}
loadEntries(room) {
this._makeEntries(room);
this._initSorting(room);
this._sortEntries();
}
onMemberSpoke(member) {
if (this.memberTabOrder[member.userId] === undefined) {
this.list.push(new MemberEntry(member));
}
this.memberTabOrder[member.userId] = this.memberOrderSeq++;
this._sortEntries();
}
/**
* @param {DOMElement}
*/
setTextArea(textArea) {
this.textArea = textArea;
}
/**
* @return {Boolean}
*/
isTabCompleting() {
// actually have things to tab over
return this.completing && this.matchedList.length > 1;
}
stopTabCompleting() {
this.completing = false;
this.currentIndex = 0;
this._notifyStateChange();
}
startTabCompleting(passive) {
this.originalText = this.textArea.value; // cache starting text
// grab the partial word from the text which we'll be tab-completing
var res = MATCH_REGEX.exec(this.originalText);
if (!res) {
this.matchedList = [];
return;
}
// ES6 destructuring; ignore first element (the complete match)
var [, boundaryGroup, partialGroup] = res;
if (partialGroup.length === 0 && passive) {
return;
}
this.isFirstWord = partialGroup.length === this.originalText.length;
this.completing = true;
this.currentIndex = 0;
this.matchedList = [
new Entry(partialGroup) // first entry is always the original partial
];
// find matching entries in the set of entries given to us
this.list.forEach((entry) => {
if (entry.text.toLowerCase().indexOf(partialGroup.toLowerCase()) === 0) {
this.matchedList.push(entry);
}
});
// console.log("calculated completions => %s", JSON.stringify(this.matchedList));
}
/**
* Do an auto-complete with the given word. This terminates the tab-complete.
* @param {Entry} entry The tab-complete entry to complete to.
*/
completeTo(entry) {
this.textArea.value = this._replaceWith(
entry.getFillText(), true, entry.getSuffix(this.isFirstWord)
);
this.stopTabCompleting();
// keep focus on the text area
this.textArea.focus();
}
/**
* @param {Number} numAheadToPeek Return *up to* this many elements.
* @return {Entry[]}
*/
peek(numAheadToPeek) {
if (this.matchedList.length === 0) {
return [];
}
var peekList = [];
// return the current match item and then one with an index higher, and
// so on until we've reached the requested limit. If we hit the end of
// the list of options we're done.
for (var i = 0; i < numAheadToPeek; i++) {
var nextIndex;
if (this.opts.allowLooping) {
nextIndex = (this.currentIndex + i) % this.matchedList.length;
}
else {
nextIndex = this.currentIndex + i;
if (nextIndex === this.matchedList.length) {
break;
}
}
peekList.push(this.matchedList[nextIndex]);
}
// console.log("Peek list(%s): %s", numAheadToPeek, JSON.stringify(peekList));
return peekList;
}
handleTabPress(passive, shiftKey) {
var wasInPassiveMode = this.inPassiveMode && !passive;
this.inPassiveMode = passive;
if (!this.completing) {
this.startTabCompleting(passive);
}
if (shiftKey) {
this.nextMatchedEntry(-1);
}
else {
// if we were in passive mode we got out of sync by incrementing the
// index to show the peek view but not set the text area. Therefore,
// we want to set the *current* index rather than the *next* index.
this.nextMatchedEntry(wasInPassiveMode ? 0 : 1);
}
this._notifyStateChange();
}
/**
* @param {DOMEvent} e
*/
onKeyDown(ev) {
if (!this.textArea) {
console.error("onKeyDown called before a <textarea> was set!");
return;
}
if (ev.keyCode !== KEY_TAB) {
// pressing any key (except shift, windows, cmd (OSX) and ctrl/alt combinations)
// aborts the current tab completion
if (this.completing && ev.keyCode !== KEY_SHIFT &&
!ev.metaKey && !ev.ctrlKey && !ev.altKey && ev.keyCode !== KEY_WINDOWS) {
// they're resuming typing; reset tab complete state vars.
this.stopTabCompleting();
}
// explicitly pressing any key except tab removes passive mode. Tab doesn't remove
// passive mode because handleTabPress needs to know when passive mode is toggling
// off so it can resync the textarea/peek list. If tab did remove passive mode then
// handleTabPress would never be able to tell when passive mode toggled off.
this.inPassiveMode = false;
// pressing any key at all (except tab) restarts the automatic tab-complete timer
if (this.opts.autoEnterTabComplete) {
const cachedText = ev.target.value;
clearTimeout(this.enterTabCompleteTimerId);
this.enterTabCompleteTimerId = setTimeout(() => {
if (this.completing) {
// If you highlight text and CTRL+X it, tab-completing will not be reset.
// This check makes sure that if something like a cut operation has been
// done, that we correctly refresh the tab-complete list. Normal backspace
// operations get caught by the stopTabCompleting() section above, but
// because the CTRL key is held, this does not execute for CTRL+X.
if (cachedText !== this.textArea.value) {
this.stopTabCompleting();
}
}
if (!this.completing) {
this.handleTabPress(true, false);
}
}, DELAY_TIME_MS);
}
return;
}
// ctrl-tab/alt-tab etc shouldn't trigger a complete
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
// tab key has been pressed at this point
this.handleTabPress(false, ev.shiftKey);
// prevent the default TAB operation (typically focus shifting)
ev.preventDefault();
}
/**
* Set the textarea to the next value in the matched list.
* @param {Number} offset Offset to apply *before* setting the next value.
*/
nextMatchedEntry(offset) {
if (this.matchedList.length === 0) {
return;
}
// work out the new index, wrapping if necessary.
this.currentIndex += offset;
if (this.currentIndex >= this.matchedList.length) {
this.currentIndex = 0;
}
else if (this.currentIndex < 0) {
this.currentIndex = this.matchedList.length - 1;
}
var isTransitioningToOriginalText = (
// impossible to transition if they've never hit tab
!this.inPassiveMode && this.currentIndex === 0
);
if (!this.inPassiveMode) {
// set textarea to this new value
this.textArea.value = this._replaceWith(
this.matchedList[this.currentIndex].getFillText(),
this.currentIndex !== 0, // don't suffix the original text!
this.matchedList[this.currentIndex].getSuffix(this.isFirstWord)
);
}
// visual display to the user that we looped - TODO: This should be configurable
if (isTransitioningToOriginalText) {
this.textArea.style["background-color"] = "#faa";
setTimeout(() => { // yay for lexical 'this'!
this.textArea.style["background-color"] = "";
}, 150);
if (!this.opts.allowLooping) {
this.stopTabCompleting();
}
}
else {
this.textArea.style["background-color"] = ""; // cancel blinks TODO: required?
}
}
_replaceWith(newVal, includeSuffix, suffix) {
// The regex to replace the input matches a character of whitespace AND
// the partial word. If we just use string.replace() with the regex it will
// replace the partial word AND the character of whitespace. We want to
// preserve whatever that character is (\n, \t, etc) so find out what it is now.
var boundaryChar;
var res = MATCH_REGEX.exec(this.originalText);
if (res) {
boundaryChar = res[1]; // the first captured group
}
if (boundaryChar === undefined) {
console.warn("Failed to find boundary char on text: '%s'", this.originalText);
boundaryChar = "";
}
suffix = suffix || "";
if (!includeSuffix) {
suffix = "";
}
var replacementText = boundaryChar + newVal + suffix;
return this.originalText.replace(MATCH_REGEX, function() {
return replacementText; // function form to avoid `$` special-casing
});
}
_notifyStateChange() {
if (this.opts.onStateChange) {
this.opts.onStateChange(this.completing);
}
}
_sortEntries() {
// largest comes first
const KIND_ORDER = {
command: 1,
member: 2,
};
this.list.sort((a, b) => {
const kindOrderDifference = KIND_ORDER[b.kind] - KIND_ORDER[a.kind];
if (kindOrderDifference != 0) {
return kindOrderDifference;
}
if (a.kind == 'member') {
let orderA = this.memberTabOrder[a.member.userId];
let orderB = this.memberTabOrder[b.member.userId];
if (orderA === undefined) orderA = -1;
if (orderB === undefined) orderB = -1;
return orderB - orderA;
}
// anything else we have no ordering for
return 0;
});
}
_makeEntries(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
this.list = MemberEntry.fromMemberList(members).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
);
}
_initSorting(room) {
this.memberTabOrder = {};
this.memberOrderSeq = 0;
for (const ev of room.getLiveTimeline().getEvents()) {
this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++;
}
}
}
module.exports = TabComplete;

View file

@ -1,125 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var sdk = require("./index");
class Entry {
constructor(text) {
this.text = text;
}
/**
* @return {string} The text to display in this entry.
*/
getText() {
return this.text;
}
/**
* @return {string} The text to insert into the input box. Most of the time
* this is the same as getText().
*/
getFillText() {
return this.text;
}
/**
* @return {ReactClass} Raw JSX
*/
getImageJsx() {
return null;
}
/**
* @return {?string} The unique key= prop for React dedupe
*/
getKey() {
return null;
}
/**
* @return {?string} The suffix to append to the tab-complete, or null to
* not do this.
*/
getSuffix(isFirstWord) {
return null;
}
/**
* Called when this entry is clicked.
*/
onClick() {
// NOP
}
}
class CommandEntry extends Entry {
constructor(cmd, cmdWithArgs) {
super(cmdWithArgs);
this.kind = 'command';
this.cmd = cmd;
}
getFillText() {
return this.cmd;
}
getKey() {
return this.getFillText();
}
getSuffix(isFirstWord) {
return " "; // force a space after the command.
}
}
CommandEntry.fromCommands = function(commandArray) {
return commandArray.map(function(cmd) {
return new CommandEntry(cmd.getCommand(), cmd.getCommandWithArgs());
});
};
class MemberEntry extends Entry {
constructor(member) {
super((member.name || member.userId).replace(' (IRC)', ''));
this.member = member;
this.kind = 'member';
}
getImageJsx() {
var MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
return (
<MemberAvatar member={this.member} width={24} height={24} />
);
}
getKey() {
return this.member.userId;
}
getSuffix(isFirstWord) {
return isFirstWord ? ": " : " ";
}
}
MemberEntry.fromMemberList = function(members) {
return members.map(function(m) {
return new MemberEntry(m);
});
};
module.exports.Entry = Entry;
module.exports.MemberEntry = MemberEntry;
module.exports.CommandEntry = CommandEntry;

View file

@ -24,7 +24,7 @@ const onAction = function(payload) {
if (payload.action === 'unknown_device_error' && !isDialogOpen) { if (payload.action === 'unknown_device_error' && !isDialogOpen) {
const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog'); const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
isDialogOpen = true; isDialogOpen = true;
Modal.createDialog(UnknownDeviceDialog, { Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, {
devices: payload.err.devices, devices: payload.err.devices,
room: payload.room, room: payload.room,
onFinished: (r) => { onFinished: (r) => {

View file

@ -15,6 +15,8 @@ limitations under the License.
*/ */
var MatrixClientPeg = require('./MatrixClientPeg'); var MatrixClientPeg = require('./MatrixClientPeg');
import UserSettingsStore from './UserSettingsStore';
import shouldHideEvent from './shouldHideEvent';
var sdk = require('./index'); var sdk = require('./index');
module.exports = { module.exports = {
@ -37,13 +39,33 @@ module.exports = {
}, },
doesRoomHaveUnreadMessages: function(room) { doesRoomHaveUnreadMessages: function(room) {
var readUpToId = room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); var myUserId = MatrixClientPeg.get().credentials.userId;
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
// despite the name of the method :((
var readUpToId = room.getEventReadUpTo(myUserId);
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/riot-web/issues/3263
// https://github.com/vector-im/riot-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/riot-web/issues/3363
if (room.timeline.length &&
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId)
{
return false;
}
// this just looks at whatever history we have, which if we've only just started // this just looks at whatever history we have, which if we've only just started
// up probably won't be very much, so if the last couple of events are ones that // up probably won't be very much, so if the last couple of events are ones that
// don't count, we don't know if there are any events that do count between where // don't count, we don't know if there are any events that do count between where
// we have and the read receipt. We could fetch more history to try & find out, // we have and the read receipt. We could fetch more history to try & find out,
// but currently we just guess. // but currently we just guess.
const syncedSettings = UserSettingsStore.getSyncedSettings();
// Loop through messages, starting with the most recent... // Loop through messages, starting with the most recent...
for (var i = room.timeline.length - 1; i >= 0; --i) { for (var i = room.timeline.length - 1; i >= 0; --i) {
var ev = room.timeline[i]; var ev = room.timeline[i];
@ -53,7 +75,7 @@ module.exports = {
// that counts and we can stop looking because the user's read // that counts and we can stop looking because the user's read
// this and everything before. // this and everything before.
return false; return false;
} else if (this.eventTriggersUnreadCount(ev)) { } else if (!shouldHideEvent(ev, syncedSettings) && this.eventTriggersUnreadCount(ev)) {
// We've found a message that counts before we hit // We've found a message that counts before we hit
// the read marker, so this room is definitely unread. // the read marker, so this room is definitely unread.
return true; return true;

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var dis = require("./dispatcher"); import dis from './dispatcher';
var MIN_DISPATCH_INTERVAL_MS = 500; const MIN_DISPATCH_INTERVAL_MS = 500;
var CURRENTLY_ACTIVE_THRESHOLD_MS = 2000; const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000;
/** /**
* This class watches for user activity (moving the mouse or pressing a key) * This class watches for user activity (moving the mouse or pressing a key)
@ -58,16 +58,15 @@ class UserActivity {
/** /**
* Return true if there has been user activity very recently * Return true if there has been user activity very recently
* (ie. within a few seconds) * (ie. within a few seconds)
* @returns {boolean} true if user is currently/very recently active
*/ */
userCurrentlyActive() { userCurrentlyActive() {
return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS; return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS;
} }
_onUserActivity(event) { _onUserActivity(event) {
if (event.screenX && event.type == "mousemove") { if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
event.screenY === this.lastScreenY)
{
// mouse hasn't actually moved // mouse hasn't actually moved
return; return;
} }
@ -79,28 +78,24 @@ class UserActivity {
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) { if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) {
this.lastDispatchAtTs = this.lastActivityAtTs; this.lastDispatchAtTs = this.lastActivityAtTs;
dis.dispatch({ dis.dispatch({
action: 'user_activity' action: 'user_activity',
}); });
if (!this.activityEndTimer) { if (!this.activityEndTimer) {
this.activityEndTimer = setTimeout( this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS);
this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS
);
} }
} }
} }
_onActivityEndTimer() { _onActivityEndTimer() {
var now = new Date().getTime(); const now = new Date().getTime();
var targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS; const targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS;
if (now >= targetTime) { if (now >= targetTime) {
dis.dispatch({ dis.dispatch({
action: 'user_activity_end' action: 'user_activity_end',
}); });
this.activityEndTimer = undefined; this.activityEndTimer = undefined;
} else { } else {
this.activityEndTimer = setTimeout( this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), targetTime - now);
this._onActivityEndTimer.bind(this), targetTime - now
);
} }
} }
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import q from 'q'; import Promise from 'bluebird';
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import Notifier from './Notifier'; import Notifier from './Notifier';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
@ -27,14 +27,17 @@ export default {
LABS_FEATURES: [ LABS_FEATURES: [
{ {
name: "-", name: "-",
id: 'rich_text_editor', id: 'matrix_apps',
default: false, default: false,
// XXX: Always use default, ignore localStorage and remove from labs
override: true,
}, },
], ],
// horrible but it works. The locality makes this somewhat more palatable. // horrible but it works. The locality makes this somewhat more palatable.
doTranslations: function() { doTranslations: function() {
this.LABS_FEATURES[0].name = _t("New Composer & Autocomplete"); this.LABS_FEATURES[0].name = _t("Matrix Apps");
}, },
loadProfileInfo: function() { loadProfileInfo: function() {
@ -48,7 +51,7 @@ export default {
loadThreePids: function() { loadThreePids: function() {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
return q({ return Promise.resolve({
threepids: [], threepids: [],
}); // guests can't poke 3pid endpoint }); // guests can't poke 3pid endpoint
} }
@ -171,22 +174,36 @@ export default {
localStorage.setItem('mx_local_settings', JSON.stringify(settings)); localStorage.setItem('mx_local_settings', JSON.stringify(settings));
}, },
isFeatureEnabled: function(feature: string): boolean { getFeatureById(feature: string) {
// Disable labs for guests.
if (MatrixClientPeg.get().isGuest()) return false;
if (localStorage.getItem(`mx_labs_feature_${feature}`) === null) {
for (let i = 0; i < this.LABS_FEATURES.length; i++) { for (let i = 0; i < this.LABS_FEATURES.length; i++) {
const f = this.LABS_FEATURES[i]; const f = this.LABS_FEATURES[i];
if (f.id === feature) { if (f.id === feature) {
return f.default; return f;
} }
} }
} return null;
return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true';
}, },
setFeatureEnabled: function(feature: string, enabled: boolean) { isFeatureEnabled: function(featureId: string): boolean {
localStorage.setItem(`mx_labs_feature_${feature}`, enabled); // Disable labs for guests.
if (MatrixClientPeg.get().isGuest()) return false;
const feature = this.getFeatureById(featureId);
if (!feature) {
console.warn(`Unknown feature "${featureId}"`);
return false;
}
// Return the default if this feature has an override to be the default value or
// if the feature has never been toggled and is therefore not in localStorage
if (Object.keys(feature).includes('override') ||
localStorage.getItem(`mx_labs_feature_${featureId}`) === null
) {
return feature.default;
}
return localStorage.getItem(`mx_labs_feature_${featureId}`) === 'true';
},
setFeatureEnabled: function(featureId: string, enabled: boolean) {
localStorage.setItem(`mx_labs_feature_${featureId}`, enabled);
}, },
}; };

View file

@ -64,7 +64,7 @@ module.exports = React.createClass({
}); });
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
} }
if (oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') { if (oldNode && oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') {
oldNode.style.visibility = c.props.style.visibility; oldNode.style.visibility = c.props.style.visibility;
} }
self.children[c.key] = old; self.children[c.key] = old;

58
src/WidgetUtils.js Normal file
View file

@ -0,0 +1,58 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
* @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
static canUserModifyWidgets(roomId) {
if (!roomId) {
console.warn('No room ID specified');
return false;
}
const client = MatrixClientPeg.get();
if (!client) {
console.warn('User must be be logged in');
return false;
}
const room = client.getRoom(roomId);
if (!room) {
console.warn(`Room ID ${roomId} is not recognised`);
return false;
}
const me = client.credentials.userId;
if (!me) {
console.warn('Failed to get user ID');
return false;
}
const member = room.getMember(me);
if (!member || member.membership !== "join") {
console.warn(`User ${me} is not in room ${roomId}`);
return false;
}
return room.currentState.maySendStateEvent('im.vector.modular.widgets', me);
}
}

View file

@ -28,23 +28,31 @@ module.exports = React.createClass({
}, },
getInitialState: function() { getInitialState: function() {
return { device: this.refreshDevice() }; return { device: null };
}, },
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false; this._unmounted = false;
var client = MatrixClientPeg.get(); var client = MatrixClientPeg.get();
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
// no need to redownload keys if we already have the device // first try to load the device from our store.
if (this.state.device) { //
return; this.refreshDevice().then((dev) => {
if (dev) {
return dev;
} }
client.downloadKeys([this.props.event.getSender()], true).done(()=>{
// tell the client to try to refresh the device list for this user
return client.downloadKeys([this.props.event.getSender()], true).then(() => {
return this.refreshDevice();
});
}).then((dev) => {
if (this._unmounted) { if (this._unmounted) {
return; return;
} }
this.setState({ device: this.refreshDevice() });
this.setState({ device: dev });
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
}, (err)=>{ }, (err)=>{
console.log("Error downloading devices", err); console.log("Error downloading devices", err);
}); });
@ -59,12 +67,16 @@ module.exports = React.createClass({
}, },
refreshDevice: function() { refreshDevice: function() {
return MatrixClientPeg.get().getEventSenderDeviceInfo(this.props.event); // Promise.resolve to handle transition from static result to promise; can be removed
// in future
return Promise.resolve(MatrixClientPeg.get().getEventSenderDeviceInfo(this.props.event));
}, },
onDeviceVerificationChanged: function(userId, device) { onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.event.getSender()) { if (userId == this.props.event.getSender()) {
this.setState({ device: this.refreshDevice() }); this.refreshDevice().then((dev) => {
this.setState({ device: dev });
});
} }
}, },

View file

@ -19,7 +19,7 @@ import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter'; import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) { constructor(commandRegex?: RegExp) {
if (commandRegex) { if (commandRegex) {
if (!commandRegex.global) { if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set'); throw new Error('commandRegex must have global flag set');

View file

@ -22,7 +22,7 @@ import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider'; import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider'; import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider'; import EmojiProvider from './EmojiProvider';
import Q from 'q'; import Promise from 'bluebird';
export type SelectionRange = { export type SelectionRange = {
start: number, start: number,
@ -34,6 +34,9 @@ export type Completion = {
component: ?Component, component: ?Component,
range: SelectionRange, range: SelectionRange,
command: ?string, command: ?string,
// If provided, apply a LINK entity to the completion with the
// data = { url: href }.
href: ?string,
}; };
const PROVIDERS = [ const PROVIDERS = [
@ -52,21 +55,24 @@ export async function getCompletions(query: string, selection: SelectionRange, f
otherwise, we run into a condition where new completions are displayed otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended to predict whether an action will actually do what is intended
*/
It ends up containing a list of Q promise states, which are objects with const completionsList = await Promise.all(
state (== "fulfilled" || "rejected") and value. */ // Array of inspections of promises that might timeout. Instead of allowing a
const completionsList = await Q.allSettled( // single timeout to reject the Promise.all, reflect each one and once they've all
PROVIDERS.map(provider => { // settled, filter for the fulfilled ones
return Q(provider.getCompletions(query, selection, force)) PROVIDERS.map((provider) => {
.timeout(PROVIDER_COMPLETION_TIMEOUT); return provider
}) .getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT)
.reflect();
}),
); );
return completionsList return completionsList.filter(
.filter(completion => completion.state === "fulfilled") (inspection) => inspection.isFulfilled(),
.map((completionsState, i) => { ).map((completionsState, i) => {
return { return {
completions: completionsState.value, completions: completionsState.value(),
provider: PROVIDERS[i], provider: PROVIDERS[i],
/* the currently matched "command" the completer tried to complete /* the currently matched "command" the completer tried to complete

View file

@ -18,9 +18,10 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Fuse from 'fuse.js'; import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
// TODO merge this with the factory mechanics of SlashCommands?
// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file // Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file
const COMMANDS = [ const COMMANDS = [
{ {
@ -33,6 +34,16 @@ const COMMANDS = [
args: '<user-id> [reason]', args: '<user-id> [reason]',
description: 'Bans user with given id', description: 'Bans user with given id',
}, },
{
command: '/unban',
args: '<user-id>',
description: 'Unbans user with given id',
},
{
command: '/op',
args: '<user-id> [<power-level>]',
description: 'Define the power level of a user',
},
{ {
command: '/deop', command: '/deop',
args: '<user-id>', args: '<user-id>',
@ -48,6 +59,16 @@ const COMMANDS = [
args: '<room-alias>', args: '<room-alias>',
description: 'Joins room with given alias', description: 'Joins room with given alias',
}, },
{
command: '/part',
args: '[<room-alias>]',
description: 'Leave room',
},
{
command: '/topic',
args: '<topic>',
description: 'Sets the room topic',
},
{ {
command: '/kick', command: '/kick',
args: '<user-id> [reason]', args: '<user-id> [reason]',
@ -63,6 +84,17 @@ const COMMANDS = [
args: '<query>', args: '<query>',
description: 'Searches DuckDuckGo for results', description: 'Searches DuckDuckGo for results',
}, },
{
command: '/tint',
args: '<color1> [<color2>]',
description: 'Changes colour scheme of current room',
},
{
command: '/verify',
args: '<user-id> <device-id> <device-signing-key>',
description: 'Verifies a user, device, and pubkey tuple',
},
// Omitting `/markdown` as it only seems to apply to OldComposer
]; ];
const COMMAND_RE = /(^\/\w*)/g; const COMMAND_RE = /(^\/\w*)/g;
@ -72,7 +104,7 @@ let instance = null;
export default class CommandProvider extends AutocompleteProvider { export default class CommandProvider extends AutocompleteProvider {
constructor() { constructor() {
super(COMMAND_RE); super(COMMAND_RE);
this.fuse = new Fuse(COMMANDS, { this.matcher = new FuzzyMatcher(COMMANDS, {
keys: ['command', 'args', 'description'], keys: ['command', 'args', 'description'],
}); });
} }
@ -81,7 +113,7 @@ export default class CommandProvider extends AutocompleteProvider {
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
completions = this.fuse.search(command[0]).map((result) => { completions = this.matcher.match(command[0]).map((result) => {
return { return {
completion: result.command + ' ', completion: result.command + ' ',
component: (<TextualCompletion component: (<TextualCompletion

View file

@ -18,31 +18,117 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import {emojioneList, shortnameToImage, shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
import Fuse from 'fuse.js'; import FuzzyMatcher from './FuzzyMatcher';
import sdk from '../index'; import sdk from '../index';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter'; import type {SelectionRange, Completion} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
const EMOJI_REGEX = /:\w*:?/g; import EmojiData from '../stripped-emoji.json';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const LIMIT = 20;
const CATEGORY_ORDER = [
'people',
'food',
'objects',
'activity',
'nature',
'travel',
'flags',
'regional',
'symbols',
'modifier',
];
// Match for ":wink:" or ascii-style ";-)" provided by emojione
// (^|\s|(emojiUnicode)) to make sure we're either at the start of the string or there's a
// whitespace character or an emoji before the emoji. The reason for unicodeRegexp is
// that we need to support inputting multiple emoji with no space between them.
const EMOJI_REGEX = new RegExp('(?:^|\\s|' + unicodeRegexp + ')(' + asciiRegexp + '|:\\w*:?)$', 'g');
// We also need to match the non-zero-length prefixes to remove them from the final match,
// and update the range so that we don't replace the whitespace or the previous emoji.
const MATCH_PREFIX_REGEX = new RegExp('(\\s|' + unicodeRegexp + ')');
const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort(
(a, b) => {
if (a.category === b.category) {
return a.emoji_order - b.emoji_order;
}
return CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
},
).map((a, index) => {
return {
name: a.name,
shortname: a.shortname,
aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '',
// Include the index so that we can preserve the original order
_orderBy: index,
};
});
let instance = null; let instance = null;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class EmojiProvider extends AutocompleteProvider { export default class EmojiProvider extends AutocompleteProvider {
constructor() { constructor() {
super(EMOJI_REGEX); super(EMOJI_REGEX);
this.fuse = new Fuse(EMOJI_SHORTNAMES, {}); this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: ['aliases_ascii', 'shortname'],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
});
this.nameMatcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: ['name'],
// For removing punctuation
shouldMatchWordsOnly: true,
});
} }
async getCompletions(query: string, selection: SelectionRange) { async getCompletions(query: string, selection: SelectionRange) {
const EmojiText = sdk.getComponent('views.elements.EmojiText'); const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
completions = this.fuse.search(command[0]).map(result => { let matchedString = command[0];
const shortname = EMOJI_SHORTNAMES[result];
// Remove prefix of any length (single whitespace or unicode emoji)
const prefixMatch = MATCH_PREFIX_REGEX.exec(matchedString);
if (prefixMatch) {
matchedString = matchedString.slice(prefixMatch[0].length);
range.start += prefixMatch[0].length;
}
completions = this.matcher.match(matchedString);
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
const sorters = [];
// First, sort by score (Infinity if matchedString not in shortname)
sorters.push((c) => score(matchedString, c.shortname));
// If the matchedString is not empty, sort by length of shortname. Example:
// matchedString = ":bookmark"
// completions = [":bookmark:", ":bookmark_tabs:", ...]
if (matchedString.length > 1) {
sorters.push((c) => c.shortname.length);
}
// Finally, sort by original ordering
sorters.push((c) => c._orderBy);
completions = _sortBy(_uniq(completions), sorters);
completions = completions.map((result) => {
const {shortname} = result;
const unicode = shortnameToUnicode(shortname); const unicode = shortnameToUnicode(shortname);
return { return {
completion: unicode, completion: unicode,
@ -51,7 +137,7 @@ export default class EmojiProvider extends AutocompleteProvider {
), ),
range, range,
}; };
}).slice(0, 8); }).slice(0, LIMIT);
} }
return completions; return completions;
} }

View file

@ -0,0 +1,107 @@
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
//import Levenshtein from 'liblevenshtein';
//import _at from 'lodash/at';
//import _flatMap from 'lodash/flatMap';
//import _sortBy from 'lodash/sortBy';
//import _sortedUniq from 'lodash/sortedUniq';
//import _keys from 'lodash/keys';
//
//class KeyMap {
// keys: Array<String>;
// objectMap: {[String]: Array<Object>};
// priorityMap: {[String]: number}
//}
//
//const DEFAULT_RESULT_COUNT = 10;
//const DEFAULT_DISTANCE = 5;
// FIXME Until Fuzzy matching works better, we use prefix matching.
import PrefixMatcher from './QueryMatcher';
export default PrefixMatcher;
//class FuzzyMatcher { // eslint-disable-line no-unused-vars
// /**
// * @param {object[]} objects the objects to perform a match on
// * @param {string[]} keys an array of keys within each object to match on
// * Keys can refer to object properties by name and as in JavaScript (for nested properties)
// *
// * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the
// * resulting KeyMap.
// *
// * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
// * @return {KeyMap}
// */
// static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
// const keyMap = new KeyMap();
// const map = {};
// const priorities = {};
//
// objects.forEach((object, i) => {
// const keyValues = _at(object, keys);
// console.log(object, keyValues, keys);
// for (const keyValue of keyValues) {
// if (!map.hasOwnProperty(keyValue)) {
// map[keyValue] = [];
// }
// map[keyValue].push(object);
// }
// priorities[object] = i;
// });
//
// keyMap.objectMap = map;
// keyMap.priorityMap = priorities;
// keyMap.keys = _sortBy(_keys(map), [(value) => priorities[value]]);
// return keyMap;
// }
//
// constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
// this.options = options;
// this.keys = options.keys;
// this.setObjects(objects);
// }
//
// setObjects(objects: Array<Object>) {
// this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys);
// console.log(this.keyMap.keys);
// this.matcher = new Levenshtein.Builder()
// .dictionary(this.keyMap.keys, true)
// .algorithm('transposition')
// .sort_candidates(false)
// .case_insensitive_sort(true)
// .include_distance(true)
// .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense
// .build();
// }
//
// match(query: String): Array<Object> {
// const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE);
// // TODO FIXME This is hideous. Clean up when possible.
// const val = _sortedUniq(_sortBy(_flatMap(candidates, (candidate) => {
// return this.keyMap.objectMap[candidate[0]].map((value) => {
// return {
// distance: candidate[1],
// ...value,
// };
// });
// }),
// [(candidate) => candidate.distance, (candidate) => this.keyMap.priorityMap[candidate]]));
// console.log(val);
// return val;
// }
//}

View file

@ -0,0 +1,112 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import _at from 'lodash/at';
import _flatMap from 'lodash/flatMap';
import _sortBy from 'lodash/sortBy';
import _uniq from 'lodash/uniq';
import _keys from 'lodash/keys';
class KeyMap {
keys: Array<String>;
objectMap: {[String]: Array<Object>};
priorityMap = new Map();
}
export default class QueryMatcher {
/**
* @param {object[]} objects the objects to perform a match on
* @param {string[]} keys an array of keys within each object to match on
* Keys can refer to object properties by name and as in JavaScript (for nested properties)
*
* To use, simply presort objects by required criteria, run through this function and create a QueryMatcher with the
* resulting KeyMap.
*
* TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
* @return {KeyMap}
*/
static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
const keyMap = new KeyMap();
const map = {};
objects.forEach((object, i) => {
const keyValues = _at(object, keys);
for (const keyValue of keyValues) {
if (!map.hasOwnProperty(keyValue)) {
map[keyValue] = [];
}
map[keyValue].push(object);
}
keyMap.priorityMap.set(object, i);
});
keyMap.objectMap = map;
keyMap.keys = _keys(map);
return keyMap;
}
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
this.options = options;
this.keys = options.keys;
this.setObjects(objects);
// By default, we remove any non-alphanumeric characters ([^A-Za-z0-9_]) from the
// query and the value being queried before matching
if (this.options.shouldMatchWordsOnly === undefined) {
this.options.shouldMatchWordsOnly = true;
}
// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this.options.shouldMatchPrefix === undefined) {
this.options.shouldMatchPrefix = false;
}
}
setObjects(objects: Array<Object>) {
this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys);
}
match(query: String): Array<Object> {
query = query.toLowerCase();
if (this.options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
}
if (query.length === 0) {
return [];
}
const results = [];
this.keyMap.keys.forEach((key) => {
let resultKey = key.toLowerCase();
if (this.options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this.options.shouldMatchPrefix || index === 0)) {
results.push({key, index});
}
});
return _uniq(_flatMap(_sortBy(results, (candidate) => {
return candidate.index;
}).map((candidate) => {
// return an array of objects (those given to setObjects) that have the given
// key as a property.
return this.keyMap.objectMap[candidate.key];
})));
}
}

View file

@ -19,50 +19,75 @@ import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js'; import FuzzyMatcher from './FuzzyMatcher';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms'; import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index'; import sdk from '../index';
import _sortBy from 'lodash/sortBy';
const ROOM_REGEX = /(?=#)(\S*)/g; const ROOM_REGEX = /(?=#)(\S*)/g;
let instance = null; let instance = null;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class RoomProvider extends AutocompleteProvider { export default class RoomProvider extends AutocompleteProvider {
constructor() { constructor() {
super(ROOM_REGEX, { super(ROOM_REGEX);
keys: ['displayName', 'userId'], this.matcher = new FuzzyMatcher([], {
}); keys: ['displayedAlias', 'name'],
this.fuse = new Fuse([], {
keys: ['name', 'roomId', 'aliases'],
}); });
} }
async getCompletions(query: string, selection: {start: number, end: number}, force = false) { async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get(); // Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const client = MatrixClientPeg.get();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force); const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) { if (command) {
// the only reason we need to do this is because Fuse only matches on properties // the only reason we need to do this is because Fuse only matches on properties
this.fuse.set(client.getRooms().filter(room => !!room).map(room => { this.matcher.setObjects(client.getRooms().filter(
(room) => !!room && !!getDisplayAliasForRoom(room),
).map((room) => {
return { return {
room: room, room: room,
name: room.name, name: room.name,
aliases: room.getAliases(), displayedAlias: getDisplayAliasForRoom(room),
}; };
})); }));
completions = this.fuse.search(command[0]).map(room => { const matchedString = command[0];
let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
]).map((room) => {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return { return {
completion: displayAlias, completion: displayAlias,
suffix: ' ',
href: 'https://matrix.to/#/' + displayAlias,
component: ( component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} /> <PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
), ),
range, range,
}; };
}).filter(completion => !!completion.completion && completion.completion.length > 0).slice(0, 4); })
.filter((completion) => !!completion.completion && completion.completion.length > 0)
.slice(0, 4);
} }
return completions; return completions;
} }
@ -80,12 +105,8 @@ export default class RoomProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{completions} {completions}
</div>; </div>;
} }
shouldForceComplete(): boolean {
return true;
}
} }

View file

@ -1,3 +1,4 @@
//@flow
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
@ -18,42 +19,52 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Fuse from 'fuse.js';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import sdk from '../index'; import sdk from '../index';
import FuzzyMatcher from './FuzzyMatcher';
import _pull from 'lodash/pull';
import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk';
const USER_REGEX = /@\S*/g; const USER_REGEX = /@\S*/g;
let instance = null; let instance = null;
export default class UserProvider extends AutocompleteProvider { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = [];
constructor() { constructor() {
super(USER_REGEX, { super(USER_REGEX, {
keys: ['name', 'userId'], keys: ['name'],
}); });
this.users = []; this.matcher = new FuzzyMatcher([], {
this.fuse = new Fuse([], { keys: ['name'],
keys: ['name', 'userId'], shouldMatchPrefix: true,
}); });
} }
async getCompletions(query: string, selection: {start: number, end: number}, force = false) { async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) {
return [];
}
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection, force); let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) { if (command) {
this.fuse.set(this.users); completions = this.matcher.match(command[0]).map((user) => {
completions = this.fuse.search(command[0]).map(user => { const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let completion = displayName;
if (range.start === 0) {
completion += ': ';
} else {
completion += ' ';
}
return { return {
completion, // Length of completion should equal length of text in decorator. draft-js
// relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''),
suffix: range.start === 0 ? ': ' : ' ',
href: 'https://matrix.to/#/' + user.userId,
component: ( component: (
<PillCompletion <PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24}/>} initialComponent={<MemberAvatar member={user} width={24} height={24}/>}
@ -62,7 +73,7 @@ export default class UserProvider extends AutocompleteProvider {
), ),
range, range,
}; };
}).slice(0, 4); });
} }
return completions; return completions;
} }
@ -71,8 +82,35 @@ export default class UserProvider extends AutocompleteProvider {
return '👥 ' + _t('Users'); return '👥 ' + _t('Users');
} }
setUserList(users) { setUserListFromRoom(room: Room) {
this.users = users; const events = room.getLiveTimeline().getEvents();
const lastSpoken = {};
for(const event of events) {
lastSpoken[event.getSender()] = event.getTs();
}
const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = room.getJoinedMembers().filter((member) => {
if (member.userId !== currentUserId) return true;
});
this.users = _sortBy(this.users, (member) =>
1E20 - lastSpoken[member.userId] || 1E20,
);
this.matcher.setObjects(this.users);
}
onUserSpoke(user: RoomMember) {
if(user.userId === MatrixClientPeg.get().credentials.userId) return;
// Move the user that spoke to the front of the array
this.users.splice(
this.users.findIndex((user2) => user2.userId === user.userId), 1);
this.users = [user, ...this.users];
this.matcher.setObjects(this.users);
} }
static getInstance(): UserProvider { static getInstance(): UserProvider {
@ -83,7 +121,7 @@ export default class UserProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{completions} {completions}
</div>; </div>;
} }

View file

@ -0,0 +1,531 @@
/*
Copyright 2017 Vector Creations Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index';
import dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton';
import Modal from '../../Modal';
import classnames from 'classnames';
const RoomSummaryType = PropTypes.shape({
room_id: PropTypes.string.isRequired,
profile: PropTypes.shape({
name: PropTypes.string,
avatar_url: PropTypes.string,
canonical_alias: PropTypes.string,
}).isRequired,
});
const UserSummaryType = PropTypes.shape({
summaryInfo: PropTypes.shape({
user_id: PropTypes.string.isRequired,
}).isRequired,
});
const CategoryRoomList = React.createClass({
displayName: 'CategoryRoomList',
props: {
rooms: PropTypes.arrayOf(RoomSummaryType).isRequired,
category: PropTypes.shape({
profile: PropTypes.shape({
name: PropTypes.string,
}).isRequired,
}),
},
render: function() {
const roomNodes = this.props.rooms.map((r) => {
return <FeaturedRoom key={r.room_id} summaryInfo={r} />;
});
let catHeader = null;
if (this.props.category && this.props.category.profile) {
catHeader = <div className="mx_GroupView_featuredThings_category">{this.props.category.profile.name}</div>;
}
return <div>
{catHeader}
{roomNodes}
</div>;
},
});
const FeaturedRoom = React.createClass({
displayName: 'FeaturedRoom',
props: {
summaryInfo: RoomSummaryType.isRequired,
},
onClick: function(e) {
e.preventDefault();
e.stopPropagation();
dis.dispatch({
action: 'view_room',
room_alias: this.props.summaryInfo.profile.canonical_alias,
room_id: this.props.summaryInfo.room_id,
});
},
render: function() {
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
const oobData = {
roomId: this.props.summaryInfo.room_id,
avatarUrl: this.props.summaryInfo.profile.avatar_url,
name: this.props.summaryInfo.profile.name,
};
let permalink = null;
if (this.props.summaryInfo.profile && this.props.summaryInfo.profile.canonical_alias) {
permalink = 'https://matrix.to/#/' + this.props.summaryInfo.profile.canonical_alias;
}
let roomNameNode = null;
if (permalink) {
roomNameNode = <a href={permalink} onClick={this.onClick} >{this.props.summaryInfo.profile.name}</a>;
} else {
roomNameNode = <span>{this.props.summaryInfo.profile.name}</span>;
}
return <AccessibleButton className="mx_GroupView_featuredThing" onClick={this.onClick}>
<RoomAvatar oobData={oobData} width={64} height={64} />
<div className="mx_GroupView_featuredThing_name">{roomNameNode}</div>
</AccessibleButton>;
},
});
const RoleUserList = React.createClass({
displayName: 'RoleUserList',
props: {
users: PropTypes.arrayOf(UserSummaryType).isRequired,
role: PropTypes.shape({
profile: PropTypes.shape({
name: PropTypes.string,
}).isRequired,
}),
},
render: function() {
const userNodes = this.props.users.map((u) => {
return <FeaturedUser key={u.user_id} summaryInfo={u} />;
});
let roleHeader = null;
if (this.props.role && this.props.role.profile) {
roleHeader = <div className="mx_GroupView_featuredThings_category">{this.props.role.profile.name}</div>;
}
return <div>
{roleHeader}
{userNodes}
</div>;
},
});
const FeaturedUser = React.createClass({
displayName: 'FeaturedUser',
props: {
summaryInfo: UserSummaryType.isRequired,
},
onClick: function(e) {
e.preventDefault();
e.stopPropagation();
dis.dispatch({
action: 'view_start_chat_or_reuse',
user_id: this.props.summaryInfo.user_id,
go_home_on_cancel: false,
});
},
render: function() {
// Add avatar once we get profile info inline in the summary response
//const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const permalink = 'https://matrix.to/#/' + this.props.summaryInfo.user_id;
const userNameNode = <a href={permalink} onClick={this.onClick} >{this.props.summaryInfo.user_id}</a>;
return <AccessibleButton className="mx_GroupView_featuredThing" onClick={this.onClick}>
<div className="mx_GroupView_featuredThing_name">{userNameNode}</div>
</AccessibleButton>;
},
});
export default React.createClass({
displayName: 'GroupView',
propTypes: {
groupId: PropTypes.string.isRequired,
},
getInitialState: function() {
return {
summary: null,
error: null,
editing: false,
saving: false,
uploadingAvatar: false,
};
},
componentWillMount: function() {
this._changeAvatarComponent = null;
this._loadGroupFromServer(this.props.groupId);
},
componentWillReceiveProps: function(newProps) {
if (this.props.groupId != newProps.groupId) {
this.setState({
summary: null,
error: null,
}, () => {
this._loadGroupFromServer(newProps.groupId);
});
}
},
_loadGroupFromServer: function(groupId) {
MatrixClientPeg.get().getGroupSummary(groupId).done((res) => {
this.setState({
summary: res,
error: null,
});
}, (err) => {
this.setState({
summary: null,
error: err,
});
});
},
_onEditClick: function() {
this.setState({
editing: true,
profileForm: Object.assign({}, this.state.summary.profile),
});
},
_onCancelClick: function() {
this.setState({
editing: false,
profileForm: null,
});
},
_onNameChange: function(e) {
const newProfileForm = Object.assign(this.state.profileForm, { name: e.target.value });
this.setState({
profileForm: newProfileForm,
});
},
_onShortDescChange: function(e) {
const newProfileForm = Object.assign(this.state.profileForm, { short_description: e.target.value });
this.setState({
profileForm: newProfileForm,
});
},
_onLongDescChange: function(e) {
const newProfileForm = Object.assign(this.state.profileForm, { long_description: e.target.value });
this.setState({
profileForm: newProfileForm,
});
},
_onAvatarSelected: function(ev) {
const file = ev.target.files[0];
if (!file) return;
this.setState({uploadingAvatar: true});
MatrixClientPeg.get().uploadContent(file).then((url) => {
const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url });
this.setState({
uploadingAvatar: false,
profileForm: newProfileForm,
});
}).catch((e) => {
this.setState({uploadingAvatar: false});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to upload avatar image", e);
Modal.createTrackedDialog('Failed to upload image', '', ErrorDialog, {
title: _t('Error'),
description: _t('Failed to upload image'),
});
}).done();
},
_onSaveClick: function() {
this.setState({saving: true});
MatrixClientPeg.get().setGroupProfile(this.props.groupId, this.state.profileForm).then((result) => {
this.setState({
saving: false,
editing: false,
summary: null,
});
this._loadGroupFromServer(this.props.groupId);
}).catch((e) => {
this.setState({
saving: false,
});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to save group profile", e);
Modal.createTrackedDialog('Failed to update group', '', ErrorDialog, {
title: _t('Error'),
description: _t('Failed to update group'),
});
}).done();
},
_getFeaturedRoomsNode() {
const summary = this.state.summary;
if (summary.rooms_section.rooms.length == 0) return null;
const defaultCategoryRooms = [];
const categoryRooms = {};
summary.rooms_section.rooms.forEach((r) => {
if (r.category_id === null) {
defaultCategoryRooms.push(r);
} else {
let list = categoryRooms[r.category_id];
if (list === undefined) {
list = [];
categoryRooms[r.category_id] = list;
}
list.push(r);
}
});
let defaultCategoryNode = null;
if (defaultCategoryRooms.length > 0) {
defaultCategoryNode = <CategoryRoomList rooms={defaultCategoryRooms} />;
}
const categoryRoomNodes = Object.keys(categoryRooms).map((catId) => {
const cat = summary.rooms_section.categories[catId];
return <CategoryRoomList key={catId} rooms={categoryRooms[catId]} category={cat} />;
});
return <div className="mx_GroupView_featuredThings">
<div className="mx_GroupView_featuredThings_header">
{_t('Featured Rooms:')}
</div>
{defaultCategoryNode}
{categoryRoomNodes}
</div>;
},
_getFeaturedUsersNode() {
const summary = this.state.summary;
if (summary.users_section.users.length == 0) return null;
const noRoleUsers = [];
const roleUsers = {};
summary.users_section.users.forEach((u) => {
if (u.role_id === null) {
noRoleUsers.push(u);
} else {
let list = roleUsers[u.role_id];
if (list === undefined) {
list = [];
roleUsers[u.role_id] = list;
}
list.push(u);
}
});
let noRoleNode = null;
if (noRoleUsers.length > 0) {
noRoleNode = <RoleUserList users={noRoleUsers} />;
}
const roleUserNodes = Object.keys(roleUsers).map((roleId) => {
const role = summary.users_section.roles[roleId];
return <RoleUserList key={roleId} users={roleUsers[roleId]} role={role} />;
});
return <div className="mx_GroupView_featuredThings">
<div className="mx_GroupView_featuredThings_header">
{_t('Featured Users:')}
</div>
{noRoleNode}
{roleUserNodes}
</div>;
},
render: function() {
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Loader = sdk.getComponent("elements.Spinner");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
if (this.state.summary === null && this.state.error === null || this.state.saving) {
return <Loader />;
} else if (this.state.summary) {
const summary = this.state.summary;
let avatarNode;
let nameNode;
let shortDescNode;
let rightButtons;
let roomBody;
const headerClasses = {
mx_GroupView_header: true,
};
if (this.state.editing) {
let avatarImage;
if (this.state.uploadingAvatar) {
avatarImage = <Loader />;
} else {
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
avatarImage = <GroupAvatar groupId={this.props.groupId}
groupAvatarUrl={this.state.profileForm.avatar_url}
width={48} height={48} resizeMethod='crop'
/>;
}
avatarNode = (
<div className="mx_GroupView_avatarPicker">
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
{avatarImage}
</label>
<div className="mx_GroupView_avatarPicker_edit">
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
<img src="img/camera.svg"
alt={ _t("Upload avatar") } title={ _t("Upload avatar") }
width="17" height="15" />
</label>
<input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected}/>
</div>
</div>
);
nameNode = <input type="text"
value={this.state.profileForm.name}
onChange={this._onNameChange}
placeholder={_t('Group Name')}
tabIndex="1"
/>;
shortDescNode = <input type="text"
value={this.state.profileForm.short_description}
onChange={this._onShortDescChange}
placeholder={_t('Description')}
tabIndex="2"
/>;
rightButtons = <span>
<AccessibleButton className="mx_GroupView_saveButton mx_RoomHeader_textButton" onClick={this._onSaveClick}>
{_t('Save')}
</AccessibleButton>
<AccessibleButton className='mx_GroupView_cancelButton' onClick={this._onCancelClick}>
<img src="img/cancel.svg" className='mx_filterFlipColor'
width="18" height="18" alt={_t("Cancel")}/>
</AccessibleButton>
</span>;
roomBody = <div>
<textarea className="mx_GroupView_editLongDesc" value={this.state.profileForm.long_description}
onChange={this._onLongDescChange}
tabIndex="3"
/>
</div>;
} else {
const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null;
avatarNode = <GroupAvatar
groupId={this.props.groupId}
groupAvatarUrl={groupAvatarUrl}
width={48} height={48}
/>;
if (summary.profile && summary.profile.name) {
nameNode = <div>
<span>{summary.profile.name}</span>
<span className="mx_GroupView_header_groupid">
({this.props.groupId})
</span>
</div>;
} else {
nameNode = <span>{this.props.groupId}</span>;
}
shortDescNode = <span>{summary.profile.short_description}</span>;
let description = null;
if (summary.profile && summary.profile.long_description) {
description = sanitizedHtmlNode(summary.profile.long_description);
}
roomBody = <div>
<div className="mx_GroupView_groupDesc">{description}</div>
{this._getFeaturedRoomsNode()}
{this._getFeaturedUsersNode()}
</div>;
// disabled until editing works
rightButtons = <AccessibleButton className="mx_GroupHeader_button"
onClick={this._onEditClick} title={_t("Edit Group")}
>
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/>
</AccessibleButton>;
headerClasses.mx_GroupView_header_view = true;
}
return (
<div className="mx_GroupView">
<div className={classnames(headerClasses)}>
<div className="mx_GroupView_header_leftCol">
<div className="mx_GroupView_header_avatar">
{avatarNode}
</div>
<div className="mx_GroupView_header_info">
<div className="mx_GroupView_header_name">
{nameNode}
</div>
<div className="mx_GroupView_header_shortDesc">
{shortDescNode}
</div>
</div>
</div>
<div className="mx_GroupView_header_rightCol">
{rightButtons}
</div>
</div>
{roomBody}
</div>
);
} else if (this.state.error) {
if (this.state.error.httpStatus === 404) {
return (
<div className="mx_GroupView_error">
Group {this.props.groupId} not found
</div>
);
} else {
let extraText;
if (this.state.error.errcode === 'M_UNRECOGNIZED') {
extraText = <div>{_t('This Home server does not support groups')}</div>;
}
return (
<div className="mx_GroupView_error">
Failed to load {this.props.groupId}
{extraText}
</div>
);
}
} else {
console.error("Invalid state for GroupView");
return <div />;
}
},
});

View file

@ -156,13 +156,20 @@ export default React.createClass({
} }
*/ */
var handled = false; let handled = false;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
let ctrlCmdOnly;
if (isMac) {
ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
switch (ev.keyCode) { switch (ev.keyCode) {
case KeyCode.UP: case KeyCode.UP:
case KeyCode.DOWN: case KeyCode.DOWN:
if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
var action = ev.keyCode == KeyCode.UP ? let action = ev.keyCode == KeyCode.UP ?
'view_prev_room' : 'view_next_room'; 'view_prev_room' : 'view_next_room';
dis.dispatch({action: action}); dis.dispatch({action: action});
handled = true; handled = true;
@ -184,6 +191,14 @@ export default React.createClass({
handled = true; handled = true;
} }
break; break;
case KeyCode.KEY_K:
if (ctrlCmdOnly) {
dis.dispatch({
action: 'focus_room_filter',
});
handled = true;
}
break;
} }
if (handled) { if (handled) {
@ -210,6 +225,8 @@ export default React.createClass({
const CreateRoom = sdk.getComponent('structures.CreateRoom'); const CreateRoom = sdk.getComponent('structures.CreateRoom');
const RoomDirectory = sdk.getComponent('structures.RoomDirectory'); const RoomDirectory = sdk.getComponent('structures.RoomDirectory');
const HomePage = sdk.getComponent('structures.HomePage'); const HomePage = sdk.getComponent('structures.HomePage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar'); const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar'); const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
@ -247,6 +264,10 @@ export default React.createClass({
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.rightOpacity}/>; if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.rightOpacity}/>;
break; break;
case PageTypes.MyGroups:
page_element = <MyGroups />;
break;
case PageTypes.CreateRoom: case PageTypes.CreateRoom:
page_element = <CreateRoom page_element = <CreateRoom
onRoomCreated={this.props.onRoomCreated} onRoomCreated={this.props.onRoomCreated}
@ -263,6 +284,7 @@ export default React.createClass({
break; break;
case PageTypes.HomePage: case PageTypes.HomePage:
{
// If team server config is present, pass the teamServerURL. props.teamToken // If team server config is present, pass the teamServerURL. props.teamToken
// must also be set for the team page to be displayed, otherwise the // must also be set for the team page to be displayed, otherwise the
// welcomePageUrl is used (which might be undefined). // welcomePageUrl is used (which might be undefined).
@ -274,11 +296,18 @@ export default React.createClass({
teamToken={this.props.teamToken} teamToken={this.props.teamToken}
homePageUrl={this.props.config.welcomePageUrl} homePageUrl={this.props.config.welcomePageUrl}
/>; />;
}
break; break;
case PageTypes.UserView: case PageTypes.UserView:
page_element = null; // deliberately null for now page_element = null; // deliberately null for now
right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.rightOpacity} />; right_panel = <RightPanel opacity={this.props.rightOpacity} />;
break;
case PageTypes.GroupView:
page_element = <GroupView
groupId={this.props.currentGroupId}
/>;
//right_panel = <RightPanel opacity={this.props.rightOpacity} />;
break; break;
} }

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import q from 'q'; import Promise from 'bluebird';
import React from 'react'; import React from 'react';
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
@ -131,9 +131,6 @@ module.exports = React.createClass({
// the master view we are showing. // the master view we are showing.
view: VIEWS.LOADING, view: VIEWS.LOADING,
// a thing to call showScreen with once login completes.
screenAfterLogin: this.props.initialScreenAfterLogin,
// What the LoggedInView would be showing if visible // What the LoggedInView would be showing if visible
page_type: null, page_type: null,
@ -147,8 +144,6 @@ module.exports = React.createClass({
collapse_lhs: false, collapse_lhs: false,
collapse_rhs: false, collapse_rhs: false,
ready: false,
width: 10000,
leftOpacity: 1.0, leftOpacity: 1.0,
middleOpacity: 1.0, middleOpacity: 1.0,
rightOpacity: 1.0, rightOpacity: 1.0,
@ -224,7 +219,7 @@ module.exports = React.createClass({
// Used by _viewRoom before getting state from sync // Used by _viewRoom before getting state from sync
this.firstSyncComplete = false; this.firstSyncComplete = false;
this.firstSyncPromise = q.defer(); this.firstSyncPromise = Promise.defer();
if (this.props.config.sync_timeline_limit) { if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
@ -274,6 +269,15 @@ module.exports = React.createClass({
register_hs_url: paramHs, register_hs_url: paramHs,
}); });
} }
// a thing to call showScreen with once login completes. this is kept
// outside this.state because updating it should never trigger a
// rerender.
this._screenAfterLogin = this.props.initialScreenAfterLogin;
this._windowWidth = 10000;
this.handleResize();
window.addEventListener('resize', this.handleResize);
}, },
componentDidMount: function() { componentDidMount: function() {
@ -290,9 +294,9 @@ module.exports = React.createClass({
if (this.onUserClick) { if (this.onUserClick) {
linkifyMatrix.onUserClick = this.onUserClick; linkifyMatrix.onUserClick = this.onUserClick;
} }
if (this.onGroupClick) {
window.addEventListener('resize', this.handleResize); linkifyMatrix.onGroupClick = this.onGroupClick;
this.handleResize(); }
const teamServerConfig = this.props.config.teamServerConfig || {}; const teamServerConfig = this.props.config.teamServerConfig || {};
Lifecycle.initRtsClient(teamServerConfig.teamServerURL); Lifecycle.initRtsClient(teamServerConfig.teamServerURL);
@ -309,20 +313,19 @@ module.exports = React.createClass({
// if the user has followed a login or register link, don't reanimate // if the user has followed a login or register link, don't reanimate
// the old creds, but rather go straight to the relevant page // the old creds, but rather go straight to the relevant page
const firstScreen = this.state.screenAfterLogin ? const firstScreen = this._screenAfterLogin ?
this.state.screenAfterLogin.screen : null; this._screenAfterLogin.screen : null;
if (firstScreen === 'login' || if (firstScreen === 'login' ||
firstScreen === 'register' || firstScreen === 'register' ||
firstScreen === 'forgot_password') { firstScreen === 'forgot_password') {
this.setState({loading: false});
this._showScreenAfterLogin(); this._showScreenAfterLogin();
return; return;
} }
// the extra q() ensures that synchronous exceptions hit the same codepath as // the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones. // asynchronous ones.
return q().then(() => { return Promise.resolve().then(() => {
return Lifecycle.loadSession({ return Lifecycle.loadSession({
fragmentQueryParams: this.props.startingFragmentQueryParams, fragmentQueryParams: this.props.startingFragmentQueryParams,
enableGuest: this.props.enableGuest, enableGuest: this.props.enableGuest,
@ -407,7 +410,7 @@ module.exports = React.createClass({
this._leaveRoom(payload.room_id); this._leaveRoom(payload.room_id);
break; break;
case 'reject_invite': case 'reject_invite':
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
title: _t('Reject invitation'), title: _t('Reject invitation'),
description: _t('Are you sure you want to reject the invitation?'), description: _t('Are you sure you want to reject the invitation?'),
onFinished: (confirm) => { onFinished: (confirm) => {
@ -423,7 +426,7 @@ module.exports = React.createClass({
} }
}, (err) => { }, (err) => {
modal.close(); modal.close();
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to reject invitation', '', ErrorDialog, {
title: _t('Failed to reject invitation'), title: _t('Failed to reject invitation'),
description: err.toString(), description: err.toString(),
}); });
@ -483,6 +486,18 @@ module.exports = React.createClass({
this._setPage(PageTypes.RoomDirectory); this._setPage(PageTypes.RoomDirectory);
this.notifyNewScreen('directory'); this.notifyNewScreen('directory');
break; break;
case 'view_my_groups':
this._setPage(PageTypes.MyGroups);
this.notifyNewScreen('groups');
break;
case 'view_group':
{
const groupId = payload.group_id;
this.setState({currentGroupId: groupId});
this._setPage(PageTypes.GroupView);
this.notifyNewScreen('group/' + groupId);
}
break;
case 'view_home_page': case 'view_home_page':
this._setPage(PageTypes.HomePage); this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home'); this.notifyNewScreen('home');
@ -491,7 +506,7 @@ module.exports = React.createClass({
this._setMxId(payload); this._setMxId(payload);
break; break;
case 'view_start_chat_or_reuse': case 'view_start_chat_or_reuse':
this._chatCreateOrReuse(payload.user_id); this._chatCreateOrReuse(payload.user_id, payload.go_home_on_cancel);
break; break;
case 'view_create_chat': case 'view_create_chat':
this._createChat(); this._createChat();
@ -548,7 +563,12 @@ module.exports = React.createClass({
this._onLoggedOut(); this._onLoggedOut();
break; break;
case 'will_start_client': case 'will_start_client':
this.setState({ready: false}, () => {
// if the client is about to start, we are, by definition, not ready.
// Set ready to false now, then it'll be set to true when the sync
// listener we set below fires.
this._onWillStartClient(); this._onWillStartClient();
});
break; break;
case 'new_version': case 'new_version':
this.onVersion( this.onVersion(
@ -674,7 +694,7 @@ module.exports = React.createClass({
// Wait for the first sync to complete so that if a room does have an alias, // Wait for the first sync to complete so that if a room does have an alias,
// it would have been retrieved. // it would have been retrieved.
let waitFor = q(null); let waitFor = Promise.resolve(null);
if (!this.firstSyncComplete) { if (!this.firstSyncComplete) {
if (!this.firstSyncPromise) { if (!this.firstSyncPromise) {
console.warn('Cannot view a room before first sync. room_id:', roomInfo.room_id); console.warn('Cannot view a room before first sync. room_id:', roomInfo.room_id);
@ -708,7 +728,7 @@ module.exports = React.createClass({
_setMxId: function(payload) { _setMxId: function(payload) {
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
const close = Modal.createDialog(SetMxIdDialog, { const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(), homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(),
onFinished: (submitted, credentials) => { onFinished: (submitted, credentials) => {
if (!submitted) { if (!submitted) {
@ -747,7 +767,7 @@ module.exports = React.createClass({
return; return;
} }
const ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); const ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog");
Modal.createDialog(ChatInviteDialog, { Modal.createTrackedDialog('Start a chat', '', ChatInviteDialog, {
title: _t('Start a chat'), title: _t('Start a chat'),
description: _t("Who would you like to communicate with?"), description: _t("Who would you like to communicate with?"),
placeholder: _t("Email, name or matrix ID"), placeholder: _t("Email, name or matrix ID"),
@ -767,7 +787,7 @@ module.exports = React.createClass({
return; return;
} }
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
Modal.createDialog(TextInputDialog, { Modal.createTrackedDialog('Create Room', '', TextInputDialog, {
title: _t('Create Room'), title: _t('Create Room'),
description: _t('Room name (optional)'), description: _t('Room name (optional)'),
button: _t('Create Room'), button: _t('Create Room'),
@ -781,7 +801,9 @@ module.exports = React.createClass({
}); });
}, },
_chatCreateOrReuse: function(userId) { _chatCreateOrReuse: function(userId, goHomeOnCancel) {
if (goHomeOnCancel === undefined) goHomeOnCancel = true;
const ChatCreateOrReuseDialog = sdk.getComponent( const ChatCreateOrReuseDialog = sdk.getComponent(
'views.dialogs.ChatCreateOrReuseDialog', 'views.dialogs.ChatCreateOrReuseDialog',
); );
@ -809,10 +831,10 @@ module.exports = React.createClass({
return; return;
} }
const close = Modal.createDialog(ChatCreateOrReuseDialog, { const close = Modal.createTrackedDialog('Chat create or reuse', '', ChatCreateOrReuseDialog, {
userId: userId, userId: userId,
onFinished: (success) => { onFinished: (success) => {
if (!success) { if (!success && goHomeOnCancel) {
// Dialog cancelled, default to home // Dialog cancelled, default to home
dis.dispatch({ action: 'view_home_page' }); dis.dispatch({ action: 'view_home_page' });
} }
@ -837,7 +859,7 @@ module.exports = React.createClass({
_invite: function(roomId) { _invite: function(roomId) {
const ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); const ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog");
Modal.createDialog(ChatInviteDialog, { Modal.createTrackedDialog('Chat Invite', '', ChatInviteDialog, {
title: _t('Invite new room members'), title: _t('Invite new room members'),
description: _t('Who would you like to add to this room?'), description: _t('Who would you like to add to this room?'),
button: _t('Send Invites'), button: _t('Send Invites'),
@ -851,7 +873,7 @@ module.exports = React.createClass({
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Leave room', '', QuestionDialog, {
title: _t("Leave room"), title: _t("Leave room"),
description: ( description: (
<span> <span>
@ -874,7 +896,7 @@ module.exports = React.createClass({
}, (err) => { }, (err) => {
modal.close(); modal.close();
console.error("Failed to leave room " + roomId + " " + err); console.error("Failed to leave room " + roomId + " " + err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, {
title: _t("Failed to leave room"), title: _t("Failed to leave room"),
description: (err && err.message ? err.message : description: (err && err.message ? err.message :
_t("Server may be unavailable, overloaded, or you hit a bug.")), _t("Server may be unavailable, overloaded, or you hit a bug.")),
@ -970,14 +992,12 @@ module.exports = React.createClass({
_showScreenAfterLogin: function() { _showScreenAfterLogin: function() {
// If screenAfterLogin is set, use that, then null it so that a second login will // If screenAfterLogin is set, use that, then null it so that a second login will
// result in view_home_page, _user_settings or _room_directory // result in view_home_page, _user_settings or _room_directory
if (this.state.screenAfterLogin && this.state.screenAfterLogin.screen) { if (this._screenAfterLogin && this._screenAfterLogin.screen) {
this.showScreen( this.showScreen(
this.state.screenAfterLogin.screen, this._screenAfterLogin.screen,
this.state.screenAfterLogin.params, this._screenAfterLogin.params,
); );
// XXX: is this necessary? `showScreen` should do it for us. this._screenAfterLogin = null;
this.notifyNewScreen(this.state.screenAfterLogin.screen);
this.setState({screenAfterLogin: null});
} else if (localStorage && localStorage.getItem('mx_last_room_id')) { } else if (localStorage && localStorage.getItem('mx_last_room_id')) {
// Before defaulting to directory, show the last viewed room // Before defaulting to directory, show the last viewed room
dis.dispatch({ dis.dispatch({
@ -1012,16 +1032,12 @@ module.exports = React.createClass({
*/ */
_onWillStartClient() { _onWillStartClient() {
const self = this; const self = this;
// if the client is about to start, we are, by definition, not ready.
// Set ready to false now, then it'll be set to true when the sync
// listener we set below fires.
this.setState({ready: false});
// reset the 'have completed first sync' flag, // reset the 'have completed first sync' flag,
// since we're about to start the client and therefore about // since we're about to start the client and therefore about
// to do the first sync // to do the first sync
this.firstSyncComplete = false; this.firstSyncComplete = false;
this.firstSyncPromise = q.defer(); this.firstSyncPromise = Promise.defer();
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
// Allow the JS SDK to reap timeline events. This reduces the amount of // Allow the JS SDK to reap timeline events. This reduces the amount of
@ -1074,7 +1090,7 @@ module.exports = React.createClass({
}); });
cli.on('Session.logged_out', function(call) { cli.on('Session.logged_out', function(call) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
title: _t('Signed Out'), title: _t('Signed Out'),
description: _t('For security, this session has been signed out. Please sign in again.'), description: _t('For security, this session has been signed out. Please sign in again.'),
}); });
@ -1139,6 +1155,10 @@ module.exports = React.createClass({
dis.dispatch({ dis.dispatch({
action: 'view_room_directory', action: 'view_room_directory',
}); });
} else if (screen == 'groups') {
dis.dispatch({
action: 'view_my_groups',
});
} else if (screen == 'post_registration') { } else if (screen == 'post_registration') {
dis.dispatch({ dis.dispatch({
action: 'start_post_registration', action: 'start_post_registration',
@ -1183,21 +1203,33 @@ module.exports = React.createClass({
} else if (screen.indexOf('user/') == 0) { } else if (screen.indexOf('user/') == 0) {
const userId = screen.substring(5); const userId = screen.substring(5);
// Wait for the first sync so that `getRoom` gives us a room object if it's
// in the sync response
const waitFor = this.firstSyncPromise ?
this.firstSyncPromise.promise : Promise.resolve();
waitFor.then(() => {
if (params.action === 'chat') { if (params.action === 'chat') {
this._chatCreateOrReuse(userId); this._chatCreateOrReuse(userId);
return; return;
} }
this.setState({ viewUserId: userId });
this._setPage(PageTypes.UserView); this._setPage(PageTypes.UserView);
this.notifyNewScreen('user/' + userId); this.notifyNewScreen('user/' + userId);
const member = new Matrix.RoomMember(null, userId); const member = new Matrix.RoomMember(null, userId);
if (member) {
dis.dispatch({ dis.dispatch({
action: 'view_user', action: 'view_user',
member: member, member: member,
}); });
} });
} else if (screen.indexOf('group/') == 0) {
const groupId = screen.substring(6);
// TODO: Check valid group ID
dis.dispatch({
action: 'view_group',
group_id: groupId,
});
} else { } else {
console.info("Ignoring showScreen for '%s'", screen); console.info("Ignoring showScreen for '%s'", screen);
} }
@ -1226,6 +1258,11 @@ module.exports = React.createClass({
}); });
}, },
onGroupClick: function(event, groupId) {
event.preventDefault();
dis.dispatch({action: 'view_group', group_id: groupId});
},
onLogoutClick: function(event) { onLogoutClick: function(event) {
dis.dispatch({ dis.dispatch({
action: 'logout', action: 'logout',
@ -1240,20 +1277,20 @@ module.exports = React.createClass({
const hideRhsThreshold = 820; const hideRhsThreshold = 820;
const showRhsThreshold = 820; const showRhsThreshold = 820;
if (this.state.width > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { if (this._windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
dis.dispatch({ action: 'hide_left_panel' }); dis.dispatch({ action: 'hide_left_panel' });
} }
if (this.state.width <= showLhsThreshold && window.innerWidth > showLhsThreshold) { if (this._windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
dis.dispatch({ action: 'show_left_panel' }); dis.dispatch({ action: 'show_left_panel' });
} }
if (this.state.width > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) { if (this._windowWidth > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) {
dis.dispatch({ action: 'hide_right_panel' }); dis.dispatch({ action: 'hide_right_panel' });
} }
if (this.state.width <= showRhsThreshold && window.innerWidth > showRhsThreshold) { if (this._windowWidth <= showRhsThreshold && window.innerWidth > showRhsThreshold) {
dis.dispatch({ action: 'show_right_panel' }); dis.dispatch({ action: 'show_right_panel' });
} }
this.setState({width: window.innerWidth}); this._windowWidth = window.innerWidth;
}, },
onRoomCreated: function(roomId) { onRoomCreated: function(roomId) {

View file

@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require('react'); import React from 'react';
var ReactDOM = require("react-dom"); import ReactDOM from 'react-dom';
var dis = require("../../dispatcher"); import UserSettingsStore from '../../UserSettingsStore';
var sdk = require('../../index'); import shouldHideEvent from '../../shouldHideEvent';
import dis from "../../dispatcher";
import sdk from '../../index';
var MatrixClientPeg = require('../../MatrixClientPeg'); import MatrixClientPeg from '../../MatrixClientPeg';
const MILLIS_IN_DAY = 86400000; const MILLIS_IN_DAY = 86400000;
@ -90,9 +92,6 @@ module.exports = React.createClass({
// show timestamps always // show timestamps always
alwaysShowTimestamps: React.PropTypes.bool, alwaysShowTimestamps: React.PropTypes.bool,
// hide redacted events as per old behaviour
hideRedactions: React.PropTypes.bool,
}, },
componentWillMount: function() { componentWillMount: function() {
@ -113,6 +112,8 @@ module.exports = React.createClass({
// Velocity requires // Velocity requires
this._readMarkerGhostNode = null; this._readMarkerGhostNode = null;
this._syncedSettings = UserSettingsStore.getSyncedSettings();
this._isMounted = true; this._isMounted = true;
}, },
@ -238,8 +239,20 @@ module.exports = React.createClass({
return !this._isMounted; return !this._isMounted;
}, },
_getEventTiles: function() { // TODO: Implement granular (per-room) hide options
_shouldShowEvent: function(mxEv) {
const EventTile = sdk.getComponent('rooms.EventTile'); const EventTile = sdk.getComponent('rooms.EventTile');
if (!EventTile.haveTileForEvent(mxEv)) {
return false; // no tile = no show
}
// Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true;
return !shouldHideEvent(mxEv, this._syncedSettings);
},
_getEventTiles: function() {
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
@ -249,20 +262,21 @@ module.exports = React.createClass({
// first figure out which is the last event in the list which we're // first figure out which is the last event in the list which we're
// actually going to show; this allows us to behave slightly // actually going to show; this allows us to behave slightly
// differently for the last event in the list. // differently for the last event in the list. (eg show timestamp)
// //
// we also need to figure out which is the last event we show which isn't // we also need to figure out which is the last event we show which isn't
// a local echo, to manage the read-marker. // a local echo, to manage the read-marker.
var lastShownEventIndex = -1; let lastShownEvent;
var lastShownNonLocalEchoIndex = -1; var lastShownNonLocalEchoIndex = -1;
for (i = this.props.events.length-1; i >= 0; i--) { for (i = this.props.events.length-1; i >= 0; i--) {
var mxEv = this.props.events[i]; var mxEv = this.props.events[i];
if (!EventTile.haveTileForEvent(mxEv)) { if (!this._shouldShowEvent(mxEv)) {
continue; continue;
} }
if (lastShownEventIndex < 0) { if (lastShownEvent === undefined) {
lastShownEventIndex = i; lastShownEvent = mxEv;
} }
if (mxEv.status) { if (mxEv.status) {
@ -288,25 +302,18 @@ module.exports = React.createClass({
this.currentGhostEventId = null; this.currentGhostEventId = null;
} }
var isMembershipChange = (e) => e.getType() === 'm.room.member'; const isMembershipChange = (e) => e.getType() === 'm.room.member';
for (i = 0; i < this.props.events.length; i++) { for (i = 0; i < this.props.events.length; i++) {
let mxEv = this.props.events[i]; let mxEv = this.props.events[i];
let wantTile = true;
let eventId = mxEv.getId(); let eventId = mxEv.getId();
let readMarkerInMels = false; let last = (mxEv === lastShownEvent);
if (!EventTile.haveTileForEvent(mxEv)) { const wantTile = this._shouldShowEvent(mxEv);
wantTile = false;
}
let last = (i == lastShownEventIndex);
// Wrap consecutive member events in a ListSummary, ignore if redacted // Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) && if (isMembershipChange(mxEv) && wantTile) {
EventTile.haveTileForEvent(mxEv) && let readMarkerInMels = false;
!mxEv.isRedacted()
) {
let ts1 = mxEv.getTs(); let ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new // Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and // member events. This will prevent it from being re-created unnecessarily, and
@ -323,37 +330,43 @@ module.exports = React.createClass({
ret.push(dateSeparator); ret.push(dateSeparator);
} }
// If RM event is the first in the MELS, append the RM after MELS
if (mxEv.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
let summarisedEvents = [mxEv]; let summarisedEvents = [mxEv];
for (;i + 1 < this.props.events.length; i++) { for (;i + 1 < this.props.events.length; i++) {
let collapsedMxEv = this.props.events[i + 1]; const collapsedMxEv = this.props.events[i + 1];
// Ignore redacted member events
if (!EventTile.haveTileForEvent(collapsedMxEv)) {
continue;
}
if (!isMembershipChange(collapsedMxEv) || if (!isMembershipChange(collapsedMxEv) ||
this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getDate())) { this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getDate())) {
break; break;
} }
summarisedEvents.push(collapsedMxEv);
}
// At this point, i = the index of the last event in the summary sequence
let eventTiles = summarisedEvents.map( // If RM event is in MELS mark it as such and the RM will be appended after MELS.
(e) => { if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
if (e.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true; readMarkerInMels = true;
} }
// Ignore redacted/hidden member events
if (!this._shouldShowEvent(collapsedMxEv)) {
continue;
}
summarisedEvents.push(collapsedMxEv);
}
// At this point, i = the index of the last event in the summary sequence
let eventTiles = summarisedEvents.map((e) => {
// In order to prevent DateSeparators from appearing in the expanded form // In order to prevent DateSeparators from appearing in the expanded form
// of MemberEventListSummary, render each member event as if the previous // of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the // one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeperator is inserted. // timestamp of the current event, and no DateSeperator is inserted.
let ret = this._getTilesForEvent(e, e); const ret = this._getTilesForEvent(e, e, e === lastShownEvent);
prevEvent = e; prevEvent = e;
return ret; return ret;
} }).reduce((a, b) => a.concat(b));
).reduce((a, b) => a.concat(b));
if (eventTiles.length === 0) { if (eventTiles.length === 0) {
eventTiles = null; eventTiles = null;
@ -466,8 +479,6 @@ module.exports = React.createClass({
continuation = false; continuation = false;
} }
if (mxEv.isRedacted() && this.props.hideRedactions) return ret;
var eventId = mxEv.getId(); var eventId = mxEv.getId();
var highlight = (eventId == this.props.highlightedEventId); var highlight = (eventId == this.props.highlightedEventId);

View file

@ -0,0 +1,141 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../index';
import { _t, _tJsx } from '../../languageHandler';
import withMatrixClient from '../../wrappers/withMatrixClient';
import AccessibleButton from '../views/elements/AccessibleButton';
import dis from '../../dispatcher';
import PropTypes from 'prop-types';
import Modal from '../../Modal';
const GroupTile = React.createClass({
displayName: 'GroupTile',
propTypes: {
groupId: PropTypes.string.isRequired,
},
onClick: function(e) {
e.preventDefault();
dis.dispatch({
action: 'view_group',
group_id: this.props.groupId,
});
},
render: function() {
return <a onClick={this.onClick} href="#">{this.props.groupId}</a>;
},
});
export default withMatrixClient(React.createClass({
displayName: 'MyGroups',
propTypes: {
matrixClient: React.PropTypes.object.isRequired,
},
getInitialState: function() {
return {
groups: null,
error: null,
};
},
componentWillMount: function() {
this._fetch();
},
_onCreateGroupClick: function() {
const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog");
Modal.createTrackedDialog('Create Group', '', CreateGroupDialog);
},
_fetch: function() {
this.props.matrixClient.getJoinedGroups().done((result) => {
this.setState({groups: result.groups, error: null});
}, (err) => {
this.setState({groups: null, error: err});
});
},
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let content;
if (this.state.groups) {
const groupNodes = [];
this.state.groups.forEach((g) => {
groupNodes.push(
<div key={g}>
<GroupTile groupId={g} />
</div>,
);
});
content = <div>
<div>{_t('You are a member of these groups:')}</div>
{groupNodes}
</div>;
} else if (this.state.error) {
content = <div className="mx_MyGroups_error">
{_t('Error whilst fetching joined groups')}
</div>;
} else {
content = <Loader />;
}
return <div className="mx_MyGroups">
<SimpleRoomHeader title={ _t("Groups") } />
<div className='mx_MyGroups_joinCreateBox'>
<div className="mx_MyGroups_createBox">
<div className="mx_MyGroups_joinCreateHeader">
{_t('Create a new group')}
</div>
<AccessibleButton className='mx_MyGroups_joinCreateButton' onClick={this._onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton>
{_t(
'Create a group to represent your community! '+
'Define a set of rooms and your own custom homepage '+
'to mark out your space in the Matrix universe.',
)}
</div>
<div className="mx_MyGroups_joinBox">
<div className="mx_MyGroups_joinCreateHeader">
{_t('Join an existing group')}
</div>
<AccessibleButton className='mx_MyGroups_joinCreateButton' onClick={this._onJoinGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton>
{_tJsx(
'To join an exisitng group you\'ll have to '+
'know its group identifier; this will look '+
'something like <i>+example:matrix.org</i>.',
/<i>(.*)<\/i>/,
(sub) => <i>{sub}</i>,
)}
</div>
</div>
<div className="mx_MyGroups_content">
{content}
</div>
</div>;
},
}));

View file

@ -33,9 +33,6 @@ module.exports = React.createClass({
// the room this statusbar is representing. // the room this statusbar is representing.
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
// a TabComplete object
tabComplete: React.PropTypes.object.isRequired,
// the number of messages which have arrived since we've been scrolled up // the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number, numUnreadMessages: React.PropTypes.number,
@ -143,12 +140,9 @@ module.exports = React.createClass({
(this.state.usersTyping.length > 0) || (this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages || this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline || !this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall || this.props.hasActiveCall
this.props.tabComplete.isTabCompleting()
) { ) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (this.props.tabCompleteEntries) {
return STATUS_BAR_HIDDEN;
} else if (this.props.unsentMessageError) { } else if (this.props.unsentMessageError) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
} }
@ -237,8 +231,6 @@ module.exports = React.createClass({
// return suitable content for the main (text) part of the status bar. // return suitable content for the main (text) part of the status bar.
_getContent: function() { _getContent: function() {
var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
// no conn bar trumps unread count since you can't get unread messages // no conn bar trumps unread count since you can't get unread messages
@ -259,20 +251,6 @@ module.exports = React.createClass({
); );
} }
if (this.props.tabComplete.isTabCompleting()) {
return (
<div className="mx_RoomStatusBar_tabCompleteBar">
<div className="mx_RoomStatusBar_tabCompleteWrapper">
<TabCompleteBar tabComplete={this.props.tabComplete} />
<div className="mx_RoomStatusBar_tabCompleteEol" title="->|">
<TintableSvg src="img/eol.svg" width="22" height="16"/>
{_t('Auto-complete')}
</div>
</div>
</div>
);
}
if (this.props.unsentMessageError) { if (this.props.unsentMessageError) {
return ( return (
<div className="mx_RoomStatusBar_connectionLostBar"> <div className="mx_RoomStatusBar_connectionLostBar">

View file

@ -22,7 +22,7 @@ limitations under the License.
var React = require("react"); var React = require("react");
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var q = require("q"); import Promise from 'bluebird';
var classNames = require("classnames"); var classNames = require("classnames");
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
@ -33,7 +33,6 @@ var ContentMessages = require("../../ContentMessages");
var Modal = require("../../Modal"); var Modal = require("../../Modal");
var sdk = require('../../index'); var sdk = require('../../index');
var CallHandler = require('../../CallHandler'); var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete");
var Resend = require("../../Resend"); var Resend = require("../../Resend");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var Tinter = require("../../Tinter"); var Tinter = require("../../Tinter");
@ -47,13 +46,14 @@ import UserProvider from '../../autocomplete/UserProvider';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
var DEBUG = false; let DEBUG = false;
let debuglog = function() {};
const BROWSER_SUPPORTS_SANDBOX = 'sandbox' in document.createElement('iframe');
if (DEBUG) { if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console); debuglog = console.log.bind(console);
} else {
var debuglog = function() {};
} }
module.exports = React.createClass({ module.exports = React.createClass({
@ -113,6 +113,7 @@ module.exports = React.createClass({
callState: null, callState: null,
guestsCanJoin: false, guestsCanJoin: false,
canPeek: false, canPeek: false,
showApps: false,
// error object, as from the matrix client/server API // error object, as from the matrix client/server API
// If we failed to load information about the room, // If we failed to load information about the room,
@ -142,15 +143,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership); MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("accountData", this.onAccountData);
this.tabComplete = new TabComplete({
allowLooping: false,
autoEnterTabComplete: true,
onClickCompletes: true,
onStateChange: (isCompleting) => {
this.forceUpdate();
},
});
// Start listening for RoomViewStore updates // Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate(true); this._onRoomViewStoreUpdate(true);
@ -234,10 +226,9 @@ module.exports = React.createClass({
// making it impossible to indicate a newly joined room. // making it impossible to indicate a newly joined room.
const room = this.state.room; const room = this.state.room;
if (room) { if (room) {
this._updateAutoComplete(room);
this.tabComplete.loadEntries(room);
this.setState({ this.setState({
unsentMessageError: this._getUnsentMessageError(room), unsentMessageError: this._getUnsentMessageError(room),
showApps: this._shouldShowApps(room),
}); });
this._onRoomLoaded(room); this._onRoomLoaded(room);
} }
@ -275,6 +266,19 @@ module.exports = React.createClass({
} }
}, },
_shouldShowApps: function(room) {
if (!BROWSER_SUPPORTS_SANDBOX) return false;
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
// any valid widget = show apps
for (let i = 0; i < appsStateEvents.length; i++) {
if (appsStateEvents[i].getContent().type && appsStateEvents[i].getContent().url) {
return true;
}
}
return false;
},
componentDidMount: function() { componentDidMount: function() {
var call = this._getCallForRoom(); var call = this._getCallForRoom();
var callState = call ? call.call_state : "ended"; var callState = call ? call.call_state : "ended";
@ -455,9 +459,14 @@ module.exports = React.createClass({
this._updateConfCallNotification(); this._updateConfCallNotification();
this.setState({ this.setState({
callState: callState callState: callState,
}); });
break;
case 'appsDrawer':
this.setState({
showApps: payload.show,
});
break; break;
} }
}, },
@ -499,9 +508,7 @@ module.exports = React.createClass({
// update the tab complete list as it depends on who most recently spoke, // update the tab complete list as it depends on who most recently spoke,
// and that has probably just changed // and that has probably just changed
if (ev.sender) { if (ev.sender) {
this.tabComplete.onMemberSpoke(ev.sender); UserProvider.getInstance().onUserSpoke(ev.sender);
// nb. we don't need to update the new autocomplete here since
// its results are currently ordered purely by search score.
} }
}, },
@ -524,6 +531,7 @@ module.exports = React.createClass({
this._warnAboutEncryption(room); this._warnAboutEncryption(room);
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
UserProvider.getInstance().setUserListFromRoom(room);
}, },
_warnAboutEncryption: function(room) { _warnAboutEncryption: function(room) {
@ -536,7 +544,7 @@ module.exports = React.createClass({
} }
if (!userHasUsedEncryption) { if (!userHasUsedEncryption) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('E2E Warning', '', QuestionDialog, {
title: _t("Warning!"), title: _t("Warning!"),
hasCancelButton: false, hasCancelButton: false,
description: ( description: (
@ -699,8 +707,7 @@ module.exports = React.createClass({
this._updateConfCallNotification(); this._updateConfCallNotification();
// refresh the tab complete list // refresh the tab complete list
this.tabComplete.loadEntries(this.state.room); UserProvider.getInstance().setUserListFromRoom(this.state.room);
this._updateAutoComplete(this.state.room);
// if we are now a member of the room, where we were not before, that // if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking // means we have finished joining a room we were previously peeking
@ -768,7 +775,7 @@ module.exports = React.createClass({
onSearchResultsFillRequest: function(backwards) { onSearchResultsFillRequest: function(backwards) {
if (!backwards) { if (!backwards) {
return q(false); return Promise.resolve(false);
} }
if (this.state.searchResults.next_batch) { if (this.state.searchResults.next_batch) {
@ -778,7 +785,7 @@ module.exports = React.createClass({
return this._handleSearchResult(searchPromise); return this._handleSearchResult(searchPromise);
} else { } else {
debuglog("no more search results"); debuglog("no more search results");
return q(false); return Promise.resolve(false);
} }
}, },
@ -813,7 +820,7 @@ module.exports = React.createClass({
}); });
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
const close = Modal.createDialog(SetMxIdDialog, { const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
homeserverUrl: cli.getHomeserverUrl(), homeserverUrl: cli.getHomeserverUrl(),
onFinished: (submitted, credentials) => { onFinished: (submitted, credentials) => {
if (submitted) { if (submitted) {
@ -839,7 +846,7 @@ module.exports = React.createClass({
return; return;
} }
q().then(() => { Promise.resolve().then(() => {
const signUrl = this.props.thirdPartyInvite ? const signUrl = this.props.thirdPartyInvite ?
this.props.thirdPartyInvite.inviteSignUrl : undefined; this.props.thirdPartyInvite.inviteSignUrl : undefined;
dis.dispatch({ dis.dispatch({
@ -858,7 +865,7 @@ module.exports = React.createClass({
} }
} }
} }
return q(); return Promise.resolve();
}); });
}, },
@ -927,7 +934,7 @@ module.exports = React.createClass({
} }
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to upload file " + file + " " + error); console.error("Failed to upload file " + file + " " + error);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, {
title: _t('Failed to upload file'), title: _t('Failed to upload file'),
description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
}); });
@ -1014,7 +1021,7 @@ module.exports = React.createClass({
}, function(error) { }, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Search failed: " + error); console.error("Search failed: " + error);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Search failed', '', ErrorDialog, {
title: _t("Search failed"), title: _t("Search failed"),
description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")),
}); });
@ -1141,7 +1148,7 @@ module.exports = React.createClass({
console.error(result.reason); console.error(result.reason);
}); });
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to save room settings', '', ErrorDialog, {
title: _t("Failed to save settings"), title: _t("Failed to save settings"),
description: fails.map(function(result) { return result.reason; }).join("\n"), description: fails.map(function(result) { return result.reason; }).join("\n"),
}); });
@ -1188,7 +1195,7 @@ module.exports = React.createClass({
}, function(err) { }, function(err) {
var errCode = err.errcode || _t("unknown error code"); var errCode = err.errcode || _t("unknown error code");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, {
title: _t("Error"), title: _t("Error"),
description: _t("Failed to forget room %(errCode)s", { errCode: errCode }), description: _t("Failed to forget room %(errCode)s", { errCode: errCode }),
}); });
@ -1210,7 +1217,7 @@ module.exports = React.createClass({
var msg = error.message ? error.message : JSON.stringify(error); var msg = error.message ? error.message : JSON.stringify(error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, {
title: _t("Failed to reject invite"), title: _t("Failed to reject invite"),
description: msg, description: msg,
}); });
@ -1425,14 +1432,6 @@ module.exports = React.createClass({
} }
}, },
_updateAutoComplete: function(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
UserProvider.getInstance().setUserList(members);
},
render: function() { render: function() {
const RoomHeader = sdk.getComponent('rooms.RoomHeader'); const RoomHeader = sdk.getComponent('rooms.RoomHeader');
const MessageComposer = sdk.getComponent('rooms.MessageComposer'); const MessageComposer = sdk.getComponent('rooms.MessageComposer');
@ -1560,7 +1559,6 @@ module.exports = React.createClass({
isStatusAreaExpanded = this.state.statusBarVisible; isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
tabComplete={this.tabComplete}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
unsentMessageError={this.state.unsentMessageError} unsentMessageError={this.state.unsentMessageError}
atEndOfLiveTimeline={this.state.atEndOfLiveTimeline} atEndOfLiveTimeline={this.state.atEndOfLiveTimeline}
@ -1613,11 +1611,13 @@ module.exports = React.createClass({
var auxPanel = ( var auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room} <AuxPanel ref="auxPanel" room={this.state.room}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler} conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile} draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification} displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight} maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize} > onResize={this.onChildResize}
showApps={this.state.showApps && !this.state.editingRoomSettings} >
{ aux } { aux }
</AuxPanel> </AuxPanel>
); );
@ -1630,8 +1630,13 @@ module.exports = React.createClass({
if (canSpeak) { if (canSpeak) {
messageComposer = messageComposer =
<MessageComposer <MessageComposer
room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile} room={this.state.room}
callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>; onResize={this.onChildResize}
uploadFile={this.uploadFile}
callState={this.state.callState}
opacity={ this.props.opacity }
showApps={ this.state.showApps }
/>;
} }
// TODO: Why aren't we storing the term/scope/count in this format // TODO: Why aren't we storing the term/scope/count in this format

View file

@ -17,7 +17,7 @@ limitations under the License.
var React = require("react"); var React = require("react");
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar'); var GeminiScrollbar = require('react-gemini-scrollbar');
var q = require("q"); import Promise from 'bluebird';
var KeyCode = require('../../KeyCode'); var KeyCode = require('../../KeyCode');
var DEBUG_SCROLL = false; var DEBUG_SCROLL = false;
@ -145,7 +145,7 @@ module.exports = React.createClass({
return { return {
stickyBottom: true, stickyBottom: true,
startAtBottom: true, startAtBottom: true,
onFillRequest: function(backwards) { return q(false); }, onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {}, onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {}, onScroll: function() {},
}; };
@ -386,19 +386,12 @@ module.exports = React.createClass({
debuglog("ScrollPanel: starting "+dir+" fill"); debuglog("ScrollPanel: starting "+dir+" fill");
// onFillRequest can end up calling us recursively (via onScroll // onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call. That // events) so make sure we set this before firing off the call.
// does present the risk that we might not ever actually fire off the
// fill request, so wrap it in a try/catch.
this._pendingFillRequests[dir] = true; this._pendingFillRequests[dir] = true;
var fillPromise;
try {
fillPromise = this.props.onFillRequest(backwards);
} catch (e) {
this._pendingFillRequests[dir] = false;
throw e;
}
q.finally(fillPromise, () => { Promise.try(() => {
return this.props.onFillRequest(backwards);
}).finally(() => {
this._pendingFillRequests[dir] = false; this._pendingFillRequests[dir] = false;
}).then((hasMoreResults) => { }).then((hasMoreResults) => {
if (this.unmounted) { if (this.unmounted) {

View file

@ -17,7 +17,7 @@ limitations under the License.
var React = require('react'); var React = require('react');
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var q = require("q"); import Promise from 'bluebird';
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
var EventTimeline = Matrix.EventTimeline; var EventTimeline = Matrix.EventTimeline;
@ -181,9 +181,6 @@ var TimelinePanel = React.createClass({
// always show timestamps on event tiles? // always show timestamps on event tiles?
alwaysShowTimestamps: syncedSettings.alwaysShowTimestamps, alwaysShowTimestamps: syncedSettings.alwaysShowTimestamps,
// hide redacted events as per old behaviour
hideRedactions: syncedSettings.hideRedactions,
}; };
}, },
@ -311,13 +308,13 @@ var TimelinePanel = React.createClass({
if (!this.state[canPaginateKey]) { if (!this.state[canPaginateKey]) {
debuglog("TimelinePanel: have given up", dir, "paginating this timeline"); debuglog("TimelinePanel: have given up", dir, "paginating this timeline");
return q(false); return Promise.resolve(false);
} }
if(!this._timelineWindow.canPaginate(dir)) { if(!this._timelineWindow.canPaginate(dir)) {
debuglog("TimelinePanel: can't", dir, "paginate any further"); debuglog("TimelinePanel: can't", dir, "paginate any further");
this.setState({[canPaginateKey]: false}); this.setState({[canPaginateKey]: false});
return q(false); return Promise.resolve(false);
} }
debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
@ -350,9 +347,9 @@ var TimelinePanel = React.createClass({
}); });
}, },
onMessageListScroll: function() { onMessageListScroll: function(e) {
if (this.props.onScroll) { if (this.props.onScroll) {
this.props.onScroll(); this.props.onScroll(e);
} }
if (this.props.manageReadMarkers) { if (this.props.manageReadMarkers) {
@ -926,7 +923,7 @@ var TimelinePanel = React.createClass({
var message = (error.errcode == 'M_FORBIDDEN') var message = (error.errcode == 'M_FORBIDDEN')
? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.") ? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.")
: _t("Tried to load a specific point in this room's timeline, but was unable to find it."); : _t("Tried to load a specific point in this room's timeline, but was unable to find it.");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, {
title: _t("Failed to load timeline position"), title: _t("Failed to load timeline position"),
description: message, description: message,
onFinished: onFinished, onFinished: onFinished,
@ -1122,7 +1119,6 @@ var TimelinePanel = React.createClass({
return ( return (
<MessagePanel ref="messagePanel" <MessagePanel ref="messagePanel"
hidden={ this.props.hidden } hidden={ this.props.hidden }
hideRedactions={ this.state.hideRedactions }
backPaginating={ this.state.backPaginating } backPaginating={ this.state.backPaginating }
forwardPaginating={ forwardPaginating } forwardPaginating={ forwardPaginating }
events={ this.state.events } events={ this.state.events }

View file

@ -22,7 +22,7 @@ const PlatformPeg = require("../../PlatformPeg");
const Modal = require('../../Modal'); const Modal = require('../../Modal');
const dis = require("../../dispatcher"); const dis = require("../../dispatcher");
import sessionStore from '../../stores/SessionStore'; import sessionStore from '../../stores/SessionStore';
const q = require('q'); import Promise from 'bluebird';
const packageJson = require('../../../package.json'); const packageJson = require('../../../package.json');
const UserSettingsStore = require('../../UserSettingsStore'); const UserSettingsStore = require('../../UserSettingsStore');
const CallMediaHandler = require('../../CallMediaHandler'); const CallMediaHandler = require('../../CallMediaHandler');
@ -81,6 +81,14 @@ const SETTINGS_LABELS = [
id: 'showTwelveHourTimestamps', id: 'showTwelveHourTimestamps',
label: 'Show timestamps in 12 hour format (e.g. 2:30pm)', label: 'Show timestamps in 12 hour format (e.g. 2:30pm)',
}, },
{
id: 'hideJoinLeaves',
label: 'Hide join/leave messages (invites/kicks/bans unaffected)',
},
{
id: 'hideAvatarDisplaynameChanges',
label: 'Hide avatar and display name changes',
},
{ {
id: 'useCompactLayout', id: 'useCompactLayout',
label: 'Use compact timeline layout', label: 'Use compact timeline layout',
@ -90,8 +98,16 @@ const SETTINGS_LABELS = [
label: 'Hide removed messages', label: 'Hide removed messages',
}, },
{ {
id: 'disableMarkdown', id: 'enableSyntaxHighlightLanguageDetection',
label: 'Disable markdown formatting', label: 'Enable automatic language detection for syntax highlighting',
},
{
id: 'MessageComposerInput.autoReplaceEmoji',
label: 'Automatically replace plain text Emoji',
},
{
id: 'Pill.shouldHidePillAvatar',
label: 'Hide avatars in user and room mentions',
}, },
/* /*
{ {
@ -199,7 +215,7 @@ module.exports = React.createClass({
this._addThreepid = null; this._addThreepid = null;
if (PlatformPeg.get()) { if (PlatformPeg.get()) {
q().then(() => { Promise.resolve().then(() => {
return PlatformPeg.get().getAppVersion(); return PlatformPeg.get().getAppVersion();
}).done((appVersion) => { }).done((appVersion) => {
if (this._unmounted) return; if (this._unmounted) return;
@ -297,7 +313,7 @@ module.exports = React.createClass({
}, },
_refreshMediaDevices: function() { _refreshMediaDevices: function() {
q().then(() => { Promise.resolve().then(() => {
return CallMediaHandler.getDevices(); return CallMediaHandler.getDevices();
}).then((mediaDevices) => { }).then((mediaDevices) => {
// console.log("got mediaDevices", mediaDevices, this._unmounted); // console.log("got mediaDevices", mediaDevices, this._unmounted);
@ -312,7 +328,7 @@ module.exports = React.createClass({
_refreshFromServer: function() { _refreshFromServer: function() {
const self = this; const self = this;
q.all([ Promise.all([
UserSettingsStore.loadProfileInfo(), UserSettingsStore.loadThreePids(), UserSettingsStore.loadProfileInfo(), UserSettingsStore.loadThreePids(),
]).done(function(resps) { ]).done(function(resps) {
self.setState({ self.setState({
@ -323,7 +339,7 @@ module.exports = React.createClass({
}, function(error) { }, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to load user settings: " + error); console.error("Failed to load user settings: " + error);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Can\'t load user settings', '', ErrorDialog, {
title: _t("Can't load user settings"), title: _t("Can't load user settings"),
description: ((error && error.message) ? error.message : _t("Server may be unavailable or overloaded")), description: ((error && error.message) ? error.message : _t("Server may be unavailable or overloaded")),
}); });
@ -356,7 +372,7 @@ module.exports = React.createClass({
// const errMsg = (typeof err === "string") ? err : (err.error || ""); // const errMsg = (typeof err === "string") ? err : (err.error || "");
console.error("Failed to set avatar: " + err); console.error("Failed to set avatar: " + err);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to set avatar', '', ErrorDialog, {
title: _t("Failed to set avatar."), title: _t("Failed to set avatar."),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -365,7 +381,7 @@ module.exports = React.createClass({
onLogoutClicked: function(ev) { onLogoutClicked: function(ev) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Logout E2E Export', '', QuestionDialog, {
title: _t("Sign out"), title: _t("Sign out"),
description: description:
<div> <div>
@ -401,7 +417,7 @@ module.exports = React.createClass({
} }
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change password: " + errMsg); console.error("Failed to change password: " + errMsg);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to change password', '', ErrorDialog, {
title: _t("Error"), title: _t("Error"),
description: errMsg, description: errMsg,
}); });
@ -409,7 +425,7 @@ module.exports = React.createClass({
onPasswordChanged: function() { onPasswordChanged: function() {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Password changed', '', ErrorDialog, {
title: _t("Success"), title: _t("Success"),
description: _t( description: _t(
"Your password was successfully changed. You will not receive " + "Your password was successfully changed. You will not receive " +
@ -434,7 +450,7 @@ module.exports = React.createClass({
const emailAddress = this.refs.add_email_input.value; const emailAddress = this.refs.add_email_input.value;
if (!Email.looksValid(emailAddress)) { if (!Email.looksValid(emailAddress)) {
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Invalid email address', '', ErrorDialog, {
title: _t("Invalid Email Address"), title: _t("Invalid Email Address"),
description: _t("This doesn't appear to be a valid email address"), description: _t("This doesn't appear to be a valid email address"),
}); });
@ -444,7 +460,7 @@ module.exports = React.createClass({
// we always bind emails when registering, so let's do the // we always bind emails when registering, so let's do the
// same here. // same here.
this._addThreepid.addEmailAddress(emailAddress, true).done(() => { this._addThreepid.addEmailAddress(emailAddress, true).done(() => {
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Verification Pending', '', QuestionDialog, {
title: _t("Verification Pending"), title: _t("Verification Pending"),
description: _t( description: _t(
"Please check your email and click on the link it contains. Once this " + "Please check your email and click on the link it contains. Once this " +
@ -456,7 +472,7 @@ module.exports = React.createClass({
}, (err) => { }, (err) => {
this.setState({email_add_pending: false}); this.setState({email_add_pending: false});
console.error("Unable to add email address " + emailAddress + " " + err); console.error("Unable to add email address " + emailAddress + " " + err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Unable to add email address', '', ErrorDialog, {
title: _t("Unable to add email address"), title: _t("Unable to add email address"),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -467,7 +483,7 @@ module.exports = React.createClass({
onRemoveThreepidClicked: function(threepid) { onRemoveThreepidClicked: function(threepid) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Remove 3pid', '', QuestionDialog, {
title: _t("Remove Contact Information?"), title: _t("Remove Contact Information?"),
description: _t("Remove %(threePid)s?", { threePid: threepid.address }), description: _t("Remove %(threePid)s?", { threePid: threepid.address }),
button: _t('Remove'), button: _t('Remove'),
@ -481,7 +497,7 @@ module.exports = React.createClass({
}).catch((err) => { }).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to remove contact information: " + err); console.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Remove 3pid failed', '', ErrorDialog, {
title: _t("Unable to remove contact information"), title: _t("Unable to remove contact information"),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -513,7 +529,7 @@ module.exports = React.createClass({
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const message = _t("Unable to verify email address.") + " " + const message = _t("Unable to verify email address.") + " " +
_t("Please check your email and click on the link it contains. Once this is done, click continue."); _t("Please check your email and click on the link it contains. Once this is done, click continue.");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Verification Pending', '', QuestionDialog, {
title: _t("Verification Pending"), title: _t("Verification Pending"),
description: message, description: message,
button: _t('Continue'), button: _t('Continue'),
@ -522,7 +538,7 @@ module.exports = React.createClass({
} else { } else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to verify email address: " + err); console.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Unable to verify email address', '', ErrorDialog, {
title: _t("Unable to verify email address."), title: _t("Unable to verify email address."),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -532,7 +548,7 @@ module.exports = React.createClass({
_onDeactivateAccountClicked: function() { _onDeactivateAccountClicked: function() {
const DeactivateAccountDialog = sdk.getComponent("dialogs.DeactivateAccountDialog"); const DeactivateAccountDialog = sdk.getComponent("dialogs.DeactivateAccountDialog");
Modal.createDialog(DeactivateAccountDialog, {}); Modal.createTrackedDialog('Deactivate Account', '', DeactivateAccountDialog, {});
}, },
_onBugReportClicked: function() { _onBugReportClicked: function() {
@ -540,7 +556,7 @@ module.exports = React.createClass({
if (!BugReportDialog) { if (!BugReportDialog) {
return; return;
} }
Modal.createDialog(BugReportDialog, {}); Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
}, },
_onClearCacheClicked: function() { _onClearCacheClicked: function() {
@ -564,39 +580,36 @@ module.exports = React.createClass({
}); });
// reject the invites // reject the invites
const promises = rooms.map((room) => { const promises = rooms.map((room) => {
return MatrixClientPeg.get().leave(room.roomId); return MatrixClientPeg.get().leave(room.roomId).catch((e) => {
});
// purposefully drop errors to the floor: we'll just have a non-zero number on the UI // purposefully drop errors to the floor: we'll just have a non-zero number on the UI
// after trying to reject all the invites. // after trying to reject all the invites.
q.allSettled(promises).then(() => { });
});
Promise.all(promises).then(() => {
this.setState({ this.setState({
rejectingInvites: false, rejectingInvites: false,
}); });
}).done(); });
}, },
_onExportE2eKeysClicked: function() { _onExportE2eKeysClicked: function() {
Modal.createDialogAsync( Modal.createTrackedDialogAsync('Export E2E Keys', '', (cb) => {
(cb) => {
require.ensure(['../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { require.ensure(['../../async-components/views/dialogs/ExportE2eKeysDialog'], () => {
cb(require('../../async-components/views/dialogs/ExportE2eKeysDialog')); cb(require('../../async-components/views/dialogs/ExportE2eKeysDialog'));
}, "e2e-export"); }, "e2e-export");
}, { }, {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
}, });
);
}, },
_onImportE2eKeysClicked: function() { _onImportE2eKeysClicked: function() {
Modal.createDialogAsync( Modal.createTrackedDialogAsync('Import E2E Keys', '', (cb) => {
(cb) => {
require.ensure(['../../async-components/views/dialogs/ImportE2eKeysDialog'], () => { require.ensure(['../../async-components/views/dialogs/ImportE2eKeysDialog'], () => {
cb(require('../../async-components/views/dialogs/ImportE2eKeysDialog')); cb(require('../../async-components/views/dialogs/ImportE2eKeysDialog'));
}, "e2e-export"); }, "e2e-export");
}, { }, {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
}, });
);
}, },
_renderReferral: function() { _renderReferral: function() {
@ -642,6 +655,10 @@ module.exports = React.createClass({
}, },
_renderUserInterfaceSettings: function() { _renderUserInterfaceSettings: function() {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = (e) =>
UserSettingsStore.setLocalSetting('autocompleteDelay', + e.target.value);
return ( return (
<div> <div>
<h3>{ _t("User Interface") }</h3> <h3>{ _t("User Interface") }</h3>
@ -649,8 +666,21 @@ module.exports = React.createClass({
{ this._renderUrlPreviewSelector() } { this._renderUrlPreviewSelector() }
{ SETTINGS_LABELS.map( this._renderSyncedSetting ) } { SETTINGS_LABELS.map( this._renderSyncedSetting ) }
{ THEMES.map( this._renderThemeSelector ) } { THEMES.map( this._renderThemeSelector ) }
<table>
<tbody>
<tr>
<td><strong>{_t('Autocomplete Delay (ms):')}</strong></td>
<td>
<input
type="number"
defaultValue={UserSettingsStore.getLocalSetting('autocompleteDelay', 200)}
onChange={onChange}
/>
</td>
</tr>
</tbody>
</table>
{ this._renderLanguageSetting() } { this._renderLanguageSetting() }
</div> </div>
</div> </div>
); );
@ -829,7 +859,13 @@ module.exports = React.createClass({
if (this.props.enableLabs === false) return null; if (this.props.enableLabs === false) return null;
UserSettingsStore.doTranslations(); UserSettingsStore.doTranslations();
const features = UserSettingsStore.LABS_FEATURES.map((feature) => { const features = [];
UserSettingsStore.LABS_FEATURES.forEach((feature) => {
// This feature has an override and will be set to the default, so do not
// show it here.
if (feature.override) {
return;
}
// TODO: this ought to be a separate component so that we don't need // TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render // to rebind the onChange each time we render
const onChange = (e) => { const onChange = (e) => {
@ -837,7 +873,7 @@ module.exports = React.createClass({
this.forceUpdate(); this.forceUpdate();
}; };
return ( features.push(
<div key={feature.id} className="mx_UserSettings_toggle"> <div key={feature.id} className="mx_UserSettings_toggle">
<input <input
type="checkbox" type="checkbox"
@ -847,9 +883,14 @@ module.exports = React.createClass({
onChange={ onChange } onChange={ onChange }
/> />
<label htmlFor={feature.id}>{feature.name}</label> <label htmlFor={feature.id}>{feature.name}</label>
</div> </div>);
);
}); });
// No labs section when there are no features in labs
if (features.length === 0) {
return null;
}
return ( return (
<div> <div>
<h3>{ _t("Labs") }</h3> <h3>{ _t("Labs") }</h3>
@ -978,7 +1019,7 @@ module.exports = React.createClass({
this._refreshMediaDevices, this._refreshMediaDevices,
function() { function() {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('No media permissions', '', ErrorDialog, {
title: _t('No media permissions'), title: _t('No media permissions'),
description: _t('You may need to manually permit Riot to access your microphone/webcam'), description: _t('You may need to manually permit Riot to access your microphone/webcam'),
}); });
@ -1114,7 +1155,7 @@ module.exports = React.createClass({
const threepidsSection = this.state.threepids.map((val, pidIndex) => { const threepidsSection = this.state.threepids.map((val, pidIndex) => {
const id = "3pid-" + val.address; const id = "3pid-" + val.address;
// TODO; make a separate component to avoid having to rebind onClick // TODO: make a separate component to avoid having to rebind onClick
// each time we render // each time we render
const onRemoveClick = (e) => this.onRemoveThreepidClicked(val); const onRemoveClick = (e) => this.onRemoveThreepidClicked(val);
return ( return (

View file

@ -89,7 +89,7 @@ module.exports = React.createClass({
} }
else { else {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'), title: _t('Warning!'),
description: description:
<div> <div>
@ -121,15 +121,13 @@ module.exports = React.createClass({
}, },
_onExportE2eKeysClicked: function() { _onExportE2eKeysClicked: function() {
Modal.createDialogAsync( Modal.createTrackedDialogAsync('Export E2E Keys', 'Forgot Password', (cb) => {
(cb) => {
require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => {
cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog')); cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog'));
}, "e2e-export"); }, "e2e-export");
}, { }, {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
} });
);
}, },
onInputChanged: function(stateKey, ev) { onInputChanged: function(stateKey, ev) {
@ -152,7 +150,7 @@ module.exports = React.createClass({
showErrorDialog: function(body, title) { showErrorDialog: function(body, title) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
title: title, title: title,
description: body, description: body,
}); });

View file

@ -19,8 +19,11 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t, _tJsx } from '../../../languageHandler'; import { _t, _tJsx } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import Login from '../../../Login'; import Login from '../../../Login';
import UserSettingsStore from '../../../UserSettingsStore';
import PlatformPeg from '../../../PlatformPeg';
// For validating phone numbers without country codes // For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/; const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/;
@ -72,9 +75,14 @@ module.exports = React.createClass({
}, },
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false;
this._initLoginLogic(); this._initLoginLogic();
}, },
componentWillUnmount: function() {
this._unmounted = true;
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
this.setState({ this.setState({
busy: true, busy: true,
@ -87,6 +95,9 @@ module.exports = React.createClass({
).then((data) => { ).then((data) => {
this.props.onLoggedIn(data); this.props.onLoggedIn(data);
}, (error) => { }, (error) => {
if(this._unmounted) {
return;
}
let errorText; let errorText;
// Some error strings only apply for logging in // Some error strings only apply for logging in
@ -109,8 +120,11 @@ module.exports = React.createClass({
loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403, loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403,
}); });
}).finally(() => { }).finally(() => {
if(this._unmounted) {
return;
}
this.setState({ this.setState({
busy: false busy: false,
}); });
}).done(); }).done();
}, },
@ -295,6 +309,23 @@ module.exports = React.createClass({
} }
}, },
_onLanguageChange: function(newLang) {
if(languageHandler.getCurrentLanguage() !== newLang) {
UserSettingsStore.setLocalSetting('language', newLang);
PlatformPeg.get().reload();
}
},
_renderLanguageSetting: function() {
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
return <div className="mx_Login_language_div">
<LanguageDropdown onOptionChange={this._onLanguageChange}
className="mx_Login_language"
value={languageHandler.getCurrentLanguage()}
/>
</div>;
},
render: function() { render: function() {
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const LoginHeader = sdk.getComponent("login.LoginHeader"); const LoginHeader = sdk.getComponent("login.LoginHeader");
@ -343,6 +374,7 @@ module.exports = React.createClass({
</a> </a>
{ loginAsGuestJsx } { loginAsGuestJsx }
{ returnToAppJsx } { returnToAppJsx }
{ this._renderLanguageSetting() }
<LoginFooter /> <LoginFooter />
</div> </div>
</div> </div>

View file

@ -17,7 +17,7 @@ limitations under the License.
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import q from 'q'; import Promise from 'bluebird';
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
@ -180,7 +180,7 @@ module.exports = React.createClass({
// will just nop. The point of this being we might not have the email address // will just nop. The point of this being we might not have the email address
// that the user registered with at this stage (depending on whether this // that the user registered with at this stage (depending on whether this
// is the client they initiated registration). // is the client they initiated registration).
let trackPromise = q(null); let trackPromise = Promise.resolve(null);
if (this._rtsClient && extra.emailSid) { if (this._rtsClient && extra.emailSid) {
// Track referral if this.props.referrer set, get team_token in order to // Track referral if this.props.referrer set, get team_token in order to
// retrieve team config and see welcome page etc. // retrieve team config and see welcome page etc.
@ -232,7 +232,7 @@ module.exports = React.createClass({
_setupPushers: function(matrixClient) { _setupPushers: function(matrixClient) {
if (!this.props.brand) { if (!this.props.brand) {
return q(); return Promise.resolve();
} }
return matrixClient.getPushers().then((resp)=>{ return matrixClient.getPushers().then((resp)=>{
const pushers = resp.pushers; const pushers = resp.pushers;

View file

@ -0,0 +1,66 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default React.createClass({
displayName: 'GroupAvatar',
propTypes: {
groupId: PropTypes.string,
groupAvatarUrl: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
},
getDefaultProps: function() {
return {
width: 36,
height: 36,
resizeMethod: 'crop',
};
},
getGroupAvatarUrl: function() {
return MatrixClientPeg.get().mxcUrlToHttp(
this.props.groupAvatarUrl,
this.props.width,
this.props.height,
this.props.resizeMethod,
);
},
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// extract the props we use from props so we can pass any others through
// should consider adding this as a global rule in js-sdk?
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {groupId, groupAvatarUrl, ...otherProps} = this.props;
return (
<BaseAvatar
name={this.props.groupId[1]}
idName={this.props.groupId}
url={this.getGroupAvatarUrl()}
{...otherProps}
/>
);
},
});

View file

@ -72,7 +72,7 @@ module.exports = React.createClass({
}, },
getRoomAvatarUrl: function(props) { getRoomAvatarUrl: function(props) {
if (!this.props.room) return null; if (!props.room) return null;
return props.room.getAvatarUrl( return props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
@ -84,7 +84,7 @@ module.exports = React.createClass({
}, },
getOneToOneAvatar: function(props) { getOneToOneAvatar: function(props) {
if (!this.props.room) return null; if (!props.room) return null;
var mlist = props.room.currentState.members; var mlist = props.room.currentState.members;
var userIds = []; var userIds = [];
@ -126,9 +126,16 @@ module.exports = React.createClass({
}, },
getFallbackAvatar: function(props) { getFallbackAvatar: function(props) {
if (!this.props.room) return null; let roomId = null;
if (props.oobData && props.oobData.roomId) {
roomId = this.props.oobData.roomId;
} else if (props.room) {
roomId = props.room.roomId;
} else {
return null;
}
return Avatar.defaultAvatarUrlForString(props.room.roomId); return Avatar.defaultAvatarUrlForString(roomId);
}, },
render: function() { render: function() {

View file

@ -23,7 +23,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import q from 'q'; import Promise from 'bluebird';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
const TRUNCATE_QUERY_LIST = 40; const TRUNCATE_QUERY_LIST = 40;
@ -103,7 +103,7 @@ module.exports = React.createClass({
const ChatCreateOrReuseDialog = sdk.getComponent( const ChatCreateOrReuseDialog = sdk.getComponent(
"views.dialogs.ChatCreateOrReuseDialog", "views.dialogs.ChatCreateOrReuseDialog",
); );
const close = Modal.createDialog(ChatCreateOrReuseDialog, { const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, {
userId: userId, userId: userId,
onFinished: (success) => { onFinished: (success) => {
this.props.onFinished(success); this.props.onFinished(success);
@ -367,7 +367,7 @@ module.exports = React.createClass({
.catch(function(err) { .catch(function(err) {
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t("Failed to invite"), title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -380,7 +380,7 @@ module.exports = React.createClass({
.catch(function(err) { .catch(function(err) {
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, {
title: _t("Failed to invite user"), title: _t("Failed to invite user"),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -401,7 +401,7 @@ module.exports = React.createClass({
.catch(function(err) { .catch(function(err) {
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t("Failed to invite"), title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -448,7 +448,7 @@ module.exports = React.createClass({
if (errorList.length > 0) { if (errorList.length > 0) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, {
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
description: errorList.join(", "), description: errorList.join(", "),
}); });
@ -498,7 +498,7 @@ module.exports = React.createClass({
} }
// wait a bit to let the user finish typing // wait a bit to let the user finish typing
return q.delay(500).then(() => { return Promise.delay(500).then(() => {
if (cancelled) return null; if (cancelled) return null;
return MatrixClientPeg.get().lookupThreePid(medium, address); return MatrixClientPeg.get().lookupThreePid(medium, address);
}).then((res) => { }).then((res) => {

View file

@ -0,0 +1,199 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
// We match fairly liberally and leave it up to the server to reject if
// there are invalid characters etc.
const GROUP_REGEX = /^\+(.*?):(.*)$/;
export default React.createClass({
displayName: 'CreateGroupDialog',
propTypes: {
onFinished: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
groupName: '',
groupId: '',
groupError: null,
creating: false,
createError: null,
};
},
_onGroupNameChange: function(e) {
this.setState({
groupName: e.target.value,
});
},
_onGroupIdChange: function(e) {
this.setState({
groupId: e.target.value,
});
},
_onGroupIdBlur: function(e) {
this._checkGroupId();
},
_checkGroupId: function(e) {
const parsedGroupId = this._parseGroupId(this.state.groupId);
let error = null;
if (parsedGroupId === null) {
error = _t(
"Group IDs must be of the form +localpart:%(domain)s",
{domain: MatrixClientPeg.get().getDomain()},
);
} else {
const domain = parsedGroupId[1];
if (domain !== MatrixClientPeg.get().getDomain()) {
error = _t(
"It is currently only possible to create groups on your own home server: "+
"use a group ID ending with %(domain)s",
{domain: MatrixClientPeg.get().getDomain()},
);
}
}
this.setState({
groupIdError: error,
});
return error;
},
_onFormSubmit: function(e) {
e.preventDefault();
if (this._checkGroupId()) return;
const parsedGroupId = this._parseGroupId(this.state.groupId);
const profile = {};
if (this.state.groupName !== '') {
profile.name = this.state.groupName;
}
this.setState({creating: true});
MatrixClientPeg.get().createGroup({
localpart: parsedGroupId[0],
profile: profile,
}).then((result) => {
dis.dispatch({
action: 'view_group',
group_id: result.group_id,
});
this.props.onFinished(true);
}).catch((e) => {
this.setState({createError: e});
}).finally(() => {
this.setState({creating: false});
}).done();
},
_onCancel: function() {
this.props.onFinished(false);
},
/**
* Parse a string that may be a group ID
* If the string is a valid group ID, return a list of [localpart, domain],
* otherwise return null.
*
* @param {string} groupId The ID of the group
* @return {string[]} array of localpart, domain
*/
_parseGroupId: function(groupId) {
const matches = GROUP_REGEX.exec(this.state.groupId);
if (!matches || matches.length < 3) {
return null;
}
return [matches[1], matches[2]];
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Spinner = sdk.getComponent('elements.Spinner');
if (this.state.creating) {
return <Spinner />;
}
let createErrorNode;
if (this.state.createError) {
// XXX: We should catch errcodes and give sensible i18ned messages for them,
// rather than displaying what the server gives us, but synapse doesn't give
// any yet.
createErrorNode = <div className="error">
<div>{_t('Room creation failed')}</div>
<div>{this.state.createError.message}</div>
</div>;
}
return (
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
onEnterPressed={this._onFormSubmit}
title={_t('Create Group')}
>
<form onSubmit={this._onFormSubmit}>
<div className="mx_Dialog_content">
<div className="mx_CreateGroupDialog_inputRow">
<div className="mx_CreateGroupDialog_label">
<label htmlFor="groupname">{_t('Group Name')}</label>
</div>
<div>
<input id="groupname" className="mx_CreateGroupDialog_input"
autoFocus={true} size="64"
placeholder={_t('Example')}
onChange={this._onGroupNameChange}
value={this.state.groupName}
/>
</div>
</div>
<div className="mx_CreateGroupDialog_inputRow">
<div className="mx_CreateGroupDialog_label">
<label htmlFor="groupid">{_t('Group ID')}</label>
</div>
<div>
<input id="groupid" className="mx_CreateGroupDialog_input"
size="64"
placeholder={_t('+example:%(domain)s', {domain: MatrixClientPeg.get().getDomain()})}
onChange={this._onGroupIdChange}
onBlur={this._onGroupIdBlur}
value={this.state.groupId}
/>
</div>
</div>
<div className="error">
{this.state.groupIdError}
</div>
{createErrorNode}
</div>
<div className="mx_Dialog_buttons">
<button onClick={this._onCancel}>
{ _t("Cancel") }
</button>
<input type="submit" value={_t('Create')} className="mx_Dialog_primary" />
</div>
</form>
</BaseDialog>
);
},
});

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import Analytics from '../../../Analytics';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import * as Lifecycle from '../../../Lifecycle'; import * as Lifecycle from '../../../Lifecycle';
import Velocity from 'velocity-vector'; import Velocity from 'velocity-vector';
@ -54,6 +55,7 @@ export default class DeactivateAccountDialog extends React.Component {
user: MatrixClientPeg.get().credentials.userId, user: MatrixClientPeg.get().credentials.userId,
password: this._passwordField.value, password: this._passwordField.value,
}).done(() => { }).done(() => {
Analytics.trackEvent('Account', 'Deactivate Account');
Lifecycle.onLoggedOut(); Lifecycle.onLoggedOut();
this.props.onFinished(false); this.props.onFinished(false);
}, (err) => { }, (err) => {

View file

@ -16,7 +16,7 @@ limitations under the License.
/* /*
* Usage: * Usage:
* Modal.createDialog(ErrorDialog, { * Modal.createTrackedDialog('An Identifier', 'some detail', ErrorDialog, {
* title: "some text", (default: "Error") * title: "some text", (default: "Error")
* description: "some more text", * description: "some more text",
* button: "Button Text", * button: "Button Text",

View file

@ -88,7 +88,7 @@ export default React.createClass({
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
console.log("KeyShareDialog: Starting verify dialog"); console.log("KeyShareDialog: Starting verify dialog");
Modal.createDialog(DeviceVerifyDialog, { Modal.createTrackedDialog('Key Share', 'Starting dialog', DeviceVerifyDialog, {
userId: this.props.userId, userId: this.props.userId,
device: this.state.deviceInfo, device: this.state.deviceInfo,
onFinished: (verified) => { onFinished: (verified) => {

View file

@ -31,7 +31,7 @@ export default React.createClass({
_sendBugReport: function() { _sendBugReport: function() {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
Modal.createDialog(BugReportDialog, {}); Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
}, },
_continueClicked: function() { _continueClicked: function() {

View file

@ -55,7 +55,7 @@ export default React.createClass({
const emailAddress = this.state.emailAddress; const emailAddress = this.state.emailAddress;
if (!Email.looksValid(emailAddress)) { if (!Email.looksValid(emailAddress)) {
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Invalid Email Address', '', ErrorDialog, {
title: _t("Invalid Email Address"), title: _t("Invalid Email Address"),
description: _t("This doesn't appear to be a valid email address"), description: _t("This doesn't appear to be a valid email address"),
}); });
@ -65,7 +65,7 @@ export default React.createClass({
// we always bind emails when registering, so let's do the // we always bind emails when registering, so let's do the
// same here. // same here.
this._addThreepid.addEmailAddress(emailAddress, true).done(() => { this._addThreepid.addEmailAddress(emailAddress, true).done(() => {
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Verification Pending', '', QuestionDialog, {
title: _t("Verification Pending"), title: _t("Verification Pending"),
description: _t( description: _t(
"Please check your email and click on the link it contains. Once this " + "Please check your email and click on the link it contains. Once this " +
@ -77,7 +77,7 @@ export default React.createClass({
}, (err) => { }, (err) => {
this.setState({emailBusy: false}); this.setState({emailBusy: false});
console.error("Unable to add email address " + emailAddress + " " + err); console.error("Unable to add email address " + emailAddress + " " + err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Unable to add email address', '', ErrorDialog, {
title: _t("Unable to add email address"), title: _t("Unable to add email address"),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
@ -106,7 +106,7 @@ export default React.createClass({
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const message = _t("Unable to verify email address.") + " " + const message = _t("Unable to verify email address.") + " " +
_t("Please check your email and click on the link it contains. Once this is done, click continue."); _t("Please check your email and click on the link it contains. Once this is done, click continue.");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Verification Pending', '3pid Auth Failed', QuestionDialog, {
title: _t("Verification Pending"), title: _t("Verification Pending"),
description: message, description: message,
button: _t('Continue'), button: _t('Continue'),
@ -115,7 +115,7 @@ export default React.createClass({
} else { } else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to verify email address: " + err); console.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Unable to verify email address', '', ErrorDialog, {
title: _t("Unable to verify email address."), title: _t("Unable to verify email address."),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import q from 'q'; import Promise from 'bluebird';
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
@ -106,6 +106,16 @@ export default React.createClass({
}, },
_doUsernameCheck: function() { _doUsernameCheck: function() {
// XXX: SPEC-1
// Check if username is valid
// Naive impl copied from https://github.com/matrix-org/matrix-react-sdk/blob/66c3a6d9ca695780eb6b662e242e88323053ff33/src/components/views/login/RegistrationForm.js#L190
if (encodeURIComponent(this.state.username) !== this.state.username) {
this.setState({
usernameError: _t('User names may only contain letters, numbers, dots, hyphens and underscores.'),
});
return Promise.reject();
}
// Check if username is available // Check if username is available
return this._matrixClient.isUsernameAvailable(this.state.username).then( return this._matrixClient.isUsernameAvailable(this.state.username).then(
(isAvailable) => { (isAvailable) => {
@ -242,7 +252,7 @@ export default React.createClass({
return ( return (
<BaseDialog className="mx_SetMxIdDialog" <BaseDialog className="mx_SetMxIdDialog"
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title="To get started, please pick a username!" title={_t('To get started, please pick a username!')}
> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_SetMxIdDialog_input_group"> <div className="mx_SetMxIdDialog_input_group">

View file

@ -0,0 +1,75 @@
import React from 'react';
import PropTypes from 'prop-types';
import url from 'url';
import { _t } from '../../../languageHandler';
export default class AppPermission extends React.Component {
constructor(props) {
super(props);
const curlBase = this.getCurlBase();
this.state = { curlBase: curlBase};
}
// Return string representation of content URL without query parameters
getCurlBase() {
const wurl = url.parse(this.props.url);
let curl;
let curlString;
const searchParams = new URLSearchParams(wurl.search);
if(this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) {
curl = url.parse(searchParams.get('url'));
if(curl) {
curl.search = curl.query = "";
curlString = curl.format();
}
}
if (!curl && wurl) {
wurl.search = wurl.query = "";
curlString = wurl.format();
}
return curlString;
}
isScalarWurl(wurl) {
if(wurl && wurl.hostname && (
wurl.hostname === 'scalar.vector.im' ||
wurl.hostname === 'scalar-staging.riot.im' ||
wurl.hostname === 'scalar-develop.riot.im' ||
wurl.hostname === 'demo.riot.im' ||
wurl.hostname === 'localhost'
)) {
return true;
}
return false;
}
render() {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src='img/warning.svg' alt={_t('Warning!')}/>
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>Do you want to load widget from URL:</span> <span className='mx_AppPermissionWarningTextURL'>{this.state.curlBase}</span>
</div>
<input
className='mx_AppPermissionButton'
type='button'
value={_t('Allow')}
onClick={this.props.onPermissionGranted}
/>
</div>
);
}
}
AppPermission.propTypes = {
url: PropTypes.string.isRequired,
onPermissionGranted: PropTypes.func.isRequired,
};
AppPermission.defaultProps = {
onPermissionGranted: function() {},
};

View file

@ -0,0 +1,284 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import url from 'url';
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import ScalarAuthClient from '../../../ScalarAuthClient';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner';
import WidgetUtils from '../../../WidgetUtils';
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const betaHelpMsg = 'This feature is currently experimental and is intended for beta testing only';
export default React.createClass({
displayName: 'AppTile',
propTypes: {
id: React.PropTypes.string.isRequired,
url: React.PropTypes.string.isRequired,
name: React.PropTypes.string.isRequired,
room: React.PropTypes.object.isRequired,
type: React.PropTypes.string.isRequired,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: React.PropTypes.bool,
},
getDefaultProps: function() {
return {
url: "",
};
},
getInitialState: function() {
const widgetPermissionId = [this.props.room.roomId, encodeURIComponent(this.props.url)].join('_');
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
return {
loading: false,
widgetUrl: this.props.url,
widgetPermissionId: widgetPermissionId,
hasPermissionToLoad: Boolean(hasPermissionToLoad === 'true'),
error: null,
deleting: false,
};
},
// Returns true if props.url is a scalar URL, typically https://scalar.vector.im/api
isScalarUrl: function() {
const scalarUrl = SdkConfig.get().integrations_rest_url;
return scalarUrl && this.props.url.startsWith(scalarUrl);
},
isMixedContent: function() {
const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.url);
const childContentProtocol = u.protocol;
if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') {
console.warn("Refusing to load mixed-content app:",
parentContentProtocol, childContentProtocol, window.location, this.props.url);
return true;
}
return false;
},
componentWillMount: function() {
if (!this.isScalarUrl()) {
return;
}
// Fetch the token before loading the iframe as we need to mangle the URL
this.setState({
loading: true,
});
this._scalarClient = new ScalarAuthClient();
this._scalarClient.getScalarToken().done((token) => {
// Append scalar_token as a query param
this._scalarClient.scalarToken = token;
const u = url.parse(this.props.url);
if (!u.search) {
u.search = "?scalar_token=" + encodeURIComponent(token);
} else {
u.search += "&scalar_token=" + encodeURIComponent(token);
}
this.setState({
error: null,
widgetUrl: u.format(),
loading: false,
});
}, (err) => {
this.setState({
error: err.message,
loading: false,
});
});
},
_canUserModify: function() {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
},
_onEditClick: function(e) {
console.log("Edit widget ID ", this.props.id);
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = this._scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'type_' + this.props.type);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
},
/* If user has permission to modify widgets, delete the widget, otherwise revoke access for the widget to load in the user's browser
*/
_onDeleteClick: function() {
if (this._canUserModify()) {
console.log("Delete widget %s", this.props.id);
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
this.setState({deleting: false});
});
} else {
console.log("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission();
}
},
// Widget labels to render, depending upon user permissions
// These strings are translated at the point that they are inserted in to the DOM, in the render method
_deleteWidgetLabel() {
if (this._canUserModify()) {
return 'Delete widget';
}
return 'Revoke widget access';
},
/* TODO -- Store permission in account data so that it is persisted across multiple devices */
_grantWidgetPermission() {
console.warn('Granting permission to load widget - ', this.state.widgetUrl);
localStorage.setItem(this.state.widgetPermissionId, true);
this.setState({hasPermissionToLoad: true});
},
_revokeWidgetPermission() {
console.warn('Revoking permission to load widget - ', this.state.widgetUrl);
localStorage.removeItem(this.state.widgetPermissionId);
this.setState({hasPermissionToLoad: false});
},
formatAppTileName: function() {
let appTileName = "No name";
if(this.props.name && this.props.name.trim()) {
appTileName = this.props.name.trim();
appTileName = appTileName[0].toUpperCase() + appTileName.slice(1).toLowerCase();
}
return appTileName;
},
render: function() {
let appTileBody;
// Don't render widget if it is in the process of being deleted
if (this.state.deleting) {
return <div></div>;
}
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to prgramatically remove the sandbox attribute, but
// this would only be for content hosted on the same origin as the riot client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts allow-presentation";
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
if (this.state.loading) {
appTileBody = (
<div className='mx_AppTileBody mx_AppLoading'>
<MessageSpinner msg='Loading...'/>
</div>
);
} else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) {
appTileBody = (
<div className="mx_AppTileBody">
<AppWarning
errorMsg="Error - Mixed content"
/>
</div>
);
} else {
appTileBody = (
<div className="mx_AppTileBody">
<iframe
ref="appFrame"
src={safeWidgetUrl}
allowFullScreen="true"
sandbox={sandboxFlags}
></iframe>
</div>
);
}
} else {
appTileBody = (
<div className="mx_AppTileBody">
<AppPermission
url={this.state.widgetUrl}
onPermissionGranted={this._grantWidgetPermission}
/>
</div>
);
}
// editing is done in scalar
const showEditButton = Boolean(this._scalarClient && this._canUserModify());
const deleteWidgetLabel = this._deleteWidgetLabel();
let deleteIcon = 'img/cancel.svg';
let deleteClasses = 'mx_filterFlipColor mx_AppTileMenuBarWidget';
if(this._canUserModify()) {
deleteIcon = 'img/cancel-red.svg';
deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
}
return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
<div className="mx_AppTileMenuBar">
{this.formatAppTileName()}
<span className="mx_AppTileMenuBarWidgets">
<span className="mx_Beta" alt={betaHelpMsg} title={betaHelpMsg}>&#946;</span>
{/* Edit widget */}
{showEditButton && <img
src="img/edit.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8"
alt={_t('Edit')}
title={_t('Edit')}
onClick={this._onEditClick}
/>}
{/* Delete widget */}
<img src={deleteIcon}
className={deleteClasses}
width="8" height="8"
alt={_t(deleteWidgetLabel)}
title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick}
/>
</span>
</div>
{appTileBody}
</div>
);
},
});

View file

@ -0,0 +1,25 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const AppWarning = (props) => {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src='img/warning.svg' alt={_t('Warning!')}/>
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{props.errorMsg}</span>
</div>
</div>
);
};
AppWarning.propTypes = {
errorMsg: PropTypes.string,
};
AppWarning.defaultProps = {
errorMsg: 'Error',
};
export default AppWarning;

View file

@ -52,7 +52,7 @@ export default React.createClass({
onVerifyClick: function() { onVerifyClick: function() {
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
Modal.createDialog(DeviceVerifyDialog, { Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: this.props.userId, userId: this.props.userId,
device: this.state.device, device: this.state.device,
}); });

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import q from 'q'; import Promise from 'bluebird';
/** /**
* A component which wraps an EditableText, with a spinner while updates take * A component which wraps an EditableText, with a spinner while updates take
@ -148,5 +148,5 @@ EditableTextContainer.defaultProps = {
initialValue: "", initialValue: "",
placeholder: "", placeholder: "",
blurToSubmit: false, blurToSubmit: false,
onSubmit: function(v) {return q(); }, onSubmit: function(v) {return Promise.resolve(); },
}; };

View file

@ -0,0 +1,34 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
module.exports = React.createClass({
displayName: 'MessageSpinner',
render: function() {
const w = this.props.w || 32;
const h = this.props.h || 32;
const imgClass = this.props.imgClassName || "";
const msg = this.props.msg || "Loading...";
return (
<div className="mx_Spinner">
<div className="mx_Spinner_Msg">{msg}</div>&nbsp;
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
</div>
);
},
});

View file

@ -0,0 +1,212 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import classNames from 'classnames';
import { Room, RoomMember } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix';
import { getDisplayAliasForRoom } from '../../../Rooms';
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
// For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+])[^\/]*)$/;
const Pill = React.createClass({
statics: {
isPillUrl: (url) => {
return !!REGEX_MATRIXTO.exec(url);
},
isMessagePillUrl: (url) => {
return !!REGEX_LOCAL_MATRIXTO.exec(url);
},
TYPE_USER_MENTION: 'TYPE_USER_MENTION',
TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION',
},
props: {
// The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl)
url: PropTypes.string,
// Whether the pill is in a message
inMessage: PropTypes.bool,
// The room in which this pill is being rendered
room: PropTypes.instanceOf(Room),
// Whether to include an avatar in the pill
shouldShowPillAvatar: PropTypes.bool,
},
getInitialState() {
return {
// ID/alias of the room/user
resourceId: null,
// Type of pill
pillType: null,
// The member related to the user pill
member: null,
// The room related to the room pill
room: null,
};
},
componentWillReceiveProps(nextProps) {
let regex = REGEX_MATRIXTO;
if (nextProps.inMessage) {
regex = REGEX_LOCAL_MATRIXTO;
}
// Default to the empty array if no match for simplicity
// resource and prefix will be undefined instead of throwing
const matrixToMatch = regex.exec(nextProps.url) || [];
const resourceId = matrixToMatch[1]; // The room/user ID
const prefix = matrixToMatch[2]; // The first character of prefix
const pillType = {
'@': Pill.TYPE_USER_MENTION,
'#': Pill.TYPE_ROOM_MENTION,
'!': Pill.TYPE_ROOM_MENTION,
}[prefix];
let member;
let room;
switch (pillType) {
case Pill.TYPE_USER_MENTION: {
const localMember = nextProps.room.getMember(resourceId);
member = localMember;
if (!localMember) {
member = new RoomMember(null, resourceId);
this.doProfileLookup(resourceId, member);
}
}
break;
case Pill.TYPE_ROOM_MENTION: {
const localRoom = resourceId[0] === '#' ?
MatrixClientPeg.get().getRooms().find((r) => {
return r.getAliases().includes(resourceId);
}) : MatrixClientPeg.get().getRoom(resourceId);
room = localRoom;
if (!localRoom) {
// TODO: This would require a new API to resolve a room alias to
// a room avatar and name.
// this.doRoomProfileLookup(resourceId, member);
}
}
break;
}
this.setState({resourceId, pillType, member, room});
},
componentWillMount() {
this._unmounted = false;
this.componentWillReceiveProps(this.props);
},
componentWillUnmount() {
this._unmounted = true;
},
doProfileLookup: function(userId, member) {
MatrixClientPeg.get().getProfileInfo(userId).then((resp) => {
if (this._unmounted) {
return;
}
member.name = resp.displayname;
member.rawDisplayName = resp.displayname;
member.events.member = {
getContent: () => {
return {avatar_url: resp.avatar_url};
},
};
this.setState({member});
}).catch((err) => {
console.error('Could not retrieve profile data for ' + userId + ':', err);
});
},
onUserPillClicked: function() {
dis.dispatch({
action: 'view_user',
member: this.state.member,
});
},
render: function() {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
const resource = this.state.resourceId;
let avatar = null;
let linkText = resource;
let pillClass;
let userId;
let href = this.props.url;
let onClick;
switch (this.state.pillType) {
case Pill.TYPE_USER_MENTION: {
// If this user is not a member of this room, default to the empty member
const member = this.state.member;
if (member) {
userId = member.userId;
linkText = member.rawDisplayName.replace(' (IRC)', ''); // FIXME when groups are done
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16}/>;
}
pillClass = 'mx_UserPill';
href = null;
onClick = this.onUserPillClicked.bind(this);
}
}
break;
case Pill.TYPE_ROOM_MENTION: {
const room = this.state.room;
if (room) {
linkText = (room ? getDisplayAliasForRoom(room) : null) || resource;
if (this.props.shouldShowPillAvatar) {
avatar = <RoomAvatar room={room} width={16} height={16}/>;
}
pillClass = 'mx_RoomPill';
}
}
break;
}
const classes = classNames(pillClass, {
"mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId,
});
if (this.state.pillType) {
return this.props.inMessage ?
<a className={classes} href={href} onClick={onClick} title={resource} data-offset-key={this.props.offsetKey}>
{avatar}
{linkText}
</a> :
<span className={classes} title={resource} data-offset-key={this.props.offsetKey}>
{avatar}
{linkText}
</span>;
} else {
// Deliberately render nothing if the URL isn't recognised
return null;
}
},
});
export default Pill;

View file

@ -69,12 +69,21 @@ class PasswordLogin extends React.Component {
onSubmitForm(ev) { onSubmitForm(ev) {
ev.preventDefault(); ev.preventDefault();
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) {
this.props.onSubmit( this.props.onSubmit(
this.state.username, '', // XXX: Synapse breaks if you send null here:
this.state.phoneCountry, this.state.phoneCountry,
this.state.phoneNumber, this.state.phoneNumber,
this.state.password, this.state.password,
); );
return;
}
this.props.onSubmit(
this.state.username,
null,
null,
this.state.password,
);
} }
onUsernameChanged(ev) { onUsernameChanged(ev) {

View file

@ -95,7 +95,7 @@ module.exports = React.createClass({
if (this.allFieldsValid()) { if (this.allFieldsValid()) {
if (this.refs.email.value == '') { if (this.refs.email.value == '') {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"), title: _t("Warning!"),
description: description:
<div> <div>

View file

@ -122,7 +122,7 @@ module.exports = React.createClass({
showHelpPopup: function() { showHelpPopup: function() {
var CustomServerDialog = sdk.getComponent('login.CustomServerDialog'); var CustomServerDialog = sdk.getComponent('login.CustomServerDialog');
Modal.createDialog(CustomServerDialog); Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
}, },
render: function() { render: function() {

View file

@ -282,7 +282,7 @@ module.exports = React.createClass({
}); });
}).catch((err) => { }).catch((err) => {
console.warn("Unable to decrypt attachment: ", err); console.warn("Unable to decrypt attachment: ", err);
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"), title: _t("Error"),
description: _t("Error decrypting attachment"), description: _t("Error decrypting attachment"),
}); });

View file

@ -24,7 +24,7 @@ import Modal from '../../../Modal';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q'; import Promise from 'bluebird';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -123,7 +123,7 @@ module.exports = React.createClass({
this.fixupHeight(); this.fixupHeight();
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = q(null); let thumbnailPromise = Promise.resolve(null);
if (content.info.thumbnail_file) { if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile( thumbnailPromise = decryptFile(
content.info.thumbnail_file, content.info.thumbnail_file,

View file

@ -20,7 +20,7 @@ import React from 'react';
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q'; import Promise from 'bluebird';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -79,7 +79,7 @@ module.exports = React.createClass({
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} else if (content.info.thumbnail_url) { } else if (content.info && content.info.thumbnail_url) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url);
} else { } else {
return null; return null;
@ -89,7 +89,7 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
var thumbnailPromise = q(null); var thumbnailPromise = Promise.resolve(null);
if (content.info.thumbnail_file) { if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile( thumbnailPromise = decryptFile(
content.info.thumbnail_file content.info.thumbnail_file

View file

@ -29,6 +29,10 @@ import Modal from '../../../Modal';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import UserSettingsStore from "../../../UserSettingsStore";
import MatrixClientPeg from '../../../MatrixClientPeg';
import {RoomMember} from 'matrix-js-sdk';
import classNames from 'classnames';
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -79,6 +83,10 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
this._unmounted = false; this._unmounted = false;
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
// are still sent as plaintext URLs. If these are ever pillified in the composer,
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
this.pillifyLinks(this.refs.content.children);
linkifyElement(this.refs.content, linkifyMatrix.options); linkifyElement(this.refs.content, linkifyMatrix.options);
this.calculateUrlPreview(); this.calculateUrlPreview();
@ -90,8 +98,19 @@ module.exports = React.createClass({
setTimeout(() => { setTimeout(() => {
if (this._unmounted) return; if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
if (UserSettingsStore.getSyncedSetting("enableSyntaxHighlightLanguageDetection", false)) {
highlight.highlightBlock(blocks[i])
} else {
// Only syntax highlight if there's a class starting with language-
let classes = blocks[i].className.split(/\s+/).filter(function (cl) {
return cl.startsWith('language-');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]); highlight.highlightBlock(blocks[i]);
} }
}
}
}, 10); }, 10);
} }
// add event handlers to the 'copy code' buttons // add event handlers to the 'copy code' buttons
@ -131,9 +150,15 @@ module.exports = React.createClass({
if (this.props.showUrlPreview && !this.state.links.length) { if (this.props.showUrlPreview && !this.state.links.length) {
var links = this.findLinks(this.refs.content.children); var links = this.findLinks(this.refs.content.children);
if (links.length) { if (links.length) {
this.setState({ links: links.map((link)=>{ // de-dup the links (but preserve ordering)
return link.getAttribute("href"); const seen = new Set();
})}); links = links.filter((link) => {
if (seen.has(link)) return false;
seen.add(link);
return true;
});
this.setState({ links: links });
// lazy-load the hidden state of the preview widget from localstorage // lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) { if (global.localStorage) {
@ -144,14 +169,44 @@ module.exports = React.createClass({
} }
}, },
pillifyLinks: function(nodes) {
const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.tagName === "A" && node.getAttribute("href")) {
const href = node.getAttribute("href");
// If the link is a (localised) matrix.to link, replace it with a pill
const Pill = sdk.getComponent('elements.Pill');
if (Pill.isMessagePillUrl(href)) {
const pillContainer = document.createElement('span');
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pill = <Pill
url={href}
inMessage={true}
room={room}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
ReactDOM.render(pill, pillContainer);
node.parentNode.replaceChild(pillContainer, node);
}
} else if (node.children && node.children.length) {
this.pillifyLinks(node.children);
}
}
},
findLinks: function(nodes) { findLinks: function(nodes) {
var links = []; var links = [];
for (var i = 0; i < nodes.length; i++) { for (var i = 0; i < nodes.length; i++) {
var node = nodes[i]; var node = nodes[i];
if (node.tagName === "A" && node.getAttribute("href")) if (node.tagName === "A" && node.getAttribute("href"))
{ {
if (this.isLinkPreviewable(node)) { if (this.isLinkPreviewable(node)) {
links.push(node); links.push(node.getAttribute("href"));
} }
} }
else if (node.tagName === "PRE" || node.tagName === "CODE" || else if (node.tagName === "PRE" || node.tagName === "CODE" ||
@ -213,26 +268,28 @@ module.exports = React.createClass({
onEmoteSenderClick: function(event) { onEmoteSenderClick: function(event) {
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
dis.dispatch({ dis.dispatch({
action: 'insert_displayname', action: 'insert_mention',
displayname: name.replace(' (IRC)', ''), user_id: mxEvent.getSender(),
}); });
}, },
getEventTileOps: function() { getEventTileOps: function() {
var self = this;
return { return {
isWidgetHidden: function() { isWidgetHidden: () => {
return self.state.widgetHidden; return this.state.widgetHidden;
}, },
unhideWidget: function() { unhideWidget: () => {
self.setState({ widgetHidden: false }); this.setState({ widgetHidden: false });
if (global.localStorage) { if (global.localStorage) {
global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId()); global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId());
} }
}, },
getInnerText: () => {
return this.refs.content.innerText;
}
}; };
}, },
@ -251,7 +308,7 @@ module.exports = React.createClass({
let completeUrl = scalarClient.getStarterLink(starterLink); let completeUrl = scalarClient.getStarterLink(starterLink);
let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let integrationsUrl = SdkConfig.get().integrations_ui_url; let integrationsUrl = SdkConfig.get().integrations_ui_url;
Modal.createDialog(QuestionDialog, { Modal.createTrackedDialog('Add an integration', '', QuestionDialog, {
title: _t("Add an Integration"), title: _t("Add an Integration"),
description: description:
<div> <div>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var q = require("q"); import Promise from 'bluebird';
var React = require('react'); var React = require('react');
var ObjectUtils = require("../../../ObjectUtils"); var ObjectUtils = require("../../../ObjectUtils");
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
@ -104,7 +104,7 @@ module.exports = React.createClass({
} }
if (oldCanonicalAlias !== this.state.canonicalAlias) { if (oldCanonicalAlias !== this.state.canonicalAlias) {
console.log("AliasSettings: Updating canonical alias"); console.log("AliasSettings: Updating canonical alias");
promises = [q.all(promises).then( promises = [Promise.all(promises).then(
MatrixClientPeg.get().sendStateEvent( MatrixClientPeg.get().sendStateEvent(
this.props.roomId, "m.room.canonical_alias", { this.props.roomId, "m.room.canonical_alias", {
alias: this.state.canonicalAlias alias: this.state.canonicalAlias
@ -154,7 +154,7 @@ module.exports = React.createClass({
} }
else { else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Invalid alias format', '', ErrorDialog, {
title: _t('Invalid alias format'), title: _t('Invalid alias format'),
description: _t('\'%(alias)s\' is not a valid format for an alias', { alias: alias }), description: _t('\'%(alias)s\' is not a valid format for an alias', { alias: alias }),
}); });
@ -170,7 +170,7 @@ module.exports = React.createClass({
} }
else { else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createTrackedDialog('Invalid address format', '', ErrorDialog, {
title: _t('Invalid address format'), title: _t('Invalid address format'),
description: _t('\'%(alias)s\' is not a valid format for an address', { alias: alias }), description: _t('\'%(alias)s\' is not a valid format for an address', { alias: alias }),
}); });

View file

@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var q = require("q"); import Promise from 'bluebird';
var React = require('react'); var React = require('react');
var sdk = require('../../../index'); var sdk = require('../../../index');
@ -72,7 +72,7 @@ module.exports = React.createClass({
saveSettings: function() { // : Promise saveSettings: function() { // : Promise
if (!this.state.hasChanged) { if (!this.state.hasChanged) {
return q(); // They didn't explicitly give a color to save. return Promise.resolve(); // They didn't explicitly give a color to save.
} }
var originalState = this.getInitialState(); var originalState = this.getInitialState();
if (originalState.primary_color !== this.state.primary_color || if (originalState.primary_color !== this.state.primary_color ||
@ -92,7 +92,7 @@ module.exports = React.createClass({
} }
}); });
} }
return q(); // no color diff return Promise.resolve(); // no color diff
}, },
_getColorIndex: function(scheme) { _getColorIndex: function(scheme) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var q = require("q"); import Promise from 'bluebird';
var React = require('react'); var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require("../../../index"); var sdk = require("../../../index");

View file

@ -0,0 +1,207 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../WidgetUtils';
module.exports = React.createClass({
displayName: 'AppsDrawer',
propTypes: {
room: React.PropTypes.object.isRequired,
},
getInitialState: function() {
return {
apps: this._getApps(),
};
},
componentWillMount: function() {
ScalarMessaging.startListening();
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
componentDidMount: function() {
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
if (this.state.apps && this.state.apps.length < 1) {
this.onClickAddWidget();
}
// TODO -- Handle Scalar errors
// },
// (err) => {
// this.setState({
// scalar_error: err,
// });
});
}
},
componentWillUnmount: function() {
ScalarMessaging.stopListening();
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
},
/**
* Encodes a URI according to a set of template variables. Variables will be
* passed through encodeURIComponent.
* @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'.
* @param {Object} variables The key/value pairs to replace the template
* variables with. E.g. { "$bar": "baz" }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
encodeUri: function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
},
_initAppConfig: function(appId, app) {
const user = MatrixClientPeg.get().getUser(this.props.userId);
const params = {
'$matrix_user_id': this.props.userId,
'$matrix_room_id': this.props.room.roomId,
'$matrix_display_name': user ? user.displayName : this.props.userId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
};
if(app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
}
app.id = appId;
app.name = app.name || app.type;
app.url = this.encodeUri(app.url, params);
return app;
},
onRoomStateEvents: function(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') {
return;
}
this._updateApps();
},
_getApps: function() {
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets');
if (!appsStateEvents) {
return [];
}
return appsStateEvents.filter((ev) => {
return ev.getContent().type && ev.getContent().url;
}).map((ev) => {
return this._initAppConfig(ev.getStateKey(), ev.getContent());
});
},
_updateApps: function() {
const apps = this._getApps();
if (apps.length < 1) {
dis.dispatch({
action: 'appsDrawer',
show: false,
});
}
this.setState({
apps: apps,
});
},
_canUserModify: function() {
try {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
} catch(err) {
console.error(err);
return false;
}
},
onClickAddWidget: function(e) {
if (e) {
e.preventDefault();
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'add_integ') :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
},
render: function() {
const apps = this.state.apps.map(
(app, index, arr) => {
return (<AppTile
key={app.id}
id={app.id}
url={app.url}
name={app.name}
type={app.type}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
/>);
});
const addWidget = this.state.apps && this.state.apps.length < 2 && this._canUserModify() &&
(<div onClick={this.onClickAddWidget}
role="button"
tabIndex="0"
className="mx_AddWidget_button"
title={_t('Add a widget')}>
[+] {_t('Add a widget')}
</div>);
return (
<div className="mx_AppsDrawer">
<div id="apps" className="mx_AppsContainer">
{apps}
</div>
{addWidget}
</div>
);
},
});

View file

@ -5,7 +5,8 @@ import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import sdk from '../../../index'; import sdk from '../../../index';
import type {Completion} from '../../../autocomplete/Autocompleter'; import type {Completion} from '../../../autocomplete/Autocompleter';
import Q from 'q'; import Promise from 'bluebird';
import UserSettingsStore from '../../../UserSettingsStore';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import {getCompletions} from '../../../autocomplete/Autocompleter';
@ -39,26 +40,62 @@ export default class Autocomplete extends React.Component {
}; };
} }
async componentWillReceiveProps(props, state) { componentWillReceiveProps(newProps, state) {
if (props.query === this.props.query) { // Query hasn't changed so don't try to complete it
return null; if (newProps.query === this.props.query) {
}
return await this.complete(props.query, props.selection);
}
async complete(query, selection) {
let forceComplete = this.state.forceComplete;
const completionPromise = getCompletions(query, selection, forceComplete);
this.completionPromise = completionPromise;
const completions = await this.completionPromise;
// There's a newer completion request, so ignore results.
if (completionPromise !== this.completionPromise) {
return; return;
} }
const completionList = flatMap(completions, provider => provider.completions); this.complete(newProps.query, newProps.selection);
}
complete(query, selection) {
this.queryRequested = query;
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
}
if (query === "") {
this.setState({
// Clear displayed completions
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: COMPOSER_SELECTED,
// Hide the autocomplete box
hide: true,
});
return Promise.resolve(null);
}
let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200);
// Don't debounce if we are already showing completions
if (this.state.completions.length > 0 || this.state.forceComplete) {
autocompleteDelay = 0;
}
const deferred = Promise.defer();
this.debounceCompletionsRequest = setTimeout(() => {
this.processQuery(query, selection).then(() => {
deferred.resolve();
});
}, autocompleteDelay);
return deferred.promise;
}
processQuery(query, selection) {
return getCompletions(
query, selection, this.state.forceComplete,
).then((completions) => {
// Only ever process the completions for the most recent query being processed
if (query !== this.queryRequested) {
return;
}
this.processCompletions(completions);
});
}
processCompletions(completions) {
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty. // Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED; let selectionOffset = COMPOSER_SELECTED;
@ -69,33 +106,26 @@ export default class Autocomplete extends React.Component {
const currentSelection = this.state.selectionOffset === 0 ? null : const currentSelection = this.state.selectionOffset === 0 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion; this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex( selectionOffset = completionList.findIndex(
completion => completion.completion === currentSelection); (completion) => completion.completion === currentSelection);
if (selectionOffset === -1) { if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED; selectionOffset = COMPOSER_SELECTED;
} else { } else {
selectionOffset++; // selectionOffset is 1-indexed! selectionOffset++; // selectionOffset is 1-indexed!
} }
} else {
// If no completions were returned, we should turn off force completion.
forceComplete = false;
} }
let hide = this.state.hide; let hide = this.state.hide;
// These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern // If `completion.command.command` is truthy, then a provider has matched with the query
const oldMatches = this.state.completions.map(completion => !!completion.command.command), const anyMatches = completions.some((completion) => !!completion.command.command);
newMatches = completions.map(completion => !!completion.command.command); hide = !anyMatches;
// So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one
if (!isEqual(oldMatches, newMatches)) {
hide = false;
}
this.setState({ this.setState({
completions, completions,
completionList, completionList,
selectionOffset, selectionOffset,
hide, hide,
forceComplete, // Force complete is turned off each time since we can't edit the query in that case
forceComplete: false,
}); });
} }
@ -142,16 +172,17 @@ export default class Autocomplete extends React.Component {
} }
hide() { hide() {
this.setState({hide: true, selectionOffset: 0}); this.setState({hide: true, selectionOffset: 0, completions: [], completionList: []});
} }
forceComplete() { forceComplete() {
const done = Q.defer(); const done = Promise.defer();
this.setState({ this.setState({
forceComplete: true, forceComplete: true,
hide: false,
}, () => { }, () => {
this.complete(this.props.query, this.props.selection).then(() => { this.complete(this.props.query, this.props.selection).then(() => {
done.resolve(); done.resolve(this.countCompletions());
}); });
}); });
return done.promise; return done.promise;
@ -169,7 +200,7 @@ export default class Autocomplete extends React.Component {
} }
setSelection(selectionOffset: number) { setSelection(selectionOffset: number) {
this.setState({selectionOffset}); this.setState({selectionOffset, hide: false});
} }
componentDidUpdate() { componentDidUpdate() {
@ -185,21 +216,24 @@ export default class Autocomplete extends React.Component {
} }
} }
setState(state, func) {
super.setState(state, func);
}
render() { render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText'); const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 1; let position = 1;
let renderedCompletions = this.state.completions.map((completionResult, i) => { const renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => { const completions = completionResult.completions.map((completion, i) => {
const className = classNames('mx_Autocomplete_Completion', { const className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset, 'selected': position === this.state.selectionOffset,
}); });
let componentPosition = position; const componentPosition = position;
position++; position++;
let onMouseOver = () => this.setSelection(componentPosition); const onMouseOver = () => this.setSelection(componentPosition);
let onClick = () => { const onClick = () => {
this.setSelection(componentPosition); this.setSelection(componentPosition);
this.onCompletionClicked(); this.onCompletionClicked();
}; };
@ -220,7 +254,7 @@ export default class Autocomplete extends React.Component {
{completionResult.provider.renderCompletions(completions)} {completionResult.provider.renderCompletions(completions)}
</div> </div>
) : null; ) : null;
}).filter(completion => !!completion); }).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? ( return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}> <div className="mx_Autocomplete" ref={(e) => this.container = e}>

View file

@ -19,7 +19,9 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import sdk from '../../../index'; import sdk from '../../../index';
import dis from "../../../dispatcher"; import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils'; import ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t, _tJsx} from '../../../languageHandler'; import { _t, _tJsx} from '../../../languageHandler';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({ module.exports = React.createClass({
@ -28,6 +30,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// js-sdk room object // js-sdk room object
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
userId: React.PropTypes.string.isRequired,
showApps: React.PropTypes.bool,
// Conference Handler implementation // Conference Handler implementation
conferenceHandler: React.PropTypes.object, conferenceHandler: React.PropTypes.object,
@ -70,10 +74,10 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var CallView = sdk.getComponent("voip.CallView"); const CallView = sdk.getComponent("voip.CallView");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
var fileDropTarget = null; let fileDropTarget = null;
if (this.props.draggingFile) { if (this.props.draggingFile) {
fileDropTarget = ( fileDropTarget = (
<div className="mx_RoomView_fileDropTarget"> <div className="mx_RoomView_fileDropTarget">
@ -87,14 +91,13 @@ module.exports = React.createClass({
); );
} }
var conferenceCallNotification = null; let conferenceCallNotification = null;
if (this.props.displayConfCallNotification) { if (this.props.displayConfCallNotification) {
let supportedText = ''; let supportedText = '';
let joinNode; let joinNode;
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
supportedText = _t(" (unsupported)"); supportedText = _t(" (unsupported)");
} } else {
else {
joinNode = (<span> joinNode = (<span>
{_tJsx( {_tJsx(
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.", "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
@ -105,7 +108,6 @@ module.exports = React.createClass({
] ]
)} )}
</span>); </span>);
} }
// XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages, // XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages,
// but there are translations for this in the languages we do have so I'm leaving it for now. // but there are translations for this in the languages we do have so I'm leaving it for now.
@ -118,7 +120,7 @@ module.exports = React.createClass({
); );
} }
var callView = ( const callView = (
<CallView ref="callView" room={this.props.room} <CallView ref="callView" room={this.props.room}
ConferenceHandler={this.props.conferenceHandler} ConferenceHandler={this.props.conferenceHandler}
onResize={this.props.onResize} onResize={this.props.onResize}
@ -126,8 +128,17 @@ module.exports = React.createClass({
/> />
); );
let appsDrawer = null;
if(UserSettingsStore.isFeatureEnabled('matrix_apps') && this.props.showApps) {
appsDrawer = <AppsDrawer ref="appsDrawer"
room={this.props.room}
userId={this.props.userId}
maxHeight={this.props.maxHeight}/>;
}
return ( return (
<div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} > <div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} >
{ appsDrawer }
{ fileDropTarget } { fileDropTarget }
{ callView } { callView }
{ conferenceCallNotification } { conferenceCallNotification }

Some files were not shown because too many files have changed in this diff Show more