From d8f4512439bc844e5df75050afb67aef0d578f72 Mon Sep 17 00:00:00 2001 From: Sorunome Date: Wed, 22 May 2019 20:41:27 +0200 Subject: [PATCH 01/35] add basic spoiler support --- res/css/views/rooms/_EventTile.scss | 27 +++++++++++++++++ src/HtmlUtils.js | 2 +- src/components/views/elements/Spoiler.js | 32 ++++++++++++++++++++ src/components/views/messages/TextualBody.js | 30 ++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/components/views/elements/Spoiler.js diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 72881231f8..dd078d7f30 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -280,6 +280,33 @@ limitations under the License. overflow-y: hidden; } +/* Spoiler stuff */ +.mx_EventTile_spoiler { + cursor: pointer; +} + +.mx_EventTile_spoiler_reason { + color: $event-timestamp-color; + font-size: 11px; +} + +.mx_EventTile_spoiler_content { + background-color: black; +} + +.mx_EventTile_spoiler_content > span { + visibility: hidden; +} + + +.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { + background-color: initial; +} + +.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content > span { + visibility: visible; +} + /* End to end encryption stuff */ .mx_EventTile:hover .mx_EventTile_e2eIcon { opacity: 1; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index d06c31682d..626b228357 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -250,7 +250,7 @@ const sanitizeHtmlParams = { allowedAttributes: { // custom ones first: font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix - span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix + span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], diff --git a/src/components/views/elements/Spoiler.js b/src/components/views/elements/Spoiler.js new file mode 100644 index 0000000000..e1e0febbab --- /dev/null +++ b/src/components/views/elements/Spoiler.js @@ -0,0 +1,32 @@ +'use strict'; + +import React from 'react'; + +module.exports = React.createClass({ + displayName: 'Spoiler', + + getInitialState() { + return { + visible: false, + }; + }, + + toggleVisible() { + this.setState({ visible: !this.state.visible }); + }, + + render: function() { + const reason = this.props.reason ? ( + {"(" + this.props.reason + ")"} + ) : null; + return ( + + { reason } +   + + + + + ); + } +}) diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 1fc16d6a53..e6d67b034d 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -96,6 +96,8 @@ module.exports = React.createClass({ }, _applyFormatting() { + this.activateSpoilers(this.refs.content.children); + // pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer // are still sent as plaintext URLs. If these are ever pillified in the composer, // we should be pillify them here by doing the linkifying BEFORE the pillifying. @@ -184,6 +186,34 @@ module.exports = React.createClass({ } }, + activateSpoilers: function(nodes) { + let node = nodes[0]; + while (node) { + if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") { + const spoilerContainer = document.createElement('span'); + + const reason = node.getAttribute("data-mx-spoiler"); + const Spoiler = sdk.getComponent('elements.Spoiler'); + node.removeAttribute("data-mx-spoiler"); // we don't want to recurse + const spoiler = ; + + ReactDOM.render(spoiler, spoilerContainer); + node.parentNode.replaceChild(spoilerContainer, node); + + node = spoilerContainer; + } + + if (node.childNodes && node.childNodes.length) { + this.activateSpoilers(node.childNodes); + } + + node = node.nextSibling; + } + }, + pillifyLinks: function(nodes) { const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar"); let node = nodes[0]; From eddac4b188303d4fe456dd8150a32c17c6a41c28 Mon Sep 17 00:00:00 2001 From: Sorunome Date: Tue, 11 Jun 2019 21:08:45 +0200 Subject: [PATCH 02/35] blur spoilers --- res/css/views/rooms/_EventTile.scss | 14 +++----------- src/components/views/elements/Spoiler.js | 4 +--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index dd078d7f30..cf3e5b7985 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -291,20 +291,12 @@ limitations under the License. } .mx_EventTile_spoiler_content { - background-color: black; + filter: blur(5px) saturate(0.1) sepia(1); + transition-duration: 0.5s; } -.mx_EventTile_spoiler_content > span { - visibility: hidden; -} - - .mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { - background-color: initial; -} - -.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content > span { - visibility: visible; + filter: none; } /* End to end encryption stuff */ diff --git a/src/components/views/elements/Spoiler.js b/src/components/views/elements/Spoiler.js index e1e0febbab..d2de7d70e0 100644 --- a/src/components/views/elements/Spoiler.js +++ b/src/components/views/elements/Spoiler.js @@ -23,9 +23,7 @@ module.exports = React.createClass({ { reason }   - - - + ); } From d0f78e9d4449e1d2c23d54819663252d874dfe23 Mon Sep 17 00:00:00 2001 From: Sorunome Date: Tue, 11 Jun 2019 22:13:47 +0200 Subject: [PATCH 03/35] stop propagation of click events on un-hiding the spoiler --- src/components/views/elements/Spoiler.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/views/elements/Spoiler.js b/src/components/views/elements/Spoiler.js index d2de7d70e0..9be7bc7784 100644 --- a/src/components/views/elements/Spoiler.js +++ b/src/components/views/elements/Spoiler.js @@ -11,7 +11,12 @@ module.exports = React.createClass({ }; }, - toggleVisible() { + toggleVisible(e) { + if (!this.state.visible) { + // we are un-blurring, we don't want this click to propagate to potential child pills + e.preventDefault(); + e.stopPropagation(); + } this.setState({ visible: !this.state.visible }); }, From cd6a980c7ed8cab5126668642038deac390186f9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Aug 2019 16:57:51 +0100 Subject: [PATCH 04/35] Only Destroy the expected persistent widget, not *ANY* Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/elements/AppTile.js | 7 +++---- src/stores/ActiveWidgetStore.js | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index a9239303b1..55ccfc8168 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -154,10 +154,9 @@ export default class AppTile extends React.Component { // Widget action listeners dis.unregister(this.dispatcherRef); - const canPersist = this.props.whitelistCapabilities.includes('m.always_on_screen'); // if it's not remaining on screen, get rid of the PersistedElement container - if (canPersist && !ActiveWidgetStore.getWidgetPersistence(this.props.id)) { - ActiveWidgetStore.destroyPersistentWidget(); + if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) { + ActiveWidgetStore.destroyPersistentWidget(this.props.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } @@ -429,7 +428,7 @@ export default class AppTile extends React.Component { this.setState({hasPermissionToLoad: false}); // Force the widget to be non-persistent - ActiveWidgetStore.destroyPersistentWidget(); + ActiveWidgetStore.destroyPersistentWidget(this.props.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } diff --git a/src/stores/ActiveWidgetStore.js b/src/stores/ActiveWidgetStore.js index f0a9f718a2..82a7f7932c 100644 --- a/src/stores/ActiveWidgetStore.js +++ b/src/stores/ActiveWidgetStore.js @@ -67,11 +67,12 @@ class ActiveWidgetStore extends EventEmitter { if (ev.getType() !== 'im.vector.modular.widgets') return; if (ev.getStateKey() === this._persistentWidgetId) { - this.destroyPersistentWidget(); + this.destroyPersistentWidget(this._persistentWidgetId); } } - destroyPersistentWidget() { + destroyPersistentWidget(id) { + if (id !== this._persistentWidgetId) return; const toDeleteId = this._persistentWidgetId; this.setWidgetPersistence(toDeleteId, false); From 72ec6c7062f19a5c261ce14ea88f5d2c5970b54b Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 23 Aug 2019 18:43:55 +0100 Subject: [PATCH 05/35] Reveal custom IS field only when required This hides the identity server at first from the custom server auth flows. For the flows that may need an IS if the HS requires it (registration, password reset), we then check with the HS before proceeding further and reveal the IS field if it is in fact needed. Fixes https://github.com/vector-im/riot-web/issues/10553 --- res/css/views/auth/_ServerConfig.scss | 28 ++--- .../structures/auth/ForgotPassword.js | 2 + .../structures/auth/Registration.js | 1 + src/components/views/auth/ServerConfig.js | 109 ++++++++++++++---- src/i18n/strings/en_EN.json | 3 +- 5 files changed, 100 insertions(+), 43 deletions(-) diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/auth/_ServerConfig.scss index a31feb75d7..a7e0057ab3 100644 --- a/res/css/views/auth/_ServerConfig.scss +++ b/res/css/views/auth/_ServerConfig.scss @@ -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,23 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ServerConfig_fields { - display: flex; - margin: 1em 0; -} - -.mx_ServerConfig_fields .mx_Field { - margin: 0 5px; -} - -.mx_ServerConfig_fields .mx_Field:first-child { - margin-left: 0; -} - -.mx_ServerConfig_fields .mx_Field:last-child { - margin-right: 0; -} - .mx_ServerConfig_help:link { opacity: 0.8; } @@ -39,3 +23,13 @@ limitations under the License. display: block; color: $warning-color; } + +.mx_ServerConfig_identityServer { + transform: scaleY(0); + transform-origin: top; + transition: transform 0.25s; + + &.mx_ServerConfig_identityServer_shown { + transform: scaleY(1); + } +} diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 6d80f66d64..11c0ff8295 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018, 2019 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. @@ -208,6 +209,7 @@ module.exports = React.createClass({ serverConfig={this.props.serverConfig} onServerConfigChange={this.props.onServerConfigChange} delayTimeMs={0} + showIdentityServerIfRequiredByHomeserver={true} onAfterSubmit={this.onServerDetailsNextPhaseClick} submitText={_t("Next")} submitClass="mx_Login_submit" diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 63c5b267cf..2fd028ea1d 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -499,6 +499,7 @@ module.exports = React.createClass({ serverConfig={this.props.serverConfig} onServerConfigChange={this.props.onServerConfigChange} delayTimeMs={250} + showIdentityServerIfRequiredByHomeserver={true} {...serverDetailsProps} />; break; diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js index 467ba307d0..7d7a99a79c 100644 --- a/src/components/views/auth/ServerConfig.js +++ b/src/components/views/auth/ServerConfig.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 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. @@ -23,6 +24,8 @@ import { _t } from '../../../languageHandler'; import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import SdkConfig from "../../../SdkConfig"; +import { createClient } from 'matrix-js-sdk/lib/matrix'; +import classNames from 'classnames'; /* * A pure UI component which displays the HS and IS to use. @@ -46,6 +49,10 @@ export default class ServerConfig extends React.PureComponent { // Optional class for the submit button. Only applies if the submit button // is to be rendered. submitClass: PropTypes.string, + + // Whether the flow this component is embedded in requires an identity + // server when the homeserver says it will need one. + showIdentityServerIfRequiredByHomeserver: PropTypes.bool, }; static defaultProps = { @@ -61,6 +68,7 @@ export default class ServerConfig extends React.PureComponent { errorText: "", hsUrl: props.serverConfig.hsUrl, isUrl: props.serverConfig.isUrl, + showIdentityServer: false, }; } @@ -75,7 +83,29 @@ export default class ServerConfig extends React.PureComponent { // TODO: Do we want to support .well-known lookups here? // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to // find their homeserver without demanding they use "https://matrix.org" - return this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl); + const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl); + if (!result) { + return result; + } + + // If the UI flow this component is embedded in requires an identity + // server when the homeserver says it will need one, check first and + // reveal this field if not already shown. + // XXX: This a backward compatibility path for homeservers that require + // an identity server to be passed during certain flows. + // See also https://github.com/matrix-org/synapse/pull/5868. + if ( + this.props.showIdentityServerIfRequiredByHomeserver && + !this.state.showIdentityServer && + await this.isIdentityServerRequiredByHomeserver() + ) { + this.setState({ + showIdentityServer: true, + }); + return null; + } + + return result; } async validateAndApplyServer(hsUrl, isUrl) { @@ -126,6 +156,15 @@ export default class ServerConfig extends React.PureComponent { } } + async isIdentityServerRequiredByHomeserver() { + // XXX: We shouldn't have to create a whole new MatrixClient just to + // check if the homeserver requires an identity server... Should it be + // extracted to a static utils function...? + return createClient({ + baseUrl: this.state.hsUrl, + }).doesServerRequireIdServerParam(); + } + onHomeserverBlur = (ev) => { this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => { this.validateServer(); @@ -171,8 +210,49 @@ export default class ServerConfig extends React.PureComponent { Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); }; - render() { + _renderHomeserverSection() { const Field = sdk.getComponent('elements.Field'); + return
+ {_t("Enter your custom homeserver URL What does this mean?", {}, { + a: sub => + {sub} + , + })} + +
; + } + + _renderIdentityServerSection() { + const Field = sdk.getComponent('elements.Field'); + const classes = classNames({ + "mx_ServerConfig_identityServer": true, + "mx_ServerConfig_identityServer_shown": this.state.showIdentityServer, + }); + return
+ {_t("Enter your custom identity server URL What does this mean?", {}, { + a: sub => + {sub} + , + })} + +
; + } + + render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const errorText = this.state.errorText @@ -191,31 +271,10 @@ export default class ServerConfig extends React.PureComponent { return (

{_t("Other servers")}

- {_t("Enter custom server URLs What does this mean?", {}, { - a: sub => - { sub } - , - })} {errorText} + {this._renderHomeserverSection()} + {this._renderIdentityServerSection()}
-
- - -
{submitButton}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 83a9602a51..f90bdc8bb5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1461,8 +1461,9 @@ "Other users can invite you to rooms using your contact details.": "Other users can invite you to rooms using your contact details.", "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.": "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.", "Other servers": "Other servers", - "Enter custom server URLs What does this mean?": "Enter custom server URLs What does this mean?", + "Enter your custom homeserver URL What does this mean?": "Enter your custom homeserver URL What does this mean?", "Homeserver URL": "Homeserver URL", + "Enter your custom identity server URL What does this mean?": "Enter your custom identity server URL What does this mean?", "Identity Server URL": "Identity Server URL", "Free": "Free", "Join millions for free on the largest public server": "Join millions for free on the largest public server", From 713205e0ab11f05eabffaae71face5d130ccb5b3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:10:11 +0200 Subject: [PATCH 06/35] close autocomplete when removing auto-completed part --- src/editor/model.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 9d129afa69..7f87bdea23 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -90,10 +90,14 @@ export default class EditorModel { _removePart(index) { this._parts.splice(index, 1); - if (this._activePartIdx >= index) { + if (index === this._activePartIdx) { + this._activePartIdx = null; + } else if (this._activePartIdx > index) { --this._activePartIdx; } - if (this._autoCompletePartIdx >= index) { + if (index === this._autoCompletePartIdx) { + this._autoCompletePartIdx = null; + } else if (this._autoCompletePartIdx > index) { --this._autoCompletePartIdx; } } From 0f6465a1dbc1af6efde1638bbcd565bdceb6d0a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:11:18 +0200 Subject: [PATCH 07/35] don't close autocomplete when hitting tab that's not what the slate impl does and it's not an improvement --- src/editor/autocomplete.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index ac662c32d8..cf3082ce13 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -52,9 +52,6 @@ export default class AutocompleteWrapperModel { } else { await acComponent.moveSelection(e.shiftKey ? -1 : +1); } - this._updateCallback({ - close: true, - }); } onUpArrow() { From f76a23d5dd3c78985c86bf9e255c7f8b19b47a33 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:12:44 +0200 Subject: [PATCH 08/35] return promise from updating autocomplete so one can await if needed --- src/components/views/rooms/BasicMessageComposer.js | 2 +- src/editor/model.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 662167b714..ad0e76c3e4 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -304,7 +304,7 @@ export default class BasicMessageEditor extends React.Component { // not really, but we could not serialize the parts, and just change the autoCompleter partCreator.setAutoCompleteCreator(autoCompleteCreator( () => this._autocompleteRef, - query => this.setState({query}), + query => new Promise(resolve => this.setState({query}, resolve)), )); this.historyManager = new HistoryManager(partCreator); // initial render of model diff --git a/src/editor/model.js b/src/editor/model.js index 7f87bdea23..b020bd8fb5 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -186,13 +186,14 @@ export default class EditorModel { this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; let newPosition = this.positionForOffset(caretOffset, true); - this._setActivePart(newPosition, canOpenAutoComplete); + const acPromise = this._setActivePart(newPosition, canOpenAutoComplete); if (this._transformCallback) { const transformAddedLen = this._transform(newPosition, inputType, diff); newPosition = this.positionForOffset(caretOffset + transformAddedLen, true); } this._updateInProgress = false; this._updateCallback(newPosition, inputType, diff); + return acPromise; } _transform(newPosition, inputType, diff) { @@ -218,13 +219,14 @@ export default class EditorModel { } // not _autoComplete, only there if active part is autocomplete part if (this.autoComplete) { - this.autoComplete.onPartUpdate(part, pos.offset); + return this.autoComplete.onPartUpdate(part, pos.offset); } } else { this._activePartIdx = null; this._autoComplete = null; this._autoCompletePartIdx = null; } + return Promise.resolve(); } _onAutoComplete = ({replacePart, caretOffset, close}) => { From 68c2bb7ca60caccc67d146fbfcaa05c2fb3ec114 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:15:10 +0200 Subject: [PATCH 09/35] introduce `transform` method so update can be called with a position and also for multiple transformations at once. This removes the need to call the update callback from `replaceRange()` as well --- src/editor/model.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index b020bd8fb5..4c657f3168 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -35,6 +35,11 @@ import Range from "./range"; * This is used to adjust the caret position. */ +/** + * @callback ManualTransformCallback + * @return the caret position + */ + export default class EditorModel { constructor(parts, partCreator, updateCallback = null) { this._parts = parts; @@ -44,7 +49,6 @@ export default class EditorModel { this._autoCompletePartIdx = null; this._transformCallback = null; this.setUpdateCallback(updateCallback); - this._updateInProgress = false; } /** @@ -170,7 +174,6 @@ export default class EditorModel { } update(newValue, inputType, caret) { - this._updateInProgress = true; const diff = this._diff(newValue, inputType, caret); const position = this.positionForOffset(diff.at, caret.atNodeEnd); let removedOffsetDecrease = 0; @@ -191,7 +194,6 @@ export default class EditorModel { const transformAddedLen = this._transform(newPosition, inputType, diff); newPosition = this.positionForOffset(caretOffset + transformAddedLen, true); } - this._updateInProgress = false; this._updateCallback(newPosition, inputType, diff); return acPromise; } @@ -422,8 +424,18 @@ export default class EditorModel { insertIdx += 1; } this._mergeAdjacentParts(); - if (!this._updateInProgress) { - this._updateCallback(); - } + } + + /** + * Performs a transformation not part of an update cycle. + * Modifying the model should only happen inside a transform call if not part of an update call. + * @param {ManualTransformCallback} callback to run the transformations in + * @return {Promise} a promise when auto-complete (if applicable) is done updating + */ + transform(callback) { + const pos = callback(); + const acPromise = this._setActivePart(pos, true); + this._updateCallback(pos); + return acPromise; } } From f02713d08eac01dba489f572f63899a400c508f7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:16:43 +0200 Subject: [PATCH 10/35] force completion when hitting tab by replacing word before caret with pill-candidate and forcing auto complete --- .../views/rooms/BasicMessageComposer.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index ad0e76c3e4..10fa676989 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -269,6 +269,9 @@ export default class BasicMessageEditor extends React.Component { default: return; // don't preventDefault on anything else } + } else if (event.key === "Tab") { + this._tabCompleteName(event); + handled = true; } } if (handled) { @@ -277,6 +280,22 @@ export default class BasicMessageEditor extends React.Component { } } + async _tabCompleteName(event) { + const {model} = this.props; + const caret = this.getCaret(); + const position = model.positionForOffset(caret.offset, caret.atNodeEnd); + const range = model.startRange(position); + range.expandBackwardsWhile((index, offset, part) => { + return part.text[offset] !== " " && (part.type === "plain" || part.type === "pill-candidate"); + }); + const {partCreator} = model; + await model.transform(() => { + const addedLen = range.replace([partCreator.pillCandidate(range.text)]); + return model.positionForOffset(caret.offset + addedLen, true); + }); + await model.autoComplete.onTab(); + } + isModified() { return this._modifiedFlag; } From f5bb872efa6623bab3fe03036be87471a8f0ea3b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:17:41 +0200 Subject: [PATCH 11/35] some cleanup --- src/components/views/rooms/BasicMessageComposer.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 10fa676989..4aa622b6c2 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -75,10 +75,10 @@ export default class BasicMessageEditor extends React.Component { this._modifiedFlag = false; } - _replaceEmoticon = (caret, inputType, diff) => { + _replaceEmoticon = (caretPosition, inputType, diff) => { const {model} = this.props; - const range = model.startRange(caret); - // expand range max 8 characters backwards from caret, + const range = model.startRange(caretPosition); + // expand range max 8 characters backwards from caretPosition, // as a space to look for an emoticon let n = 8; range.expandBackwardsWhile((index, offset) => { @@ -91,6 +91,7 @@ export default class BasicMessageEditor extends React.Component { const query = emoticonMatch[1].toLowerCase().replace("-", ""); const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false); if (data) { + const {partCreator} = model; const hasPrecedingSpace = emoticonMatch[0][0] === " "; // we need the range to only comprise of the emoticon // because we'll replace the whole range with an emoji, @@ -99,7 +100,7 @@ export default class BasicMessageEditor extends React.Component { range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); // this returns the amount of added/removed characters during the replace // so the caret position can be adjusted. - return range.replace([this.props.model.partCreator.plain(data.unicode + " ")]); + return range.replace([partCreator.plain(data.unicode + " ")]); } } } @@ -160,7 +161,7 @@ export default class BasicMessageEditor extends React.Component { } _refreshLastCaretIfNeeded() { - // TODO: needed when going up and down in editing messages ... not sure why yet + // XXX: needed when going up and down in editing messages ... not sure why yet // because the editors should stop doing this when when blurred ... // maybe it's on focus and the _editorRef isn't available yet or something. if (!this._editorRef) { From e0ec827a64eacb70cb45dc4540f69f136f07fca5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:18:09 +0200 Subject: [PATCH 12/35] extra docs --- src/editor/parts.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/editor/parts.js b/src/editor/parts.js index f9b4243de4..bc420ecde1 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -366,6 +366,8 @@ export class PartCreator { constructor(room, client, autoCompleteCreator = null) { this._room = room; this._client = client; + // pre-create the creator as an object even without callback so it can already be passed + // to PillCandidatePart (e.g. while deserializing) and set later on this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)}; } From 8e66d382deee95f1e2bb82a1171d23846e39aeae Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:18:22 +0200 Subject: [PATCH 13/35] don't crash on race with room members and initial composer render not ideal, but for now this prevents a crash at startup when a user-pill is persisted in local storage --- src/editor/parts.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/editor/parts.js b/src/editor/parts.js index bc420ecde1..8d0fe36c28 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -284,6 +284,9 @@ class UserPillPart extends PillPart { } setAvatar(node) { + if (!this._member) { + return; + } const name = this._member.name || this._member.userId; const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId); let avatarUrl = Avatar.avatarUrlForMember( From d8bb9ecedfa4f0e8d7d87dea6b4f5c6fe59ab1c0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:37:04 +0200 Subject: [PATCH 14/35] bring insert method inline with transform callback, add docs before the insertPartsAt method would call the update callback on its own, but now we have the concept of a transformation session, so lets bring the API in line --- .../views/rooms/SendMessageComposer.js | 19 +++++++++++++++---- src/editor/model.js | 17 ++++++++++------- src/editor/range.js | 6 ++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index c8fac0b667..698356a175 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -279,22 +279,33 @@ export default class SendMessageComposer extends React.Component { }; _insertMention(userId) { + const {model} = this; + const {partCreator} = model; const member = this.props.room.getMember(userId); const displayName = member ? member.rawDisplayName : userId; - const userPillPart = this.model.partCreator.userPill(displayName, userId); - this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); + const userPillPart = partCreator.userPill(displayName, userId); + const caret = this._editorRef.getCaret(); + const position = model.positionForOffset(caret.offset, caret.atNodeEnd); + model.transform(() => { + const addedLen = model.insert([userPillPart], position); + return model.positionForOffset(caret.offset + addedLen, true); + }); // refocus on composer, as we just clicked "Mention" this._editorRef && this._editorRef.focus(); } _insertQuotedMessage(event) { - const {partCreator} = this.model; + const {model} = this; + const {partCreator} = model; const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); // add two newlines quoteParts.push(partCreator.newline()); quoteParts.push(partCreator.newline()); - this.model.insertPartsAt(quoteParts, {offset: 0}); + model.transform(() => { + const addedLen = model.insert(quoteParts, model.positionForOffset(0)); + return model.positionForOffset(addedLen, true); + }); // refocus on composer, as we just clicked "Quote" this._editorRef && this._editorRef.focus(); } diff --git a/src/editor/model.js b/src/editor/model.js index 4c657f3168..ca04d9fdd0 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -158,8 +158,14 @@ export default class EditorModel { this._updateCallback(caret, inputType); } - insertPartsAt(parts, caret) { - const position = this.positionForOffset(caret.offset, caret.atNodeEnd); + /** + * Inserts the given parts at the given position. + * Should be run inside a `model.transform()` callback. + * @param {Part[]} parts the parts to replace the range with + * @param {DocumentPosition} position the position to start inserting at + * @return {Number} the amount of characters added + */ + insert(parts, position) { const insertIndex = this._splitAt(position); let newTextLength = 0; for (let i = 0; i < parts.length; ++i) { @@ -167,10 +173,7 @@ export default class EditorModel { newTextLength += part.text.length; this._insertPart(insertIndex + i, part); } - // put caret after new part - const lastPartIndex = insertIndex + parts.length - 1; - const newPosition = new DocumentPosition(lastPartIndex, newTextLength); - this._updateCallback(newPosition); + return newTextLength; } update(newValue, inputType, caret) { @@ -403,7 +406,7 @@ export default class EditorModel { return new Range(this, position); } - // called from Range.replace + //mostly internal, called from Range.replace replaceRange(startPosition, endPosition, parts) { const newStartPartIndex = this._splitAt(startPosition); const idxDiff = newStartPartIndex - startPosition.index; diff --git a/src/editor/range.js b/src/editor/range.js index e2ecc5d12b..1aaf480733 100644 --- a/src/editor/range.js +++ b/src/editor/range.js @@ -41,6 +41,12 @@ export default class Range { return text; } + /** + * Splits the model at the range boundaries and replaces with the given parts. + * Should be run inside a `model.transform()` callback. + * @param {Part[]} parts the parts to replace the range with + * @return {Number} the net amount of characters added, can be negative. + */ replace(parts) { const newLength = parts.reduce((sum, part) => sum + part.text.length, 0); let oldLength = 0; From 994bcb5c85058ffbe071976c1c3620eca74d8a00 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Aug 2019 16:39:30 +0200 Subject: [PATCH 15/35] dont expect rendered to be called from `range.replace()` anymore as this is now called from the `transform` method, unused in this test --- test/editor/range-test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/editor/range-test.js b/test/editor/range-test.js index 5a95da952d..e5fa48ea15 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -60,7 +60,6 @@ describe('editor/range', function() { expect(model.parts[2].type).toBe("plain"); expect(model.parts[2].text).toBe("!!!!"); expect(model.parts.length).toBe(3); - expect(renderer.count).toBe(1); }); it('range replace across parts', function() { const renderer = createRenderer(); @@ -83,6 +82,5 @@ describe('editor/range', function() { expect(model.parts[2].type).toBe("plain"); expect(model.parts[2].text).toBe(" me"); expect(model.parts.length).toBe(3); - expect(renderer.count).toBe(1); }); }); From ac2b8b874f2cc66e0eb6cc5b92539addc37aa050 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 27 Aug 2019 17:29:56 -0400 Subject: [PATCH 16/35] Don't infinite loop on server change ServerConfig assumed that the state was already correct when checking the given urls against the default, but that is not neccessarily the case (eg. the validation can return a different url to what the user entered). This would cause an infinite loop because it would keep firing onServerConfigChange to change to the desired URLs but the state would never change. Fixes part of https://github.com/vector-im/riot-web/issues/10666 --- src/components/views/auth/ServerConfig.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js index 467ba307d0..8197afd512 100644 --- a/src/components/views/auth/ServerConfig.js +++ b/src/components/views/auth/ServerConfig.js @@ -82,7 +82,12 @@ export default class ServerConfig extends React.PureComponent { // Always try and use the defaults first const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { - this.setState({busy: false, errorText: ""}); + this.setState({ + hsUrl: defaultConfig.hsUrl, + isUrl: defaultConfig.isUrl, + busy: false, + errorText: "", + }); this.props.onServerConfigChange(defaultConfig); return defaultConfig; } From ac6b03551adf81574ab7666acbc6222c9525bf0c Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 28 Aug 2019 11:24:11 +0100 Subject: [PATCH 17/35] Describe props default Co-Authored-By: Travis Ralston --- src/components/views/auth/ServerConfig.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js index 7d7a99a79c..427bb73e78 100644 --- a/src/components/views/auth/ServerConfig.js +++ b/src/components/views/auth/ServerConfig.js @@ -51,7 +51,7 @@ export default class ServerConfig extends React.PureComponent { submitClass: PropTypes.string, // Whether the flow this component is embedded in requires an identity - // server when the homeserver says it will need one. + // server when the homeserver says it will need one. Default false. showIdentityServerIfRequiredByHomeserver: PropTypes.bool, }; From 7000b5a5adead76beffee8f0ad7b9983ecf63eac Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 28 Aug 2019 11:32:36 +0100 Subject: [PATCH 18/35] Update i18n --- src/i18n/strings/en_EN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f90bdc8bb5..5a450bed81 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1460,11 +1460,11 @@ "Use an email address to recover your account.": "Use an email address to recover your account.", "Other users can invite you to rooms using your contact details.": "Other users can invite you to rooms using your contact details.", "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.": "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.", - "Other servers": "Other servers", "Enter your custom homeserver URL What does this mean?": "Enter your custom homeserver URL What does this mean?", "Homeserver URL": "Homeserver URL", "Enter your custom identity server URL What does this mean?": "Enter your custom identity server URL What does this mean?", "Identity Server URL": "Identity Server URL", + "Other servers": "Other servers", "Free": "Free", "Join millions for free on the largest public server": "Join millions for free on the largest public server", "Premium": "Premium", From f70f983c8cf524b32a5f0c0baf7413cff0ab93de Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Aug 2019 12:00:37 +0100 Subject: [PATCH 19/35] Expose upgrade room permissions in room settings and fix command Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/SlashCommands.js | 21 ++++++++++++------- .../tabs/room/RolesRoomSettingsTab.js | 2 ++ src/i18n/strings/en_EN.json | 2 ++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 72ace22cb6..5ed1adb40f 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -139,8 +139,13 @@ export const CommandMap = { description: _td('Upgrades a room to a new version'), runFn: function(roomId, args) { if (args) { - const room = MatrixClientPeg.get().getRoom(roomId); - Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + if (!room.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { + return reject(_t("You do not have the required permissions to use this command.")); + } + + const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', QuestionDialog, { title: _t('Room upgrade confirmation'), description: ( @@ -198,13 +203,13 @@ export const CommandMap = { ), button: _t("Upgrade"), - onFinished: (confirm) => { - if (!confirm) return; - - MatrixClientPeg.get().upgradeRoom(roomId, args); - }, }); - return success(); + + return success(finished.then((confirm) => { + if (!confirm) return; + + return cli.upgradeRoom(roomId, args); + })); } return reject(this.getUsage()); }, diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js index c4b1ae8ddc..581c4314bc 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js @@ -30,6 +30,7 @@ const plEventsToLabels = { "m.room.history_visibility": _td("Change history visibility"), "m.room.power_levels": _td("Change permissions"), "m.room.topic": _td("Change topic"), + "m.room.tombstone": _td("Upgrade the room"), "im.vector.modular.widgets": _td("Modify widgets"), }; @@ -42,6 +43,7 @@ const plEventsToShow = { "m.room.history_visibility": {isState: true}, "m.room.power_levels": {isState: true}, "m.room.topic": {isState: true}, + "m.room.tombstone": {isState: true}, "im.vector.modular.widgets": {isState: true}, }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 62b6467b94..661a775781 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -146,6 +146,7 @@ "/ddg is not a command": "/ddg is not a command", "To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.", "Upgrades a room to a new version": "Upgrades a room to a new version", + "You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.", "Room upgrade confirmation": "Room upgrade confirmation", "Upgrading a room can be destructive and isn't always necessary.": "Upgrading a room can be destructive and isn't always necessary.", "Room upgrades are usually recommended when a room version is considered unstable. Unstable room versions might have bugs, missing features, or security vulnerabilities.": "Room upgrades are usually recommended when a room version is considered unstable. Unstable room versions might have bugs, missing features, or security vulnerabilities.", @@ -680,6 +681,7 @@ "Change history visibility": "Change history visibility", "Change permissions": "Change permissions", "Change topic": "Change topic", + "Upgrade the room": "Upgrade the room", "Modify widgets": "Modify widgets", "Failed to unban": "Failed to unban", "Unban": "Unban", From 591fa3d8c559dd1963a9a07f3a96f3a658b3ab97 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Aug 2019 14:46:47 +0100 Subject: [PATCH 20/35] Don't use cursor: pointer on roomsettings avatar if you can't change it Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/views/settings/_ProfileSettings.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 3e97a0ff6d..432b713c1b 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -43,7 +43,6 @@ limitations under the License. height: 88px; margin-left: 13px; position: relative; - cursor: pointer; } .mx_ProfileSettings_avatar > * { @@ -71,6 +70,7 @@ limitations under the License. text-align: center; vertical-align: middle; font-size: 10px; + cursor: pointer; } .mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlay:not(.mx_ProfileSettings_avatarOverlay_disabled) { From c44fbb73d0d2f27415330eb172613cc4cc93b9c5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Aug 2019 15:52:39 +0200 Subject: [PATCH 21/35] fix bug when replacing range starting at end of previous part --- src/editor/model.js | 15 ++++++--------- src/editor/offset.js | 26 ++++++++++++++++++++++++++ src/editor/position.js | 16 ++++++++++++++++ test/editor/range-test.js | 20 ++++++++++++++++++-- 4 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 src/editor/offset.js diff --git a/src/editor/model.js b/src/editor/model.js index ca04d9fdd0..59371cc3e6 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -408,16 +408,13 @@ export default class EditorModel { //mostly internal, called from Range.replace replaceRange(startPosition, endPosition, parts) { + // convert end position to offset, so it is independent of how the document is split into parts + // which we'll change when splitting up at the start position + const endOffset = endPosition.asOffset(this); const newStartPartIndex = this._splitAt(startPosition); - const idxDiff = newStartPartIndex - startPosition.index; - // if both position are in the same part, and we split it at start position, - // the offset of the end position needs to be decreased by the offset of the start position - const removedOffset = startPosition.index === endPosition.index ? startPosition.offset : 0; - const adjustedEndPosition = new DocumentPosition( - endPosition.index + idxDiff, - endPosition.offset - removedOffset, - ); - const newEndPartIndex = this._splitAt(adjustedEndPosition); + // convert it back to position once split at start + endPosition = endOffset.asPosition(this); + const newEndPartIndex = this._splitAt(endPosition); for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) { this._removePart(i); } diff --git a/src/editor/offset.js b/src/editor/offset.js new file mode 100644 index 0000000000..7054836bdc --- /dev/null +++ b/src/editor/offset.js @@ -0,0 +1,26 @@ +/* +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. +*/ + +export default class DocumentOffset { + constructor(offset, atEnd) { + this.offset = offset; + this.atEnd = atEnd; + } + + asPosition(model) { + return model.positionForOffset(this.offset, this.atEnd); + } +} diff --git a/src/editor/position.js b/src/editor/position.js index 5dcb31fe65..98b158e547 100644 --- a/src/editor/position.js +++ b/src/editor/position.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import DocumentOffset from "./offset"; + export default class DocumentPosition { constructor(index, offset) { this._index = index; @@ -104,4 +106,18 @@ export default class DocumentPosition { } } } + + asOffset(model) { + if (this.index === -1) { + return new DocumentOffset(0, true); + } + let offset = 0; + for (let i = 0; i < this.index; ++i) { + offset += model.parts[i].text.length; + } + offset += this.offset; + const lastPart = model.parts[this.index]; + const atEnd = offset >= lastPart.text.length; + return new DocumentOffset(offset, atEnd); + } } diff --git a/test/editor/range-test.js b/test/editor/range-test.js index e5fa48ea15..468cb60c76 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -52,7 +52,6 @@ describe('editor/range', function() { range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); expect(range.text).toBe("world"); range.replace([pc.roomPill(pillChannel)]); - console.log({parts: JSON.stringify(model.serializeParts())}); expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].text).toBe("hello "); expect(model.parts[1].type).toBe("room-pill"); @@ -73,7 +72,6 @@ describe('editor/range', function() { const range = model.startRange(model.positionForOffset(14)); // after "replace" range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); expect(range.text).toBe("replace"); - console.log("range.text", {text: range.text}); range.replace([pc.roomPill(pillChannel)]); expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].text).toBe("try to "); @@ -83,4 +81,22 @@ describe('editor/range', function() { expect(model.parts[2].text).toBe(" me"); expect(model.parts.length).toBe(3); }); + // bug found while implementing tab completion + it('replace a part with an identical part with start position at end of previous part', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("hello "), + pc.pillCandidate("man"), + ], pc, renderer); + const range = model.startRange(model.positionForOffset(9, true)); // before "man" + range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); + expect(range.text).toBe("man"); + range.replace([pc.pillCandidate(range.text)]); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].type).toBe("pill-candidate"); + expect(model.parts[1].text).toBe("man"); + expect(model.parts.length).toBe(2); + }); }); From 85efb71a23033842f5a540f7e1d4a0ed2ac2a847 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Aug 2019 15:53:16 +0200 Subject: [PATCH 22/35] add visual bell when no replacements are available also add try/catch in _tabCompleteName so errors don't get swallowed --- .../views/rooms/_BasicMessageComposer.scss | 9 +++ .../views/rooms/BasicMessageComposer.js | 56 +++++++++++++------ src/editor/autocomplete.js | 6 +- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index b6035e5859..a4b5bb51d0 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -27,6 +27,15 @@ limitations under the License. white-space: nowrap; } + @keyframes visualbell { + from { background-color: #faa; } + to { background-color: $primary-bg-color; } + } + + &.mx_BasicMessageComposer_input_error { + animation: 0.2s visualbell; + } + .mx_BasicMessageComposer_input { white-space: pre-wrap; word-wrap: break-word; diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 4aa622b6c2..19304ec557 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -14,6 +14,8 @@ 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 classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; import EditorModel from '../../../editor/model'; @@ -271,7 +273,7 @@ export default class BasicMessageEditor extends React.Component { return; // don't preventDefault on anything else } } else if (event.key === "Tab") { - this._tabCompleteName(event); + this._tabCompleteName(); handled = true; } } @@ -281,20 +283,30 @@ export default class BasicMessageEditor extends React.Component { } } - async _tabCompleteName(event) { - const {model} = this.props; - const caret = this.getCaret(); - const position = model.positionForOffset(caret.offset, caret.atNodeEnd); - const range = model.startRange(position); - range.expandBackwardsWhile((index, offset, part) => { - return part.text[offset] !== " " && (part.type === "plain" || part.type === "pill-candidate"); - }); - const {partCreator} = model; - await model.transform(() => { - const addedLen = range.replace([partCreator.pillCandidate(range.text)]); - return model.positionForOffset(caret.offset + addedLen, true); - }); - await model.autoComplete.onTab(); + async _tabCompleteName() { + try { + await new Promise(resolve => this.setState({showVisualBell: false}, resolve)); + const {model} = this.props; + const caret = this.getCaret(); + const position = model.positionForOffset(caret.offset, caret.atNodeEnd); + const range = model.startRange(position); + range.expandBackwardsWhile((index, offset, part) => { + return part.text[offset] !== " " && (part.type === "plain" || part.type === "pill-candidate"); + }); + const {partCreator} = model; + // await for auto-complete to be open + await model.transform(() => { + const addedLen = range.replace([partCreator.pillCandidate(range.text)]); + return model.positionForOffset(caret.offset + addedLen, true); + }); + await model.autoComplete.onTab(); + if (!model.autoComplete.hasSelection()) { + this.setState({showVisualBell: true}); + model.autoComplete.close(); + } + } catch (err) { + console.error(err); + } } isModified() { @@ -324,7 +336,14 @@ export default class BasicMessageEditor extends React.Component { // not really, but we could not serialize the parts, and just change the autoCompleter partCreator.setAutoCompleteCreator(autoCompleteCreator( () => this._autocompleteRef, - query => new Promise(resolve => this.setState({query}, resolve)), + query => { + return new Promise(resolve => this.setState({query}, resolve)); + // if setState + // if (this.state.query === query) { + // return Promise.resolve(); + // } else { + // } + }, )); this.historyManager = new HistoryManager(partCreator); // initial render of model @@ -365,7 +384,10 @@ export default class BasicMessageEditor extends React.Component { /> ); } - return (
+ const classes = classNames("mx_BasicMessageComposer", { + "mx_BasicMessageComposer_input_error": this.state.showVisualBell, + }); + return (
{ autoComplete }
Date: Wed, 28 Aug 2019 10:34:50 -0400 Subject: [PATCH 23/35] Update email help text Fixes https://github.com/vector-im/riot-web/issues/10674 --- src/components/views/auth/RegistrationForm.js | 36 ++++++++++++++----- src/i18n/strings/en_EN.json | 5 ++- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.js index cf1b074fe1..d3f275ffc3 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.js @@ -444,6 +444,15 @@ module.exports = React.createClass({ return true; }, + _showPhoneNumber() { + const threePidLogin = !SdkConfig.get().disable_3pid_login; + const haveIs = Boolean(this.props.serverConfig.isUrl); + if (!threePidLogin || !haveIs || !this._authStepIsUsed('m.login.msisdn')) { + return false; + } + return true; + }, + renderEmail() { if (!this._showEmail()) { return null; @@ -490,9 +499,7 @@ module.exports = React.createClass({ }, renderPhoneNumber() { - const threePidLogin = !SdkConfig.get().disable_3pid_login; - const haveIs = Boolean(this.props.serverConfig.isUrl); - if (!threePidLogin || !haveIs || !this._authStepIsUsed('m.login.msisdn')) { + if (!this._showPhoneNumber()) { return null; } const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); @@ -564,11 +571,24 @@ module.exports = React.createClass({ ); - const emailHelperText = this._showEmail() ?
- {_t("Use an email address to recover your account.") + " "} - {_t("Other users can invite you to rooms using your contact details.")} -
: null; - + let emailHelperText = null; + if (this._showEmail()) { + if (this._showPhoneNumber()) { + emailHelperText =
+ {_t( + "Set an email for account recovery. " + + "Use email or phone to optionally be discoverable by existing contacts.", + )} +
; + } else { + emailHelperText =
+ {_t( + "Set an email for account recovery. " + + "Use email to optionally be discoverable by existing contacts.", + )} +
; + } + } const haveIs = Boolean(this.props.serverConfig.isUrl); const noIsText = haveIs ? null :
{_t( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 62b6467b94..f1d494bd9b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1461,9 +1461,8 @@ "Phone (optional)": "Phone (optional)", "Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s", "Create your Matrix account on ": "Create your Matrix account on ", - "Use an email address to recover your account.": "Use an email address to recover your account.", - "Other users can invite you to rooms using your contact details.": "Other users can invite you to rooms using your contact details.", - "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.": "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.", + "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.": "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.", + "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.": "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.", "Other servers": "Other servers", "Enter custom server URLs What does this mean?": "Enter custom server URLs What does this mean?", "Homeserver URL": "Homeserver URL", From aa9c0b24fe65c2e282d8dad23e97b11ff9aaf1c9 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 28 Aug 2019 10:37:57 -0400 Subject: [PATCH 24/35] re-run i18n --- src/i18n/strings/en_EN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f1d494bd9b..2930adb145 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1463,6 +1463,7 @@ "Create your Matrix account on ": "Create your Matrix account on ", "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.": "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.", "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.": "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.", + "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.": "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.", "Other servers": "Other servers", "Enter custom server URLs What does this mean?": "Enter custom server URLs What does this mean?", "Homeserver URL": "Homeserver URL", From 29f96e659aa2ba52e388fe7d6781233eef6cccdb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Aug 2019 17:53:03 +0200 Subject: [PATCH 25/35] remove leftover code --- src/components/views/rooms/BasicMessageComposer.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 19304ec557..49815c6f23 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -336,14 +336,7 @@ export default class BasicMessageEditor extends React.Component { // not really, but we could not serialize the parts, and just change the autoCompleter partCreator.setAutoCompleteCreator(autoCompleteCreator( () => this._autocompleteRef, - query => { - return new Promise(resolve => this.setState({query}, resolve)); - // if setState - // if (this.state.query === query) { - // return Promise.resolve(); - // } else { - // } - }, + query => new Promise(resolve => this.setState({query}, resolve)), )); this.historyManager = new HistoryManager(partCreator); // initial render of model From eddaece43e871975d112735e544857d42ca3ef9d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Aug 2019 18:00:57 +0200 Subject: [PATCH 26/35] add visual bell color to theme + choose better value for dark mode --- res/css/views/rooms/_BasicMessageComposer.scss | 2 +- res/css/views/rooms/_MessageComposer.scss | 2 +- res/themes/dark/css/_dark.scss | 2 ++ res/themes/light/css/_light.scss | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index a4b5bb51d0..bce0ecf325 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -28,7 +28,7 @@ limitations under the License. } @keyframes visualbell { - from { background-color: #faa; } + from { background-color: $visual-bell-bg-color; } to { background-color: $primary-bg-color; } } diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 6e17251cb0..5b4a9b764b 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -129,7 +129,7 @@ limitations under the License. } @keyframes visualbell { - from { background-color: #faa; } + from { background-color: $visual-bell-bg-color; } to { background-color: $primary-bg-color; } } diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 90cd8e8558..f54d25ab29 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -146,6 +146,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color $button-link-fg-color: $accent-color; $button-link-bg-color: transparent; +$visual-bell-bg-color: #800; + $room-warning-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index d8d4b0a11b..be46367fbb 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -247,6 +247,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color $button-link-fg-color: $accent-color; $button-link-bg-color: transparent; +$visual-bell-bg-color: #faa; + // Toggle switch $togglesw-off-color: #c1c9d6; $togglesw-on-color: $accent-color; From e531b29307c2e46c6a8a21a03a1469b099d5325b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 29 Aug 2019 12:50:23 +0200 Subject: [PATCH 27/35] don't ignore BR elements when converting to editor dom to text --- src/editor/dom.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/editor/dom.js b/src/editor/dom.js index 1b683c2c5e..4f15a57303 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -84,6 +84,14 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) { foundCaret = true; } } + // usually newlines are entered as new DIV elements, + // but for example while pasting in some browsers, they are still + // converted to BRs, so also take these into account when they + // are not the last element in the DIV. + if (node.tagName === "BR" && node.nextSibling) { + text += "\n"; + focusNodeOffset += 1; + } const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node); if (nodeText) { if (!foundCaret) { From 80523f5dbed3486abfe1a628e16e4905a1adb62c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 29 Aug 2019 12:51:33 +0200 Subject: [PATCH 28/35] still convert \n to NewlinePart when pasting/dropping before we skipped the complete validation (which creates NewlineParts) when pasting or dropping text. We don't want to create PillCandidatePart when inserting text like this, as it would open the auto-complete, but newlines should still be applied. So instead of skipping validation, pass the inputType to the validation code so they can only reject pill candidate characters when not pasting. --- src/editor/model.js | 22 +++++++--------------- src/editor/parts.js | 43 ++++++++++++++++++++----------------------- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 59371cc3e6..0fbaa4bb3c 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -183,15 +183,14 @@ export default class EditorModel { if (diff.removed) { removedOffsetDecrease = this.removeText(position, diff.removed.length); } - const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop"; let addedLen = 0; if (diff.added) { - // these shouldn't trigger auto-complete, you just want to append a piece of text - addedLen = this._addText(position, diff.added, {validate: canOpenAutoComplete}); + addedLen = this._addText(position, diff.added, inputType); } this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; let newPosition = this.positionForOffset(caretOffset, true); + const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop"; const acPromise = this._setActivePart(newPosition, canOpenAutoComplete); if (this._transformCallback) { const transformAddedLen = this._transform(newPosition, inputType, diff); @@ -333,22 +332,20 @@ export default class EditorModel { * inserts `str` into the model at `pos`. * @param {Object} pos * @param {string} str - * @param {Object} options + * @param {string} inputType the source of the input, see html InputEvent.inputType * @param {bool} options.validate Whether characters will be validated by the part. * Validating allows the inserted text to be parsed according to the part rules. * @return {Number} how far from position (in characters) the insertion ended. * This can be more than the length of `str` when crossing non-editable parts, which are skipped. */ - _addText(pos, str, {validate=true}) { + _addText(pos, str, inputType) { let {index} = pos; const {offset} = pos; let addLen = str.length; const part = this._parts[index]; if (part) { if (part.canEdit) { - if (validate && part.validateAndInsert(offset, str)) { - str = null; - } else if (!validate && part.insert(offset, str)) { + if (part.validateAndInsert(offset, str, inputType)) { str = null; } else { const splitPart = part.split(offset); @@ -367,13 +364,8 @@ export default class EditorModel { index = 0; } while (str) { - const newPart = this._partCreator.createPartForInput(str, index); - if (validate) { - str = newPart.appendUntilRejected(str); - } else { - newPart.insert(0, str); - str = null; - } + const newPart = this._partCreator.createPartForInput(str, index, inputType); + str = newPart.appendUntilRejected(str, inputType); this._insertPart(index, newPart); index += 1; } diff --git a/src/editor/parts.js b/src/editor/parts.js index 8d0fe36c28..d14fcf98a2 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -23,7 +23,7 @@ class BasePart { this._text = text; } - acceptsInsertion(chr) { + acceptsInsertion(chr, offset, inputType) { return true; } @@ -56,10 +56,11 @@ class BasePart { } // append str, returns the remaining string if a character was rejected. - appendUntilRejected(str) { + appendUntilRejected(str, inputType) { + const offset = this.text.length; for (let i = 0; i < str.length; ++i) { const chr = str.charAt(i); - if (!this.acceptsInsertion(chr, i)) { + if (!this.acceptsInsertion(chr, offset + i, inputType)) { this._text = this._text + str.substr(0, i); return str.substr(i); } @@ -69,10 +70,10 @@ class BasePart { // inserts str at offset if all the characters in str were accepted, otherwise don't do anything // return whether the str was accepted or not. - validateAndInsert(offset, str) { + validateAndInsert(offset, str, inputType) { for (let i = 0; i < str.length; ++i) { const chr = str.charAt(i); - if (!this.acceptsInsertion(chr)) { + if (!this.acceptsInsertion(chr, offset + i, inputType)) { return false; } } @@ -82,16 +83,6 @@ class BasePart { return true; } - insert(offset, str) { - if (this.canEdit) { - const beforeInsert = this._text.substr(0, offset); - const afterInsert = this._text.substr(offset); - this._text = beforeInsert + str + afterInsert; - return true; - } - return false; - } - createAutoComplete() {} trim(len) { @@ -119,8 +110,15 @@ class BasePart { // exported for unit tests, should otherwise only be used through PartCreator export class PlainPart extends BasePart { - acceptsInsertion(chr) { - return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; + acceptsInsertion(chr, offset, inputType) { + if (chr === "\n") { + return false; + } + // when not pasting or dropping text, reject characters that should start a pill candidate + if (inputType !== "insertFromPaste" && inputType !== "insertFromDrop") { + return chr !== "@" && chr !== "#" && chr !== ":"; + } + return true; } toDOMNode() { @@ -141,7 +139,6 @@ export class PlainPart extends BasePart { updateDOMNode(node) { if (node.textContent !== this.text) { - // console.log("changing plain text from", node.textContent, "to", this.text); node.textContent = this.text; } } @@ -211,8 +208,8 @@ class PillPart extends BasePart { } class NewlinePart extends BasePart { - acceptsInsertion(chr, i) { - return (this.text.length + i) === 0 && chr === "\n"; + acceptsInsertion(chr, offset) { + return offset === 0 && chr === "\n"; } acceptsRemoval(position, chr) { @@ -331,11 +328,11 @@ class PillCandidatePart extends PlainPart { return this._autoCompleteCreator.create(updateCallback); } - acceptsInsertion(chr, i) { - if ((this.text.length + i) === 0) { + acceptsInsertion(chr, offset, inputType) { + if (offset === 0) { return true; } else { - return super.acceptsInsertion(chr, i); + return super.acceptsInsertion(chr, offset, inputType); } } From 891ccf0f4ce0850ffa596bfa50fa380263072be4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 29 Aug 2019 13:56:21 +0200 Subject: [PATCH 29/35] don't update model while doing IME composition this prevents the composition from being disrupted because the DOM is modified, and also complete compositions are added to the undo history like this. --- .../views/rooms/BasicMessageComposer.js | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 49815c6f23..a72f00c28f 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -75,6 +75,7 @@ export default class BasicMessageEditor extends React.Component { this._editorRef = null; this._autocompleteRef = null; this._modifiedFlag = false; + this._isIMEComposing = false; } _replaceEmoticon = (caretPosition, inputType, diff) => { @@ -119,11 +120,9 @@ export default class BasicMessageEditor extends React.Component { if (this.props.placeholder) { const {isEmpty} = this.props.model; if (isEmpty) { - this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`); - this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty"); + this._showPlaceholder(); } else { - this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty"); - this._editorRef.style.removeProperty("--placeholder"); + this._hidePlaceholder(); } } this.setState({autoComplete: this.props.model.autoComplete}); @@ -135,7 +134,31 @@ export default class BasicMessageEditor extends React.Component { } } + _showPlaceholder() { + this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`); + this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty"); + } + + _hidePlaceholder() { + this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty"); + this._editorRef.style.removeProperty("--placeholder"); + } + + _onCompositionStart = (event) => { + this._isIMEComposing = true; + // even if the model is empty, the composition text shouldn't be mixed with the placeholder + this._hidePlaceholder(); + } + + _onCompositionEnd = (event) => { + this._isIMEComposing = false; + } + _onInput = (event) => { + // ignore any input while doing IME compositions + if (this._isIMEComposing) { + return; + } this._modifiedFlag = true; const sel = document.getSelection(); const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); @@ -323,6 +346,8 @@ export default class BasicMessageEditor extends React.Component { componentWillUnmount() { this._editorRef.removeEventListener("input", this._onInput, true); + this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true); + this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true); } componentDidMount() { @@ -344,6 +369,8 @@ export default class BasicMessageEditor extends React.Component { // attach input listener by hand so React doesn't proxy the events, // as the proxied event doesn't support inputType, which we need. this._editorRef.addEventListener("input", this._onInput, true); + this._editorRef.addEventListener("compositionstart", this._onCompositionStart, true); + this._editorRef.addEventListener("compositionend", this._onCompositionEnd, true); this._editorRef.focus(); } From fe7ac11abc1aa8417dd05e7c090b635a394f1507 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 29 Aug 2019 16:19:05 +0200 Subject: [PATCH 30/35] New composer: support pasting files --- .../views/rooms/BasicMessageComposer.js | 4 ++++ .../views/rooms/SendMessageComposer.js | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 49815c6f23..48ce81e895 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -309,6 +309,10 @@ export default class BasicMessageEditor extends React.Component { } } + getEditableRootNode() { + return this._editorRef; + } + isModified() { return this._modifiedFlag; } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 698356a175..0e03d83467 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -32,6 +32,7 @@ import {processCommandInput} from '../../../SlashCommands'; import sdk from '../../../index'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; +import ContentMessages from '../../../ContentMessages'; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -226,8 +227,13 @@ export default class SendMessageComposer extends React.Component { this._clearStoredEditorState(); } + componentDidMount() { + this._editorRef.getEditableRootNode().addEventListener("paste", this._onPaste, true); + } + componentWillUnmount() { dis.unregister(this.dispatcherRef); + this._editorRef.getEditableRootNode().removeEventListener("paste", this._onPaste, true); } componentWillMount() { @@ -310,6 +316,19 @@ export default class SendMessageComposer extends React.Component { this._editorRef && this._editorRef.focus(); } + _onPaste = (event) => { + const {clipboardData} = event; + if (clipboardData.files.length) { + // This actually not so much for 'files' as such (at time of writing + // neither chrome nor firefox let you paste a plain file copied + // from Finder) but more images copied from a different website + // / word processor etc. + ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(clipboardData.files), this.props.room.roomId, this.context.matrixClient, + ); + } + } + render() { return (
From 8ff2c42f39f96e32a6f157a35a80fa7e775f3042 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Aug 2019 14:23:57 +0000 Subject: [PATCH 31/35] Bump eslint-utils from 1.4.0 to 1.4.2 Bumps [eslint-utils](https://github.com/mysticatea/eslint-utils) from 1.4.0 to 1.4.2. - [Release notes](https://github.com/mysticatea/eslint-utils/releases) - [Commits](https://github.com/mysticatea/eslint-utils/compare/v1.4.0...v1.4.2) Signed-off-by: dependabot[bot] --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index c664d0b7dc..1989f4339a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2852,16 +2852,16 @@ eslint-scope@^4.0.0, eslint-scope@^4.0.3: estraverse "^4.1.1" eslint-utils@^1.3.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.0.tgz#e2c3c8dba768425f897cf0f9e51fe2e241485d4c" - integrity sha512-7ehnzPaP5IIEh1r1tkjuIrxqhNkzUJa9z3R92tLJdZIVdWaczEhr3EbhGtsMrVxi1KeR8qA7Off6SWc5WNQqyQ== + version "1.4.2" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab" + integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q== dependencies: eslint-visitor-keys "^1.0.0" eslint-visitor-keys@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" - integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== + version "1.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" + integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== eslint@^5.12.0: version "5.16.0" From 752eb178939a5cc92f04ba33b0ae4f413aee5e47 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 28 Aug 2019 15:12:00 +0100 Subject: [PATCH 32/35] Remove subtext in room invite dialog The subtext here was deemed redundant. Part of https://github.com/vector-im/riot-web/issues/10619 --- src/RoomInvite.js | 1 - src/components/views/dialogs/AddressPickerDialog.js | 11 ++++++++--- src/i18n/strings/en_EN.json | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/RoomInvite.js b/src/RoomInvite.js index b2382e206f..7a3b59cef8 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -68,7 +68,6 @@ export function showRoomInviteDialog(roomId) { Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { title: _t('Invite new room members'), - description: _t('Who would you like to add to this room?'), button: _t('Send Invites'), placeholder: _t("Email, name or Matrix ID"), validAddressTypes, diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index ac2181f1f2..364db97bf5 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -577,6 +577,13 @@ module.exports = createReactClass({ const AddressSelector = sdk.getComponent("elements.AddressSelector"); this.scrollElement = null; + let inputLabel; + if (this.props.description) { + inputLabel =
+ +
; + } + const query = []; // create the invite list if (this.state.selectedList.length > 0) { @@ -640,9 +647,7 @@ module.exports = createReactClass({ return ( -
- -
+ {inputLabel}
{ query }
{ error } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e024f4e2e9..513d9e64c7 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -117,7 +117,6 @@ "Email, name or Matrix ID": "Email, name or Matrix ID", "Start Chat": "Start Chat", "Invite new room members": "Invite new room members", - "Who would you like to add to this room?": "Who would you like to add to this room?", "Send Invites": "Send Invites", "Failed to start chat": "Failed to start chat", "Operation failed": "Operation failed", From 166fb696c240327267f1e42fd59a1d758636a270 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 29 Aug 2019 15:20:14 +0100 Subject: [PATCH 33/35] Allow connecting to an IS from address picker This allows those who previously disconnected from an IS to either choose the default IS or a custom one from Settings via the address picker dialog. Part of https://github.com/vector-im/riot-web/issues/10619 --- res/css/_common.scss | 6 ++ res/css/views/auth/_AuthBody.scss | 3 +- .../views/dialogs/_AddressPickerDialog.scss | 3 + res/themes/dark/css/_dark.scss | 5 ++ res/themes/light/css/_light.scss | 5 ++ src/RoomInvite.js | 18 ++++- .../views/dialogs/AddressPickerDialog.js | 71 +++++++++++++++++-- src/components/views/settings/SetIdServer.js | 10 +-- src/i18n/strings/en_EN.json | 2 + src/utils/IdentityServerUtils.js | 30 ++++++++ 10 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 src/utils/IdentityServerUtils.js diff --git a/res/css/_common.scss b/res/css/_common.scss index 859c0006a1..8252d5930e 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -281,6 +281,12 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { box-shadow: 2px 15px 30px 0 $dialog-shadow-color; border-radius: 4px; overflow-y: auto; + + a:link, + a:hover, + a:visited { + @mixin mx_Dialog_link; + } } .mx_Dialog_fixedWidth { diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 49a87d8077..b05629003e 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -39,8 +39,7 @@ limitations under the License. a:link, a:hover, a:visited { - color: $accent-color; - text-decoration: none; + @mixin mx_Dialog_link; } input[type=text], diff --git a/res/css/views/dialogs/_AddressPickerDialog.scss b/res/css/views/dialogs/_AddressPickerDialog.scss index 2771ac4052..168310507c 100644 --- a/res/css/views/dialogs/_AddressPickerDialog.scss +++ b/res/css/views/dialogs/_AddressPickerDialog.scss @@ -67,3 +67,6 @@ limitations under the License. pointer-events: none; } +.mx_AddressPickerDialog_identityServer { + margin-top: 1em; +} diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index f54d25ab29..ef0b91b41a 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -202,6 +202,11 @@ $interactive-tooltip-fg-color: #ffffff; background-color: $button-secondary-bg-color; } +@define-mixin mx_Dialog_link { + color: $accent-color; + text-decoration: none; +} + // Nasty hacks to apply a filter to arbitrary monochrome artwork to make it // better match the theme. Typically applied to dark grey 'off' buttons or // light grey 'on' buttons. diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index be46367fbb..bfaac09761 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -328,3 +328,8 @@ $interactive-tooltip-fg-color: #ffffff; color: $accent-color; background-color: $button-secondary-bg-color; } + +@define-mixin mx_Dialog_link { + color: $accent-color; + text-decoration: none; +} diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 7a3b59cef8..856a2ca577 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -51,7 +51,14 @@ export function showStartChatInviteDialog() { Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { title: _t('Start a chat'), description: _t("Who would you like to communicate with?"), - placeholder: _t("Email, name or Matrix ID"), + placeholder: (validAddressTypes) => { + // The set of valid address type can be mutated inside the dialog + // when you first have no IS but agree to use one in the dialog. + if (validAddressTypes.includes('email')) { + return _t("Email, name or Matrix ID"); + } + return _t("Name or Matrix ID"); + }, validAddressTypes, button: _t("Start Chat"), onFinished: _onStartDmFinished, @@ -69,7 +76,14 @@ export function showRoomInviteDialog(roomId) { Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { title: _t('Invite new room members'), button: _t('Send Invites'), - placeholder: _t("Email, name or Matrix ID"), + placeholder: (validAddressTypes) => { + // The set of valid address type can be mutated inside the dialog + // when you first have no IS but agree to use one in the dialog. + if (validAddressTypes.includes('email')) { + return _t("Email, name or Matrix ID"); + } + return _t("Name or Matrix ID"); + }, validAddressTypes, onFinished: (shouldInvite, addrs) => { _onRoomInviteFinished(roomId, shouldInvite, addrs); diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 364db97bf5..8f0a42198e 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -24,11 +24,14 @@ import createReactClass from 'create-react-class'; import { _t, _td } from '../../../languageHandler'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; +import dis from '../../../dispatcher'; import Promise from 'bluebird'; import { addressTypes, getAddressType } from '../../../UserAddress.js'; import GroupStore from '../../../stores/GroupStore'; import * as Email from '../../../email'; import IdentityAuthClient from '../../../IdentityAuthClient'; +import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils'; +import { abbreviateUrl } from '../../../utils/UrlUtils'; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -49,7 +52,7 @@ module.exports = createReactClass({ // Extra node inserted after picker input, dropdown and errors extraNode: PropTypes.node, value: PropTypes.string, - placeholder: PropTypes.string, + placeholder: PropTypes.oneOfType(PropTypes.string, PropTypes.func), roomId: PropTypes.string, button: PropTypes.string, focus: PropTypes.bool, @@ -91,6 +94,9 @@ module.exports = createReactClass({ // List of UserAddressType objects representing the set of // auto-completion results for the current search query. suggestedList: [], + // List of address types initialised from props, but may change while the + // dialog is open. + validAddressTypes: this.props.validAddressTypes, }; }, @@ -101,6 +107,15 @@ module.exports = createReactClass({ } }, + getPlaceholder() { + const { placeholder } = this.props; + if (typeof placeholder === "string") { + return placeholder; + } + // Otherwise it's a function, as checked by prop types. + return placeholder(this.state.validAddressTypes); + }, + onButtonClick: function() { let selectedList = this.state.selectedList.slice(); // Check the text input field to see if user has an unconverted address @@ -434,7 +449,7 @@ module.exports = createReactClass({ // This is important, otherwise there's no way to invite // a perfectly valid address if there are close matches. const addrType = getAddressType(query); - if (this.props.validAddressTypes.includes(addrType)) { + if (this.state.validAddressTypes.includes(addrType)) { if (addrType === 'email' && !Email.looksValid(query)) { this.setState({searchError: _t("That doesn't look like a valid email address")}); return; @@ -470,7 +485,7 @@ module.exports = createReactClass({ isKnown: false, }; - if (!this.props.validAddressTypes.includes(addrType)) { + if (!this.state.validAddressTypes.includes(addrType)) { hasError = true; } else if (addrType === 'mx-user-id') { const user = MatrixClientPeg.get().getUser(addrObj.address); @@ -571,6 +586,24 @@ module.exports = createReactClass({ this._addAddressesToList(text.split(/[\s,]+/)); }, + onUseDefaultIdentityServerClick(e) { + e.preventDefault(); + + // Update the IS in account data. Actually using it may trigger terms. + useDefaultIdentityServer(); + + // Add email as a valid address type. + const { validAddressTypes } = this.state; + validAddressTypes.push('email'); + this.setState({ validAddressTypes }); + }, + + onManageSettingsClick(e) { + e.preventDefault(); + dis.dispatch({ action: 'view_user_settings' }); + this.onCancel(); + }, + render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); @@ -610,7 +643,7 @@ module.exports = createReactClass({ ref="textinput" className="mx_AddressPickerDialog_input" onChange={this.onQueryChanged} - placeholder={this.props.placeholder} + placeholder={this.getPlaceholder()} defaultValue={this.props.value} autoFocus={this.props.focus}> , @@ -621,7 +654,7 @@ module.exports = createReactClass({ let error; let addressSelector; if (this.state.invalidAddressError) { - const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t])); + const validTypeDescriptions = this.state.validAddressTypes.map((t) => _t(addressTypeName[t])); error =
{ _t("You have entered an invalid address.") }
@@ -644,6 +677,33 @@ module.exports = createReactClass({ ); } + let identityServer; + if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email')) { + const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); + if (defaultIdentityServerUrl) { + identityServer =
{_t( + "Use an identity server to invite by email. " + + "Use the default (%(defaultIdentityServerName)s) " + + "or manage in Settings.", + { + defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), + }, + { + default: sub => {sub}, + settings: sub => {sub}, + }, + )}
; + } else { + identityServer =
{_t( + "Use an identity server to invite by email. " + + "Manage in Settings.", + {}, { + settings: sub => {sub}, + }, + )}
; + } + } + return ( @@ -653,6 +713,7 @@ module.exports = createReactClass({ { error } { addressSelector } { this.props.extraNode } + { identityServer }
Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.", + "Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.", "The following users may not exist": "The following users may not exist", "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?", "Invite anyway and never warn me again": "Invite anyway and never warn me again", diff --git a/src/utils/IdentityServerUtils.js b/src/utils/IdentityServerUtils.js new file mode 100644 index 0000000000..883bd52149 --- /dev/null +++ b/src/utils/IdentityServerUtils.js @@ -0,0 +1,30 @@ +/* +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. +*/ + +import SdkConfig from '../SdkConfig'; +import MatrixClientPeg from '../MatrixClientPeg'; + +export function getDefaultIdentityServerUrl() { + return SdkConfig.get()['validated_server_config']['isUrl']; +} + +export function useDefaultIdentityServer() { + const url = getDefaultIdentityServerUrl(); + // Account data change will update localstorage, client, etc through dispatcher + MatrixClientPeg.get().setAccountData("m.identity_server", { + base_url: url, + }); +} From 4ae130bd2747102e0455a2f601a0b37b8c02bb94 Mon Sep 17 00:00:00 2001 From: Sorunome Date: Thu, 29 Aug 2019 18:13:52 +0200 Subject: [PATCH 34/35] add license header, descriptive comment and change to class --- src/components/views/elements/Spoiler.js | 36 +++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/views/elements/Spoiler.js b/src/components/views/elements/Spoiler.js index 9be7bc7784..b75967b225 100644 --- a/src/components/views/elements/Spoiler.js +++ b/src/components/views/elements/Spoiler.js @@ -1,15 +1,28 @@ -'use strict'; +/* + Copyright 2019 Sorunome + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ import React from 'react'; -module.exports = React.createClass({ - displayName: 'Spoiler', - - getInitialState() { - return { +export default class Spoiler extends React.Component { + constructor(props) { + super(props); + this.state = { visible: false, }; - }, + } toggleVisible(e) { if (!this.state.visible) { @@ -18,12 +31,15 @@ module.exports = React.createClass({ e.stopPropagation(); } this.setState({ visible: !this.state.visible }); - }, + } - render: function() { + render() { const reason = this.props.reason ? ( {"(" + this.props.reason + ")"} ) : null; + // react doesn't allow appending a DOM node as child. + // as such, we pass the this.props.contentHtml instead and then set the raw + // HTML content. This is secure as the contents have already been parsed previously return ( { reason } @@ -32,4 +48,4 @@ module.exports = React.createClass({ ); } -}) +} From c144edfcacc048a9428934a0b76daae35012b811 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 29 Aug 2019 18:39:35 +0200 Subject: [PATCH 35/35] dont capture enter to close autocomplete --- src/components/views/rooms/BasicMessageComposer.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 49815c6f23..f5745302d4 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -245,14 +245,6 @@ export default class BasicMessageEditor extends React.Component { if (model.autoComplete) { const autoComplete = model.autoComplete; switch (event.key) { - case "Enter": - // only capture enter when something is selected in the list, - // otherwise don't handle so the contents of the composer gets sent - if (autoComplete.hasSelection()) { - autoComplete.onEnter(event); - handled = true; - } - break; case "ArrowUp": autoComplete.onUpArrow(event); handled = true;