diff --git a/CHANGELOG.md b/CHANGELOG.md index 988a85fd43..245d0c7e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +Changes in [1.1.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.2) (2019-05-15) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.1...v1.1.2) + + * Always thumbnail for GIFs + [\#2976](https://github.com/matrix-org/matrix-react-sdk/pull/2976) + * Fix Single Sign-on + [\#2975](https://github.com/matrix-org/matrix-react-sdk/pull/2975) + Changes in [1.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.1) (2019-05-14) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0...v1.1.1) diff --git a/package.json b/package.json index bcbf6ea29f..9c55ff43c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "1.1.1", + "version": "1.1.2", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/_components.scss b/res/css/_components.scss index 6e681894e3..2e0c91bd8c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -89,6 +89,7 @@ @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MemberEventListSummary.scss"; +@import "./views/elements/_MessageEditor.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_ReplyThread.scss"; diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss new file mode 100644 index 0000000000..ec6d903753 --- /dev/null +++ b/res/css/views/elements/_MessageEditor.scss @@ -0,0 +1,64 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_MessageEditor { + border-radius: 4px; + background-color: $header-panel-bg-color; + padding: 11px 13px 7px 56px; + + .mx_MessageEditor_editor { + border-radius: 4px; + border: solid 1px #e9edf1; + background-color: #ffffff; + padding: 10px; + white-space: pre-wrap; + word-wrap: break-word; + outline: none; + + span { + display: inline-block; + padding: 0 5px; + border-radius: 4px; + color: white; + } + + span.user-pill, span.room-pill { + border-radius: 16px; + display: inline-block; + color: $primary-fg-color; + background-color: $other-user-pill-bg-color; + padding-left: 5px; + padding-right: 5px; + } + } + + .mx_MessageEditor_buttons { + display: flex; + flex-direction: row; + justify-content: end; + padding: 5px 0; + + .mx_AccessibleButton { + margin-left: 5px; + padding: 5px 40px; + } + } + + .mx_MessageEditor_AutoCompleteWrapper { + position: relative; + height: 0; + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index e66c99e95b..749cfeebe6 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -69,6 +69,10 @@ limitations under the License. mask-image: url('$(res)/img/reply.svg'); } +.mx_MessageActionBar_editButton::after { + mask-image: url('$(res)/img/edit.svg'); +} + .mx_MessageActionBar_optionsButton::after { mask-image: url('$(res)/img/icon_context.svg'); } diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index cac97cb60d..0b9c7e2368 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -87,6 +87,11 @@ limitations under the License. } } +.mx_MemberList_invite.mx_AccessibleButton_disabled { + background-color: $greyed-fg-color;; + cursor: not-allowed; +} + .mx_MemberList_invite span { background-image: url('$(res)/img/feather-customised/user-add.svg'); background-repeat: no-repeat; diff --git a/res/img/edit.svg b/res/img/edit.svg new file mode 100644 index 0000000000..95bd44f606 --- /dev/null +++ b/res/img/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/reply.svg b/res/img/reply.svg index 8cbbad3550..540e228883 100644 --- a/res/img/reply.svg +++ b/res/img/reply.svg @@ -1,6 +1,6 @@ - + - - + + - + \ No newline at end of file diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index adadd39333..fc15170b87 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -257,11 +257,11 @@ $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1); $message-action-bar-bg-color: $primary-bg-color; $message-action-bar-fg-color: $primary-fg-color; $message-action-bar-border-color: #e9edf1; -$message-action-bar-hover-border-color: #b8c1d2; +$message-action-bar-hover-border-color: $focus-bg-color; $reaction-row-button-bg-color: $header-panel-bg-color; $reaction-row-button-border-color: #e9edf1; -$reaction-row-button-hover-border-color: #bebebe; +$reaction-row-button-hover-border-color: $focus-bg-color; $reaction-row-button-selected-bg-color: #e9fff9; $reaction-row-button-selected-border-color: $accent-color; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0e2389fd1c..a7f90f847d 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -369,7 +369,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { // If there's an inconsistency between account data in local storage and the // crypto store, we'll be generally confused when handling encrypted data. // Show a modal recommending a full reset of storage. - if (results.dataInLocalStorage && !results.dataInCryptoStore) { + if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { const signOut = await _showStorageEvictedDialog(); if (signOut) { await _clearStorage(); diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index cd40c7874e..82bd273846 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -121,6 +121,7 @@ class MatrixClientPeg { // check that we have a version of the js-sdk which includes initCrypto if (this.matrixClient.initCrypto) { await this.matrixClient.initCrypto(); + StorageManager.setCryptoInitialised(true); } } catch (e) { if (e && e.name === 'InvalidCryptoStoreError') { @@ -176,6 +177,7 @@ class MatrixClientPeg { _createClient(creds: MatrixClientCreds) { const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions"); + const enableEdits = SettingsStore.isFeatureEnabled("feature_message_editing"); const opts = { baseUrl: creds.homeserverUrl, @@ -187,6 +189,7 @@ class MatrixClientPeg { forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), verificationMethods: [verificationMethods.SAS], unstableClientRelationAggregation: aggregateRelations, + unstableClientRelationReplacements: enableEdits, }; this.matrixClient = createMatrixClient(opts); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 2037217710..6f21bb6951 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -450,9 +450,14 @@ module.exports = React.createClass({ _getTilesForEvent: function(prevEvent, mxEv, last) { const EventTile = sdk.getComponent('rooms.EventTile'); + const MessageEditor = sdk.getComponent('elements.MessageEditor'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); const ret = []; + if (this.props.editEvent && this.props.editEvent.getId() === mxEv.getId()) { + return []; + } + // is this a continuation of the previous message? let continuation = false; @@ -521,6 +526,7 @@ module.exports = React.createClass({ ); }, diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js new file mode 100644 index 0000000000..a2005feace --- /dev/null +++ b/src/components/views/elements/MessageEditor.js @@ -0,0 +1,176 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import sdk from '../../../index'; +import {_t} from '../../../languageHandler'; +import PropTypes from 'prop-types'; +import dis from '../../../dispatcher'; +import EditorModel from '../../../editor/model'; +import {setCaretPosition} from '../../../editor/caret'; +import {getCaretOffsetAndText} from '../../../editor/dom'; +import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize'; +import {parseEvent} from '../../../editor/deserialize'; +import Autocomplete from '../rooms/Autocomplete'; +import {PartCreator} from '../../../editor/parts'; +import {renderModel} from '../../../editor/render'; +import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; + +export default class MessageEditor extends React.Component { + static propTypes = { + // the message event being edited + event: PropTypes.instanceOf(MatrixEvent).isRequired, + }; + + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }; + + constructor(props, context) { + super(props, context); + const partCreator = new PartCreator( + () => this._autocompleteRef, + query => this.setState({query}), + ); + this.model = new EditorModel( + parseEvent(this.props.event), + partCreator, + this._updateEditorState, + ); + const room = this.context.matrixClient.getRoom(this.props.event.getRoomId()); + this.state = { + autoComplete: null, + room, + }; + this._editorRef = null; + this._autocompleteRef = null; + } + + _updateEditorState = (caret) => { + renderModel(this._editorRef, this.model); + if (caret) { + try { + setCaretPosition(this._editorRef, this.model, caret); + } catch (err) { + console.error(err); + } + } + this.setState({autoComplete: this.model.autoComplete}); + } + + _onInput = (event) => { + const sel = document.getSelection(); + const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); + this.model.update(text, event.inputType, caret); + } + + _onKeyDown = (event) => { + if (event.metaKey || event.altKey || event.shiftKey) { + return; + } + if (!this.model.autoComplete) { + return; + } + const autoComplete = this.model.autoComplete; + switch (event.key) { + case "Enter": + autoComplete.onEnter(event); break; + case "ArrowUp": + autoComplete.onUpArrow(event); break; + case "ArrowDown": + autoComplete.onDownArrow(event); break; + case "Tab": + autoComplete.onTab(event); break; + case "Escape": + autoComplete.onEscape(event); break; + default: + return; // don't preventDefault on anything else + } + event.preventDefault(); + } + + _onCancelClicked = () => { + dis.dispatch({action: "edit_event", event: null}); + } + + _onSaveClicked = () => { + const newContent = { + "msgtype": "m.text", + "body": textSerialize(this.model), + }; + if (requiresHtml(this.model)) { + newContent.format = "org.matrix.custom.html"; + newContent.formatted_body = htmlSerialize(this.model); + } + const content = Object.assign({ + "m.new_content": newContent, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": this.props.event.getId(), + }, + }, newContent); + + const roomId = this.props.event.getRoomId(); + this.context.matrixClient.sendMessage(roomId, content); + + dis.dispatch({action: "edit_event", event: null}); + } + + _onAutoCompleteConfirm = (completion) => { + this.model.autoComplete.onComponentConfirm(completion); + } + + _onAutoCompleteSelectionChange = (completion) => { + this.model.autoComplete.onComponentSelectionChange(completion); + } + + componentDidMount() { + this._updateEditorState(); + } + + render() { + let autoComplete; + if (this.state.autoComplete) { + const query = this.state.query; + const queryLen = query.length; + autoComplete =
+ this._autocompleteRef = ref} + query={query} + onConfirm={this._onAutoCompleteConfirm} + onSelectionChange={this._onAutoCompleteSelectionChange} + selection={{beginning: true, end: queryLen, start: queryLen}} + room={this.state.room} + /> +
; + } + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return
+ { autoComplete } +
this._editorRef = ref} + >
+
+ {_t("Cancel")} + {_t("Save")} +
+
; + } +} diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 52630d7b0e..fe6c22ab1e 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -58,6 +58,13 @@ export default class MessageActionBar extends React.PureComponent { }); } + onEditClick = (ev) => { + dis.dispatch({ + action: 'edit_event', + event: this.props.mxEvent, + }); + } + onOptionsClick = (ev) => { const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); const buttonRect = ev.target.getBoundingClientRect(); @@ -96,6 +103,10 @@ export default class MessageActionBar extends React.PureComponent { return SettingsStore.isFeatureEnabled("feature_reactions"); } + isEditingEnabled() { + return SettingsStore.isFeatureEnabled("feature_message_editing"); + } + renderAgreeDimension() { if (!this.isReactionsEnabled()) { return null; @@ -128,6 +139,7 @@ export default class MessageActionBar extends React.PureComponent { let agreeDimensionReactionButtons; let likeDimensionReactionButtons; let replyButton; + let editButton; if (isContentActionable(this.props.mxEvent)) { agreeDimensionReactionButtons = this.renderAgreeDimension(); @@ -136,12 +148,19 @@ export default class MessageActionBar extends React.PureComponent { title={_t("Reply")} onClick={this.onReplyClick} />; + if (this.isEditingEnabled()) { + editButton = ; + } } return
{agreeDimensionReactionButtons} {likeDimensionReactionButtons} {replyButton} + {editButton} ; }, }); diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 404a0f0889..deb3c5cc0f 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -137,6 +137,7 @@ module.exports = React.createClass({ // exploit that events are immutable :) return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || nextProps.highlights !== this.props.highlights || + nextProps.replacingEventId !== this.props.replacingEventId || nextProps.highlightLink !== this.props.highlightLink || nextProps.showUrlPreview !== this.props.showUrlPreview || nextState.links !== this.state.links || diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index e75456ea50..a19a4eaad0 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -60,18 +60,22 @@ export default class Autocomplete extends React.Component { }; } - componentWillReceiveProps(newProps, state) { - if (this.props.room.roomId !== newProps.room.roomId) { + componentDidMount() { + this._applyNewProps(); + } + + _applyNewProps(oldQuery, oldRoom) { + if (oldRoom && this.props.room.roomId !== oldRoom.roomId) { this.autocompleter.destroy(); - this.autocompleter = new Autocompleter(newProps.room); + this.autocompleter = new Autocompleter(this.props.room); } // Query hasn't changed so don't try to complete it - if (newProps.query === this.props.query) { + if (oldQuery === this.props.query) { return; } - this.complete(newProps.query, newProps.selection); + this.complete(this.props.query, this.props.selection); } componentWillUnmount() { @@ -233,7 +237,8 @@ export default class Autocomplete extends React.Component { } } - componentDidUpdate() { + componentDidUpdate(prevProps) { + this._applyNewProps(prevProps.query, prevProps.room); // this is the selected completion, so scroll it into view if needed const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`]; if (selectedCompletion && this.container) { @@ -298,6 +303,9 @@ Autocomplete.propTypes = { // method invoked with range and text content when completion is confirmed onConfirm: PropTypes.func.isRequired, + // method invoked when selected (if any) completion changes + onSelectionChange: PropTypes.func, + // The room in which we're autocompleting room: PropTypes.instanceOf(Room), }; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 1706019e94..f38e3c3946 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -779,6 +779,7 @@ module.exports = withMatrixClient(React.createClass({ { thread } = powerLevels.kick; can.ban = me.powerLevel >= powerLevels.ban; + can.invite = me.powerLevel >= powerLevels.invite; can.mute = me.powerLevel >= editPowerLevel; can.modifyLevel = me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel); can.modifyLevelMax = me.powerLevel; @@ -727,7 +728,7 @@ module.exports = withMatrixClient(React.createClass({ ); } - if (!member || !member.membership || member.membership === 'leave') { + if (this.state.can.invite && (!member || !member.membership || member.membership === 'leave')) { const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId(); const onInviteUserButton = async () => { try { diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index 8350001d01..5718276768 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -449,10 +449,23 @@ module.exports = React.createClass({ const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.roomId); let inviteButton; + if (room && room.getMyMembership() === 'join') { + // assume we can invite until proven false + let canInvite = true; + + const plEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + const me = room.getMember(cli.getUserId()); + if (plEvent && me) { + const content = plEvent.getContent(); + if (content && content.invite > me.powerLevel) { + canInvite = false; + } + } + const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); inviteButton = - + { _t('Invite to this room') } ; } diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js new file mode 100644 index 0000000000..d2f73b1dff --- /dev/null +++ b/src/editor/autocomplete.js @@ -0,0 +1,97 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {UserPillPart, RoomPillPart, PlainPart} from "./parts"; + +export default class AutocompleteWrapperModel { + constructor(updateCallback, getAutocompleterComponent, updateQuery) { + this._updateCallback = updateCallback; + this._getAutocompleterComponent = getAutocompleterComponent; + this._updateQuery = updateQuery; + this._query = null; + } + + onEscape(e) { + this._getAutocompleterComponent().onEscape(e); + this._updateCallback({ + replacePart: new PlainPart(this._queryPart.text), + caretOffset: this._queryOffset, + close: true, + }); + } + + onEnter() { + this._updateCallback({close: true}); + } + + onTab() { + //forceCompletion here? + } + + onUpArrow() { + this._getAutocompleterComponent().onUpArrow(); + } + + onDownArrow() { + this._getAutocompleterComponent().onDownArrow(); + } + + onPartUpdate(part, offset) { + // cache the typed value and caret here + // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text) + this._queryPart = part; + this._queryOffset = offset; + this._updateQuery(part.text); + } + + onComponentSelectionChange(completion) { + if (!completion) { + this._updateCallback({ + replacePart: this._queryPart, + caretOffset: this._queryOffset, + }); + } else { + this._updateCallback({ + replacePart: this._partForCompletion(completion), + }); + } + } + + onComponentConfirm(completion) { + this._updateCallback({ + replacePart: this._partForCompletion(completion), + close: true, + }); + } + + _partForCompletion(completion) { + const firstChr = completion.completionId && completion.completionId[0]; + switch (firstChr) { + case "@": { + const displayName = completion.completion; + const userId = completion.completionId; + return new UserPillPart(userId, displayName); + } + case "#": { + const displayAlias = completion.completionId; + return new RoomPillPart(displayAlias); + } + // also used for emoji completion + default: + return new PlainPart(completion.completion); + } + } +} diff --git a/src/editor/caret.js b/src/editor/caret.js new file mode 100644 index 0000000000..3a784aa8eb --- /dev/null +++ b/src/editor/caret.js @@ -0,0 +1,56 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export function setCaretPosition(editor, model, caretPosition) { + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + const {parts} = model; + let lineIndex = 0; + let nodeIndex = -1; + for (let i = 0; i <= caretPosition.index; ++i) { + const part = parts[i]; + if (part && part.type === "newline") { + lineIndex += 1; + nodeIndex = -1; + } else { + nodeIndex += 1; + } + } + let focusNode; + const lineNode = editor.childNodes[lineIndex]; + if (lineNode) { + if (lineNode.childNodes.length === 0 && caretPosition.offset === 0) { + focusNode = lineNode; + } else { + focusNode = lineNode.childNodes[nodeIndex]; + + if (focusNode && focusNode.nodeType === Node.ELEMENT_NODE) { + focusNode = focusNode.childNodes[0]; + } + } + } + // node not found, set caret at end + if (!focusNode) { + range.selectNodeContents(editor); + range.collapse(false); + } else { + // make sure we have a text node + range.setStart(focusNode, caretPosition.offset); + range.collapse(true); + } + sel.addRange(range); +} diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js new file mode 100644 index 0000000000..a7f28badb1 --- /dev/null +++ b/src/editor/deserialize.js @@ -0,0 +1,76 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MATRIXTO_URL_PATTERN } from '../linkify-matrix'; +import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts"; + +function parseHtmlMessage(html) { + const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); + // no nodes from parsing here should be inserted in the document, + // as scripts in event handlers, etc would be executed then. + // we're only taking text, so that is fine + const nodes = Array.from(new DOMParser().parseFromString(html, "text/html").body.childNodes); + const parts = nodes.map(n => { + switch (n.nodeType) { + case Node.TEXT_NODE: + return new PlainPart(n.nodeValue); + case Node.ELEMENT_NODE: + switch (n.nodeName) { + case "MX-REPLY": + return null; + case "A": { + const {href} = n; + const pillMatch = REGEX_MATRIXTO.exec(href) || []; + const resourceId = pillMatch[1]; // The room/user ID + const prefix = pillMatch[2]; // The first character of prefix + switch (prefix) { + case "@": return new UserPillPart(resourceId, n.textContent); + case "#": return new RoomPillPart(resourceId, n.textContent); + default: return new PlainPart(n.textContent); + } + } + case "BR": + return new NewlinePart("\n"); + default: + return new PlainPart(n.textContent); + } + default: + return null; + } + }).filter(p => !!p); + return parts; +} + +export function parseEvent(event) { + const content = event.getContent(); + if (content.format === "org.matrix.custom.html") { + return parseHtmlMessage(content.formatted_body || ""); + } else { + const body = content.body || ""; + const lines = body.split("\n"); + const parts = lines.reduce((parts, line, i) => { + const isLast = i === lines.length - 1; + const text = new PlainPart(line); + const newLine = !isLast && new NewlinePart("\n"); + if (newLine) { + return parts.concat(text, newLine); + } else { + return parts.concat(text); + } + }, []); + return parts; + } +} diff --git a/src/editor/diff.js b/src/editor/diff.js new file mode 100644 index 0000000000..6dc8b746e4 --- /dev/null +++ b/src/editor/diff.js @@ -0,0 +1,78 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +function firstDiff(a, b) { + const compareLen = Math.min(a.length, b.length); + for (let i = 0; i < compareLen; ++i) { + if (a[i] !== b[i]) { + return i; + } + } + return compareLen; +} + +function lastDiff(a, b) { + const compareLen = Math.min(a.length, b.length); + for (let i = 0; i < compareLen; ++i) { + if (a[a.length - i] !== b[b.length - i]) { + return i; + } + } + return compareLen; +} + +function diffStringsAtEnd(oldStr, newStr) { + const len = Math.min(oldStr.length, newStr.length); + const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len); + if (startInCommon && oldStr.length > newStr.length) { + return {removed: oldStr.substr(len), at: len}; + } else if (startInCommon && oldStr.length < newStr.length) { + return {added: newStr.substr(len), at: len}; + } else { + const commonStartLen = firstDiff(oldStr, newStr); + return { + removed: oldStr.substr(commonStartLen), + added: newStr.substr(commonStartLen), + at: commonStartLen, + }; + } +} + +export function diffDeletion(oldStr, newStr) { + if (oldStr === newStr) { + return {}; + } + const firstDiffIdx = firstDiff(oldStr, newStr); + const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1; + return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)}; +} + +export function diffInsertion(oldStr, newStr) { + const diff = diffDeletion(newStr, oldStr); + if (diff.removed) { + return {at: diff.at, added: diff.removed}; + } else { + return diff; + } +} + +export function diffAtCaret(oldValue, newValue, caretPosition) { + const diffLen = newValue.length - oldValue.length; + const caretPositionBeforeInput = caretPosition - diffLen; + const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput); + const newValueBeforeCaret = newValue.substr(0, caretPosition); + return diffStringsAtEnd(oldValueBeforeCaret, newValueBeforeCaret); +} diff --git a/src/editor/dom.js b/src/editor/dom.js new file mode 100644 index 0000000000..0899fd25b3 --- /dev/null +++ b/src/editor/dom.js @@ -0,0 +1,84 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +function walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback) { + let node = editor.firstChild; + while (node && node !== editor) { + enterNodeCallback(node); + if (node.firstChild) { + node = node.firstChild; + } else if (node.nextSibling) { + node = node.nextSibling; + } else { + while (!node.nextSibling && node !== editor) { + node = node.parentElement; + if (node !== editor) { + leaveNodeCallback(node); + } + } + if (node !== editor) { + node = node.nextSibling; + } + } + } +} + +export function getCaretOffsetAndText(editor, sel) { + let {focusNode} = sel; + const {focusOffset} = sel; + let caretOffset = focusOffset; + let foundCaret = false; + let text = ""; + + if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) { + focusNode = focusNode.childNodes[focusOffset - 1]; + caretOffset = focusNode.textContent.length; + } + + function enterNodeCallback(node) { + const nodeText = node.nodeType === Node.TEXT_NODE && node.nodeValue; + if (!foundCaret) { + if (node === focusNode) { + foundCaret = true; + } + } + if (nodeText) { + if (!foundCaret) { + caretOffset += nodeText.length; + } + text += nodeText; + } + } + + function leaveNodeCallback(node) { + // if this is not the last DIV (which are only used as line containers atm) + // we don't just check if there is a nextSibling because sometimes the caret ends up + // after the last DIV and it creates a newline if you type then, + // whereas you just want it to be appended to the current line + if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") { + text += "\n"; + if (!foundCaret) { + caretOffset += 1; + } + } + } + + walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback); + + const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; + const caret = {atNodeEnd, offset: caretOffset}; + return {caret, text}; +} diff --git a/src/editor/model.js b/src/editor/model.js new file mode 100644 index 0000000000..85dd425b0e --- /dev/null +++ b/src/editor/model.js @@ -0,0 +1,264 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {diffAtCaret, diffDeletion} from "./diff"; + +export default class EditorModel { + constructor(parts, partCreator, updateCallback) { + this._parts = parts; + this._partCreator = partCreator; + this._activePartIdx = null; + this._autoComplete = null; + this._autoCompletePartIdx = null; + this._updateCallback = updateCallback; + } + + _insertPart(index, part) { + this._parts.splice(index, 0, part); + if (this._activePartIdx >= index) { + ++this._activePartIdx; + } + if (this._autoCompletePartIdx >= index) { + ++this._autoCompletePartIdx; + } + } + + _removePart(index) { + this._parts.splice(index, 1); + if (this._activePartIdx >= index) { + --this._activePartIdx; + } + if (this._autoCompletePartIdx >= index) { + --this._autoCompletePartIdx; + } + } + + _replacePart(index, part) { + this._parts.splice(index, 1, part); + } + + get parts() { + return this._parts; + } + + get autoComplete() { + if (this._activePartIdx === this._autoCompletePartIdx) { + return this._autoComplete; + } + return null; + } + + serializeParts() { + return this._parts.map(({type, text}) => {return {type, text};}); + } + + _diff(newValue, inputType, caret) { + const previousValue = this.parts.reduce((text, p) => text + p.text, ""); + // can't use caret position with drag and drop + if (inputType === "deleteByDrag") { + return diffDeletion(previousValue, newValue); + } else { + return diffAtCaret(previousValue, newValue, caret.offset); + } + } + + update(newValue, inputType, caret) { + const diff = this._diff(newValue, inputType, caret); + const position = this._positionForOffset(diff.at, caret.atNodeEnd); + let removedOffsetDecrease = 0; + if (diff.removed) { + removedOffsetDecrease = this._removeText(position, diff.removed.length); + } + let addedLen = 0; + if (diff.added) { + addedLen = this._addText(position, diff.added); + } + this._mergeAdjacentParts(); + const caretOffset = diff.at - removedOffsetDecrease + addedLen; + const newPosition = this._positionForOffset(caretOffset, true); + this._setActivePart(newPosition); + this._updateCallback(newPosition); + } + + _setActivePart(pos) { + const {index} = pos; + const part = this._parts[index]; + if (part) { + if (index !== this._activePartIdx) { + this._activePartIdx = index; + if (this._activePartIdx !== this._autoCompletePartIdx) { + // else try to create one + const ac = part.createAutoComplete(this._onAutoComplete); + if (ac) { + // make sure that react picks up the difference between both acs + this._autoComplete = ac; + this._autoCompletePartIdx = index; + } + } + } + // not _autoComplete, only there if active part is autocomplete part + if (this.autoComplete) { + this.autoComplete.onPartUpdate(part, pos.offset); + } + } else { + this._activePartIdx = null; + this._autoComplete = null; + this._autoCompletePartIdx = null; + } + } + + _onAutoComplete = ({replacePart, caretOffset, close}) => { + let pos; + if (replacePart) { + this._replacePart(this._autoCompletePartIdx, replacePart); + let index = this._autoCompletePartIdx; + if (caretOffset === undefined) { + caretOffset = 0; + index += 1; + } + pos = new DocumentPosition(index, caretOffset); + } + if (close) { + this._autoComplete = null; + this._autoCompletePartIdx = null; + } + // rerender even if editor contents didn't change + // to make sure the MessageEditor checks + // model.autoComplete being empty and closes it + this._updateCallback(pos); + } + + _mergeAdjacentParts(docPos) { + let prevPart = this._parts[0]; + for (let i = 1; i < this._parts.length; ++i) { + let part = this._parts[i]; + const isEmpty = !part.text.length; + const isMerged = !isEmpty && prevPart.merge(part); + if (isEmpty || isMerged) { + // remove empty or merged part + part = prevPart; + this._removePart(i); + //repeat this index, as it's removed now + --i; + } + prevPart = part; + } + } + + /** + * removes `len` amount of characters at `pos`. + * @param {Object} pos + * @param {Number} len + * @return {Number} how many characters before pos were also removed, + * usually because of non-editable parts that can only be removed in their entirety. + */ + _removeText(pos, len) { + let {index, offset} = pos; + let removedOffsetDecrease = 0; + while (len > 0) { + // part might be undefined here + let part = this._parts[index]; + const amount = Math.min(len, part.text.length - offset); + if (part.canEdit) { + const replaceWith = part.remove(offset, amount); + if (typeof replaceWith === "string") { + this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); + } + part = this._parts[index]; + // remove empty part + if (!part.text.length) { + this._removePart(index); + } else { + index += 1; + } + } else { + removedOffsetDecrease += offset; + this._removePart(index); + } + len -= amount; + offset = 0; + } + return removedOffsetDecrease; + } + + /** + * inserts `str` into the model at `pos`. + * @param {Object} pos + * @param {string} str + * @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) { + let {index} = pos; + const {offset} = pos; + let addLen = str.length; + const part = this._parts[index]; + if (part) { + if (part.canEdit) { + if (part.insertAll(offset, str)) { + str = null; + } else { + const splitPart = part.split(offset); + index += 1; + this._insertPart(index, splitPart); + } + } else { + // not-editable, insert str after this part + addLen += part.text.length - offset; + index += 1; + } + } + while (str) { + const newPart = this._partCreator.createPartForInput(str); + str = newPart.appendUntilRejected(str); + this._insertPart(index, newPart); + index += 1; + } + return addLen; + } + + _positionForOffset(totalOffset, atPartEnd) { + let currentOffset = 0; + const index = this._parts.findIndex(part => { + const partLen = part.text.length; + if ( + (atPartEnd && (currentOffset + partLen) >= totalOffset) || + (!atPartEnd && (currentOffset + partLen) > totalOffset) + ) { + return true; + } + currentOffset += partLen; + return false; + }); + + return new DocumentPosition(index, totalOffset - currentOffset); + } +} + +class DocumentPosition { + constructor(index, offset) { + this._index = index; + this._offset = offset; + } + + get index() { + return this._index; + } + + get offset() { + return this._offset; + } +} diff --git a/src/editor/parts.js b/src/editor/parts.js new file mode 100644 index 0000000000..a20b857fee --- /dev/null +++ b/src/editor/parts.js @@ -0,0 +1,274 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import AutocompleteWrapperModel from "./autocomplete"; + +class BasePart { + constructor(text = "") { + this._text = text; + } + + acceptsInsertion(chr) { + return true; + } + + acceptsRemoval(position, chr) { + return true; + } + + merge(part) { + return false; + } + + split(offset) { + const splitText = this.text.substr(offset); + this._text = this.text.substr(0, offset); + return new PlainPart(splitText); + } + + // removes len chars, or returns the plain text this part should be replaced with + // if the part would become invalid if it removed everything. + remove(offset, len) { + // validate + const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len); + for (let i = offset; i < (len + offset); ++i) { + const chr = this.text.charAt(i); + if (!this.acceptsRemoval(i, chr)) { + return strWithRemoval; + } + } + this._text = strWithRemoval; + } + + // append str, returns the remaining string if a character was rejected. + appendUntilRejected(str) { + for (let i = 0; i < str.length; ++i) { + const chr = str.charAt(i); + if (!this.acceptsInsertion(chr)) { + this._text = this._text + str.substr(0, i); + return str.substr(i); + } + } + this._text = this._text + str; + } + + // 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. + insertAll(offset, str) { + for (let i = 0; i < str.length; ++i) { + const chr = str.charAt(i); + if (!this.acceptsInsertion(chr)) { + return false; + } + } + const beforeInsert = this._text.substr(0, offset); + const afterInsert = this._text.substr(offset); + this._text = beforeInsert + str + afterInsert; + return true; + } + + createAutoComplete() {} + + trim(len) { + const remaining = this._text.substr(len); + this._text = this._text.substr(0, len); + return remaining; + } + + get text() { + return this._text; + } + + get canEdit() { + return true; + } + + toString() { + return `${this.type}(${this.text})`; + } +} + +export class PlainPart extends BasePart { + acceptsInsertion(chr) { + return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; + } + + toDOMNode() { + return document.createTextNode(this.text); + } + + merge(part) { + if (part.type === this.type) { + this._text = this.text + part.text; + return true; + } + return false; + } + + get type() { + return "plain"; + } + + updateDOMNode(node) { + if (node.textContent !== this.text) { + // console.log("changing plain text from", node.textContent, "to", this.text); + node.textContent = this.text; + } + } + + canUpdateDOMNode(node) { + return node.nodeType === Node.TEXT_NODE; + } +} + +class PillPart extends BasePart { + constructor(resourceId, label) { + super(label); + this.resourceId = resourceId; + } + + acceptsInsertion(chr) { + return chr !== " "; + } + + acceptsRemoval(position, chr) { + return position !== 0; //if you remove initial # or @, pill should become plain + } + + toDOMNode() { + const container = document.createElement("span"); + container.className = this.type; + container.appendChild(document.createTextNode(this.text)); + return container; + } + + updateDOMNode(node) { + const textNode = node.childNodes[0]; + if (textNode.textContent !== this.text) { + // console.log("changing pill text from", textNode.textContent, "to", this.text); + textNode.textContent = this.text; + } + if (node.className !== this.type) { + // console.log("turning", node.className, "into", this.type); + node.className = this.type; + } + } + + canUpdateDOMNode(node) { + return node.nodeType === Node.ELEMENT_NODE && + node.nodeName === "SPAN" && + node.childNodes.length === 1 && + node.childNodes[0].nodeType === Node.TEXT_NODE; + } + + get canEdit() { + return false; + } +} + +export class NewlinePart extends BasePart { + acceptsInsertion(chr) { + return this.text.length === 0 && chr === "\n"; + } + + acceptsRemoval(position, chr) { + return true; + } + + toDOMNode() { + return document.createElement("br"); + } + + merge() { + return false; + } + + updateDOMNode() {} + + canUpdateDOMNode(node) { + return node.tagName === "BR"; + } + + get type() { + return "newline"; + } +} + +export class RoomPillPart extends PillPart { + constructor(displayAlias) { + super(displayAlias, displayAlias); + } + + get type() { + return "room-pill"; + } +} + +export class UserPillPart extends PillPart { + get type() { + return "user-pill"; + } +} + + +export class PillCandidatePart extends PlainPart { + constructor(text, autoCompleteCreator) { + super(text); + this._autoCompleteCreator = autoCompleteCreator; + } + + createAutoComplete(updateCallback) { + return this._autoCompleteCreator(updateCallback); + } + + acceptsInsertion(chr) { + return true; + } + + acceptsRemoval(position, chr) { + return true; + } + + get type() { + return "pill-candidate"; + } +} + +export class PartCreator { + constructor(getAutocompleterComponent, updateQuery) { + this._autoCompleteCreator = (updateCallback) => { + return new AutocompleteWrapperModel(updateCallback, getAutocompleterComponent, updateQuery); + }; + } + + createPartForInput(input) { + switch (input[0]) { + case "#": + case "@": + case ":": + return new PillCandidatePart("", this._autoCompleteCreator); + case "\n": + return new NewlinePart(); + default: + return new PlainPart(); + } + } + + createDefaultPart(text) { + return new PlainPart(text); + } +} + diff --git a/src/editor/render.js b/src/editor/render.js new file mode 100644 index 0000000000..abc5d42fa1 --- /dev/null +++ b/src/editor/render.js @@ -0,0 +1,81 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export function renderModel(editor, model) { + const lines = model.parts.reduce((lines, part) => { + if (part.type === "newline") { + lines.push([]); + } else { + const lastLine = lines[lines.length - 1]; + lastLine.push(part); + } + return lines; + }, [[]]); + // TODO: refactor this code, DRY it + lines.forEach((parts, i) => { + let lineContainer = editor.childNodes[i]; + while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) { + editor.removeChild(lineContainer); + lineContainer = editor.childNodes[i]; + } + if (!lineContainer) { + lineContainer = document.createElement("div"); + editor.appendChild(lineContainer); + } + + if (parts.length) { + parts.forEach((part, j) => { + let partNode = lineContainer.childNodes[j]; + while (partNode && !part.canUpdateDOMNode(partNode)) { + lineContainer.removeChild(partNode); + partNode = lineContainer.childNodes[j]; + } + if (partNode && part) { + part.updateDOMNode(partNode); + } else if (part) { + lineContainer.appendChild(part.toDOMNode()); + } + }); + + let surplusElementCount = Math.max(0, lineContainer.childNodes.length - parts.length); + while (surplusElementCount) { + lineContainer.removeChild(lineContainer.lastChild); + --surplusElementCount; + } + } else { + // empty div needs to have a BR in it to give it height + let foundBR = false; + let partNode = lineContainer.firstChild; + while (partNode) { + if (!foundBR && partNode.tagName === "BR") { + foundBR = true; + } else { + lineContainer.removeChild(partNode); + } + partNode = partNode.nextSibling; + } + if (!foundBR) { + lineContainer.appendChild(document.createElement("br")); + } + } + + let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length); + while (surplusElementCount) { + editor.removeChild(editor.lastChild); + --surplusElementCount; + } + }); +} diff --git a/src/editor/serialize.js b/src/editor/serialize.js new file mode 100644 index 0000000000..57cc79b375 --- /dev/null +++ b/src/editor/serialize.js @@ -0,0 +1,43 @@ +export function htmlSerialize(model) { + return model.parts.reduce((html, part) => { + switch (part.type) { + case "newline": + return html + "
"; + case "plain": + case "pill-candidate": + return html + part.text; + case "room-pill": + case "user-pill": + return html + `${part.text}`; + } + }, ""); +} + +export function textSerialize(model) { + return model.parts.reduce((text, part) => { + switch (part.type) { + case "newline": + return text + "\n"; + case "plain": + case "pill-candidate": + return text + part.text; + case "room-pill": + case "user-pill": + return text + `${part.resourceId}`; + } + }, ""); +} + +export function requiresHtml(model) { + return model.parts.some(part => { + switch (part.type) { + case "newline": + case "plain": + case "pill-candidate": + return false; + case "room-pill": + case "user-pill": + return true; + } + }); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 91829a80b4..58719e216a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -301,6 +301,7 @@ "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", "Custom Notification Sounds": "Custom Notification Sounds", + "Edit messages after they have been sent (refresh to apply changes)": "Edit messages after they have been sent (refresh to apply changes)", "React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "Use compact timeline layout": "Use compact timeline layout", @@ -904,6 +905,7 @@ "Agree or Disagree": "Agree or Disagree", "Like or Dislike": "Like or Dislike", "Reply": "Reply", + "Edit": "Edit", "Options": "Options", "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", @@ -980,7 +982,6 @@ "Reload widget": "Reload widget", "Popout widget": "Popout widget", "Picture": "Picture", - "Edit": "Edit", "Revoke widget access": "Revoke widget access", "Create new room": "Create new room", "Unblacklist": "Unblacklist", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 60740bae31..0efc10233f 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -119,6 +119,12 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_message_editing": { + isFeature: true, + displayName: _td("Edit messages after they have been sent (refresh to apply changes)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_notification_sounds": { isFeature: true, displayName: _td("Custom Notification Sounds"), diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js index 47c901cd9f..3a1e51c610 100644 --- a/src/shouldHideEvent.js +++ b/src/shouldHideEvent.js @@ -45,6 +45,7 @@ export default function shouldHideEvent(ev) { // Hide redacted events if (ev.isRedacted() && !isEnabled('showRedactions')) return true; + if (ev.isRelation("m.replace")) return true; const eventDiff = memberEventDiff(ev); diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js index 1c0931273b..49a120a470 100644 --- a/src/utils/StorageManager.js +++ b/src/utils/StorageManager.js @@ -50,11 +50,15 @@ export async function checkConsistency() { let dataInLocalStorage = false; let dataInCryptoStore = false; + let cryptoInited = false; let healthy = true; if (localStorage) { dataInLocalStorage = localStorage.length > 0; log(`Local storage contains data? ${dataInLocalStorage}`); + + cryptoInited = localStorage.getItem("mx_crypto_initialised"); + log(`Crypto initialised? ${cryptoInited}`); } else { healthy = false; error("Local storage cannot be used on this browser"); @@ -84,10 +88,11 @@ export async function checkConsistency() { track("Crypto store disabled"); } - if (dataInLocalStorage && !dataInCryptoStore) { + if (dataInLocalStorage && cryptoInited && !dataInCryptoStore) { healthy = false; error( - "Data exists in local storage but not in crypto store. " + + "Data exists in local storage and crypto is marked as initialised " + + " but no data found in crypto store. " + "IndexedDB storage has likely been evicted by the browser!", ); track("Crypto store evicted"); @@ -104,6 +109,7 @@ export async function checkConsistency() { return { dataInLocalStorage, dataInCryptoStore, + cryptoInited, healthy, }; } @@ -155,3 +161,17 @@ export function trackStores(client) { }); } } + +/** + * Sets whether crypto has ever been successfully + * initialised on this client. + * StorageManager uses this to determine whether indexeddb + * has been wiped by the browser: this flag is saved to localStorage + * and if it is true and not crypto data is found, an error is + * presented to the user. + * + * @param {bool} cryptoInited True if crypto has been set up + */ +export function setCryptoInitialised(cryptoInited) { + localStorage.setItem("mx_crypto_initialised", cryptoInited); +}