diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index a89c083518..9ecd39ffc2 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -47,7 +47,6 @@ src/components/views/rooms/UserTile.js src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangePassword.js src/components/views/settings/DevicesPanel.js -src/components/views/settings/IntegrationsManager.js src/components/views/settings/Notifications.js src/GroupAddressPicker.js src/HtmlUtils.js diff --git a/res/css/_components.scss b/res/css/_components.scss index 2a91f08ee4..843f314bd1 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -50,7 +50,6 @@ @import "./views/context_menus/_TopLeftMenu.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; -@import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; diff --git a/res/css/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 9bcd79a357..68e5f5c19f 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -30,6 +30,7 @@ limitations under the License. .mx_Login_submit:disabled { opacity: 0.3; + cursor: default; } .mx_AuthBody a.mx_Login_sso_link:link, diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 16ac876869..cce3b5dbf5 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -72,7 +72,6 @@ limitations under the License. } .mx_Field input { - width: 100%; box-sizing: border-box; } @@ -110,7 +109,6 @@ limitations under the License. .mx_AuthBody_fieldRow > .mx_Field { margin: 0 5px; - flex: 1; } .mx_AuthBody_fieldRow > .mx_Field:first-child { diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/auth/_ServerConfig.scss index fe96da2019..a31feb75d7 100644 --- a/res/css/views/auth/_ServerConfig.scss +++ b/res/css/views/auth/_ServerConfig.scss @@ -20,7 +20,6 @@ limitations under the License. } .mx_ServerConfig_fields .mx_Field { - flex: 1; margin: 0 5px; } diff --git a/res/css/views/dialogs/_BugReportDialog.scss b/res/css/views/dialogs/_BugReportDialog.scss deleted file mode 100644 index 90ef55b945..0000000000 --- a/res/css/views/dialogs/_BugReportDialog.scss +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright 2017 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_BugReportDialog .mx_Field { - flex: 1; -} - -.mx_BugReportDialog_field_input { - // TODO: We should really apply this to all .mx_Field inputs. - // See https://github.com/vector-im/riot-web/issues/9344. - flex: 1; -} diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 1f5d36b57a..8e669acd10 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -23,7 +23,11 @@ limitations under the License. cursor: default !important; } -.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button, .mx_DevTools_RoomStateExplorer_query { +.mx_DevTools_RoomStateExplorer_query { + margin-bottom: 10px; +} + +.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button { margin-bottom: 10px; width: 100%; } @@ -75,7 +79,6 @@ limitations under the License. max-width: 684px; min-height: 250px; padding: 10px; - width: 100%; } .mx_DevTools_content .mx_Field_input { diff --git a/res/css/views/dialogs/_SetPasswordDialog.scss b/res/css/views/dialogs/_SetPasswordDialog.scss index 28a8b7c9d7..325ff6c6ed 100644 --- a/res/css/views/dialogs/_SetPasswordDialog.scss +++ b/res/css/views/dialogs/_SetPasswordDialog.scss @@ -21,7 +21,6 @@ limitations under the License. color: $primary-fg-color; background-color: $primary-bg-color; font-size: 15px; - width: 100%; max-width: 280px; margin-bottom: 10px; } diff --git a/res/css/views/elements/_EditableItemList.scss b/res/css/views/elements/_EditableItemList.scss index be96d811d3..51fa4c4423 100644 --- a/res/css/views/elements/_EditableItemList.scss +++ b/res/css/views/elements/_EditableItemList.scss @@ -42,12 +42,6 @@ limitations under the License. margin-right: 5px; } -.mx_EditableItemList_newItem .mx_Field input { - // Use 100% of the space available for the input, but don't let the 10px - // padding on either side of the input to push it out of alignment. - width: calc(100% - 20px); -} - .mx_EditableItemList_label { margin-bottom: 5px; -} \ No newline at end of file +} diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index 147bb3b471..a6ac680116 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -18,6 +18,8 @@ limitations under the License. .mx_Field { display: flex; + flex: 1; + min-width: 0; position: relative; margin: 1em 0; border-radius: 4px; @@ -42,6 +44,7 @@ limitations under the License. padding: 8px 9px; color: $primary-fg-color; background-color: $primary-bg-color; + flex: 1; } .mx_Field select { diff --git a/res/css/views/elements/_PowerSelector.scss b/res/css/views/elements/_PowerSelector.scss index 69f3a8eebb..799f6f246e 100644 --- a/res/css/views/elements/_PowerSelector.scss +++ b/res/css/views/elements/_PowerSelector.scss @@ -20,6 +20,5 @@ limitations under the License. .mx_PowerSelector .mx_Field select, .mx_PowerSelector .mx_Field input { - width: 100%; box-sizing: border-box; } diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 749cfeebe6..685c2bb018 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -26,6 +26,8 @@ limitations under the License. top: -18px; right: 8px; user-select: none; + // Ensure the action bar appears above over things, like the read marker. + z-index: 1; > * { display: inline-block; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index a6194832a3..62632eab27 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -169,6 +169,9 @@ limitations under the License. .mx_EventTile_sending .mx_RoomPill { opacity: 0.5; } +.mx_EventTile_sending.mx_EventTile_redacted .mx_UnknownBody { + opacity: 0.4; +} .mx_EventTile_notSent { color: $event-notsent-color; diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index c3b3ca2f7d..bb38c41581 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -43,6 +43,8 @@ limitations under the License. .mx_MemberInfo_name h2 { flex: 1; + overflow-x: auto; + max-height: 50px; } .mx_MemberInfo h2 { diff --git a/res/css/views/settings/_EmailAddresses.scss b/res/css/views/settings/_EmailAddresses.scss index eef804a33b..4f9541af2c 100644 --- a/res/css/views/settings/_EmailAddresses.scss +++ b/res/css/views/settings/_EmailAddresses.scss @@ -35,9 +35,3 @@ limitations under the License. .mx_ExistingEmailAddress_confirmBtn { margin-right: 5px; } - -.mx_EmailAddresses_new .mx_Field input { - // Use 100% of the space available for the input, but don't let the 10px - // padding on either side of the input to push it out of alignment. - width: calc(100% - 20px); -} diff --git a/res/css/views/settings/_IntegrationsManager.scss b/res/css/views/settings/_IntegrationsManager.scss index 93ee0e20fe..c5769d3645 100644 --- a/res/css/views/settings/_IntegrationsManager.scss +++ b/res/css/views/settings/_IntegrationsManager.scss @@ -29,3 +29,16 @@ limitations under the License. width: 100%; height: 100%; } + +.mx_IntegrationsManager_loading h3 { + text-align: center; +} + +.mx_IntegrationsManager_error { + text-align: center; + padding-top: 20px; +} + +.mx_IntegrationsManager_error h3 { + color: $warning-color; +} \ No newline at end of file diff --git a/res/css/views/settings/_PhoneNumbers.scss b/res/css/views/settings/_PhoneNumbers.scss index 2f54babd6f..a3891882c2 100644 --- a/res/css/views/settings/_PhoneNumbers.scss +++ b/res/css/views/settings/_PhoneNumbers.scss @@ -36,12 +36,6 @@ limitations under the License. margin-right: 5px; } -.mx_PhoneNumbers_new .mx_Field input { - // Use 100% of the space available for the input, but don't let the 10px - // padding on either side of the input to push it out of alignment. - width: calc(100% - 20px); -} - .mx_PhoneNumbers_input { display: flex; align-items: center; diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index b2e449ac34..a972162618 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -22,11 +22,6 @@ limitations under the License. flex-grow: 1; } -.mx_ProfileSettings_controls .mx_Field #profileDisplayName, -.mx_ProfileSettings_controls .mx_Field #profileTopic { - width: calc(100% - 20px); // subtract 10px padding on left and right -} - .mx_ProfileSettings_controls .mx_Field #profileTopic { height: 4em; } diff --git a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss index 91d7ed2c7d..af55820d66 100644 --- a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss +++ b/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss @@ -17,7 +17,3 @@ limitations under the License. .mx_GeneralRoomSettingsTab_profileSection { margin-top: 10px; } - -.mx_GeneralRoomSettingsTab .mx_AliasSettings .mx_Field select { - width: 100%; -} diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss index bec013674a..091c98ffb8 100644 --- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss @@ -14,33 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_GeneralUserSettingsTab_changePassword, -.mx_GeneralUserSettingsTab_themeSection { - display: block; -} - .mx_GeneralUserSettingsTab_changePassword .mx_Field, .mx_GeneralUserSettingsTab_themeSection .mx_Field { - display: block; margin-right: 100px; // Align with the other fields on the page } -.mx_GeneralUserSettingsTab_changePassword .mx_Field input { - display: block; - width: calc(100% - 20px); // subtract 10px padding on left and right -} - .mx_GeneralUserSettingsTab_changePassword .mx_Field:first-child { margin-top: 0; } -.mx_GeneralUserSettingsTab_themeSection .mx_Field select { - display: block; - width: 100%; -} - .mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses, .mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers, .mx_GeneralUserSettingsTab_languageInput { margin-right: 100px; // Align with the other fields on the page -} \ No newline at end of file +} diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss index f447221b7a..b3430f47af 100644 --- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss @@ -17,11 +17,3 @@ limitations under the License. .mx_PreferencesUserSettingsTab .mx_Field { margin-right: 100px; // Align with the rest of the controls } - -.mx_PreferencesUserSettingsTab .mx_Field input { - display: block; - - // Subtract 10px padding on left and right - // This is to keep the input aligned with the rest of the tab's controls. - width: calc(100% - 20px); -} diff --git a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss index f5dba9831e..36c8cfd896 100644 --- a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss @@ -14,11 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VoiceUserSettingsTab .mx_Field select { - width: 100%; - max-width: 100%; -} - .mx_VoiceUserSettingsTab .mx_Field { margin-right: 100px; // align with the rest of the fields } diff --git a/src/CallHandler.js b/src/CallHandler.js index e47209eebe..5b58400ae6 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -344,7 +344,7 @@ function _onAction(payload) { } async function _startCallApp(roomId, type) { - // check for a working intgrations manager. Technically we could put + // check for a working integrations manager. Technically we could put // the state event in anyway, but the resulting widget would then not // work for us. Better that the user knows before everyone else in the // room sees it. diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js new file mode 100644 index 0000000000..1b3fb588eb --- /dev/null +++ b/src/ComposerHistoryManager.js @@ -0,0 +1,86 @@ +//@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 {Value} from 'slate'; + +import _clamp from 'lodash/clamp'; + +type MessageFormat = 'rich' | 'markdown'; + +class HistoryItem { + // We store history items in their native format to ensure history is accurate + // and then convert them if our RTE has subsequently changed format. + value: Value; + format: MessageFormat = 'rich'; + + constructor(value: ?Value, format: ?MessageFormat) { + this.value = value; + this.format = format; + } + + static fromJSON(obj: Object): HistoryItem { + return new HistoryItem( + Value.fromJSON(obj.value), + obj.format, + ); + } + + toJSON(): Object { + return { + value: this.value.toJSON(), + format: this.format, + }; + } +} + +export default class ComposerHistoryManager { + history: Array = []; + prefix: string; + lastIndex: number = 0; // used for indexing the storage + currentIndex: number = 0; // used for indexing the loaded validated history Array + + constructor(roomId: string, prefix: string = 'mx_composer_history_') { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + let item; + for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { + try { + this.history.push( + HistoryItem.fromJSON(JSON.parse(item)), + ); + } catch (e) { + console.warn("Throwing away unserialisable history", e); + } + } + this.lastIndex = this.currentIndex; + // reset currentIndex to account for any unserialisable history + this.currentIndex = this.history.length; + } + + save(value: Value, format: MessageFormat) { + const item = new HistoryItem(value, format); + this.history.push(item); + this.currentIndex = this.history.length; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); + } + + getItem(offset: number): ?HistoryItem { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + return this.history[this.currentIndex]; + } +} diff --git a/src/ContentMessages.js b/src/ContentMessages.js index ee3e8f1390..2d58622db8 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -425,19 +425,25 @@ export default class ContentMessages { } const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + let uploadAll = false; for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; - const shouldContinue = await new Promise((resolve) => { - Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { - file, - currentIndex: i, - totalFiles: okFiles.length, - onFinished: (shouldContinue) => { - resolve(shouldContinue); - }, + if (!uploadAll) { + const shouldContinue = await new Promise((resolve) => { + Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { + file, + currentIndex: i, + totalFiles: okFiles.length, + onFinished: (shouldContinue, shouldUploadAll) => { + if (shouldUploadAll) { + uploadAll = true; + } + resolve(shouldContinue); + }, + }); }); - }); - if (!shouldContinue) break; + if (!shouldContinue) break; + } this._sendContentToRoom(file, roomId, matrixClient); } } diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 61c51d4a20..79e5206f50 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -17,9 +17,12 @@ limitations under the License. import URL from 'url'; import dis from './dispatcher'; -import IntegrationManager from './IntegrationManager'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; +import sdk from "./index"; +import Modal from "./Modal"; +import MatrixClientPeg from "./MatrixClientPeg"; +import RoomViewStore from "./stores/RoomViewStore"; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -189,7 +192,14 @@ export default class FromWidgetPostMessageApi { const data = event.data.data || event.data.widgetData; const integType = (data && data.integType) ? data.integType : null; const integId = (data && data.integId) ? data.integId : null; - IntegrationManager.open(integType, integId); + + // The dialog will take care of scalar auth for us + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + room: MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), + screen: 'type_' + integType, + integrationId: integId, + }, "mx_IntegrationsManager"); } else if (action === 'set_always_on_screen') { // This is a new message: there is no reason to support the deprecated widgetData here const data = event.data.data; diff --git a/src/IntegrationManager.js b/src/IntegrationManager.js deleted file mode 100644 index 165ee6390d..0000000000 --- a/src/IntegrationManager.js +++ /dev/null @@ -1,78 +0,0 @@ -/* -Copyright 2017 New Vector 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 sdk from './index'; -import SdkConfig from './SdkConfig'; -import ScalarMessaging from './ScalarMessaging'; -import ScalarAuthClient from './ScalarAuthClient'; -import RoomViewStore from './stores/RoomViewStore'; - -if (!global.mxIntegrationManager) { - global.mxIntegrationManager = {}; -} - -export default class IntegrationManager { - static _init() { - if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) { - if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { - ScalarMessaging.startListening(); - global.mxIntegrationManager.client = new ScalarAuthClient(); - - return global.mxIntegrationManager.client.connect().then(() => { - global.mxIntegrationManager.connected = true; - }).catch((e) => { - console.error("Failed to connect to integrations server", e); - global.mxIntegrationManager.error = e; - }); - } else { - console.error('Invalid integration manager config', SdkConfig.get()); - } - } - } - - /** - * Launch the integrations manager on the stickers integration page - * @param {string} integName integration / widget type - * @param {string} integId integration / widget ID - * @param {function} onFinished Callback to invoke on integration manager close - */ - static async open(integName, integId, onFinished) { - await IntegrationManager._init(); - if (global.mxIntegrationManager.client) { - await global.mxIntegrationManager.client.connect(); - } else { - return; - } - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - if (global.mxIntegrationManager.error || - !(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) { - console.error("Scalar error", global.mxIntegrationManager); - return; - } - const integType = 'type_' + integName; - const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ? - global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom( - {roomId: RoomViewStore.getRoomId()}, - integType, - integId, - ) : - null; - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { - src: src, - onFinished: onFinished, - }, "mx_IntegrationsManager"); - } -} diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 7c78b71f2b..195377fbe9 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -233,7 +233,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { guest: true, }, true).then(() => true); }, (err) => { - console.error("Failed to register as guest: " + err + " " + err.data); + console.error("Failed to register as guest", err); return false; }); } diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 574f05bf85..07499a3a87 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -51,6 +51,7 @@ interface MatrixClientCreds { class MatrixClientPeg { constructor() { this.matrixClient = null; + this._justRegisteredUserId = null; // These are the default options used when when the // client is started in 'start'. These can be altered @@ -85,6 +86,31 @@ class MatrixClientPeg { MatrixActionCreators.stop(); } + /* + * If we've registered a user ID we set this to the ID of the + * user we've just registered. If they then go & log in, we + * can send them to the welcome user (obviously this doesn't + * guarentee they'll get a chat with the welcome user). + * + * @param {string} uid The user ID of the user we've just registered + */ + setJustRegisteredUserId(uid) { + this._justRegisteredUserId = uid; + } + + /* + * Returns true if the current user has just been registered by this + * client as determined by setJustRegisteredUserId() + * + * @returns {bool} True if user has just been registered + */ + currentUserIsJustRegistered() { + return ( + this.matrixClient && + this.matrixClient.credentials.userId === this._justRegisteredUserId + ); + } + /** * Replace this MatrixClientPeg's client with a client instance that has * homeserver / identity server URLs and active credentials diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 24979aff65..27d8f0d0da 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -29,6 +29,14 @@ class ScalarAuthClient { this.scalarToken = null; } + /** + * Determines if setting up a ScalarAuthClient is even possible + * @returns {boolean} true if possible, false otherwise. + */ + static isPossible() { + return SdkConfig.get()['integrations_rest_url'] && SdkConfig.get()['integrations_ui_url']; + } + connect() { return this.getScalarToken().then((tok) => { this.scalarToken = tok; @@ -41,7 +49,8 @@ class ScalarAuthClient { // Returns a scalar_token string getScalarToken() { - const token = window.localStorage.getItem("mx_scalar_token"); + let token = this.scalarToken; + if (!token) token = window.localStorage.getItem("mx_scalar_token"); if (!token) { return this.registerForToken(); diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 6c05b45111..cd752fc2ce 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -292,16 +292,6 @@ const LoggedInView = React.createClass({ const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); switch (ev.keyCode) { - case KeyCode.UP: - case KeyCode.DOWN: - if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { - const action = ev.keyCode == KeyCode.UP ? - 'view_prev_room' : 'view_next_room'; - dis.dispatch({action: action}); - handled = true; - } - break; - case KeyCode.PAGE_UP: case KeyCode.PAGE_DOWN: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 56cf81b985..fb35ab548b 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -51,7 +51,9 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import { startAnyRegistrationFlow } from "../../Registration.js"; import { messageForSyncError } from '../../utils/ErrorUtils'; import ResizeNotifier from "../../utils/ResizeNotifier"; -import {ValidatedServerConfig} from "../../utils/AutoDiscoveryUtils"; +import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; +import DMRoomMap from '../../utils/DMRoomMap'; // Disable warnings for now: we use deprecated bluebird functions // and need to migrate, but they spam the console with warnings. @@ -676,7 +678,7 @@ export default React.createClass({ }); }, - _startRegistration: function(params) { + _startRegistration: async function(params) { const newState = { view: VIEWS.REGISTER, }; @@ -689,10 +691,12 @@ export default React.createClass({ params.is_url && params.sid ) { + newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( + params.hs_url, params.is_url, + ); + newState.register_client_secret = params.client_secret; newState.register_session_id = params.session_id; - newState.register_hs_url = params.hs_url; - newState.register_is_url = params.is_url; newState.register_id_sid = params.sid; } @@ -884,6 +888,7 @@ export default React.createClass({ } return; } + MatrixClientPeg.setJustRegisteredUserId(credentials.user_id); this.onRegistered(credentials); }, onDifferentServerClicked: (ev) => { @@ -1128,29 +1133,81 @@ export default React.createClass({ } }, + /** + * Starts a chat with the welcome user, if the user doesn't already have one + * @returns {string} The room ID of the new room, or null if no room was created + */ + async _startWelcomeUserChat() { + // We can end up with multiple tabs post-registration where the user + // might then end up with a session and we don't want them all making + // a chat with the welcome user: try to de-dupe. + // We need to wait for the first sync to complete for this to + // work though. + let waitFor; + if (!this.firstSyncComplete) { + waitFor = this.firstSyncPromise.promise; + } else { + waitFor = Promise.resolve(); + } + await waitFor; + + const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId( + this.props.config.welcomeUserId, + ); + if (welcomeUserRooms.length === 0) { + const roomId = await createRoom({ + dmUserId: this.props.config.welcomeUserId, + // Only view the welcome user if we're NOT looking at a room + andView: !this.state.currentRoomId, + spinner: false, // we're already showing one: we don't need another one + }); + // This is a bit of a hack, but since the deduplication relies + // on m.direct being up to date, we need to force a sync + // of the database, otherwise if the user goes to the other + // tab before the next save happens (a few minutes), the + // saved sync will be restored from the db and this code will + // run without the update to m.direct, making another welcome + // user room (it doesn't wait for new data from the server, just + // the saved sync to be loaded). + const saveWelcomeUser = (ev) => { + if ( + ev.getType() == 'm.direct' && + ev.getContent() && + ev.getContent()[this.props.config.welcomeUserId] + ) { + MatrixClientPeg.get().store.save(true); + MatrixClientPeg.get().removeListener( + "accountData", saveWelcomeUser, + ); + } + }; + MatrixClientPeg.get().on("accountData", saveWelcomeUser); + + return roomId; + } + return null; + }, + /** * Called when a new logged in session has started */ _onLoggedIn: async function() { this.setStateForNewView({ view: VIEWS.LOGGED_IN }); - if (this._is_registered) { - this._is_registered = false; + if (MatrixClientPeg.currentUserIsJustRegistered()) { + MatrixClientPeg.setJustRegisteredUserId(null); if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { - const roomId = await createRoom({ - dmUserId: this.props.config.welcomeUserId, - // Only view the welcome user if we're NOT looking at a room - andView: !this.state.currentRoomId, - }); - // if successful, return because we're already - // viewing the welcomeUserId room - // else, if failed, fall through to view_home_page - if (roomId) { - return; + const welcomeUserRoom = await this._startWelcomeUserChat(); + if (welcomeUserRoom === null) { + // We didn't rediret to the welcome user room, so show + // the homepage. + dis.dispatch({action: 'view_home_page'}); } + } else { + // The user has just logged in after registering, + // so show the homepage. + dis.dispatch({action: 'view_home_page'}); } - // The user has just logged in after registering - dis.dispatch({action: 'view_home_page'}); } else { this._showScreenAfterLogin(); } @@ -1691,9 +1748,6 @@ export default React.createClass({ return MatrixClientPeg.get(); } } - // XXX: This should be in state or ideally store(s) because we risk not - // rendering the most up-to-date view of state otherwise. - this._is_registered = true; return Lifecycle.setLoggedIn(credentials); }, diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 698768067a..8352872a2d 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -517,7 +517,8 @@ module.exports = React.createClass({ const DateSeparator = sdk.getComponent('messages.DateSeparator'); const ret = []; - const isEditing = this.props.editEvent && this.props.editEvent.getId() === mxEv.getId(); + const isEditing = this.props.editState && + this.props.editState.getEvent().getId() === mxEv.getId(); // is this a continuation of the previous message? let continuation = false; @@ -585,13 +586,13 @@ module.exports = React.createClass({ continuation={continuation} isRedacted={mxEv.isRedacted()} replacingEventId={mxEv.replacingEventId()} - isEditing={isEditing} + editState={isEditing && this.props.editState} onHeightChanged={this._onHeightChanged} readReceipts={readReceipts} readReceiptMap={this._readReceiptMap} showUrlPreview={this.props.showUrlPreview} checkUnmounting={this._isUnmounting} - eventSendStatus={mxEv.replacementOrOwnStatus()} + eventSendStatus={mxEv.getAssociatedStatus()} tileShape={this.props.tileShape} isTwelveHour={this.props.isTwelveHour} permalinkCreator={this.props.permalinkCreator} diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 220c56d754..9c48b8ede1 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -35,6 +35,7 @@ const Modal = require("../../Modal"); const UserActivity = require("../../UserActivity"); import { KeyCode } from '../../Keyboard'; import Timer from '../../utils/Timer'; +import EditorStateTransfer from '../../utils/EditorStateTransfer'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -411,7 +412,8 @@ const TimelinePanel = React.createClass({ this.forceUpdate(); } if (payload.action === "edit_event") { - this.setState({editEvent: payload.event}, () => { + const editState = payload.event ? new EditorStateTransfer(payload.event) : null; + this.setState({editState}, () => { if (payload.event && this.refs.messagePanel) { this.refs.messagePanel.scrollToEventIfNeeded( payload.event.getId(), @@ -1306,7 +1308,7 @@ const TimelinePanel = React.createClass({ tileShape={this.props.tileShape} resizeNotifier={this.props.resizeNotifier} getRelationsForEvent={this.getRelationsForEvent} - editEvent={this.state.editEvent} + editState={this.state.editState} showReactions={this.props.showReactions} /> ); diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index bf4a86e410..662444379f 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -28,6 +28,8 @@ import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import * as ServerType from '../../views/auth/ServerTypeSelector'; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; +import * as Lifecycle from '../../../Lifecycle'; +import MatrixClientPeg from "../../../MatrixClientPeg"; // Phases // Show controls to configure server details @@ -80,6 +82,9 @@ module.exports = React.createClass({ // Phase of the overall registration dialog. phase: PHASE_REGISTRATION, flows: null, + // If set, we've registered but are not going to log + // the user in to their new account automatically. + completedNoSignin: false, // We perform liveliness checks later, but for now suppress the errors. // We also track the server dead errors independently of the regular errors so @@ -163,6 +168,8 @@ module.exports = React.createClass({ _replaceClient: async function(serverConfig) { this.setState({ errorText: null, + serverDeadError: null, + serverErrorIsFatal: false, // busy while we do liveness check (we need to avoid trying to render // the UI auth component while we don't have a matrix client) busy: true, @@ -175,7 +182,10 @@ module.exports = React.createClass({ serverConfig.hsUrl, serverConfig.isUrl, ); - this.setState({serverIsAlive: true}); + this.setState({ + serverIsAlive: true, + serverErrorIsFatal: false, + }); } catch (e) { this.setState({ busy: false, @@ -209,6 +219,7 @@ module.exports = React.createClass({ errorText: _t("Registration has been disabled on this homeserver."), }); } else { + console.log("Unable to query for supported registration methods.", e); this.setState({ errorText: _t("Unable to query for supported registration methods."), }); @@ -282,21 +293,29 @@ module.exports = React.createClass({ return; } - this.setState({ - // we're still busy until we get unmounted: don't show the registration form again - busy: true, + MatrixClientPeg.setJustRegisteredUserId(response.user_id); + + const newState = { doingUIAuth: false, - }); + }; + if (response.access_token) { + const cli = await this.props.onLoggedIn({ + userId: response.user_id, + deviceId: response.device_id, + homeserverUrl: this.state.matrixClient.getHomeserverUrl(), + identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), + accessToken: response.access_token, + }); - const cli = await this.props.onLoggedIn({ - userId: response.user_id, - deviceId: response.device_id, - homeserverUrl: this.state.matrixClient.getHomeserverUrl(), - identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), - accessToken: response.access_token, - }); + this._setupPushers(cli); + // we're still busy until we get unmounted: don't show the registration form again + newState.busy = true; + } else { + newState.busy = false; + newState.completedNoSignin = true; + } - this._setupPushers(cli); + this.setState(newState); }, _setupPushers: function(matrixClient) { @@ -353,6 +372,12 @@ module.exports = React.createClass({ }, _makeRegisterRequest: function(auth) { + // We inhibit login if we're trying to register with an email address: this + // avoids a lot of complex race conditions that can occur if we try to log + // the user in one one or both of the tabs they might end up with after + // clicking the email link. + let inhibitLogin = Boolean(this.state.formVals.email); + // Only send the bind params if we're sending username / pw params // (Since we need to send no params at all to use the ones saved in the // session). @@ -360,6 +385,8 @@ module.exports = React.createClass({ email: true, msisdn: true, } : {}; + // Likewise inhibitLogin + if (!this.state.formVals.password) inhibitLogin = null; return this.state.matrixClient.register( this.state.formVals.username, @@ -368,6 +395,7 @@ module.exports = React.createClass({ auth, bindThreepids, null, + inhibitLogin, ); }, @@ -379,6 +407,19 @@ module.exports = React.createClass({ }; }, + // Links to the login page shown after registration is completed are routed through this + // which checks the user hasn't already logged in somewhere else (perhaps we should do + // this more generally?) + _onLoginClickWithCheck: async function(ev) { + ev.preventDefault(); + + const sessionLoaded = await Lifecycle.loadSession({}); + if (!sessionLoaded) { + // ok fine, there's still no session: really go to the login page + this.props.onLoginClick(); + } + }, + renderServerComponent() { const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); const ServerConfig = sdk.getComponent("auth.ServerConfig"); @@ -390,7 +431,9 @@ module.exports = React.createClass({ // If we're on a different phase, we only show the server type selector, // which is always shown if we allow custom URLs at all. - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { + // (if there's a fatal server error, we need to show the full server + // config as the user may need to change servers to resolve the error). + if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) { return
; } + let body; + if (this.state.completedNoSignin) { + let regDoneText; + if (this.state.formVals.password) { + // We're the client that started the registration + regDoneText = _t( + "Log in to your new account.", {}, + { + a: (sub) => {sub}, + }, + ); + } else { + // We're not the original client: the user probably got to us by clicking the + // email validation link. We can't offer a 'go straight to your account' link + // as we don't have the original creds. + regDoneText = _t( + "You can now close this window or log in to your new account.", {}, + { + a: (sub) => {sub}, + }, + ); + } + body =
+

{_t("Registration Successful")}

+

{ regDoneText }

+
; + } else { + body =
+

{ _t('Create your account') }

+ { errorText } + { serverDeadSection } + { this.renderServerComponent() } + { this.renderRegisterComponent() } + { goBack } + { signIn } +
; + } + return ( -

{ _t('Create your account') }

- { errorText } - { serverDeadSection } - { this.renderServerComponent() } - { this.renderRegisterComponent() } - { goBack } - { signIn } + { body }
); diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js index 8d2e2e7bba..9dcc2c9abe 100644 --- a/src/components/views/auth/ServerConfig.js +++ b/src/components/views/auth/ServerConfig.js @@ -101,16 +101,28 @@ export default class ServerConfig extends React.PureComponent { return result; } catch (e) { console.error(e); - let message = _t("Unable to validate homeserver/identity server"); - if (e.translatedMessage) { - message = e.translatedMessage; - } - this.setState({ - busy: false, - errorText: message, - }); - return null; + const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); + if (!stateForError.isFatalError) { + this.setState({ + busy: false, + }); + // carry on anyway + const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true); + this.props.onServerConfigChange(result); + return result; + } else { + let message = _t("Unable to validate homeserver/identity server"); + if (e.translatedMessage) { + message = e.translatedMessage; + } + this.setState({ + busy: false, + errorText: message, + }); + + return null; + } } } diff --git a/src/components/views/dialogs/UploadConfirmDialog.js b/src/components/views/dialogs/UploadConfirmDialog.js index e7b22950d6..7e682a8301 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.js +++ b/src/components/views/dialogs/UploadConfirmDialog.js @@ -49,6 +49,10 @@ export default class UploadConfirmDialog extends React.Component { this.props.onFinished(true); } + _onUploadAllClick = () => { + this.props.onFinished(true, true); + } + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); @@ -85,6 +89,13 @@ export default class UploadConfirmDialog extends React.Component {
; } + let uploadAllButton; + if (this.props.currentIndex + 1 < this.props.totalFiles) { + uploadAllButton = ; + } + return ( + > + {uploadAllButton} + ); } diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 959cee7ace..034a3318a5 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -240,19 +240,13 @@ export default class AppTile extends React.Component { if (this.props.onEditClick) { this.props.onEditClick(); } else { + // The dialog handles scalar auth for us const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - this._scalarClient.connect().done(() => { - const src = this._scalarClient.getScalarInterfaceUrlForRoom( - this.props.room, 'type_' + this.props.type, this.props.id); - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { - src: src, - }, "mx_IntegrationsManager"); - }, (err) => { - this.setState({ - error: err.message, - }); - console.error('Error ensuring a valid scalar_token exists', err); - }); + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + room: this.props.room, + screen: 'type_' + this.props.type, + integrationId: this.props.id, + }, "mx_IntegrationsManager"); } } diff --git a/src/components/views/elements/ManageIntegsButton.js b/src/components/views/elements/ManageIntegsButton.js index 165cd20eb5..ef5604dba6 100644 --- a/src/components/views/elements/ManageIntegsButton.js +++ b/src/components/views/elements/ManageIntegsButton.js @@ -1,5 +1,6 @@ /* Copyright 2017 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,95 +18,34 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import sdk from '../../../index'; -import classNames from 'classnames'; -import SdkConfig from '../../../SdkConfig'; import ScalarAuthClient from '../../../ScalarAuthClient'; -import ScalarMessaging from '../../../ScalarMessaging'; import Modal from "../../../Modal"; import { _t } from '../../../languageHandler'; -import AccessibleButton from './AccessibleButton'; export default class ManageIntegsButton extends React.Component { constructor(props) { super(props); - - this.state = { - scalarError: null, - }; - - this.onManageIntegrations = this.onManageIntegrations.bind(this); } - componentWillMount() { - ScalarMessaging.startListening(); - this.scalarClient = null; - - if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { - this.scalarClient = new ScalarAuthClient(); - this.scalarClient.connect().done(() => { - this.forceUpdate(); - }, (err) => { - this.setState({scalarError: err}); - console.error('Error whilst initialising scalarClient for ManageIntegsButton', err); - }); - } - } - - componentWillUnmount() { - ScalarMessaging.stopListening(); - } - - onManageIntegrations(ev) { + onManageIntegrations = (ev) => { ev.preventDefault(); - if (this.state.scalarError && !this.scalarClient.hasCredentials()) { - return; - } + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - this.scalarClient.connect().done(() => { - Modal.createDialog(IntegrationsManager, { - src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? - this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) : - null, - }, "mx_IntegrationsManager"); - }, (err) => { - this.setState({scalarError: err}); - console.error('Error ensuring a valid scalar_token exists', err); - }); - } + Modal.createDialog(IntegrationsManager, { + room: this.props.room, + }, "mx_IntegrationsManager"); + }; render() { let integrationsButton =
; - let integrationsWarningTriangle =
; - let integrationsErrorPopup =
; - if (this.scalarClient !== null) { - const integrationsButtonClasses = classNames({ - mx_RoomHeader_button: true, - mx_RoomHeader_manageIntegsButton: true, - mx_ManageIntegsButton_error: !!this.state.scalarError, - }); - - if (this.state.scalarError && !this.scalarClient.hasCredentials()) { - integrationsWarningTriangle = ; - // Popup shown when hovering over integrationsButton_error (via CSS) - integrationsErrorPopup = ( - - { _t('Could not connect to the integration server') } - - ); - } - + if (ScalarAuthClient.isPossible()) { + const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); integrationsButton = ( - - { integrationsWarningTriangle } - { integrationsErrorPopup } - + /> ); } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index ed86bcb0a3..9faae4588b 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -28,13 +28,14 @@ import {parseEvent} from '../../../editor/deserialize'; import Autocomplete from '../rooms/Autocomplete'; import {PartCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; -import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; +import EditorStateTransfer from '../../../utils/EditorStateTransfer'; +import {MatrixClient} from 'matrix-js-sdk'; import classNames from 'classnames'; export default class MessageEditor extends React.Component { static propTypes = { // the message event being edited - event: PropTypes.instanceOf(MatrixEvent).isRequired, + editState: PropTypes.instanceOf(EditorStateTransfer).isRequired, }; static contextTypes = { @@ -44,16 +45,7 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); const room = this._getRoom(); - const partCreator = new PartCreator( - () => this._autocompleteRef, - query => this.setState({query}), - room, - ); - this.model = new EditorModel( - parseEvent(this.props.event, room), - partCreator, - this._updateEditorState, - ); + this.model = null; this.state = { autoComplete: null, room, @@ -64,7 +56,7 @@ export default class MessageEditor extends React.Component { } _getRoom() { - return this.context.matrixClient.getRoom(this.props.event.getRoomId()); + return this.context.matrixClient.getRoom(this.props.editState.getEvent().getRoomId()); } _updateEditorState = (caret) => { @@ -133,7 +125,7 @@ export default class MessageEditor extends React.Component { if (this._hasModifications || !this._isCaretAtStart()) { return; } - const previousEvent = findEditableEvent(this._getRoom(), false, this.props.event.getId()); + const previousEvent = findEditableEvent(this._getRoom(), false, this.props.editState.getEvent().getId()); if (previousEvent) { dis.dispatch({action: 'edit_event', event: previousEvent}); event.preventDefault(); @@ -142,7 +134,7 @@ export default class MessageEditor extends React.Component { if (this._hasModifications || !this._isCaretAtEnd()) { return; } - const nextEvent = findEditableEvent(this._getRoom(), true, this.props.event.getId()); + const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId()); if (nextEvent) { dis.dispatch({action: 'edit_event', event: nextEvent}); } else { @@ -158,16 +150,28 @@ export default class MessageEditor extends React.Component { dis.dispatch({action: 'focus_composer'}); } + _isEmote() { + const firstPart = this.model.parts[0]; + return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me "); + } + _sendEdit = () => { + const isEmote = this._isEmote(); + let model = this.model; + if (isEmote) { + // trim "/me " + model = model.clone(); + model.removeText({index: 0, offset: 0}, 4); + } const newContent = { - "msgtype": "m.text", - "body": textSerialize(this.model), + "msgtype": isEmote ? "m.emote" : "m.text", + "body": textSerialize(model), }; const contentBody = { msgtype: newContent.msgtype, body: ` * ${newContent.body}`, }; - const formattedBody = htmlSerializeIfNeeded(this.model); + const formattedBody = htmlSerializeIfNeeded(model); if (formattedBody) { newContent.format = "org.matrix.custom.html"; newContent.formatted_body = formattedBody; @@ -178,11 +182,11 @@ export default class MessageEditor extends React.Component { "m.new_content": newContent, "m.relates_to": { "rel_type": "m.replace", - "event_id": this.props.event.getId(), + "event_id": this.props.editState.getEvent().getId(), }, }, contentBody); - const roomId = this.props.event.getRoomId(); + const roomId = this.props.editState.getEvent().getRoomId(); this.context.matrixClient.sendMessage(roomId, content); dis.dispatch({action: "edit_event", event: null}); @@ -197,12 +201,63 @@ export default class MessageEditor extends React.Component { this.model.autoComplete.onComponentSelectionChange(completion); } + componentWillUnmount() { + const sel = document.getSelection(); + const {caret} = getCaretOffsetAndText(this._editorRef, sel); + const parts = this.model.serializeParts(); + this.props.editState.setEditorState(caret, parts); + } + componentDidMount() { + this.model = this._createEditorModel(); + // initial render of model this._updateEditorState(); - setCaretPosition(this._editorRef, this.model, this.model.getPositionAtEnd()); + // initial caret position + this._initializeCaret(); this._editorRef.focus(); } + _createEditorModel() { + const {editState} = this.props; + const room = this._getRoom(); + const partCreator = new PartCreator( + () => this._autocompleteRef, + query => this.setState({query}), + room, + this.context.matrixClient, + ); + let parts; + if (editState.hasEditorState()) { + // if restoring state from a previous editor, + // restore serialized parts from the state + parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); + } else { + // otherwise, parse the body of the event + parts = parseEvent(editState.getEvent(), room, this.context.matrixClient); + } + + return new EditorModel( + parts, + partCreator, + this._updateEditorState, + ); + } + + _initializeCaret() { + const {editState} = this.props; + let caretPosition; + if (editState.hasEditorState()) { + // if restoring state from a previous editor, + // restore caret position from the state + const caret = editState.getCaret(); + caretPosition = this.model.positionForOffset(caret.offset, caret.atNodeEnd); + } else { + // otherwise, set it at the end + caretPosition = this.model.getPositionAtEnd(); + } + setCaretPosition(this._editorRef, this.model, caretPosition); + } + render() { let autoComplete; if (this.state.autoComplete) { diff --git a/src/components/views/groups/GroupRoomInfo.js b/src/components/views/groups/GroupRoomInfo.js index db060218d4..7296b25344 100644 --- a/src/components/views/groups/GroupRoomInfo.js +++ b/src/components/views/groups/GroupRoomInfo.js @@ -224,7 +224,7 @@ module.exports = React.createClass({
- { this.state.groupRoom.canonical_alias } + { this.state.groupRoom.canonicalAlias }
diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 8c90ec5a46..6d7aada542 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -90,7 +90,7 @@ module.exports = React.createClass({ tileShape={this.props.tileShape} maxImageHeight={this.props.maxImageHeight} replacingEventId={this.props.replacingEventId} - isEditing={this.props.isEditing} + editState={this.props.editState} onHeightChanged={this.props.onHeightChanged} />; }, }); diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 1fc16d6a53..6f480b8d3c 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -90,7 +90,7 @@ module.exports = React.createClass({ componentDidMount: function() { this._unmounted = false; - if (!this.props.isEditing) { + if (!this.props.editState) { this._applyFormatting(); } }, @@ -131,8 +131,8 @@ module.exports = React.createClass({ }, componentDidUpdate: function(prevProps) { - if (!this.props.isEditing) { - const stoppedEditing = prevProps.isEditing && !this.props.isEditing; + if (!this.props.editState) { + const stoppedEditing = prevProps.editState && !this.props.editState; const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId; if (messageWasEdited || stoppedEditing) { this._applyFormatting(); @@ -153,7 +153,7 @@ module.exports = React.createClass({ nextProps.replacingEventId !== this.props.replacingEventId || nextProps.highlightLink !== this.props.highlightLink || nextProps.showUrlPreview !== this.props.showUrlPreview || - nextProps.isEditing !== this.props.isEditing || + nextProps.editState !== this.props.editState || nextState.links !== this.state.links || nextState.editedMarkerHovered !== this.state.editedMarkerHovered || nextState.widgetHidden !== this.state.widgetHidden); @@ -469,9 +469,9 @@ module.exports = React.createClass({ }, render: function() { - if (this.props.isEditing) { + if (this.props.editState) { const MessageEditor = sdk.getComponent('elements.MessageEditor'); - return ; + return ; } const mxEvent = this.props.mxEvent; const content = mxEvent.getContent(); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index e0e7a48b8c..3e5528996f 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -24,8 +24,6 @@ import AppTile from '../elements/AppTile'; import Modal from '../../../Modal'; import dis from '../../../dispatcher'; import sdk from '../../../index'; -import SdkConfig from '../../../SdkConfig'; -import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarMessaging from '../../../ScalarMessaging'; import { _t } from '../../../languageHandler'; import WidgetUtils from '../../../utils/WidgetUtils'; @@ -63,20 +61,6 @@ module.exports = React.createClass({ }, componentDidMount: function() { - this.scalarClient = null; - if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { - this.scalarClient = new ScalarAuthClient(); - this.scalarClient.connect().then(() => { - this.forceUpdate(); - }).catch((e) => { - console.log('Failed to connect to integrations server'); - // TODO -- Handle Scalar errors - // this.setState({ - // scalar_error: err, - // }); - }); - } - this.dispatcherRef = dis.register(this.onAction); }, @@ -144,16 +128,10 @@ module.exports = React.createClass({ _launchManageIntegrations: function() { const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager'); - this.scalarClient.connect().done(() => { - const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? - this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') : - null; - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { - src: src, - }, 'mx_IntegrationsManager'); - }, (err) => { - console.error('Error ensuring a valid scalar_token exists', err); - }); + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + room: this.props.room, + screen: 'add_integ', + }, 'mx_IntegrationsManager'); }, onClickAddWidget: function(e) { diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 9aef5433c3..243cfe2f75 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -171,26 +171,13 @@ export default class Autocomplete extends React.Component { } // called from MessageComposerInput - onUpArrow(): ?Completion { + moveSelection(delta): ?Completion { const completionCount = this.countCompletions(); - // completionCount + 1, since 0 means composer is selected - const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1) - % (completionCount + 1); - if (!completionCount) { - return null; - } - this.setSelection(selectionOffset); - } + if (completionCount === 0) return; // there are no items to move the selection through - // called from MessageComposerInput - onDownArrow(): ?Completion { - const completionCount = this.countCompletions(); - // completionCount + 1, since 0 means composer is selected - const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1); - if (!completionCount) { - return null; - } - this.setSelection(selectionOffset); + // Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected + const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1); + this.setSelection(index); } onEscape(e): boolean { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 850f496e24..9837b4a029 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -552,13 +552,14 @@ module.exports = withMatrixClient(React.createClass({ const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); + const isEditing = !!this.props.editState; const classes = classNames({ mx_EventTile: true, - mx_EventTile_isEditing: this.props.isEditing, + mx_EventTile_isEditing: isEditing, mx_EventTile_info: isInfoMessage, mx_EventTile_12hr: this.props.isTwelveHour, mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting', - mx_EventTile_sending: isSending, + mx_EventTile_sending: !isEditing && isSending, mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent', mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), mx_EventTile_selected: this.props.isSelectedEvent, @@ -632,7 +633,7 @@ module.exports = withMatrixClient(React.createClass({ } const MessageActionBar = sdk.getComponent('messages.MessageActionBar'); - const actionBar = !this.props.isEditing ? { this.suppressAutoComplete = false; + this.direction = ''; + + // Navigate autocomplete list with arrow keys + if (this.autocomplete.countCompletions() > 0) { + if (!(ev.ctrlKey || ev.shiftKey || ev.altKey || ev.metaKey)) { + switch (ev.keyCode) { + case KeyCode.LEFT: + this.autocomplete.moveSelection(-1); + ev.preventDefault(); + return true; + case KeyCode.RIGHT: + this.autocomplete.moveSelection(+1); + ev.preventDefault(); + return true; + case KeyCode.UP: + this.autocomplete.moveSelection(-1); + ev.preventDefault(); + return true; + case KeyCode.DOWN: + this.autocomplete.moveSelection(+1); + ev.preventDefault(); + return true; + } + } + } // skip void nodes - see // https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095 @@ -680,8 +708,6 @@ export default class MessageComposerInput extends React.Component { this.direction = 'Previous'; } else if (ev.keyCode === KeyCode.RIGHT) { this.direction = 'Next'; - } else { - this.direction = ''; } switch (ev.keyCode) { @@ -1039,6 +1065,7 @@ export default class MessageComposerInput extends React.Component { if (cmd) { if (!cmd.error) { + this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown'); this.setState({ editorState: this.createEditorState(), }, ()=>{ @@ -1116,6 +1143,8 @@ export default class MessageComposerInput extends React.Component { let sendHtmlFn = ContentHelpers.makeHtmlMessage; let sendTextFn = ContentHelpers.makeTextMessage; + this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown'); + if (commandText && commandText.startsWith('/me')) { if (replyingToEv) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -1175,36 +1204,89 @@ export default class MessageComposerInput extends React.Component { }; onVerticalArrow = (e, up) => { - if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) { + if (e.ctrlKey || e.shiftKey || e.metaKey) return; + + // selection must be collapsed + const selection = this.state.editorState.selection; + if (!selection.isCollapsed) return; + // and we must be at the edge of the document (up=start, down=end) + const document = this.state.editorState.document; + if (up) { + if (!selection.anchor.isAtStartOfNode(document)) return; + } else { + if (!selection.anchor.isAtEndOfNode(document)) return; + } + + const editingEnabled = SettingsStore.isFeatureEnabled("feature_message_editing"); + const shouldSelectHistory = (editingEnabled && e.altKey) || !editingEnabled; + const shouldEditLastMessage = editingEnabled && !e.altKey && up; + + if (shouldSelectHistory) { + // Try select composer history + const selected = this.selectHistory(up); + if (selected) { + // We're selecting history, so prevent the key event from doing anything else + e.preventDefault(); + } + } else if (shouldEditLastMessage) { + const editEvent = findEditableEvent(this.props.room, false); + if (editEvent) { + // We're selecting history, so prevent the key event from doing anything else + e.preventDefault(); + dis.dispatch({ + action: 'edit_event', + event: editEvent, + }); + } + } + }; + + selectHistory = (up) => { + const delta = up ? -1 : 1; + + // True if we are not currently selecting history, but composing a message + if (this.historyManager.currentIndex === this.historyManager.history.length) { + // We can't go any further - there isn't any more history, so nop. + if (!up) { + return; + } + this.setState({ + currentlyComposedEditorState: this.state.editorState, + }); + } else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) { + // True when we return to the message being composed currently + this.setState({ + editorState: this.state.currentlyComposedEditorState, + }); + this.historyManager.currentIndex = this.historyManager.history.length; return; } - // Select history only if we are not currently auto-completing - if (this.autocomplete.state.completionList.length === 0) { - const selection = this.state.editorState.selection; + let editorState; + const historyItem = this.historyManager.getItem(delta); + if (!historyItem) return; - // selection must be collapsed - if (!selection.isCollapsed) return; - const document = this.state.editorState.document; - - // and we must be at the edge of the document (up=start, down=end) - if (up) { - if (!selection.anchor.isAtStartOfNode(document)) return; - - const editEvent = findEditableEvent(this.props.room, false); - if (editEvent) { - // We're selecting history, so prevent the key event from doing anything else - e.preventDefault(); - dis.dispatch({ - action: 'edit_event', - event: editEvent, - }); - } - } + if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) { + editorState = this.richToMdEditorState(historyItem.value); + } else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) { + editorState = this.mdToRichEditorState(historyItem.value); } else { - this.moveAutocompleteSelection(up); - e.preventDefault(); + editorState = historyItem.value; } + + // Move selection to the end of the selected history + const change = editorState.change().moveToEndOfNode(editorState.document); + + // We don't call this.onChange(change) now, as fixups on stuff like pills + // should already have been done and persisted in the history. + editorState = change.value; + + this.suppressAutoComplete = true; + + this.setState({ editorState }, ()=>{ + this._editor.focus(); + }); + return true; }; onTab = async (e) => { @@ -1212,23 +1294,19 @@ export default class MessageComposerInput extends React.Component { someCompletions: null, }); e.preventDefault(); - if (this.autocomplete.state.completionList.length === 0) { + if (this.autocomplete.countCompletions() === 0) { // Force completions to show for the text currently entered const completionCount = await this.autocomplete.forceComplete(); this.setState({ someCompletions: completionCount > 0, }); // Select the first item by moving "down" - await this.moveAutocompleteSelection(false); + await this.autocomplete.moveSelection(+1); } else { - await this.moveAutocompleteSelection(e.shiftKey); + await this.autocomplete.moveSelection(e.shiftKey ? -1 : +1); } }; - moveAutocompleteSelection = (up) => { - up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow(); - }; - onEscape = async (e) => { e.preventDefault(); if (this.autocomplete) { diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index a0e3f1b7a9..6918810842 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -14,12 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import { _t } from '../../../languageHandler'; +import {_t, _td} from '../../../languageHandler'; import AppTile from '../elements/AppTile'; import MatrixClientPeg from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import sdk from '../../../index'; -import SdkConfig from '../../../SdkConfig'; import ScalarAuthClient from '../../../ScalarAuthClient'; import dis from '../../../dispatcher'; import AccessibleButton from '../elements/AccessibleButton'; @@ -53,6 +52,9 @@ export default class Stickerpicker extends React.Component { this.popoverWidth = 300; this.popoverHeight = 300; + // This is loaded by _acquireScalarClient on an as-needed basis. + this.scalarClient = null; + this.state = { showStickers: false, imError: null, @@ -63,14 +65,34 @@ export default class Stickerpicker extends React.Component { }; } - _removeStickerpickerWidgets() { + _acquireScalarClient() { + if (this.scalarClient) return Promise.resolve(this.scalarClient); + if (ScalarAuthClient.isPossible()) { + this.scalarClient = new ScalarAuthClient(); + return this.scalarClient.connect().then(() => { + this.forceUpdate(); + return this.scalarClient; + }).catch((e) => { + this._imError(_td("Failed to connect to integrations server"), e); + }); + } else { + this._imError(_td("No integrations server is configured to manage stickers with")); + } + } + + async _removeStickerpickerWidgets() { + const scalarClient = await this._acquireScalarClient(); console.warn('Removing Stickerpicker widgets'); if (this.state.widgetId) { - this.scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => { - console.warn('Assets disabled'); - }).catch((err) => { - console.error('Failed to disable assets'); - }); + if (scalarClient) { + scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => { + console.warn('Assets disabled'); + }).catch((err) => { + console.error('Failed to disable assets'); + }); + } else { + console.error("Cannot disable assets: no scalar client"); + } } else { console.warn('No widget ID specified, not disabling assets'); } @@ -87,19 +109,7 @@ export default class Stickerpicker extends React.Component { // Close the sticker picker when the window resizes window.addEventListener('resize', this._onResize); - this.scalarClient = null; - if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { - this.scalarClient = new ScalarAuthClient(); - this.scalarClient.connect().then(() => { - this.forceUpdate(); - }).catch((e) => { - this._imError("Failed to connect to integrations server", e); - }); - } - - if (!this.state.imError) { - this.dispatcherRef = dis.register(this._onWidgetAction); - } + this.dispatcherRef = dis.register(this._onWidgetAction); // Track updates to widget state in account data MatrixClientPeg.get().on('accountData', this._updateWidget); @@ -126,7 +136,7 @@ export default class Stickerpicker extends React.Component { console.error(errorMsg, e); this.setState({ showStickers: false, - imError: errorMsg, + imError: _t(errorMsg), }); } @@ -339,22 +349,13 @@ export default class Stickerpicker extends React.Component { */ _launchManageIntegrations() { const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - this.scalarClient.connect().done(() => { - const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? - this.scalarClient.getScalarInterfaceUrlForRoom( - this.props.room, - 'type_' + widgetType, - this.state.widgetId, - ) : - null; - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { - src: src, - }, "mx_IntegrationsManager"); - this.setState({showStickers: false}); - }, (err) => { - this.setState({imError: err}); - console.error('Error ensuring a valid scalar_token exists', err); - }); + + // The integrations manager will handle scalar auth for us. + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + room: this.props.room, + screen: `type_${widgetType}`, + integrationId: this.state.widgetId, + }, "mx_IntegrationsManager"); } render() { diff --git a/src/components/views/settings/IntegrationsManager.js b/src/components/views/settings/IntegrationsManager.js index a517771f1d..754693b73e 100644 --- a/src/components/views/settings/IntegrationsManager.js +++ b/src/components/views/settings/IntegrationsManager.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,50 +15,124 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import dis from '../../../dispatcher'; +import ScalarAuthClient from '../../../ScalarAuthClient'; -const React = require('react'); -const sdk = require('../../../index'); -const MatrixClientPeg = require('../../../MatrixClientPeg'); -const dis = require('../../../dispatcher'); +export default class IntegrationsManager extends React.Component { + static propTypes = { + // the room object where the integrations manager should be opened in + room: PropTypes.object.isRequired, -module.exports = React.createClass({ - displayName: 'IntegrationsManager', + // the screen name to open + screen: PropTypes.string, - propTypes: { - src: React.PropTypes.string.isRequired, // the source of the integration manager being embedded - onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed - }, + // the integration ID to open + integrationId: PropTypes.string, - // XXX: keyboard shortcuts for managing dialogs should be done by the modal - // dialog base class somehow, surely... - componentDidMount: function() { + // callback when the manager is dismissed + onFinished: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + + this.state = { + loading: true, + configured: ScalarAuthClient.isPossible(), + connected: false, // true if a `src` is set and able to be connected to + src: null, // string for where to connect to + }; + } + + componentWillMount() { + if (!this.state.configured) return; + + const scalarClient = new ScalarAuthClient(); + scalarClient.connect().then(() => { + const hasCredentials = scalarClient.hasCredentials(); + if (!hasCredentials) { + this.setState({ + connected: false, + loading: false, + }); + } else { + const src = scalarClient.getScalarInterfaceUrlForRoom( + this.props.room, + this.props.screen, + this.props.integrationId, + ); + this.setState({ + loading: false, + connected: true, + src: src, + }); + } + }).catch(err => { + console.error(err); + this.setState({ + loading: false, + connected: false, + }); + }); + } + + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); document.addEventListener("keydown", this.onKeyDown); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { dis.unregister(this.dispatcherRef); document.removeEventListener("keydown", this.onKeyDown); - }, + } - onKeyDown: function(ev) { - if (ev.keyCode == 27) { // escape + onKeyDown = (ev) => { + if (ev.keyCode === 27) { // escape ev.stopPropagation(); ev.preventDefault(); this.props.onFinished(); } - }, + }; - onAction: function(payload) { + onAction = (payload) => { if (payload.action === 'close_scalar') { this.props.onFinished(); } - }, + }; - render: function() { - return ( - - ); - }, -}); + render() { + if (!this.state.configured) { + return ( +
+

{_t("No integrations server configured")}

+

{_t("This Riot instance does not have an integrations server configured.")}

+
+ ); + } + + if (this.state.loading) { + const Spinner = sdk.getComponent("elements.Spinner"); + return ( +
+

{_t("Connecting to integrations server...")}

+ +
+ ); + } + + if (!this.state.connected) { + return ( +
+

{_t("Cannot connect to integrations server")}

+

{_t("The integrations server is offline or it cannot reach your homeserver.")}

+
+ ); + } + + return ; + } +} diff --git a/src/createRoom.js b/src/createRoom.js index 39b634a0ef..120043247d 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -30,12 +30,15 @@ import {getAddressType} from "./UserAddress"; * @param {object=} opts parameters for creating the room * @param {string=} opts.dmUserId If specified, make this a DM room for this user and invite them * @param {object=} opts.createOpts set of options to pass to createRoom call. + * @param {bool=} opts.spinner True to show a modal spinner while the room is created. + * Default: True * * @returns {Promise} which resolves to the room id, or null if the * action was aborted or failed. */ function createRoom(opts) { opts = opts || {}; + if (opts.spinner === undefined) opts.spinner = true; const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const Loader = sdk.getComponent("elements.Spinner"); @@ -87,11 +90,12 @@ function createRoom(opts) { }, ]; - const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + let modal; + if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); let roomId; return client.createRoom(createOpts).finally(function() { - modal.close(); + if (modal) modal.close(); }).then(function(res) { roomId = res.room_id; if (opts.dmUserId) { diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index ceaf18c444..6cb5974729 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -18,12 +18,13 @@ limitations under the License. import {UserPillPart, RoomPillPart, PlainPart} from "./parts"; export default class AutocompleteWrapperModel { - constructor(updateCallback, getAutocompleterComponent, updateQuery, room) { + constructor(updateCallback, getAutocompleterComponent, updateQuery, room, client) { this._updateCallback = updateCallback; this._getAutocompleterComponent = getAutocompleterComponent; this._updateQuery = updateQuery; this._query = null; this._room = room; + this._client = client; } onEscape(e) { @@ -42,17 +43,13 @@ export default class AutocompleteWrapperModel { async onTab(e) { const acComponent = this._getAutocompleterComponent(); - if (acComponent.state.completionList.length === 0) { + if (acComponent.countCompletions() === 0) { // Force completions to show for the text currently entered await acComponent.forceComplete(); // Select the first item by moving "down" - await acComponent.onDownArrow(); + await acComponent.moveSelection(+1); } else { - if (e.shiftKey) { - await acComponent.onUpArrow(); - } else { - await acComponent.onDownArrow(); - } + await acComponent.moveSelection(e.shiftKey ? -1 : +1); } this._updateCallback({ close: true, @@ -60,11 +57,11 @@ export default class AutocompleteWrapperModel { } onUpArrow() { - this._getAutocompleterComponent().onUpArrow(); + this._getAutocompleterComponent().moveSelection(-1); } onDownArrow() { - this._getAutocompleterComponent().onDownArrow(); + this._getAutocompleterComponent().moveSelection(+1); } onPartUpdate(part, offset) { @@ -106,7 +103,7 @@ export default class AutocompleteWrapperModel { } case "#": { const displayAlias = completion.completionId; - return new RoomPillPart(displayAlias); + return new RoomPillPart(displayAlias, this._client); } // also used for emoji completion default: diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js index 64b219c2a9..2d98bbc41a 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.js @@ -21,7 +21,7 @@ import { walkDOMDepthFirst } from "./dom"; const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); -function parseLink(a, room) { +function parseLink(a, room, client) { const {href} = a; const pillMatch = REGEX_MATRIXTO.exec(href) || []; const resourceId = pillMatch[1]; // The room/user ID @@ -34,7 +34,7 @@ function parseLink(a, room) { room.getMember(resourceId), ); case "#": - return new RoomPillPart(resourceId); + return new RoomPillPart(resourceId, client); default: { if (href === a.textContent) { return new PlainPart(a.textContent); @@ -57,10 +57,10 @@ function parseCodeBlock(n) { return parts; } -function parseElement(n, room) { +function parseElement(n, room, client) { switch (n.nodeName) { case "A": - return parseLink(n, room); + return parseLink(n, room, client); case "BR": return new NewlinePart("\n"); case "EM": @@ -140,7 +140,7 @@ function prefixQuoteLines(isFirstNode, parts) { } } -function parseHtmlMessage(html, room) { +function parseHtmlMessage(html, room, client) { // no nodes from parsing here should be inserted in the document, // as scripts in event handlers, etc would be executed then. // we're only taking text, so that is fine @@ -165,7 +165,7 @@ function parseHtmlMessage(html, room) { if (n.nodeType === Node.TEXT_NODE) { newParts.push(new PlainPart(n.nodeValue)); } else if (n.nodeType === Node.ELEMENT_NODE) { - const parseResult = parseElement(n, room); + const parseResult = parseElement(n, room, client); if (parseResult) { if (Array.isArray(parseResult)) { newParts.push(...parseResult); @@ -205,14 +205,15 @@ function parseHtmlMessage(html, room) { return parts; } -export function parseEvent(event, room) { +export function parseEvent(event, room, client) { const content = event.getContent(); + let parts; if (content.format === "org.matrix.custom.html") { - return parseHtmlMessage(content.formatted_body || "", room); + parts = parseHtmlMessage(content.formatted_body || "", room, client); } else { const body = content.body || ""; const lines = body.split("\n"); - const parts = lines.reduce((parts, line, i) => { + parts = lines.reduce((parts, line, i) => { const isLast = i === lines.length - 1; const text = new PlainPart(line); const newLine = !isLast && new NewlinePart("\n"); @@ -222,6 +223,9 @@ export function parseEvent(event, room) { return parts.concat(text); } }, []); - return parts; } + if (content.msgtype === "m.emote") { + parts.unshift(new PlainPart("/me ")); + } + return parts; } diff --git a/src/editor/model.js b/src/editor/model.js index fb6b417530..7cc6041044 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -27,6 +27,10 @@ export default class EditorModel { this._updateCallback = updateCallback; } + clone() { + return new EditorModel(this._parts, this._partCreator, this._updateCallback); + } + _insertPart(index, part) { this._parts.splice(index, 0, part); if (this._activePartIdx >= index) { @@ -73,7 +77,7 @@ export default class EditorModel { } serializeParts() { - return this._parts.map(({type, text}) => {return {type, text};}); + return this._parts.map(p => p.serialize()); } _diff(newValue, inputType, caret) { @@ -88,10 +92,10 @@ export default class EditorModel { update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); - const position = this._positionForOffset(diff.at, caret.atNodeEnd); + const position = this.positionForOffset(diff.at, caret.atNodeEnd); let removedOffsetDecrease = 0; if (diff.removed) { - removedOffsetDecrease = this._removeText(position, diff.removed.length); + removedOffsetDecrease = this.removeText(position, diff.removed.length); } let addedLen = 0; if (diff.added) { @@ -99,7 +103,7 @@ export default class EditorModel { } this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; - let newPosition = this._positionForOffset(caretOffset, true); + let newPosition = this.positionForOffset(caretOffset, true); newPosition = newPosition.skipUneditableParts(this._parts); this._setActivePart(newPosition); this._updateCallback(newPosition); @@ -177,7 +181,7 @@ export default class EditorModel { * @return {Number} how many characters before pos were also removed, * usually because of non-editable parts that can only be removed in their entirety. */ - _removeText(pos, len) { + removeText(pos, len) { let {index, offset} = pos; let removedOffsetDecrease = 0; while (len > 0) { @@ -248,7 +252,7 @@ export default class EditorModel { return addLen; } - _positionForOffset(totalOffset, atPartEnd) { + positionForOffset(totalOffset, atPartEnd) { let currentOffset = 0; const index = this._parts.findIndex(part => { const partLen = part.text.length; diff --git a/src/editor/parts.js b/src/editor/parts.js index be3080db12..a122c7ab7a 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -17,7 +17,6 @@ limitations under the License. import AutocompleteWrapperModel from "./autocomplete"; import Avatar from "../Avatar"; -import MatrixClientPeg from "../MatrixClientPeg"; class BasePart { constructor(text = "") { @@ -102,6 +101,10 @@ class BasePart { toString() { return `${this.type}(${this.text})`; } + + serialize() { + return {type: this.type, text: this.text}; + } } export class PlainPart extends BasePart { @@ -233,13 +236,12 @@ export class NewlinePart extends BasePart { } export class RoomPillPart extends PillPart { - constructor(displayAlias) { + constructor(displayAlias, client) { super(displayAlias, displayAlias); - this._room = this._findRoomByAlias(displayAlias); + this._room = this._findRoomByAlias(displayAlias, client); } - _findRoomByAlias(alias) { - const client = MatrixClientPeg.get(); + _findRoomByAlias(alias, client) { if (alias[0] === '#') { return client.getRooms().find((r) => { return r.getAliases().includes(alias); @@ -300,6 +302,12 @@ export class UserPillPart extends PillPart { get className() { return "mx_UserPill mx_Pill"; } + + serialize() { + const obj = super.serialize(); + obj.userId = this.resourceId; + return obj; + } } @@ -335,13 +343,16 @@ export class PillCandidatePart extends PlainPart { } export class PartCreator { - constructor(getAutocompleterComponent, updateQuery, room) { + constructor(getAutocompleterComponent, updateQuery, room, client) { + this._room = room; + this._client = client; this._autoCompleteCreator = (updateCallback) => { return new AutocompleteWrapperModel( updateCallback, getAutocompleterComponent, updateQuery, room, + client, ); }; } @@ -362,5 +373,22 @@ export class PartCreator { createDefaultPart(text) { return new PlainPart(text); } + + deserializePart(part) { + switch (part.type) { + case "plain": + return new PlainPart(part.text); + case "newline": + return new NewlinePart(part.text); + case "pill-candidate": + return new PillCandidatePart(part.text, this._autoCompleteCreator); + case "room-pill": + return new RoomPillPart(part.text, this._client); + case "user-pill": { + const member = this._room.getMember(part.userId); + return new UserPillPart(part.userId, part.text, member); + } + } + } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 69ac59f984..69af5c9d75 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -483,6 +483,11 @@ "Email Address": "Email Address", "Disable Notifications": "Disable Notifications", "Enable Notifications": "Enable Notifications", + "No integrations server configured": "No integrations server configured", + "This Riot instance does not have an integrations server configured.": "This Riot instance does not have an integrations server configured.", + "Connecting to integrations server...": "Connecting to integrations server...", + "Cannot connect to integrations server": "Cannot connect to integrations server", + "The integrations server is offline or it cannot reach your homeserver.": "The integrations server is offline or it cannot reach your homeserver.", "Delete Backup": "Delete Backup", "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", @@ -864,6 +869,8 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", + "Failed to connect to integrations server": "Failed to connect to integrations server", + "No integrations server is configured to manage stickers with": "No integrations server is configured to manage stickers with", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "Add some now": "Add some now", "Stickerpack": "Stickerpack", @@ -1017,7 +1024,6 @@ "Rotate Right": "Rotate Right", "Rotate clockwise": "Rotate clockwise", "Download this file": "Download this file", - "Integrations Error": "Integrations Error", "Manage Integrations": "Manage Integrations", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", @@ -1249,6 +1255,7 @@ "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", "Upload files": "Upload files", "Upload": "Upload", + "Upload all": "Upload all", "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.", "These files are too large to upload. The file size limit is %(limit)s.": "These files are too large to upload. The file size limit is %(limit)s.", "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Some files are too large to be uploaded. The file size limit is %(limit)s.", @@ -1557,6 +1564,9 @@ "Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.", "Unable to query for supported registration methods.": "Unable to query for supported registration methods.", "This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.", + "Log in to your new account.": "Log in to your new account.", + "You can now close this window or log in to your new account.": "You can now close this window or log in to your new account.", + "Registration Successful": "Registration Successful", "Create your account": "Create your account", "Commands": "Commands", "Results from DuckDuckGo": "Results from DuckDuckGo", diff --git a/src/utils/EditorStateTransfer.js b/src/utils/EditorStateTransfer.js new file mode 100644 index 0000000000..c7782a9ea8 --- /dev/null +++ b/src/utils/EditorStateTransfer.js @@ -0,0 +1,49 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +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. +*/ + +/** + * Used while editing, to pass the event, and to preserve editor state + * from one editor instance to another when remounting the editor + * upon receiving the remote echo for an unsent event. + */ +export default class EditorStateTransfer { + constructor(event) { + this._event = event; + this._serializedParts = null; + this.caret = null; + } + + setEditorState(caret, serializedParts) { + this._caret = caret; + this._serializedParts = serializedParts; + } + + hasEditorState() { + return !!this._serializedParts; + } + + getSerializedParts() { + return this._serializedParts; + } + + getCaret() { + return this._caret; + } + + getEvent() { + return this._event; + } +} diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js index ff20a68e3c..ffc47e2277 100644 --- a/src/utils/EventUtils.js +++ b/src/utils/EventUtils.js @@ -46,8 +46,12 @@ export function isContentActionable(mxEvent) { } export function canEditContent(mxEvent) { - return isContentActionable(mxEvent) && - mxEvent.getOriginalContent().msgtype === "m.text" && + if (mxEvent.status === EventStatus.CANCELLED || mxEvent.getType() !== "m.room.message") { + return false; + } + const content = mxEvent.getOriginalContent(); + const {msgtype} = content; + return (msgtype === "m.text" || msgtype === "m.emote") && mxEvent.getSender() === MatrixClientPeg.get().getUserId(); } @@ -64,7 +68,7 @@ export function canEditOwnEvent(mxEvent) { const MAX_JUMP_DISTANCE = 100; export function findEditableEvent(room, isForward, fromEventId = undefined) { const liveTimeline = room.getLiveTimeline(); - const events = liveTimeline.getEvents(); + const events = liveTimeline.getEvents().concat(room.getPendingEvents()); const maxIdx = events.length - 1; const inc = isForward ? 1 : -1; const beginIdx = isForward ? 0 : maxIdx; diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index 88d1c804ca..2d1fb29bd9 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -103,12 +103,6 @@ describe('InteractiveAuthDialog', function() { password: "s3kr3t", user: "@user:id", })).toBe(true); - - // there should now be a spinner - ReactTestUtils.findRenderedComponentWithType( - dlg, sdk.getComponent('elements.Spinner'), - ); - // let the request complete return Promise.delay(1); }).then(() => {