diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 4584d7ed65..ffd492d491 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -146,7 +146,6 @@ src/Roles.js src/RoomListSorter.js src/RoomNotifs.js src/Rooms.js -src/RtsClient.js src/ScalarAuthClient.js src/ScalarMessaging.js src/SdkConfig.js diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000000..81770c6585 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,6 @@ +[include] +src/**/*.js +test/**/*.js + +[ignore] +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 16eeca8fa7..8bc4bbcfce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,127 @@ +Changes in [0.9.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.7) (2017-06-22) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.6...v0.9.7) + + * Fix ability to invite users with caps in their user IDs + [\#1128](https://github.com/matrix-org/matrix-react-sdk/pull/1128) + * Fix another race with first-sync + [\#1131](https://github.com/matrix-org/matrix-react-sdk/pull/1131) + * Make the indexeddb worker script work again + [\#1132](https://github.com/matrix-org/matrix-react-sdk/pull/1132) + * Use the web worker when clearing js-sdk stores + [\#1133](https://github.com/matrix-org/matrix-react-sdk/pull/1133) + +Changes in [0.9.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.6) (2017-06-20) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.5...v0.9.6) + + * Fix infinite spinner on email registration + [\#1120](https://github.com/matrix-org/matrix-react-sdk/pull/1120) + * Translate help promots in room list + [\#1121](https://github.com/matrix-org/matrix-react-sdk/pull/1121) + * Internationalise the drop targets + [\#1122](https://github.com/matrix-org/matrix-react-sdk/pull/1122) + * Fix another infinite spin on register + [\#1124](https://github.com/matrix-org/matrix-react-sdk/pull/1124) + + +Changes in [0.9.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.5) (2017-06-19) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.5-rc.2...v0.9.5) + + * Don't peek when creating a room + [\#1113](https://github.com/matrix-org/matrix-react-sdk/pull/1113) + * More translations & translation fixes + + +Changes in [0.9.5-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.5-rc.2) (2017-06-16) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.5-rc.1...v0.9.5-rc.2) + + * Avoid getting stuck in a loop in CAS login + [\#1109](https://github.com/matrix-org/matrix-react-sdk/pull/1109) + * Update from Weblate. + [\#1101](https://github.com/matrix-org/matrix-react-sdk/pull/1101) + * Correctly inspect state when rejecting invite + [\#1108](https://github.com/matrix-org/matrix-react-sdk/pull/1108) + * Make sure to pass the roomAlias to the preview header if we have it + [\#1107](https://github.com/matrix-org/matrix-react-sdk/pull/1107) + * Make sure captcha disappears when container does + [\#1106](https://github.com/matrix-org/matrix-react-sdk/pull/1106) + * Fix URL previews + [\#1105](https://github.com/matrix-org/matrix-react-sdk/pull/1105) + +Changes in [0.9.5-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.5-rc.1) (2017-06-15) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.4...v0.9.5-rc.1) + + * Groundwork for tests including a teamserver login + [\#1098](https://github.com/matrix-org/matrix-react-sdk/pull/1098) + * Show a spinner when accepting an invite and waitingForRoom + [\#1100](https://github.com/matrix-org/matrix-react-sdk/pull/1100) + * Display a spinner until new room object after join success + [\#1099](https://github.com/matrix-org/matrix-react-sdk/pull/1099) + * Luke/attempt fix peeking regression + [\#1097](https://github.com/matrix-org/matrix-react-sdk/pull/1097) + * Show correct text in set email password dialog (2) + [\#1096](https://github.com/matrix-org/matrix-react-sdk/pull/1096) + * Don't create a guest login if user went to /login + [\#1092](https://github.com/matrix-org/matrix-react-sdk/pull/1092) + * Give password confirmation correct title, description + [\#1095](https://github.com/matrix-org/matrix-react-sdk/pull/1095) + * Make enter submit change password form + [\#1094](https://github.com/matrix-org/matrix-react-sdk/pull/1094) + * When not specified, remove roomAlias state in RoomViewStore + [\#1093](https://github.com/matrix-org/matrix-react-sdk/pull/1093) + * Update from Weblate. + [\#1091](https://github.com/matrix-org/matrix-react-sdk/pull/1091) + * Fixed pagination infinite loop caused by long messages + [\#1045](https://github.com/matrix-org/matrix-react-sdk/pull/1045) + * Clear persistent storage on login and logout + [\#1085](https://github.com/matrix-org/matrix-react-sdk/pull/1085) + * DM guessing: prefer oldest joined member + [\#1087](https://github.com/matrix-org/matrix-react-sdk/pull/1087) + * Ask for email address after setting password for the first time + [\#1090](https://github.com/matrix-org/matrix-react-sdk/pull/1090) + * i18n for setting password flow + [\#1089](https://github.com/matrix-org/matrix-react-sdk/pull/1089) + * remove mx_filterFlipColor from verified e2e icon so its not purple :/ + [\#1088](https://github.com/matrix-org/matrix-react-sdk/pull/1088) + * width and height must be int otherwise synapse cries + [\#1083](https://github.com/matrix-org/matrix-react-sdk/pull/1083) + * remove RoomViewStore listener from MatrixChat on unmount + [\#1084](https://github.com/matrix-org/matrix-react-sdk/pull/1084) + * Add script to copy translations between files + [\#1082](https://github.com/matrix-org/matrix-react-sdk/pull/1082) + * Only process user_directory response if it's for the current query + [\#1081](https://github.com/matrix-org/matrix-react-sdk/pull/1081) + * Fix regressions with starting a 1-1. + [\#1080](https://github.com/matrix-org/matrix-react-sdk/pull/1080) + * allow forcing of TURN + [\#1079](https://github.com/matrix-org/matrix-react-sdk/pull/1079) + * Remove a bunch of dead code from react-sdk + [\#1077](https://github.com/matrix-org/matrix-react-sdk/pull/1077) + * Improve error logging/reporting in megolm import/export + [\#1061](https://github.com/matrix-org/matrix-react-sdk/pull/1061) + * Delinting + [\#1064](https://github.com/matrix-org/matrix-react-sdk/pull/1064) + * Show reason for a call hanging up unexpectedly. + [\#1071](https://github.com/matrix-org/matrix-react-sdk/pull/1071) + * Add reason for ban in room settings + [\#1072](https://github.com/matrix-org/matrix-react-sdk/pull/1072) + * adds mx_filterFlipColor so that the dark theme will invert this image + [\#1070](https://github.com/matrix-org/matrix-react-sdk/pull/1070) + +Changes in [0.9.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.4) (2017-06-14) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.3...v0.9.4) + + * Ask for email address after setting password for the first time + [\#1090](https://github.com/matrix-org/matrix-react-sdk/pull/1090) + * DM guessing: prefer oldest joined member + [\#1087](https://github.com/matrix-org/matrix-react-sdk/pull/1087) + * More translations + Changes in [0.9.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.3) (2017-06-12) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.3-rc.2...v0.9.3) diff --git a/package.json b/package.json index 12a17900be..8d638a5928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.9.3", + "version": "0.9.7", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -51,7 +51,7 @@ "classnames": "^2.1.2", "commonmark": "^0.27.0", "counterpart": "^0.18.0", - "draft-js": "^0.8.1", + "draft-js": "^0.9.1", "draft-js-export-html": "^0.5.0", "draft-js-export-markdown": "^0.2.0", "emojione": "2.2.3", @@ -64,7 +64,7 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "0.7.11", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "prop-types": "^15.5.8", "q": "^1.4.1", diff --git a/src/BasePlatform.js b/src/BasePlatform.js index d0d8e0c74e..a920479823 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -17,6 +17,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import dis from './dispatcher'; + /** * Base class for classes that provide platform-specific functionality * eg. Setting an application badge or displaying notifications @@ -27,6 +29,16 @@ export default class BasePlatform { constructor() { this.notificationCount = 0; this.errorDidOccur = false; + + dis.register(this._onAction.bind(this)); + } + + _onAction(payload: Object) { + switch (payload.action) { + case 'on_logged_out': + this.setNotificationCount(0); + break; + } } // Used primarily for Analytics diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js new file mode 100644 index 0000000000..3e19a78bfe --- /dev/null +++ b/src/ComposerHistoryManager.js @@ -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} from 'draft-js'; +import * as RichText from './RichText'; +import Markdown from './Markdown'; +import _flow from 'lodash/flow'; +import _clamp from 'lodash/clamp'; + +type MessageFormat = 'html' | 'markdown'; + +class HistoryItem { + message: string = ''; + format: MessageFormat = 'html'; + + constructor(message: string, format: MessageFormat) { + this.message = message; + this.format = format; + } + + toContentState(format: MessageFormat): ContentState { + let {message} = this; + if (format === 'markdown') { + if (this.format === 'html') { + message = _flow([RichText.htmlToContentState, RichText.stateToMarkdown])(message); + } + return ContentState.createFromText(message); + } else { + if (this.format === 'markdown') { + message = new Markdown(message).toHTML(); + } + return RichText.htmlToContentState(message); + } + } +} + +export default class ComposerHistoryManager { + history: Array = []; + prefix: string; + lastIndex: number = 0; + currentIndex: number = -1; + + constructor(roomId: string, prefix: string = 'mx_composer_history_') { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + for(; sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`); this.lastIndex++, this.currentIndex++) { + this.history.push( + Object.assign( + new HistoryItem(), + JSON.parse(sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`)), + ), + ); + } + this.currentIndex--; + } + + addItem(message: string, format: MessageFormat) { + const item = new HistoryItem(message, 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; + } +} diff --git a/src/DateUtils.js b/src/DateUtils.js index 0bce7c8a16..545d92dd3b 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -60,7 +60,7 @@ function twelveHourTime(date) { } module.exports = { - formatDate: function(date) { + formatDate: function(date, showTwelveHour=false) { var now = new Date(); const days = getDaysArray(); const months = getMonthsArray(); @@ -69,7 +69,7 @@ module.exports = { } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { // TODO: use standard date localize function provided in counterpart - return _t('%(weekDayName)s %(time)s', {weekDayName: days[date.getDay()], time: this.formatTime(date)}); + return _t('%(weekDayName)s %(time)s', {weekDayName: days[date.getDay()], time: this.formatTime(date, showTwelveHour)}); } else if (now.getFullYear() === date.getFullYear()) { // TODO: use standard date localize function provided in counterpart @@ -80,7 +80,7 @@ module.exports = { time: this.formatTime(date), }); } - return this.formatFullDate(date); + return this.formatFullDate(date, showTwelveHour); }, formatFullDate: function(date, showTwelveHour=false) { diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index aec32092ed..a32d05e4ff 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -124,6 +124,7 @@ var sanitizeHtmlParams = { // would make sense if we did img: ['src'], 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 selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], @@ -165,6 +166,19 @@ var sanitizeHtmlParams = { attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ 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) { // Delete any style previously assigned, style is an allowedTag for font and span // because attributes are stripped after transforming diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js new file mode 100644 index 0000000000..1da4922153 --- /dev/null +++ b/src/KeyRequestHandler.js @@ -0,0 +1,138 @@ +/* +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 sdk from './index'; +import Modal from './Modal'; + +export default class KeyRequestHandler { + constructor(matrixClient) { + this._matrixClient = matrixClient; + + // the user/device for which we currently have a dialog open + this._currentUser = null; + this._currentDevice = null; + + // userId -> deviceId -> [keyRequest] + this._pendingKeyRequests = Object.create(null); + } + + handleKeyRequest(keyRequest) { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const requestId = keyRequest.requestId; + + if (!this._pendingKeyRequests[userId]) { + this._pendingKeyRequests[userId] = Object.create(null); + } + if (!this._pendingKeyRequests[userId][deviceId]) { + this._pendingKeyRequests[userId][deviceId] = []; + } + + // check if we already have this request + const requests = this._pendingKeyRequests[userId][deviceId]; + if (requests.find((r) => r.requestId === requestId)) { + console.log("Already have this key request, ignoring"); + return; + } + + requests.push(keyRequest); + + if (this._currentUser) { + // ignore for now + console.log("Key request, but we already have a dialog open"); + return; + } + + this._processNextRequest(); + } + + handleKeyRequestCancellation(cancellation) { + // see if we can find the request in the queue + const userId = cancellation.userId; + const deviceId = cancellation.deviceId; + const requestId = cancellation.requestId; + + if (userId === this._currentUser && deviceId === this._currentDevice) { + console.log( + "room key request cancellation for the user we currently have a" + + " dialog open for", + ); + // TODO: update the dialog. For now, we just ignore the + // cancellation. + return; + } + + if (!this._pendingKeyRequests[userId]) { + return; + } + const requests = this._pendingKeyRequests[userId][deviceId]; + if (!requests) { + return; + } + const idx = requests.findIndex((r) => r.requestId === requestId); + if (idx < 0) { + return; + } + console.log("Forgetting room key request"); + requests.splice(idx, 1); + if (requests.length === 0) { + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + } + } + + _processNextRequest() { + const userId = Object.keys(this._pendingKeyRequests)[0]; + if (!userId) { + return; + } + const deviceId = Object.keys(this._pendingKeyRequests[userId])[0]; + if (!deviceId) { + return; + } + console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`); + + const finished = (r) => { + this._currentUser = null; + this._currentDevice = null; + + if (r) { + for (const req of this._pendingKeyRequests[userId][deviceId]) { + req.share(); + } + } + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + + this._processNextRequest(); + }; + + const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog"); + Modal.createDialog(KeyShareDialog, { + matrixClient: this._matrixClient, + userId: userId, + deviceId: deviceId, + onFinished: finished, + }); + this._currentUser = userId; + this._currentDevice = deviceId; + } +} + diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 54014a0166..06f5d9ef00 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -19,6 +19,7 @@ import q from 'q'; import Matrix from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; +import createMatrixClient from './utils/createMatrixClient'; import Analytics from './Analytics'; import Notifier from './Notifier'; import UserActivity from './UserActivity'; @@ -28,35 +29,25 @@ import DMRoomMap from './utils/DMRoomMap'; import RtsClient from './RtsClient'; import Modal from './Modal'; import sdk from './index'; -import { _t } from './languageHandler'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries * a number of things: * - * 0. if it looks like we are in the middle of a registration process, it does - * nothing. * - * 1. if we have a loginToken in the (real) query params, it uses that to log - * in. - * - * 2. if we have a guest access token in the fragment query params, it uses + * 1. if we have a guest access token in the fragment query params, it uses * that. * - * 3. if an access token is stored in local storage (from a previous session), + * 2. if an access token is stored in local storage (from a previous session), * it uses that. * - * 4. it attempts to auto-register as a guest user. + * 3. it attempts to auto-register as a guest user. * - * If any of steps 1-4 are successful, it will call {setLoggedIn}, which in + * If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in * turn will raise on_logged_in and will_start_client events. * * @param {object} opts * - * @param {object} opts.realQueryParams: string->string map of the - * query-parameters extracted from the real query-string of the starting - * URI. - * * @param {object} opts.fragmentQueryParams: string->string map of the * query-parameters extracted from the #-fragment of the starting URI. * @@ -70,54 +61,38 @@ import { _t } from './languageHandler'; * true; defines the IS to use. * * @returns {Promise} a promise which resolves when the above process completes. + * Resolves to `true` if we ended up starting a session, or `false` if we + * failed. */ export function loadSession(opts) { - const realQueryParams = opts.realQueryParams || {}; const fragmentQueryParams = opts.fragmentQueryParams || {}; let enableGuest = opts.enableGuest || false; const guestHsUrl = opts.guestHsUrl; const guestIsUrl = opts.guestIsUrl; const defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - if (fragmentQueryParams.client_secret && fragmentQueryParams.sid) { - // this happens during email validation: the email contains a link to the - // IS, which in turn redirects back to vector. We let MatrixChat create a - // Registration component which completes the next stage of registration. - console.log("Not registering as guest: registration already in progress."); - return q(); - } - if (!guestHsUrl) { console.warn("Cannot enable guest access: can't determine HS URL to use"); enableGuest = false; } - if (realQueryParams.loginToken) { - if (!realQueryParams.homeserver) { - console.warn("Cannot log in with token: can't determine HS URL to use"); - } else { - return _loginWithToken(realQueryParams, defaultDeviceDisplayName); - } - } - if (enableGuest && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token ) { console.log("Using guest access credentials"); - setLoggedIn({ + return _doSetLoggedIn({ userId: fragmentQueryParams.guest_user_id, accessToken: fragmentQueryParams.guest_access_token, homeserverUrl: guestHsUrl, identityServerUrl: guestIsUrl, guest: true, - }); - return q(); + }, true).then(() => true); } return _restoreFromLocalStorage().then((success) => { if (success) { - return; + return true; } if (enableGuest) { @@ -125,10 +100,30 @@ export function loadSession(opts) { } // fall back to login screen + return false; }); } -function _loginWithToken(queryParams, defaultDeviceDisplayName) { +/** + * @param {Object} queryParams string->string map of the + * query-parameters extracted from the real query-string of the starting + * URI. + * + * @param {String} defaultDeviceDisplayName + * + * @returns {Promise} promise which resolves to true if we completed the token + * login, else false + */ +export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { + if (!queryParams.loginToken) { + return q(false); + } + + if (!queryParams.homeserver) { + console.warn("Cannot log in with token: can't determine HS URL to use"); + return q(false); + } + // create a temporary MatrixClient to do the login const client = Matrix.createClient({ baseUrl: queryParams.homeserver, @@ -141,22 +136,26 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) { }, ).then(function(data) { console.log("Logged in with token"); - setLoggedIn({ - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token, - homeserverUrl: queryParams.homeserver, - identityServerUrl: queryParams.identityServer, - guest: false, + return _clearStorage().then(() => { + _persistCredentialsToLocalStorage({ + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + homeserverUrl: queryParams.homeserver, + identityServerUrl: queryParams.identityServer, + guest: false, + }); + return true; }); - }, (err) => { + }).catch((err) => { console.error("Failed to log in with login token: " + err + " " + err.data); + return false; }); } function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { - console.log("Doing guest login on %s", hsUrl); + console.log(`Doing guest login on ${hsUrl}`); // TODO: we should probably de-duplicate this and Login.loginAsGuest. // Not really sure where the right home for it is. @@ -171,17 +170,18 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { initial_device_display_name: defaultDeviceDisplayName, }, }).then((creds) => { - console.log("Registered as guest: %s", creds.user_id); - setLoggedIn({ + console.log(`Registered as guest: ${creds.user_id}`); + return _doSetLoggedIn({ userId: creds.user_id, deviceId: creds.device_id, accessToken: creds.access_token, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: true, - }); + }, true).then(() => true); }, (err) => { console.error("Failed to register as guest: " + err + " " + err.data); + return false; }); } @@ -214,17 +214,16 @@ function _restoreFromLocalStorage() { } if (accessToken && userId && hsUrl) { - console.log("Restoring session for %s", userId); + console.log(`Restoring session for ${userId}`); try { - setLoggedIn({ + return _doSetLoggedIn({ userId: userId, deviceId: deviceId, accessToken: accessToken, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: isGuest, - }); - return q(true); + }, false).then(() => true); } catch (e) { return _handleRestoreFailure(e); } @@ -237,27 +236,12 @@ function _restoreFromLocalStorage() { function _handleRestoreFailure(e) { console.log("Unable to restore session", e); - let msg = e.message; - if (msg == "OLM.BAD_LEGACY_ACCOUNT_PICKLE") { - msg = _t( - 'You need to log back in to generate end-to-end encryption keys' - + ' for this device and submit the public key to your homeserver.' - + ' This is a once off; sorry for the inconvenience.', - ); - - _clearLocalStorage(); - - return q.reject(new Error( - _t('Unable to restore previous session') + ': ' + msg, - )); - } - const def = q.defer(); const SessionRestoreErrorDialog = sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); Modal.createDialog(SessionRestoreErrorDialog, { - error: msg, + error: e.message, onFinished: (success) => { def.resolve(success); }, @@ -266,7 +250,7 @@ function _handleRestoreFailure(e) { return def.promise.then((success) => { if (success) { // user clicked continue. - _clearLocalStorage(); + _clearStorage(); return false; } @@ -277,50 +261,69 @@ function _handleRestoreFailure(e) { let rtsClient = null; export function initRtsClient(url) { - rtsClient = new RtsClient(url); + if (url) { + rtsClient = new RtsClient(url); + } else { + rtsClient = null; + } } /** - * Transitions to a logged-in state using the given credentials + * Transitions to a logged-in state using the given credentials. + * + * Starts the matrix client and all other react-sdk services that + * listen for events while a session is logged in. + * + * Also stops the old MatrixClient and clears old credentials/etc out of + * storage before starting the new client. + * * @param {MatrixClientCreds} credentials The credentials to use + * + * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ export function setLoggedIn(credentials) { + stopMatrixClient(); + return _doSetLoggedIn(credentials, true); +} + +/** + * fires on_logging_in, optionally clears localstorage, persists new credentials + * to localstorage, starts the new client. + * + * @param {MatrixClientCreds} credentials + * @param {Boolean} clearStorage + * + * @returns {Promise} promise which resolves to the new MatrixClient once it has been started + */ +async function _doSetLoggedIn(credentials, clearStorage) { credentials.guest = Boolean(credentials.guest); - Analytics.setGuest(credentials.guest); - console.log( - "setLoggedIn: mxid:", credentials.userId, - "deviceId:", credentials.deviceId, - "guest:", credentials.guest, - "hs:", credentials.homeserverUrl, + "setLoggedIn: mxid: " + credentials.userId + + " deviceId: " + credentials.deviceId + + " guest: " + credentials.guest + + " hs: " + credentials.homeserverUrl, ); + // This is dispatched to indicate that the user is still in the process of logging in // 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 // later than MatrixChat might assume. dis.dispatch({action: 'on_logging_in'}); + if (clearStorage) { + await _clearStorage(); + } + + Analytics.setGuest(credentials.guest); + // Resolves by default let teamPromise = Promise.resolve(null); - // persist the session + if (localStorage) { try { - localStorage.setItem("mx_hs_url", credentials.homeserverUrl); - localStorage.setItem("mx_is_url", credentials.identityServerUrl); - localStorage.setItem("mx_user_id", credentials.userId); - localStorage.setItem("mx_access_token", credentials.accessToken); - localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); - - // if we didn't get a deviceId from the login, leave mx_device_id unset, - // rather than setting it to "undefined". - // - // (in this case MatrixClient doesn't bother with the crypto stuff - // - that's fine for us). - if (credentials.deviceId) { - localStorage.setItem("mx_device_id", credentials.deviceId); - } + _persistCredentialsToLocalStorage(credentials); // The user registered as a PWLU (PassWord-Less User), the generated password // is cached here such that the user can change it at a later time. @@ -331,8 +334,6 @@ export function setLoggedIn(credentials) { cachedPassword: credentials.password, }); } - - console.log("Session persisted for %s", credentials.userId); } catch (e) { console.warn("Error using local storage: can't persist session!", e); } @@ -349,9 +350,6 @@ export function setLoggedIn(credentials) { console.warn("No local storage available: can't persist session!"); } - // stop any running clients before we create a new one with these new credentials - stopMatrixClient(); - MatrixClientPeg.replaceUsingCreds(credentials); teamPromise.then((teamToken) => { @@ -362,6 +360,26 @@ export function setLoggedIn(credentials) { }); startMatrixClient(); + return MatrixClientPeg.get(); +} + +function _persistCredentialsToLocalStorage(credentials) { + localStorage.setItem("mx_hs_url", credentials.homeserverUrl); + localStorage.setItem("mx_is_url", credentials.identityServerUrl); + localStorage.setItem("mx_user_id", credentials.userId); + localStorage.setItem("mx_access_token", credentials.accessToken); + localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + + // if we didn't get a deviceId from the login, leave mx_device_id unset, + // rather than setting it to "undefined". + // + // (in this case MatrixClient doesn't bother with the crypto stuff + // - that's fine for us). + if (credentials.deviceId) { + localStorage.setItem("mx_device_id", credentials.deviceId); + } + + console.log(`Session persisted for ${credentials.userId}`); } /** @@ -400,7 +418,7 @@ export function logout() { * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. */ -export function startMatrixClient() { +function startMatrixClient() { // dispatch this before starting the matrix client: it's used // to add listeners for the 'sync' event so otherwise we'd have // a race condition (and we need to dispatch synchronously for this @@ -416,34 +434,44 @@ export function startMatrixClient() { } /* - * Stops a running client and all related services, used after - * a session has been logged out / ended. + * Stops a running client and all related services, and clears persistent + * storage. Used after a session has been logged out. */ export function onLoggedOut() { - _clearLocalStorage(); stopMatrixClient(); + _clearStorage().done(); dis.dispatch({action: 'on_logged_out'}); } -function _clearLocalStorage() { +/** + * @returns {Promise} promise which resolves once the stores have been cleared + */ +function _clearStorage() { Analytics.logout(); - if (!window.localStorage) { - return; - } - const hsUrl = window.localStorage.getItem("mx_hs_url"); - const isUrl = window.localStorage.getItem("mx_is_url"); - window.localStorage.clear(); - // preserve our HS & IS URLs for convenience - // N.B. we cache them in hsUrl/isUrl and can't really inline them - // as getCurrentHsUrl() may call through to localStorage. - // NB. We do clear the device ID (as well as all the settings) - if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl); - if (isUrl) window.localStorage.setItem("mx_is_url", isUrl); + if (window.localStorage) { + const hsUrl = window.localStorage.getItem("mx_hs_url"); + const isUrl = window.localStorage.getItem("mx_is_url"); + window.localStorage.clear(); + + // preserve our HS & IS URLs for convenience + // N.B. we cache them in hsUrl/isUrl and can't really inline them + // as getCurrentHsUrl() may call through to localStorage. + // NB. We do clear the device ID (as well as all the settings) + if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl); + if (isUrl) window.localStorage.setItem("mx_is_url", isUrl); + } + + // create a temporary client to clear out the persistent stores. + const cli = createMatrixClient({ + // we'll never make any requests, so can pass a bogus HS URL + baseUrl: "", + }); + return cli.clearStores(); } /** - * Stop all the background processes related to the current client + * Stop all the background processes related to the current client. */ export function stopMatrixClient() { Notifier.stop(); @@ -454,7 +482,6 @@ export function stopMatrixClient() { if (cli) { cli.stopClient(); cli.removeAllListeners(); - cli.store.deleteAllData(); MatrixClientPeg.unset(); } } diff --git a/src/Login.js b/src/Login.js index 8db6e99b89..8225509919 100644 --- a/src/Login.js +++ b/src/Login.js @@ -178,11 +178,18 @@ export default class Login { } redirectToCas() { - var client = this._createTemporaryClient(); - var parsedUrl = url.parse(window.location.href, true); + const client = this._createTemporaryClient(); + const parsedUrl = url.parse(window.location.href, true); + + // XXX: at this point, the fragment will always be #/login, which is no + // use to anyone. Ideally, we would get the intended fragment from + // MatrixChat.screenAfterLogin so that you could follow #/room links etc + // through a CAS login. + parsedUrl.hash = ""; + parsedUrl.query["homeserver"] = client.getHomeserverUrl(); parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); - var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); + const casUrl = client.getCasLoginUrl(url.format(parsedUrl)); window.location.href = casUrl; } } diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 94e55a8d8a..0676e4600f 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -16,12 +17,10 @@ limitations under the License. 'use strict'; -import Matrix from 'matrix-js-sdk'; import utils from 'matrix-js-sdk/lib/utils'; import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; - -const localStorage = window.localStorage; +import createMatrixClient from './utils/createMatrixClient'; interface MatrixClientCreds { homeserverUrl: string, @@ -49,7 +48,6 @@ class MatrixClientPeg { this.opts = { initialSyncLimit: 20, }; - this.indexedDbWorkerScript = null; } /** @@ -60,7 +58,7 @@ class MatrixClientPeg { * @param {string} script href to the script to be passed to the web worker */ setIndexedDbWorkerScript(script) { - this.indexedDbWorkerScript = script; + createMatrixClient.indexedDbWorkerScript = script; } get(): MatrixClient { @@ -86,7 +84,9 @@ class MatrixClientPeg { let promise = this.matrixClient.store.startup(); // log any errors when starting up the database (if one exists) - promise.catch((err) => { console.error(err); }); + promise.catch((err) => { + console.error(`Error starting matrixclient store: ${err}`); + }); // regardless of errors, start the client. If we did error out, we'll // just end up doing a full initial /sync. @@ -129,22 +129,7 @@ class MatrixClientPeg { timelineSupport: true, }; - if (localStorage) { - opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); - } - if (window.indexedDB && localStorage) { - // FIXME: bodge to remove old database. Remove this after a few weeks. - window.indexedDB.deleteDatabase("matrix-js-sdk:default"); - - opts.store = new Matrix.IndexedDBStore({ - indexedDB: window.indexedDB, - dbName: "riot-web-sync", - localStorage: localStorage, - workerScript: this.indexedDbWorkerScript, - }); - } - - this.matrixClient = Matrix.createClient(opts); + this.matrixClient = createMatrixClient(opts, this.indexedDbWorkerScript); // we're going to add eventlisteners for each matrix event tile, so the // potential number of event listeners is quite high. diff --git a/src/Modal.js b/src/Modal.js index 8d53b2da7d..e100105a88 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -64,7 +64,6 @@ const AsyncWrapper = React.createClass({ render: function() { const {loader, ...otherProps} = this.props; - if (this.state.component) { const Component = this.state.component; return ; @@ -199,4 +198,7 @@ class ModalManager { } } -export default new ModalManager(); +if (!global.singletonModalManager) { + global.singletonModalManager = new ModalManager(); +} +export default global.singletonModalManager; diff --git a/src/RichText.js b/src/RichText.js index b1793d0ddf..f2f2d533a8 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -16,6 +16,7 @@ import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; import {SelectionRange} from "./autocomplete/Autocompleter"; +import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, @@ -30,9 +31,26 @@ const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/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 stateToHTML(contentState, { + inlineStyles: { + UNDERLINE: { + element: 'u' + } + } + }); +}; + +export function htmlToContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); } @@ -146,9 +164,9 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator { ) }); - markdownDecorators.push(emojiDecorator); - - return markdownDecorators; + // markdownDecorators.push(emojiDecorator); + // TODO Consider renabling "syntax highlighting" when we can do it properly + return [emojiDecorator]; } /** diff --git a/src/Rooms.js b/src/Rooms.js index 16b5ab9ee2..3ac7c68533 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -144,7 +144,18 @@ export function guessDMRoomTarget(room, me) { let oldestTs; let oldestUser; - // Pick the user who's been here longest (and isn't us) + // Pick the joined user who's been here longest (and isn't us), + for (const user of room.getJoinedMembers()) { + if (user.userId == me.userId) continue; + + if (oldestTs === undefined || user.events.member.getTs() < oldestTs) { + oldestUser = user; + oldestTs = user.events.member.getTs(); + } + } + if (oldestUser) return oldestUser; + + // if there are no joined members other than us, use the oldest member for (const user of room.currentState.getMembers()) { if (user.userId == me.userId) continue; diff --git a/src/RtsClient.js b/src/RtsClient.js index 8c3ce54b37..493b19599c 100644 --- a/src/RtsClient.js +++ b/src/RtsClient.js @@ -1,5 +1,7 @@ import 'whatwg-fetch'; +let fetchFunction = fetch; + function checkStatus(response) { if (!response.ok) { return response.text().then((text) => { @@ -31,7 +33,7 @@ const request = (url, opts) => { opts.body = JSON.stringify(opts.body); opts.headers['Content-Type'] = 'application/json'; } - return fetch(url, opts) + return fetchFunction(url, opts) .then(checkStatus) .then(parseJson); }; @@ -64,7 +66,7 @@ export default class RtsClient { client_secret: clientSecret, }, method: 'POST', - } + }, ); } @@ -74,7 +76,7 @@ export default class RtsClient { qs: { team_token: teamToken, }, - } + }, ); } @@ -91,7 +93,12 @@ export default class RtsClient { qs: { user_id: userId, }, - } + }, ); } + + // allow fetch to be replaced, for testing. + static setFetch(fn) { + fetchFunction = fn; + } } diff --git a/src/Unread.js b/src/Unread.js index 67166dc24f..8a70291cf2 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -37,7 +37,26 @@ module.exports = { }, 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 // 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 diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index cbdb839ce3..4c7d039da4 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -19,7 +19,7 @@ import React from 'react'; import type {Completion, SelectionRange} from './Autocompleter'; export default class AutocompleteProvider { - constructor(commandRegex?: RegExp, fuseOpts?: any) { + constructor(commandRegex?: RegExp) { if (commandRegex) { if (!commandRegex.global) { throw new Error('commandRegex must have global flag set'); diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index f8564a43a0..62b5a870f3 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -59,7 +59,7 @@ export async function getCompletions(query: string, selection: SelectionRange, f PROVIDERS.map(provider => { return Q(provider.getCompletions(query, selection, force)) .timeout(PROVIDER_COMPLETION_TIMEOUT); - }) + }), ); return completionsList diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 205a3737dc..9ae3a7badb 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import Fuse from 'fuse.js'; +import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; // 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 @@ -28,11 +28,21 @@ const COMMANDS = [ args: '', description: 'Displays action', }, + { + command: '/part', + args: '[#alias:domain]', + description: 'Leave room', + }, { command: '/ban', args: ' [reason]', description: 'Bans user with given id', }, + { + command: '/unban', + args: '', + description: 'Unbans user with given id', + }, { command: '/deop', args: '', @@ -63,6 +73,11 @@ const COMMANDS = [ args: '', description: 'Searches DuckDuckGo for results', }, + { + command: '/op', + args: ' []', + description: 'Define the power level of a user', + }, ]; const COMMAND_RE = /(^\/\w*)/g; @@ -72,7 +87,7 @@ let instance = null; export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); - this.fuse = new Fuse(COMMANDS, { + this.matcher = new FuzzyMatcher(COMMANDS, { keys: ['command', 'args', 'description'], }); } @@ -81,7 +96,7 @@ export default class CommandProvider extends AutocompleteProvider { let completions = []; const {command, range} = this.getCurrentCommand(query, selection); if (command) { - completions = this.fuse.search(command[0]).map((result) => { + completions = this.matcher.match(command[0]).map((result) => { return { completion: result.command + ' ', component: ( { + return { + shortname, + }; +}); let instance = null; export default class EmojiProvider extends AutocompleteProvider { constructor() { super(EMOJI_REGEX); - this.fuse = new Fuse(EMOJI_SHORTNAMES, {}); + this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, { + keys: 'shortname', + }); } async getCompletions(query: string, selection: SelectionRange) { @@ -41,8 +47,8 @@ export default class EmojiProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection); if (command) { - completions = this.fuse.search(command[0]).map(result => { - const shortname = EMOJI_SHORTNAMES[result]; + completions = this.matcher.match(command[0]).map(result => { + const {shortname} = result; const unicode = shortnameToUnicode(shortname); return { completion: unicode, diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js new file mode 100644 index 0000000000..1aa0782c22 --- /dev/null +++ b/src/autocomplete/FuzzyMatcher.js @@ -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; +// objectMap: {[String]: Array}; +// 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, keys: Array): 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, options: {[Object]: Object} = {}) { +// this.options = options; +// this.keys = options.keys; +// this.setObjects(objects); +// } +// +// setObjects(objects: Array) { +// 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 { +// 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; +// } +//} diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js new file mode 100644 index 0000000000..01fc251318 --- /dev/null +++ b/src/autocomplete/QueryMatcher.js @@ -0,0 +1,79 @@ +//@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 _sortedUniq from 'lodash/sortedUniq'; +import _keys from 'lodash/keys'; + +class KeyMap { + keys: Array; + objectMap: {[String]: Array}; + 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, keys: Array): 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, options: {[Object]: Object} = {}) { + this.options = options; + this.keys = options.keys; + this.setObjects(objects); + } + + setObjects(objects: Array) { + this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys); + } + + match(query: String): Array { + query = query.toLowerCase().replace(/[^\w]/g, ''); + const results = _sortedUniq(_sortBy(_flatMap(this.keyMap.keys, (key) => { + return key.toLowerCase().replace(/[^\w]/g, '').indexOf(query) >= 0 ? this.keyMap.objectMap[key] : []; + }), (candidate) => this.keyMap.priorityMap.get(candidate))); + return results; + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index be35c53e5d..a001f381ee 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -19,7 +19,7 @@ import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import MatrixClientPeg from '../MatrixClientPeg'; -import Fuse from 'fuse.js'; +import FuzzyMatcher from './FuzzyMatcher'; import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; @@ -30,11 +30,9 @@ let instance = null; export default class RoomProvider extends AutocompleteProvider { constructor() { - super(ROOM_REGEX, { - keys: ['displayName', 'userId'], - }); - this.fuse = new Fuse([], { - keys: ['name', 'roomId', 'aliases'], + super(ROOM_REGEX); + this.matcher = new FuzzyMatcher([], { + keys: ['name', 'roomId', 'aliases'], }); } @@ -46,17 +44,17 @@ export default class RoomProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // 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 { room: room, name: room.name, aliases: room.getAliases(), }; })); - completions = this.fuse.search(command[0]).map(room => { + completions = this.matcher.match(command[0]).map(room => { let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; return { - completion: displayAlias, + completion: displayAlias + ' ', component: ( } title={room.name} description={displayAlias} /> ), @@ -84,8 +82,4 @@ export default class RoomProvider extends AutocompleteProvider { {completions} ; } - - shouldForceComplete(): boolean { - return true; - } } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 809fec94be..4e0c0f5ea7 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -1,3 +1,4 @@ +//@flow /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd @@ -18,21 +19,27 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import Fuse from 'fuse.js'; import {PillCompletion} from './Components'; 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; let instance = null; export default class UserProvider extends AutocompleteProvider { + users: Array = []; + constructor() { super(USER_REGEX, { keys: ['name', 'userId'], }); - this.users = []; - this.fuse = new Fuse([], { + this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], }); } @@ -43,8 +50,7 @@ export default class UserProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection, force); if (command) { - this.fuse.set(this.users); - completions = this.fuse.search(command[0]).map(user => { + completions = this.matcher.match(command[0]).map(user => { let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done let completion = displayName; if (range.start === 0) { @@ -71,8 +77,31 @@ export default class UserProvider extends AutocompleteProvider { return '👥 ' + _t('Users'); } - setUserList(users) { - this.users = users; + setUserListFromRoom(room: Room) { + 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, (user) => 1E20 - lastSpoken[user.userId] || 1E20); + + this.matcher.setObjects(this.users); + } + + onUserSpoke(user: RoomMember) { + if(user.userId === MatrixClientPeg.get().credentials.userId) return; + + // Probably unsafe to compare by reference here? + _pull(this.users, user); + this.users.splice(0, 0, user); + this.matcher.setObjects(this.users); } static getInstance(): UserProvider { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 8fa35e84d7..051e49acda 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -212,6 +212,7 @@ export default React.createClass({ const HomePage = sdk.getComponent('structures.HomePage'); const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); const NewVersionBar = sdk.getComponent('globals.NewVersionBar'); + const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar'); const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar'); let page_element; @@ -239,7 +240,6 @@ export default React.createClass({ page_element = ; + } else if (this.props.checkingForUpdate) { + topBar = ; } else if (this.state.userHasGeneratedPassword) { topBar = ; } else if (!isGuest && Notifier.supportsDesktopNotifications() && !Notifier.isEnabled() && !Notifier.isToolbarHidden()) { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index a2a2c30889..b618caa1c9 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -41,9 +41,44 @@ import PageTypes from '../../PageTypes'; import createRoom from "../../createRoom"; import * as UDEHandler from '../../UnknownDeviceErrorHandler'; +import KeyRequestHandler from '../../KeyRequestHandler'; import { _t, getCurrentLanguage } from '../../languageHandler'; +/** constants for MatrixChat.state.view */ +const VIEWS = { + // a special initial state which is only used at startup, while we are + // trying to re-animate a matrix client or register as a guest. + LOADING: 0, + + // we are showing the login view + LOGIN: 1, + + // we are showing the registration view + REGISTER: 2, + + // completeing the registration flow + POST_REGISTRATION: 3, + + // showing the 'forgot password' view + FORGOT_PASSWORD: 4, + + // we have valid matrix credentials (either via an explicit login, via the + // initial re-animation/guest registration, or via a registration), and are + // now setting up a matrixclient to talk to it. This isn't an instant + // process because (a) we need to clear out indexeddb, and (b) we need to + // talk to the team server; while it is going on we show a big spinner. + LOGGING_IN: 5, + + // we are logged in with an active matrix client. + LOGGED_IN: 6, +}; + module.exports = React.createClass({ + // we export this so that the integration tests can use it :-S + statics: { + VIEWS: VIEWS, + }, + displayName: 'MatrixChat', propTypes: { @@ -59,8 +94,8 @@ module.exports = React.createClass({ // the initial queryParams extracted from the hash-fragment of the URI startingFragmentQueryParams: React.PropTypes.object, - // called when the session load completes - onLoadCompleted: React.PropTypes.func, + // called when we have completed a token login + onTokenLoginCompleted: React.PropTypes.func, // Represents the screen to display as a result of parsing the initial // window.location @@ -93,14 +128,11 @@ module.exports = React.createClass({ getInitialState: function() { const s = { - loading: true, - screen: undefined, - screenAfterLogin: this.props.initialScreenAfterLogin, + // the master view we are showing. + view: VIEWS.LOADING, - // Stashed guest credentials if the user logs out - // whilst logged in as a guest user (so they can change - // their mind & log back in) - guestCreds: null, + // a thing to call showScreen with once login completes. + screenAfterLogin: this.props.initialScreenAfterLogin, // What the LoggedInView would be showing if visible page_type: null, @@ -113,8 +145,6 @@ module.exports = React.createClass({ // If we're trying to just view a user ID (i.e. /user URL), this is it viewUserId: null, - loggedIn: false, - loggingIn: false, collapse_lhs: false, collapse_rhs: false, ready: false, @@ -127,6 +157,7 @@ module.exports = React.createClass({ newVersion: null, hasNewVersion: false, newVersionReleaseNotes: null, + checkingForUpdate: null, // Parameters used in the registration dance with the IS register_client_secret: null, @@ -143,7 +174,7 @@ module.exports = React.createClass({ realQueryParams: {}, startingFragmentQueryParams: {}, config: {}, - onLoadCompleted: () => {}, + onTokenLoginCompleted: () => {}, }; }, @@ -263,30 +294,52 @@ module.exports = React.createClass({ window.addEventListener('resize', this.handleResize); this.handleResize(); - if (this.props.config.teamServerConfig && - this.props.config.teamServerConfig.teamServerURL - ) { - Lifecycle.initRtsClient(this.props.config.teamServerConfig.teamServerURL); - } + const teamServerConfig = this.props.config.teamServerConfig || {}; + Lifecycle.initRtsClient(teamServerConfig.teamServerURL); - // the extra q() ensures that synchronous exceptions hit the same codepath as - // asynchronous ones. - q().then(() => { - return Lifecycle.loadSession({ - realQueryParams: this.props.realQueryParams, - fragmentQueryParams: this.props.startingFragmentQueryParams, - enableGuest: this.props.enableGuest, - guestHsUrl: this.getCurrentHsUrl(), - guestIsUrl: this.getCurrentIsUrl(), - defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, + // the first thing to do is to try the token params in the query-string + Lifecycle.attemptTokenLogin(this.props.realQueryParams).then((loggedIn) => { + if(loggedIn) { + this.props.onTokenLoginCompleted(); + + // don't do anything else until the page reloads - just stay in + // the 'loading' state. + return; + } + + // if the user has followed a login or register link, don't reanimate + // the old creds, but rather go straight to the relevant page + const firstScreen = this.state.screenAfterLogin ? + this.state.screenAfterLogin.screen : null; + + if (firstScreen === 'login' || + firstScreen === 'register' || + firstScreen === 'forgot_password') { + this.setState({loading: false}); + this._showScreenAfterLogin(); + return; + } + + // the extra q() ensures that synchronous exceptions hit the same codepath as + // asynchronous ones. + return q().then(() => { + return Lifecycle.loadSession({ + fragmentQueryParams: this.props.startingFragmentQueryParams, + enableGuest: this.props.enableGuest, + guestHsUrl: this.getCurrentHsUrl(), + guestIsUrl: this.getCurrentIsUrl(), + defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, + }); + }).catch((e) => { + console.error(`Error attempting to load session: ${e}`); + return false; + }).then((loadedSession) => { + if (!loadedSession) { + // fall back to showing the login screen + dis.dispatch({action: "start_login"}); + } }); - }).catch((e) => { - console.error("Unable to load session", e); - }).done(()=>{ - // stuff this through the dispatcher so that it happens - // after the on_logged_in action. - dis.dispatch({action: 'load_completed'}); - }); + }).done(); }, componentWillUnmount: function() { @@ -305,18 +358,19 @@ module.exports = React.createClass({ } }, - setStateForNewScreen: function(state) { + setStateForNewView: function(state) { + if (state.view === undefined) { + throw new Error("setStateForNewView with no view!"); + } const newState = { - screen: undefined, viewUserId: null, - loggedIn: false, - ready: false, }; Object.assign(newState, state); this.setState(newState); }, onAction: function(payload) { + // console.log(`MatrixClientPeg.onAction: ${payload.action}`); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -328,26 +382,19 @@ module.exports = React.createClass({ this._startRegistration(payload.params || {}); break; case 'start_login': - if (MatrixClientPeg.get() && - MatrixClientPeg.get().isGuest() - ) { - this.setState({ - guestCreds: MatrixClientPeg.getCredentials(), - }); - } - this.setStateForNewScreen({ - screen: 'login', + this.setStateForNewView({ + view: VIEWS.LOGIN, }); this.notifyNewScreen('login'); break; case 'start_post_registration': - this.setState({ // don't clobber loggedIn status - screen: 'post_registration', + this.setState({ + view: VIEWS.POST_REGISTRATION, }); break; case 'start_password_recovery': - this.setStateForNewScreen({ - screen: 'forgot_password', + this.setStateForNewView({ + view: VIEWS.FORGOT_PASSWORD, }); this.notifyNewScreen('forgot_password'); break; @@ -371,7 +418,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().leave(payload.room_id).done(() => { modal.close(); - if (this.currentRoomId === payload.room_id) { + if (this.state.currentRoomId === payload.room_id) { dis.dispatch({action: 'view_next_room'}); } }, (err) => { @@ -488,10 +535,11 @@ module.exports = React.createClass({ break; case 'on_logging_in': // We are now logging in, so set the state to reflect that - // and also that we're not ready (we'll be marked as logged - // in once the login completes, then ready once the sync - // completes). - this.setState({loggingIn: true, ready: false}); + // NB. This does not touch 'ready' since if our dispatches + // are delayed, the sync could already have completed + this.setStateForNewView({ + view: VIEWS.LOGGING_IN, + }); break; case 'on_logged_in': this._onLoggedIn(payload.teamToken); @@ -500,10 +548,12 @@ module.exports = React.createClass({ this._onLoggedOut(); break; case 'will_start_client': - this._onWillStartClient(); - break; - case 'load_completed': - this._onLoadCompleted(); + 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(); + }); break; case 'new_version': this.onVersion( @@ -511,6 +561,12 @@ module.exports = React.createClass({ payload.releaseNotes, ); break; + case 'check_updates': + this.setState({ checkingForUpdate: payload.value }); + break; + case 'send_event': + this.onSendEvent(payload.room_id, payload.event); + break; } }, @@ -525,8 +581,8 @@ module.exports = React.createClass({ }, _startRegistration: function(params) { - this.setStateForNewScreen({ - screen: 'register', + this.setStateForNewView({ + view: VIEWS.REGISTER, // these params may be undefined, but if they are, // unset them from our state: we don't want to // resume a previous registration session if the @@ -834,22 +890,6 @@ module.exports = React.createClass({ }); }, - /** - * Called when the sessionloader has finished - */ - _onLoadCompleted: function() { - this.props.onLoadCompleted(); - this.setState({loading: false}); - - // Show screens (like 'register') that need to be shown without _onLoggedIn - // being called. 'register' needs to be routed here when the email confirmation - // link is clicked on. - if (this.state.screenAfterLogin && - ['register'].indexOf(this.state.screenAfterLogin.screen) !== -1) { - this._showScreenAfterLogin(); - } - }, - /** * Called whenever someone changes the theme * @@ -902,9 +942,7 @@ module.exports = React.createClass({ */ _onLoggedIn: function(teamToken) { this.setState({ - guestCreds: null, - loggedIn: true, - loggingIn: false, + view: VIEWS.LOGGED_IN, }); if (teamToken) { @@ -913,10 +951,6 @@ module.exports = React.createClass({ dis.dispatch({action: 'view_home_page'}); } else if (this._is_registered) { this._is_registered = false; - // reset the 'have completed first sync' flag, - // since we've just logged in and will be about to sync - this.firstSyncComplete = false; - this.firstSyncPromise = q.defer(); // Set the display name = user ID localpart MatrixClientPeg.get().setDisplayName( @@ -946,6 +980,7 @@ module.exports = React.createClass({ this.state.screenAfterLogin.screen, this.state.screenAfterLogin.params, ); + // XXX: is this necessary? `showScreen` should do it for us. this.notifyNewScreen(this.state.screenAfterLogin.screen); this.setState({screenAfterLogin: null}); } else if (localStorage && localStorage.getItem('mx_last_room_id')) { @@ -964,8 +999,8 @@ module.exports = React.createClass({ */ _onLoggedOut: function() { this.notifyNewScreen('login'); - this.setStateForNewScreen({ - loggedIn: false, + this.setStateForNewView({ + view: VIEWS.LOGIN, ready: false, collapse_lhs: false, collapse_rhs: false, @@ -973,6 +1008,7 @@ module.exports = React.createClass({ page_type: PageTypes.RoomDirectory, }); this._teamToken = null; + this._setPageSubtitle(); }, /** @@ -981,6 +1017,12 @@ module.exports = React.createClass({ */ _onWillStartClient() { const self = this; + + // reset the 'have completed first sync' flag, + // since we're about to start the client and therefore about + // to do the first sync + this.firstSyncComplete = false; + this.firstSyncPromise = q.defer(); const cli = MatrixClientPeg.get(); // Allow the JS SDK to reap timeline events. This reduces the amount of @@ -1051,6 +1093,14 @@ module.exports = React.createClass({ } } }); + + const krh = new KeyRequestHandler(cli); + cli.on("crypto.roomKeyRequest", (req) => { + krh.handleKeyRequest(req); + }); + cli.on("crypto.roomKeyRequestCancellation", (req) => { + krh.handleKeyRequestCancellation(req); + }); }, showScreen: function(screen, params) { @@ -1128,7 +1178,7 @@ module.exports = React.createClass({ // we can't view a room unless we're logged in // (a guest account is fine) - if (this.state.loggedIn) { + if (this.state.view === VIEWS.LOGGED_IN) { dis.dispatch(payload); } } else if (screen.indexOf('user/') == 0) { @@ -1226,27 +1276,25 @@ module.exports = React.createClass({ this.showScreen("forgot_password"); }, - onReturnToGuestClick: function() { - // reanimate our guest login - if (this.state.guestCreds) { - Lifecycle.setLoggedIn(this.state.guestCreds); - this.setState({guestCreds: null}); - } + onReturnToAppClick: function() { + // treat it the same as if the user had completed the login + this._onLoggedIn(null); }, + // returns a promise which resolves to the new MatrixClient onRegistered: function(credentials, teamToken) { // XXX: These both should be in state or ideally store(s) because we risk not // rendering the most up-to-date view of state otherwise. // teamToken may not be truthy this._teamToken = teamToken; this._is_registered = true; - Lifecycle.setLoggedIn(credentials); + return Lifecycle.setLoggedIn(credentials); }, onFinishPostRegistration: function() { // Don't confuse this with "PageType" which is the middle window to show this.setState({ - screen: undefined, + view: VIEWS.LOGGED_IN, }); this.showScreen("settings"); }, @@ -1257,9 +1305,35 @@ module.exports = React.createClass({ newVersion: latest, hasNewVersion: current !== latest, newVersionReleaseNotes: releaseNotes, + checkingForUpdate: null, }); }, + onSendEvent: function(roomId, event) { + const cli = MatrixClientPeg.get(); + if (!cli) { + dis.dispatch({action: 'message_send_failed'}); + return; + } + + cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => { + dis.dispatch({action: 'message_sent'}); + }, (err) => { + if (err.name === 'UnknownDeviceError') { + dis.dispatch({ + action: 'unknown_device_error', + err: err, + room: cli.getRoom(roomId), + }); + } + dis.dispatch({action: 'message_send_failed'}); + }); + }, + + _setPageSubtitle: function(subtitle='') { + document.title = `Riot ${subtitle}`; + }, + updateStatusIndicator: function(state, prevState) { let notifCount = 0; @@ -1280,15 +1354,15 @@ module.exports = React.createClass({ PlatformPeg.get().setNotificationCount(notifCount); } - let title = "Riot "; + let subtitle = ''; if (state === "ERROR") { - title += `[${_t("Offline")}] `; + subtitle += `[${_t("Offline")}] `; } if (notifCount > 0) { - title += `[${notifCount}]`; + subtitle += `[${notifCount}]`; } - document.title = title; + this._setPageSubtitle(subtitle); }, onUserSettingsClose: function() { @@ -1314,11 +1388,9 @@ module.exports = React.createClass({ }, render: function() { - // `loading` might be set to false before `loggedIn = true`, causing the default - // (``) to be visible for a few MS (say, whilst a request is in-flight to - // the RTS). So in the meantime, use `loggingIn`, which is true between - // actions `on_logging_in` and `on_logged_in`. - if (this.state.loading || this.state.loggingIn) { + // console.log(`Rendering MatrixChat with view ${this.state.view}`); + + if (this.state.view === VIEWS.LOADING || this.state.view === VIEWS.LOGGING_IN) { const Spinner = sdk.getComponent('elements.Spinner'); return (
@@ -1328,7 +1400,7 @@ module.exports = React.createClass({ } // needs to be before normal PageTypes as you are logged in technically - if (this.state.screen == 'post_registration') { + if (this.state.view === VIEWS.POST_REGISTRATION) { const PostRegistration = sdk.getComponent('structures.login.PostRegistration'); return ( - ); - } else if (this.state.loggedIn) { - // we think we are logged in, but are still waiting for the /sync to complete - const Spinner = sdk.getComponent('elements.Spinner'); - return ( - - ); - } else if (this.state.screen == 'register') { + if (this.state.view === VIEWS.LOGGED_IN) { + // `ready` and `view==LOGGED_IN` may be set before `page_type` (because the + // latter is set via the dispatcher). If we don't yet have a `page_type`, + // keep showing the spinner for now. + if (this.state.ready && this.state.page_type) { + /* for now, we stuff the entirety of our props and state into the LoggedInView. + * we should go through and figure out what we actually need to pass down, as well + * as using something like redux to avoid having a billion bits of state kicking around. + */ + const LoggedInView = sdk.getComponent('structures.LoggedInView'); + return ( + + ); + } else { + // we think we are logged in, but are still waiting for the /sync to complete + const Spinner = sdk.getComponent('elements.Spinner'); + return ( + + ); + } + } + + if (this.state.view === VIEWS.REGISTER) { const Registration = sdk.getComponent('structures.login.Registration'); return ( ); - } else if (this.state.screen == 'forgot_password') { + } + + + if (this.state.view === VIEWS.FORGOT_PASSWORD) { const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword'); return ( ); - } else { + } + + if (this.state.view === VIEWS.LOGIN) { const Login = sdk.getComponent('structures.login.Login'); return ( ); } + + console.error(`Unknown view ${this.state.view}`); }, }); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 7df089da5b..f2dd98d35c 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -94,6 +94,7 @@ module.exports = React.createClass({ userId: null, roomLoading: true, peekLoading: false, + shouldPeek: true, // The event to be scrolled to initially initialEventId: null, @@ -170,8 +171,32 @@ module.exports = React.createClass({ initialEventId: RoomViewStore.getInitialEventId(), initialEventPixelOffset: RoomViewStore.getInitialEventPixelOffset(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), + forwardingEvent: RoomViewStore.getForwardingEvent(), + shouldPeek: RoomViewStore.shouldPeek(), }; + // finished joining, start waiting for a room and show a spinner. See onRoom. + newState.waitingForRoom = this.state.joining && !newState.joining && + !RoomViewStore.getJoinError(); + + // Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307 + console.log( + 'RVS update:', + newState.roomId, + newState.roomAlias, + 'loading?', newState.roomLoading, + 'joining?', newState.joining, + 'initial?', initial, + 'waiting?', newState.waitingForRoom, + 'shouldPeek?', newState.shouldPeek, + ); + + // NB: This does assume that the roomID will not change for the lifetime of + // the RoomView instance + if (initial) { + newState.room = MatrixClientPeg.get().getRoom(newState.roomId); + } + // Clear the search results when clicking a search result (which changes the // currently scrolled to event, this.state.initialEventId). if (this.state.initialEventId !== newState.initialEventId) { @@ -187,8 +212,9 @@ module.exports = React.createClass({ this.setState(newState, () => { // At this point, this.state.roomId could be null (e.g. the alias might not // have been resolved yet) so anything called here must handle this case. - this._onHaveRoom(); - this.onRoom(MatrixClientPeg.get().getRoom(this.state.roomId)); + if (initial) { + this._onHaveRoom(); + } }); }, @@ -204,25 +230,22 @@ module.exports = React.createClass({ // which must be by alias or invite wherever possible (peeking currently does // not work over federation). - // NB. We peek if we are not in the room, although if we try to peek into - // a room in which we have a member event (ie. we've left) synapse will just - // send us the same data as we get in the sync (ie. the last events we saw). - const room = MatrixClientPeg.get().getRoom(this.state.roomId); - let isUserJoined = null; + // NB. We peek if we have never seen the room before (i.e. js-sdk does not know + // about it). We don't peek in the historical case where we were joined but are + // now not joined because the js-sdk peeking API will clobber our historical room, + // making it impossible to indicate a newly joined room. + const room = this.state.room; if (room) { - isUserJoined = room.hasMembershipState( - MatrixClientPeg.get().credentials.userId, 'join', - ); - - this._updateAutoComplete(room); - this.tabComplete.loadEntries(room); + this.setState({ + unsentMessageError: this._getUnsentMessageError(room), + }); + this._onRoomLoaded(room); } - if (!isUserJoined && !this.state.joining && this.state.roomId) { + if (!this.state.joining && this.state.roomId) { if (this.props.autoJoin) { this.onJoinButtonClicked(); - } else if (this.state.roomId) { + } else if (!room && this.state.shouldPeek) { console.log("Attempting to peek into room %s", this.state.roomId); - this.setState({ peekLoading: true, }); @@ -246,7 +269,8 @@ module.exports = React.createClass({ } }).done(); } - } else if (isUserJoined) { + } else if (room) { + // Stop peeking because we have joined this room previously MatrixClientPeg.get().stopPeeking(); this.setState({ showApps: this._shouldShowApps(room), @@ -496,8 +520,7 @@ module.exports = React.createClass({ // and that has probably just changed if (ev.sender) { this.tabComplete.onMemberSpoke(ev.sender); - // nb. we don't need to update the new autocomplete here since - // its results are currently ordered purely by search score. + UserProvider.getInstance().onUserSpoke(ev.sender); } }, @@ -520,6 +543,8 @@ module.exports = React.createClass({ this._warnAboutEncryption(room); this._calculatePeekRules(room); this._updatePreviewUrlVisibility(room); + this.tabComplete.loadEntries(room); + UserProvider.getInstance().setUserListFromRoom(room); }, _warnAboutEncryption: function(room) { @@ -620,6 +645,7 @@ module.exports = React.createClass({ } this.setState({ room: room, + waitingForRoom: false, }, () => { this._onRoomLoaded(room); }); @@ -675,7 +701,14 @@ module.exports = React.createClass({ onRoomMemberMembership: function(ev, member, oldMembership) { if (member.userId == MatrixClientPeg.get().credentials.userId) { - this.forceUpdate(); + + if (member.membership === 'join') { + this.setState({ + waitingForRoom: false, + }); + } else { + this.forceUpdate(); + } } }, @@ -688,7 +721,7 @@ module.exports = React.createClass({ // refresh the tab complete list this.tabComplete.loadEntries(this.state.room); - this._updateAutoComplete(this.state.room); + UserProvider.getInstance().setUserListFromRoom(this.state.room); // 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 @@ -1153,8 +1186,13 @@ module.exports = React.createClass({ this.updateTint(); this.setState({ editingRoomSettings: false, - forwardingEvent: null, }); + if (this.state.forwardingEvent) { + dis.dispatch({ + action: 'forward_event', + event: null, + }); + } dis.dispatch({action: 'focus_composer'}); }, @@ -1408,14 +1446,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() { const RoomHeader = sdk.getComponent('rooms.RoomHeader'); const MessageComposer = sdk.getComponent('rooms.MessageComposer'); @@ -1429,6 +1459,10 @@ module.exports = React.createClass({ const Loader = sdk.getComponent("elements.Spinner"); const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); + // Whether the preview bar spinner should be shown. We do this when joining or + // when waiting for a room to be returned by js-sdk when joining + const previewBarSpinner = this.state.joining || this.state.waitingForRoom; + if (!this.state.room) { if (this.state.roomLoading || this.state.peekLoading) { return ( @@ -1448,7 +1482,7 @@ module.exports = React.createClass({ // We have no room object for this room, only the ID. // We've got to this room by following a link, possibly a third party invite. - var room_alias = this.state.room_alias; + const roomAlias = this.state.roomAlias; return (
@@ -1561,7 +1595,7 @@ module.exports = React.createClass({ } else if (this.state.uploadingRoomSettings) { aux = ; } else if (this.state.forwardingEvent !== null) { - aux = ; + aux = ; } else if (this.state.searching) { hideCancel = true; // has own cancel aux = ; @@ -1581,7 +1615,7 @@ module.exports = React.createClass({ excessHeight) { + break; + } // The tile may not have a scroll token, so guard it if (tile.dataset.scrollTokens) { markerScrollToken = tile.dataset.scrollTokens.split(',')[0]; } - if (tile.clientHeight > excessHeight) { - break; - } } if (markerScrollToken) { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 5ac2e77256..bfbb9831b0 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -21,6 +21,7 @@ const MatrixClientPeg = require("../../MatrixClientPeg"); const PlatformPeg = require("../../PlatformPeg"); const Modal = require('../../Modal'); const dis = require("../../dispatcher"); +import sessionStore from '../../stores/SessionStore'; const q = require('q'); const packageJson = require('../../../package.json'); const UserSettingsStore = require('../../UserSettingsStore'); @@ -92,6 +93,10 @@ const SETTINGS_LABELS = [ id: 'disableMarkdown', label: 'Disable markdown formatting', }, + { + id: 'enableSyntaxHighlightLanguageDetection', + label: 'Enable automatic language detection for syntax highlighting', + }, /* { id: 'useFixedWidthFont', @@ -169,9 +174,6 @@ module.exports = React.createClass({ // The base URL to use in the referral link. Defaults to window.location.origin. referralBaseUrl: React.PropTypes.string, - // true if RightPanel is collapsed - collapsedRhs: React.PropTypes.bool, - // Team token for the referral link. If falsy, the referral section will // not appear teamToken: React.PropTypes.string, @@ -246,6 +248,12 @@ module.exports = React.createClass({ this.setState({ language: languageHandler.getCurrentLanguage(), }); + + this._sessionStore = sessionStore; + this._sessionStoreToken = this._sessionStore.addListener( + this._setStateFromSessionStore, + ); + this._setStateFromSessionStore(); }, componentDidMount: function() { @@ -272,6 +280,22 @@ module.exports = React.createClass({ } }, + // `UserSettings` assumes that the client peg will not be null, so give it some + // sort of assurance here by only allowing a re-render if the client is truthy. + // + // This is required because `UserSettings` maintains its own state and if this state + // updates (e.g. during _setStateFromSessionStore) after the client peg has been made + // null (during logout), then it will attempt to re-render and throw errors. + shouldComponentUpdate: function() { + return Boolean(MatrixClientPeg.get()); + }, + + _setStateFromSessionStore: function() { + this.setState({ + userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()), + }); + }, + _electronSettings: function(ev, settings) { this.setState({ electron_settings: settings }); }, @@ -622,6 +646,10 @@ module.exports = React.createClass({ }, _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 (

{ _t("User Interface") }

@@ -629,8 +657,21 @@ module.exports = React.createClass({ { this._renderUrlPreviewSelector() } { SETTINGS_LABELS.map( this._renderSyncedSetting ) } { THEMES.map( this._renderThemeSelector ) } + + + + + + + +
{_t('Autocomplete Delay (ms):')} + +
{ this._renderLanguageSetting() } -
); @@ -864,6 +905,21 @@ module.exports = React.createClass({ ; }, + _renderCheckUpdate: function() { + const platform = PlatformPeg.get(); + if ('canSelfUpdate' in platform && platform.canSelfUpdate() && 'startUpdateCheck' in platform) { + return
+

{_t('Updates')}

+
+ + {_t('Check for update')} + +
+
; + } + return
; + }, + _renderBulkOptions: function() { const invitedRooms = MatrixClientPeg.get().getRooms().filter((r) => { return r.hasMembershipState(this._me, "invite"); @@ -1164,7 +1220,6 @@ module.exports = React.createClass({
@@ -1205,10 +1260,14 @@ module.exports = React.createClass({

{ _t("Account") }

- { _t("Sign out") } + { this.state.userHasGeneratedPassword ? +
+ { _t("To return to your account in future you need to set a password") } +
: null + } {accountJsx}
@@ -1262,6 +1321,8 @@ module.exports = React.createClass({
+ {this._renderCheckUpdate()} + {this._renderClearCache()} {this._renderDeactivateAccount()} diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 17fbf445b2..388198bb02 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -218,29 +218,29 @@ module.exports = React.createClass({ } trackPromise.then((teamToken) => { - this.props.onLoggedIn({ + return this.props.onLoggedIn({ userId: response.user_id, deviceId: response.device_id, homeserverUrl: this._matrixClient.getHomeserverUrl(), identityServerUrl: this._matrixClient.getIdentityServerUrl(), accessToken: response.access_token }, teamToken); - }).then(() => { - return this._setupPushers(); + }).then((cli) => { + return this._setupPushers(cli); }); }, - _setupPushers: function() { + _setupPushers: function(matrixClient) { if (!this.props.brand) { return q(); } - return MatrixClientPeg.get().getPushers().then((resp)=>{ + return matrixClient.getPushers().then((resp)=>{ const pushers = resp.pushers; for (let i = 0; i < pushers.length; ++i) { if (pushers[i].kind == 'email') { const emailPusher = pushers[i]; emailPusher.data = { brand: this.props.brand }; - MatrixClientPeg.get().setPusher(emailPusher).done(() => { + matrixClient.setPusher(emailPusher).done(() => { console.log("Set email branding to " + this.props.brand); }, (error) => { console.error("Couldn't set email branding: " + error); diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js index 9a14cb91d3..a85efadef7 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -178,7 +178,7 @@ module.exports = React.createClass({ }, onQueryChanged: function(ev) { - const query = ev.target.value.toLowerCase(); + const query = ev.target.value; if (this.queryChangedDebouncer) { clearTimeout(this.queryChangedDebouncer); } @@ -271,10 +271,11 @@ module.exports = React.createClass({ query, searchError: null, }); + const queryLowercase = query.toLowerCase(); const results = []; MatrixClientPeg.get().getUsers().forEach((user) => { - if (user.userId.toLowerCase().indexOf(query) === -1 && - user.displayName.toLowerCase().indexOf(query) === -1 + if (user.userId.toLowerCase().indexOf(queryLowercase) === -1 && + user.displayName.toLowerCase().indexOf(queryLowercase) === -1 ) { return; } diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js new file mode 100644 index 0000000000..61391d281c --- /dev/null +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -0,0 +1,172 @@ +/* +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 Modal from '../../../Modal'; +import React from 'react'; +import sdk from '../../../index'; + +import { _t } from '../../../languageHandler'; + +/** + * Dialog which asks the user whether they want to share their keys with + * an unverified device. + * + * onFinished is called with `true` if the key should be shared, `false` if it + * should not, and `undefined` if the dialog is cancelled. (In other words: + * truthy: do the key share. falsy: don't share the keys). + */ +export default React.createClass({ + propTypes: { + matrixClient: React.PropTypes.object.isRequired, + userId: React.PropTypes.string.isRequired, + deviceId: React.PropTypes.string.isRequired, + onFinished: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + deviceInfo: null, + wasNewDevice: false, + }; + }, + + componentDidMount: function() { + this._unmounted = false; + const userId = this.props.userId; + const deviceId = this.props.deviceId; + + // give the client a chance to refresh the device list + this.props.matrixClient.downloadKeys([userId], false).then((r) => { + if (this._unmounted) { return; } + + const deviceInfo = r[userId][deviceId]; + + if(!deviceInfo) { + console.warn(`No details found for device ${userId}:${deviceId}`); + + this.props.onFinished(false); + return; + } + + const wasNewDevice = !deviceInfo.isKnown(); + + this.setState({ + deviceInfo: deviceInfo, + wasNewDevice: wasNewDevice, + }); + + // if the device was new before, it's not any more. + if (wasNewDevice) { + this.props.matrixClient.setDeviceKnown( + userId, + deviceId, + true, + ); + } + }).done(); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + + _onVerifyClicked: function() { + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + + console.log("KeyShareDialog: Starting verify dialog"); + Modal.createDialog(DeviceVerifyDialog, { + userId: this.props.userId, + device: this.state.deviceInfo, + onFinished: (verified) => { + if (verified) { + // can automatically share the keys now. + this.props.onFinished(true); + } + }, + }); + }, + + _onShareClicked: function() { + console.log("KeyShareDialog: User clicked 'share'"); + this.props.onFinished(true); + }, + + _onIgnoreClicked: function() { + console.log("KeyShareDialog: User clicked 'ignore'"); + this.props.onFinished(false); + }, + + _renderContent: function() { + const displayName = this.state.deviceInfo.getDisplayName() || + this.state.deviceInfo.deviceId; + + let text; + if (this.state.wasNewDevice) { + text = "You added a new device '%(displayName)s', which is" + + " requesting encryption keys."; + } else { + text = "Your unverified device '%(displayName)s' is requesting" + + " encryption keys."; + } + text = _t(text, {displayName: displayName}); + + return ( +
+

{text}

+ +
+ + + +
+
+ ); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const Spinner = sdk.getComponent('views.elements.Spinner'); + + let content; + + if (this.state.deviceInfo) { + content = this._renderContent(); + } else { + content = ( +
+

{_t('Loading device info...')}

+ +
+ ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js new file mode 100644 index 0000000000..3c38064ee1 --- /dev/null +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -0,0 +1,164 @@ +/* +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 Email from '../../../email'; +import AddThreepid from '../../../AddThreepid'; +import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; + + +/** + * Prompt the user to set an email address. + * + * On success, `onFinished(true)` is called. + */ +export default React.createClass({ + displayName: 'SetEmailDialog', + propTypes: { + onFinished: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + emailAddress: null, + emailBusy: false, + }; + }, + + componentDidMount: function() { + }, + + onEmailAddressChanged: function(value) { + this.setState({ + emailAddress: value, + }); + }, + + onSubmit: function() { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + const emailAddress = this.state.emailAddress; + if (!Email.looksValid(emailAddress)) { + Modal.createDialog(ErrorDialog, { + title: _t("Invalid Email Address"), + description: _t("This doesn't appear to be a valid email address"), + }); + return; + } + this._addThreepid = new AddThreepid(); + // we always bind emails when registering, so let's do the + // same here. + this._addThreepid.addEmailAddress(emailAddress, true).done(() => { + Modal.createDialog(QuestionDialog, { + title: _t("Verification Pending"), + description: _t( + "Please check your email and click on the link it contains. Once this " + + "is done, click continue.", + ), + button: _t('Continue'), + onFinished: this.onEmailDialogFinished, + }); + }, (err) => { + this.setState({emailBusy: false}); + console.error("Unable to add email address " + emailAddress + " " + err); + Modal.createDialog(ErrorDialog, { + title: _t("Unable to add email address"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + this.setState({emailBusy: true}); + }, + + onCancelled: function() { + this.props.onFinished(false); + }, + + onEmailDialogFinished: function(ok) { + if (ok) { + this.verifyEmailAddress(); + } else { + this.setState({emailBusy: false}); + } + }, + + verifyEmailAddress: function() { + this._addThreepid.checkEmailLinkClicked().done(() => { + this.props.onFinished(true); + }, (err) => { + this.setState({emailBusy: false}); + if (err.errcode == 'M_THREEPID_AUTH_FAILED') { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + 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."); + Modal.createDialog(QuestionDialog, { + title: _t("Verification Pending"), + description: message, + button: _t('Continue'), + onFinished: this.onEmailDialogFinished, + }); + } else { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Unable to verify email address: " + err); + Modal.createDialog(ErrorDialog, { + title: _t("Unable to verify email address."), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + } + }); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const Spinner = sdk.getComponent('elements.Spinner'); + const EditableText = sdk.getComponent('elements.EditableText'); + + const emailInput = this.state.emailBusy ? : ; + + return ( + +
+

+ { _t('This will allow you to reset your password and receive notifications.') } +

+ { emailInput } +
+
+ + +
+
+ ); + }, +}); diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js index b0dd29582b..f154cc4f1d 100644 --- a/src/components/views/login/CaptchaForm.js +++ b/src/components/views/login/CaptchaForm.js @@ -46,6 +46,10 @@ module.exports = React.createClass({ }; }, + componentWillMount: function() { + this._captchaWidgetId = null; + }, + componentDidMount: function() { // Just putting a script tag into the returned jsx doesn't work, annoyingly, // so we do this instead. @@ -75,6 +79,10 @@ module.exports = React.createClass({ } }, + componentWillUnmount: function() { + this._resetRecaptcha(); + }, + _renderRecaptcha: function(divId) { if (!global.grecaptcha) { console.error("grecaptcha not loaded!"); @@ -90,12 +98,18 @@ module.exports = React.createClass({ } console.log("Rendering to %s", divId); - global.grecaptcha.render(divId, { + this._captchaWidgetId = global.grecaptcha.render(divId, { sitekey: publicKey, callback: this.props.onCaptchaResponse, }); }, + _resetRecaptcha: function() { + if (this._captchaWidgetId !== null) { + global.grecaptcha.reset(this._captchaWidgetId); + } + }, + _onCaptchaLoaded: function() { console.log("Loaded recaptcha script."); try { diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 46a48d14a0..9f855616fc 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -69,10 +69,19 @@ class PasswordLogin extends React.Component { onSubmitForm(ev) { ev.preventDefault(); + if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) { + this.props.onSubmit( + '', // XXX: Synapse breaks if you send null here: + this.state.phoneCountry, + this.state.phoneNumber, + this.state.password, + ); + return; + } this.props.onSubmit( this.state.username, - this.state.phoneCountry, - this.state.phoneNumber, + null, + null, this.state.password, ); } diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 46d8366592..f31b94df80 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -79,7 +79,7 @@ module.exports = React.createClass({ const content = this.props.mxEvent.getContent(); if (content.file !== undefined) { 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); } else { return null; diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index d5a1977cdd..190b1341c3 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -29,6 +29,7 @@ import Modal from '../../../Modal'; import SdkConfig from '../../../SdkConfig'; import dis from '../../../dispatcher'; import { _t } from '../../../languageHandler'; +import UserSettingsStore from "../../../UserSettingsStore"; linkifyMatrix(linkify); @@ -90,7 +91,18 @@ module.exports = React.createClass({ setTimeout(() => { if (this._unmounted) return; for (let i = 0; i < blocks.length; i++) { - highlight.highlightBlock(blocks[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]); + } + } } }, 10); } diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 5f6bac0007..d591e4f6c2 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -6,6 +6,7 @@ import isEqual from 'lodash/isEqual'; import sdk from '../../../index'; import type {Completion} from '../../../autocomplete/Autocompleter'; import Q from 'q'; +import UserSettingsStore from '../../../UserSettingsStore'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -58,7 +59,7 @@ export default class Autocomplete extends React.Component { return; } - const completionList = flatMap(completions, provider => provider.completions); + const completionList = flatMap(completions, (provider) => provider.completions); // Reset selection when completion list becomes empty. let selectionOffset = COMPOSER_SELECTED; @@ -69,27 +70,35 @@ export default class Autocomplete extends React.Component { const currentSelection = this.state.selectionOffset === 0 ? null : this.state.completionList[this.state.selectionOffset - 1].completion; selectionOffset = completionList.findIndex( - completion => completion.completion === currentSelection); + (completion) => completion.completion === currentSelection); if (selectionOffset === -1) { selectionOffset = COMPOSER_SELECTED; } else { selectionOffset++; // selectionOffset is 1-indexed! } - } else { - // If no completions were returned, we should turn off force completion. - forceComplete = false; } let hide = this.state.hide; // These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern - const oldMatches = this.state.completions.map(completion => !!completion.command.command), - newMatches = completions.map(completion => !!completion.command.command); + const oldMatches = this.state.completions.map((completion) => !!completion.command.command), + newMatches = completions.map((completion) => !!completion.command.command); // 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; } + const autocompleteDelay = UserSettingsStore.getSyncedSetting('autocompleteDelay', 200); + + // We had no completions before, but do now, so we should apply our display delay here + if (this.state.completionList.length === 0 && completionList.length > 0 && + !forceComplete && autocompleteDelay > 0) { + await Q.delay(autocompleteDelay); + } + + // Force complete is turned off each time since we can't edit the query in that case + forceComplete = false; + this.setState({ completions, completionList, @@ -149,6 +158,7 @@ export default class Autocomplete extends React.Component { const done = Q.defer(); this.setState({ forceComplete: true, + hide: false, }, () => { this.complete(this.props.query, this.props.selection).then(() => { done.resolve(); @@ -169,7 +179,7 @@ export default class Autocomplete extends React.Component { } setSelection(selectionOffset: number) { - this.setState({selectionOffset}); + this.setState({selectionOffset, hide: false}); } componentDidUpdate() { @@ -185,21 +195,24 @@ export default class Autocomplete extends React.Component { } } + setState(state, func) { + super.setState(state, func); + } + render() { const EmojiText = sdk.getComponent('views.elements.EmojiText'); let position = 1; - let renderedCompletions = this.state.completions.map((completionResult, i) => { - let completions = completionResult.completions.map((completion, i) => { - + const renderedCompletions = this.state.completions.map((completionResult, i) => { + const completions = completionResult.completions.map((completion, i) => { const className = classNames('mx_Autocomplete_Completion', { 'selected': position === this.state.selectionOffset, }); - let componentPosition = position; + const componentPosition = position; position++; - let onMouseOver = () => this.setSelection(componentPosition); - let onClick = () => { + const onMouseOver = () => this.setSelection(componentPosition); + const onClick = () => { this.setSelection(componentPosition); this.onCompletionClicked(); }; @@ -220,7 +233,7 @@ export default class Autocomplete extends React.Component { {completionResult.provider.renderCompletions(completions)} ) : null; - }).filter(completion => !!completion); + }).filter((completion) => !!completion); return !this.state.hide && renderedCompletions.length > 0 ? (
this.container = e}> diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 5c970a0966..170925999d 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -336,6 +336,7 @@ module.exports = WithMatrixClient(React.createClass({ suppressAnimation={this._suppressReadReceiptAnimation} onClick={this.toggleAllReadAvatars} timestamp={receipt.ts} + showTwelveHour={this.props.isTwelveHour} /> ); } diff --git a/src/components/views/rooms/ForwardMessage.js b/src/components/views/rooms/ForwardMessage.js index 33df201d7c..3c97128a02 100644 --- a/src/components/views/rooms/ForwardMessage.js +++ b/src/components/views/rooms/ForwardMessage.js @@ -17,7 +17,6 @@ import React from 'react'; import { _t } from '../../../languageHandler'; -import MatrixClientPeg from '../../../MatrixClientPeg'; import dis from '../../../dispatcher'; import KeyCode from '../../../KeyCode'; @@ -26,11 +25,6 @@ module.exports = React.createClass({ displayName: 'ForwardMessage', propTypes: { - currentRoomId: React.PropTypes.string.isRequired, - - /* the MatrixEvent to be forwarded */ - mxEvent: React.PropTypes.object.isRequired, - onCancelClick: React.PropTypes.func.isRequired, }, @@ -44,7 +38,6 @@ module.exports = React.createClass({ }, componentDidMount: function() { - this.dispatcherRef = dis.register(this.onAction); document.addEventListener('keydown', this._onKeyDown); }, @@ -54,30 +47,9 @@ module.exports = React.createClass({ sideOpacity: 1.0, middleOpacity: 1.0, }); - dis.unregister(this.dispatcherRef); document.removeEventListener('keydown', this._onKeyDown); }, - onAction: function(payload) { - if (payload.action === 'view_room') { - const event = this.props.mxEvent; - const Client = MatrixClientPeg.get(); - Client.sendEvent(payload.room_id, event.getType(), event.getContent()).done(() => { - dis.dispatch({action: 'message_sent'}); - }, (err) => { - if (err.name === "UnknownDeviceError") { - dis.dispatch({ - action: 'unknown_device_error', - err: err, - room: Client.getRoom(payload.room_id), - }); - } - dis.dispatch({action: 'message_send_failed'}); - }); - if (this.props.currentRoomId === payload.room_id) this.props.onCancelClick(); - } - }, - _onKeyDown: function(ev) { switch (ev.keyCode) { case KeyCode.ESCAPE: diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index b7ef9fa184..5ea92d18ce 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -20,7 +20,6 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, convertFromRaw, convertToRaw, Modifier, EditorChangeType, getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; -import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; import classNames from 'classnames'; import escape from 'lodash/escape'; import Q from 'q'; @@ -41,6 +40,7 @@ import * as HtmlUtils from '../../../HtmlUtils'; import Autocomplete from './Autocomplete'; import {Completion} from "../../../autocomplete/Autocompleter"; import Markdown from '../../../Markdown'; +import ComposerHistoryManager from '../../../ComposerHistoryManager'; import {onSendMessageFailed} from './MessageComposerInputOld'; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; @@ -58,6 +58,29 @@ function stateToMarkdown(state) { * The textInput part of the MessageComposer */ export default class MessageComposerInput extends React.Component { + static propTypes = { + tabComplete: React.PropTypes.any, + + // a callback which is called when the height of the composer is + // changed due to a change in content. + onResize: React.PropTypes.func, + + // js-sdk Room object + room: React.PropTypes.object.isRequired, + + // called with current plaintext content (as a string) whenever it changes + onContentChanged: React.PropTypes.func, + + onUpArrow: React.PropTypes.func, + + onDownArrow: React.PropTypes.func, + + // attempts to confirm currently selected completion, returns whether actually confirmed + tryComplete: React.PropTypes.func, + + onInputStateChanged: React.PropTypes.func, + }; + static getKeyBinding(e: SyntheticKeyboardEvent): string { // C-m => Toggles between rich text and markdown modes if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { @@ -77,6 +100,7 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; + historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); @@ -84,7 +108,6 @@ export default class MessageComposerInput extends React.Component { this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); this.onEditorContentChanged = this.onEditorContentChanged.bind(this); - this.setEditorState = this.setEditorState.bind(this); this.onUpArrow = this.onUpArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this); this.onTab = this.onTab.bind(this); @@ -119,7 +142,7 @@ export default class MessageComposerInput extends React.Component { */ createEditorState(richText: boolean, contentState: ?ContentState): EditorState { let decorators = richText ? RichText.getScopedRTDecorators(this.props) : - RichText.getScopedMDDecorators(this.props), + RichText.getScopedMDDecorators(this.props), compositeDecorator = new CompositeDecorator(decorators); let editorState = null; @@ -132,110 +155,13 @@ export default class MessageComposerInput extends React.Component { return EditorState.moveFocusToEnd(editorState); } - componentWillMount() { - const component = this; - this.sentHistory = { - // The list of typed messages. Index 0 is more recent - data: [], - // The position in data currently displayed - position: -1, - // The room the history is for. - roomId: null, - // The original text before they hit UP - originalText: null, - // The textarea element to set text to. - element: null, - - init: function(element, roomId) { - this.roomId = roomId; - this.element = element; - this.position = -1; - var storedData = window.sessionStorage.getItem( - "mx_messagecomposer_history_" + roomId - ); - if (storedData) { - this.data = JSON.parse(storedData); - } - if (this.roomId) { - this.setLastTextEntry(); - } - }, - - push: function(text) { - // store a message in the sent history - this.data.unshift(text); - window.sessionStorage.setItem( - "mx_messagecomposer_history_" + this.roomId, - JSON.stringify(this.data) - ); - // reset history position - this.position = -1; - this.originalText = null; - }, - - // move in the history. Returns true if we managed to move. - next: function(offset) { - if (this.position === -1) { - // user is going into the history, save the current line. - this.originalText = this.element.value; - } - else { - // user may have modified this line in the history; remember it. - this.data[this.position] = this.element.value; - } - - if (offset > 0 && this.position === (this.data.length - 1)) { - // we've run out of history - return false; - } - - // retrieve the next item (bounded). - var newPosition = this.position + offset; - newPosition = Math.max(-1, newPosition); - newPosition = Math.min(newPosition, this.data.length - 1); - this.position = newPosition; - - if (this.position !== -1) { - // show the message - this.element.value = this.data[this.position]; - } - else if (this.originalText !== undefined) { - // restore the original text the user was typing. - this.element.value = this.originalText; - } - - return true; - }, - - saveLastTextEntry: function() { - // save the currently entered text in order to restore it later. - // NB: This isn't 'originalText' because we want to restore - // sent history items too! - let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent())); - window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON); - }, - - setLastTextEntry: function() { - let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); - if (contentJSON) { - let content = convertFromRaw(JSON.parse(contentJSON)); - component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content)); - } - }, - }; - } - componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - this.sentHistory.init( - this.refs.editor, - this.props.room.roomId - ); + this.historyManager = new ComposerHistoryManager(this.props.room.roomId); } componentWillUnmount() { dis.unregister(this.dispatcherRef); - this.sentHistory.saveLastTextEntry(); } componentWillUpdate(nextProps, nextState) { @@ -247,8 +173,8 @@ export default class MessageComposerInput extends React.Component { } } - onAction(payload) { - let editor = this.refs.editor; + onAction = (payload) => { + const editor = this.refs.editor; let contentState = this.state.editorState.getCurrentContent(); switch (payload.action) { @@ -262,22 +188,22 @@ export default class MessageComposerInput extends React.Component { contentState = Modifier.replaceText( contentState, this.state.editorState.getSelection(), - `${payload.displayname}: ` + `${payload.displayname}: `, ); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); this.onEditorContentChanged(editorState); editor.focus(); } - break; + break; case 'quote': { let {body, formatted_body} = payload.event.getContent(); formatted_body = formatted_body || escape(body); if (formatted_body) { - let content = RichText.HTMLtoContentState(`
${formatted_body}
`); + let content = RichText.htmlToContentState(`
${formatted_body}
`); if (!this.state.isRichtextEnabled) { - content = ContentState.createFromText(stateToMarkdown(content)); + content = ContentState.createFromText(RichText.stateToMarkdown(content)); } const blockMap = content.getBlockMap(); @@ -291,14 +217,14 @@ export default class MessageComposerInput extends React.Component { if (this.state.isRichtextEnabled) { contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); } - let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); this.onEditorContentChanged(editorState); editor.focus(); } } - break; + break; } - } + }; onTypingActivity() { this.isTyping = true; @@ -318,7 +244,7 @@ export default class MessageComposerInput extends React.Component { startUserTypingTimer() { this.stopUserTypingTimer(); - var self = this; + const self = this; this.userTypingTimer = setTimeout(function() { self.isTyping = false; self.sendTyping(self.isTyping); @@ -335,7 +261,7 @@ export default class MessageComposerInput extends React.Component { startServerTypingTimer() { if (!this.serverTypingTimer) { - var self = this; + const self = this; this.serverTypingTimer = setTimeout(function() { if (self.isTyping) { self.sendTyping(self.isTyping); @@ -356,7 +282,7 @@ export default class MessageComposerInput extends React.Component { if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return; MatrixClientPeg.get().sendTyping( this.props.room.roomId, - this.isTyping, TYPING_SERVER_TIMEOUT + this.isTyping, TYPING_SERVER_TIMEOUT, ).done(); } @@ -367,60 +293,80 @@ export default class MessageComposerInput extends React.Component { } } - // Called by Draft to change editor contents, and by setEditorState - onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) { + // Called by Draft to change editor contents + onEditorContentChanged = (editorState: EditorState) => { editorState = RichText.attachImmutableEntitiesToEmoji(editorState); - const contentChanged = Q.defer(); - /* If a modification was made, set originalEditorState to null, since newState is now our original */ + /* Since a modification was made, set originalEditorState to null, since newState is now our original */ this.setState({ editorState, - originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState, - }, () => contentChanged.resolve()); + originalEditorState: null, + }); + }; - if (editorState.getCurrentContent().hasText()) { - this.onTypingActivity(); - } else { - this.onFinishedTyping(); + /** + * We're overriding setState here because it's the most convenient way to monitor changes to the editorState. + * Doing it using a separate function that calls setState is a possibility (and was the old approach), but that + * approach requires a callback and an extra setState whenever trying to set multiple state properties. + * + * @param state + * @param callback + */ + setState(state, callback) { + if (state.editorState != null) { + state.editorState = RichText.attachImmutableEntitiesToEmoji( + state.editorState); + + if (state.editorState.getCurrentContent().hasText()) { + this.onTypingActivity(); + } else { + this.onFinishedTyping(); + } + + if (!state.hasOwnProperty('originalEditorState')) { + state.originalEditorState = null; + } } - if (this.props.onContentChanged) { - const textContent = editorState.getCurrentContent().getPlainText(); - const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), - editorState.getCurrentContent().getBlocksAsArray()); + super.setState(state, () => { + if (callback != null) { + callback(); + } - this.props.onContentChanged(textContent, selection); - } - return contentChanged.promise; - } - - setEditorState(editorState: EditorState) { - return this.onEditorContentChanged(editorState, false); + if (this.props.onContentChanged) { + const textContent = this.state.editorState + .getCurrentContent().getPlainText(); + const selection = RichText.selectionStateToTextOffsets( + this.state.editorState.getSelection(), + this.state.editorState.getCurrentContent().getBlocksAsArray()); + this.props.onContentChanged(textContent, selection); + } + }); } enableRichtext(enabled: boolean) { + if (enabled === this.state.isRichtextEnabled) return; + let contentState = null; if (enabled) { const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); - contentState = RichText.HTMLtoContentState(md.toHTML()); + contentState = RichText.htmlToContentState(md.toHTML()); } else { - let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); + let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent()); if (markdown[markdown.length - 1] === '\n') { markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) } contentState = ContentState.createFromText(markdown); } - this.setEditorState(this.createEditorState(enabled, contentState)).then(() => { - this.setState({ - isRichtextEnabled: enabled, - }); - - UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); + this.setState({ + editorState: this.createEditorState(enabled, contentState), + isRichtextEnabled: enabled, }); + UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); } - handleKeyCommand(command: string): boolean { + handleKeyCommand = (command: string): boolean => { if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichtextEnabled); return true; @@ -434,31 +380,35 @@ export default class MessageComposerInput extends React.Component { const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; if (blockCommands.includes(command)) { - this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command)); + this.setState({ + editorState: RichUtils.toggleBlockType(this.state.editorState, command), + }); } else if (command === 'strike') { // this is the only inline style not handled by Draft by default - this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH')); + this.setState({ + editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'), + }); } } else { let contentState = this.state.editorState.getCurrentContent(), selection = this.state.editorState.getSelection(); - let modifyFn = { - 'bold': text => `**${text}**`, - 'italic': text => `*${text}*`, - 'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* - 'strike': text => `~~${text}~~`, - 'code': text => `\`${text}\``, - 'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''), - 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), - 'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''), + const modifyFn = { + 'bold': (text) => `**${text}**`, + 'italic': (text) => `*${text}*`, + 'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* + 'strike': (text) => `${text}`, + 'code-block': (text) => `\`\`\`\n${text}\n\`\`\``, + 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''), + 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''), + 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''), }[command]; if (modifyFn) { newState = EditorState.push( this.state.editorState, RichText.modifyText(contentState, selection, modifyFn), - 'insert-characters' + 'insert-characters', ); } } @@ -468,7 +418,7 @@ export default class MessageComposerInput extends React.Component { } if (newState != null) { - this.setEditorState(newState); + this.setState({editorState: newState}); return true; } @@ -481,6 +431,13 @@ export default class MessageComposerInput extends React.Component { return true; } + const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); + // If we're in any of these three types of blocks, shift enter should insert soft newlines + // And just enter should end the block + if(['blockquote', 'unordered-list-item', 'ordered-list-item'].includes(currentBlockType)) { + return false; + } + const contentState = this.state.editorState.getCurrentContent(); if (!contentState.hasText()) { return true; @@ -489,11 +446,11 @@ export default class MessageComposerInput extends React.Component { let contentText = contentState.getPlainText(), contentHTML; - var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); + const cmd = SlashCommands.processInput(this.props.room.roomId, contentText); if (cmd) { if (!cmd.error) { this.setState({ - editorState: this.createEditorState() + editorState: this.createEditorState(), }); } if (cmd.promise) { @@ -501,16 +458,15 @@ export default class MessageComposerInput extends React.Component { console.log("Command success."); }, function(err) { console.error("Command failure: %s", err); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: _t("Server error"), description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")), }); }); - } - else if (cmd.error) { + } else if (cmd.error) { console.error(cmd.error); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: _t("Command error"), description: cmd.error, @@ -521,7 +477,7 @@ export default class MessageComposerInput extends React.Component { if (this.state.isRichtextEnabled) { contentHTML = HtmlUtils.stripParagraphs( - RichText.contentStateToHTML(contentState) + RichText.contentStateToHTML(contentState), ); } else { const md = new Markdown(contentText); @@ -543,12 +499,14 @@ export default class MessageComposerInput extends React.Component { sendTextFn = this.client.sendEmoteMessage; } - // XXX: We don't actually seem to use this history? - this.sentHistory.push(contentHTML || contentText); + this.historyManager.addItem( + this.state.isRichtextEnabled ? contentHTML : contentState.getPlainText(), + this.state.isRichtextEnabled ? 'html' : 'markdown'); + let sendMessagePromise; if (contentHTML) { sendMessagePromise = sendHtmlFn.call( - this.client, this.props.room.roomId, contentText, contentHTML + this.client, this.props.room.roomId, contentText, contentHTML, ); } else { sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText); @@ -567,87 +525,106 @@ export default class MessageComposerInput extends React.Component { this.autocomplete.hide(); return true; - } + }; - async onUpArrow(e) { + onUpArrow = async (e) => { const completion = this.autocomplete.onUpArrow(); - if (completion != null) { - e.preventDefault(); + if (completion == null) { + const newContent = this.historyManager.getItem(-1, this.state.isRichtextEnabled ? 'html' : 'markdown'); + if (!newContent) return false; + const editorState = EditorState.push(this.state.editorState, + newContent, + 'insert-characters'); + this.setState({editorState}); + return true; } - return await this.setDisplayedCompletion(completion); - } - - async onDownArrow(e) { - const completion = this.autocomplete.onDownArrow(); e.preventDefault(); return await this.setDisplayedCompletion(completion); - } + }; + + onDownArrow = async (e) => { + const completion = this.autocomplete.onDownArrow(); + if (completion == null) { + const newContent = this.historyManager.getItem(+1, this.state.isRichtextEnabled ? 'html' : 'markdown'); + if (!newContent) return false; + const editorState = EditorState.push(this.state.editorState, + newContent, + 'insert-characters'); + this.setState({editorState}); + return true; + } + e.preventDefault(); + return await this.setDisplayedCompletion(completion); + }; // tab and shift-tab are mapped to down and up arrow respectively - async onTab(e) { + onTab = async (e) => { e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes - const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); - if (!didTab && this.autocomplete) { - this.autocomplete.forceComplete().then(() => { - this.onDownArrow(e); - }); + if (this.autocomplete.state.completionList.length === 0) { + await this.autocomplete.forceComplete(); + this.onDownArrow(e); + } else { + await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); } - } + }; - onEscape(e) { + onEscape = async (e) => { e.preventDefault(); if (this.autocomplete) { this.autocomplete.onEscape(e); } - this.setDisplayedCompletion(null); // restore originalEditorState - } + await this.setDisplayedCompletion(null); // restore originalEditorState + }; /* If passed null, restores the original editor content from state.originalEditorState. * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState. */ - async setDisplayedCompletion(displayedCompletion: ?Completion): boolean { + setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => { const activeEditorState = this.state.originalEditorState || this.state.editorState; if (displayedCompletion == null) { if (this.state.originalEditorState) { - this.setEditorState(this.state.originalEditorState); + let editorState = this.state.originalEditorState; + // This is a workaround from https://github.com/facebook/draft-js/issues/458 + // Due to the way we swap editorStates, Draft does not rerender at times + editorState = EditorState.forceSelection(editorState, + editorState.getSelection()); + this.setState({editorState}); + } return false; } const {range = {}, completion = ''} = displayedCompletion; - let contentState = Modifier.replaceText( + const contentState = Modifier.replaceText( activeEditorState.getCurrentContent(), RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()), - completion + completion, ); let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters'); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); - const originalEditorState = activeEditorState; - - await this.setEditorState(editorState); - this.setState({originalEditorState}); + this.setState({editorState, originalEditorState: activeEditorState}); // for some reason, doing this right away does not update the editor :( - setTimeout(() => this.refs.editor.focus(), 50); + // setTimeout(() => this.refs.editor.focus(), 50); return true; - } + }; onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) { e.preventDefault(); // don't steal focus from the editor! const command = { - code: 'code-block', - quote: 'blockquote', - bullet: 'unordered-list-item', - numbullet: 'ordered-list-item', - }[name] || name; + code: 'code-block', + quote: 'blockquote', + bullet: 'unordered-list-item', + numbullet: 'ordered-list-item', + }[name] || name; this.handleKeyCommand(command); } /* returns inline style and block type of current SelectionState so MessageComposer can render formatting - buttons. */ + buttons. */ getSelectionInfo(editorState: EditorState) { const styleName = { BOLD: 'bold', @@ -658,8 +635,8 @@ export default class MessageComposerInput extends React.Component { const originalStyle = editorState.getCurrentInlineStyle().toArray(); const style = originalStyle - .map(style => styleName[style] || null) - .filter(styleName => !!styleName); + .map((style) => styleName[style] || null) + .filter((styleName) => !!styleName); const blockName = { 'code-block': 'code', @@ -678,10 +655,10 @@ export default class MessageComposerInput extends React.Component { }; } - onMarkdownToggleClicked(e) { + onMarkdownToggleClicked = (e) => { e.preventDefault(); // don't steal focus from the editor! this.handleKeyCommand('toggle-mode'); - } + }; render() { const activeEditorState = this.state.originalEditorState || this.state.editorState; @@ -698,7 +675,7 @@ export default class MessageComposerInput extends React.Component { } const className = classNames('mx_MessageComposer_input', { - mx_MessageComposer_input_empty: hidePlaceholder, + mx_MessageComposer_input_empty: hidePlaceholder, }); const content = activeEditorState.getCurrentContent(); @@ -713,7 +690,7 @@ export default class MessageComposerInput extends React.Component { ref={(e) => this.autocomplete = e} onConfirm={this.setDisplayedCompletion} query={contentText} - selection={selection} /> + selection={selection}/>
+ spellCheck={true}/>
); diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js index 7565b0741d..64b54fe1e1 100644 --- a/src/components/views/rooms/ReadReceiptMarker.js +++ b/src/components/views/rooms/ReadReceiptMarker.js @@ -66,6 +66,9 @@ module.exports = React.createClass({ // Timestamp when the receipt was read timestamp: React.PropTypes.number, + + // True to show twelve hour format, false otherwise + showTwelveHour: React.PropTypes.bool, }, getDefaultProps: function() { @@ -172,7 +175,7 @@ module.exports = React.createClass({ if (this.props.timestamp) { title = _t( "Seen by %(userName)s at %(dateTime)s", - {userName: this.props.member.userId, dateTime: DateUtils.formatDate(new Date(this.props.timestamp))} + {userName: this.props.member.userId, dateTime: DateUtils.formatDate(new Date(this.props.timestamp), this.props.showTwelveHour)} ); } diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index f90d89c8c1..9f9f030c27 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -18,7 +18,7 @@ limitations under the License. 'use strict'; var React = require("react"); var ReactDOM = require("react-dom"); -import { _t } from '../../../languageHandler'; +import { _t, _tJsx } from '../../../languageHandler'; var GeminiScrollbar = require('react-gemini-scrollbar'); var MatrixClientPeg = require("../../../MatrixClientPeg"); var CallHandler = require('../../../CallHandler'); @@ -33,11 +33,28 @@ var Receipt = require('../../../utils/Receipt'); const HIDE_CONFERENCE_CHANS = true; -const VERBS = { - 'm.favourite': 'favourite', - 'im.vector.fake.direct': 'tag direct chat', - 'im.vector.fake.recent': 'restore', - 'm.lowpriority': 'demote', +function phraseForSection(section) { + // These would probably be better as individual strings, + // but for some reason we have translations for these strings + // as-is, so keeping it like this for now. + let verb; + switch (section) { + case 'm.favourite': + verb = _t('to favourite'); + break; + case 'im.vector.fake.direct': + verb = _t('to tag direct chat'); + break; + case 'im.vector.fake.recent': + verb = _t('to restore'); + break; + case 'm.lowpriority': + verb = _t('to demote'); + break; + default: + return _t('Drop here to tag %(section)s', {section: section}); + } + return _t('Drop here %(toAction)s', {toAction: verb}); }; module.exports = React.createClass({ @@ -478,17 +495,25 @@ module.exports = React.createClass({ switch (section) { case 'im.vector.fake.direct': return
- Press - - to start a chat with someone + {_tJsx( + "Press to start a chat with someone", + [//], + [ + (sub) => + ] + )}
; case 'im.vector.fake.recent': return
- You're not in any rooms yet! Press - - to make a room or - - to browse the directory + {_tJsx( + "You're not in any rooms yet! Press to make a room or"+ + " to browse the directory", + [//, //], + [ + (sub) => , + (sub) => + ] + )}
; } @@ -497,7 +522,7 @@ module.exports = React.createClass({ return null; } - const labelText = 'Drop here to ' + (VERBS[section] || 'tag ' + section); + const labelText = phraseForSection(section); return ; }, diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 9a3236cdb9..171af4764b 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -594,7 +594,7 @@ module.exports = React.createClass({