From 3cd9c152c24f7ee1e68721261ddb48ef21c88228 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 11 May 2019 19:45:24 +0100 Subject: [PATCH 01/86] Check permission to invite before showing invite buttons/disable them Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/views/rooms/_MemberList.scss | 5 +++++ src/components/views/rooms/MemberInfo.js | 3 ++- src/components/views/rooms/MemberList.js | 15 ++++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index cac97cb60d..c8d61c7cc5 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: grey; + cursor: not-allowed; +} + .mx_MemberList_invite span { background-image: url('$(res)/img/feather-customised/user-add.svg'); background-repeat: no-repeat; diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 35161dedf7..a8eb508497 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -589,6 +589,7 @@ module.exports = withMatrixClient(React.createClass({ can.kick = me.powerLevel >= 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') } ; } From 6599d605cda00e02f35cfce00b9bbfc8ad390d37 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 6 May 2019 17:41:15 +0200 Subject: [PATCH 02/86] wire up editor component (somewhat hacky) --- res/css/_components.scss | 1 + res/css/views/elements/_MessageEditor.scss | 42 ++++++++ res/css/views/messages/_MessageActionBar.scss | 4 + res/img/edit.svg | 97 +++++++++++++++++++ src/components/structures/MessagePanel.js | 5 + src/components/structures/TimelinePanel.js | 4 + .../views/elements/MessageEditor.js | 56 +++++++++++ .../views/messages/MessageActionBar.js | 13 +++ 8 files changed, 222 insertions(+) create mode 100644 res/css/views/elements/_MessageEditor.scss create mode 100644 res/img/edit.svg create mode 100644 src/components/views/elements/MessageEditor.js 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..57ae79da8c --- /dev/null +++ b/res/css/views/elements/_MessageEditor.scss @@ -0,0 +1,42 @@ +/* +Copyright 2019 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_MessageEditor { + border-radius: 4px; + background-color: #f3f8fd; + padding: 10px; + + .editor { + border-radius: 4px; + border: solid 1px #e9edf1; + background-color: #ffffff; + } + + .buttons { + display: flex; + flex-direction: column; + align-items: end; + padding: 5px 0; + + .mx_AccessibleButton { + background-color: $button-bg-color; + border-radius: 4px; + padding: 5px 40px; + color: $button-fg-color; + font-weight: 600; + } + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 419542036e..a0240c8171 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/img/edit.svg b/res/img/edit.svg new file mode 100644 index 0000000000..15b5ef9563 --- /dev/null +++ b/res/img/edit.svg @@ -0,0 +1,97 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 2037217710..adc78d7032 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; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 17a062be98..6529e92256 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -402,6 +402,9 @@ const TimelinePanel = React.createClass({ if (payload.action === 'ignore_state_changed') { this.forceUpdate(); } + if (payload.action === "edit_event") { + this.setState({editEvent: payload.event}); + } }, onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { @@ -1244,6 +1247,7 @@ const TimelinePanel = React.createClass({ tileShape={this.props.tileShape} resizeNotifier={this.props.resizeNotifier} getRelationsForEvent={this.getRelationsForEvent} + editEvent={this.state.editEvent} /> ); }, diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js new file mode 100644 index 0000000000..f57521dbe9 --- /dev/null +++ b/src/components/views/elements/MessageEditor.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. +*/ +import React from 'react'; +import sdk from '../../../index'; +import {_t} from '../../../languageHandler'; +import PropTypes from 'prop-types'; +import dis from '../../../dispatcher'; +import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; + +export default class MessageEditor extends React.Component { + static propTypes = { + // the latest event in this chain of replies + event: PropTypes.instanceOf(MatrixEvent).isRequired, + // called when the ReplyThread contents has changed, including EventTiles thereof + // onHeightChanged: PropTypes.func.isRequired, + }; + + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }; + + constructor(props, context) { + super(props, context); + this.state = {}; + this._onCancelClicked = this._onCancelClicked.bind(this); + } + + _onCancelClicked() { + dis.dispatch({action: "edit_event", event: null}); + } + + render() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return
+
+ {this.props.event.getContent().body} +
+
+ {_t("Cancel")} +
+
; + } +} diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 52630d7b0e..c4b8c441bd 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(); @@ -128,6 +135,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 +144,17 @@ export default class MessageActionBar extends React.PureComponent { title={_t("Reply")} onClick={this.onReplyClick} />; + editButton = ; } return
{agreeDimensionReactionButtons} {likeDimensionReactionButtons} {replyButton} + {editButton} Date: Mon, 6 May 2019 18:21:28 +0200 Subject: [PATCH 03/86] add converted prototype code --- src/editor/caret.js | 78 ++++++++++++++++++++ src/editor/diff.js | 78 ++++++++++++++++++++ src/editor/model.js | 169 ++++++++++++++++++++++++++++++++++++++++++ src/editor/parts.js | 174 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 499 insertions(+) create mode 100644 src/editor/caret.js create mode 100644 src/editor/diff.js create mode 100644 src/editor/model.js create mode 100644 src/editor/parts.js diff --git a/src/editor/caret.js b/src/editor/caret.js new file mode 100644 index 0000000000..3b803f35c3 --- /dev/null +++ b/src/editor/caret.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. +*/ + +export function getCaretPosition(editor) { + const sel = document.getSelection(); + const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; + let position = sel.focusOffset; + let node = sel.focusNode; + + // when deleting the last character of a node, + // the caret gets reported as being after the focusOffset-th node, + // with the focusNode being the editor + if (node === editor) { + let position = 0; + for (let i = 0; i < sel.focusOffset; ++i) { + position += editor.childNodes[i].textContent.length; + } + return {position, atNodeEnd: false}; + } + + // first make sure we're at the level of a direct child of editor + if (node.parentElement !== editor) { + // include all preceding siblings of the non-direct editor children + while (node.previousSibling) { + node = node.previousSibling; + position += node.textContent.length; + } + // then move up + // I guess technically there could be preceding text nodes in the parents here as well, + // but we're assuming there are no mixed text and element nodes + while (node.parentElement !== editor) { + node = node.parentElement; + } + } + // now include the text length of all preceding direct editor children + while (node.previousSibling) { + node = node.previousSibling; + position += node.textContent.length; + } + { + const {focusOffset, focusNode} = sel; + console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); + } + return {position, atNodeEnd}; +} + +export function setCaretPosition(editor, caretPosition) { + if (caretPosition) { + let focusNode = editor.childNodes[caretPosition.index]; + if (!focusNode) { + focusNode = editor; + } else { + // make sure we have a text node + if (focusNode.nodeType === Node.ELEMENT_NODE) { + focusNode = focusNode.childNodes[0]; + } + } + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(focusNode, caretPosition.offset); + range.collapse(true); + sel.addRange(range); + } +} 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/model.js b/src/editor/model.js new file mode 100644 index 0000000000..ffd2e17c01 --- /dev/null +++ b/src/editor/model.js @@ -0,0 +1,169 @@ +/* +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 {PlainPart, RoomPillPart, UserPillPart} from "./parts"; +import {diffAtCaret, diffDeletion} from "./diff"; + +export default class EditorModel { + constructor(parts = []) { + this._parts = parts; + this.actions = null; + this._previousValue = parts.reduce((text, p) => text + p.text, ""); + } + + _insertPart(index, part) { + this._parts.splice(index, 0, part); + } + + _removePart(index) { + this._parts.splice(index, 1); + } + + _replacePart(index, part) { + this._parts.splice(index, 1, part); + } + + get parts() { + return this._parts; + } + + _diff(newValue, inputType, caret) { + if (inputType === "deleteByDrag") { + return diffDeletion(this._previousValue, newValue); + } else { + return diffAtCaret(this._previousValue, newValue, caret.position); + } + } + + update(newValue, inputType, caret) { + const diff = this._diff(newValue, inputType, caret); + const position = this._positionForOffset(diff.at, caret.atNodeEnd); + console.log("update at", {position, diff}); + if (diff.removed) { + this._removeText(position, diff.removed.length); + } + if (diff.added) { + this._addText(position, diff.added); + } + this._mergeAdjacentParts(); + this._previousValue = newValue; + const caretOffset = diff.at + (diff.added ? diff.added.length : 0); + return this._positionForOffset(caretOffset, true); + } + + _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; + } + } + + _removeText(pos, len) { + let {index, offset} = pos; + while (len !== 0) { + // part might be undefined here + let part = this._parts[index]; + const amount = Math.min(len, part.text.length - offset); + const replaceWith = part.remove(offset, amount); + if (typeof replaceWith === "string") { + this._replacePart(index, new PlainPart(replaceWith)); + } + part = this._parts[index]; + // remove empty part + if (!part.text.length) { + this._removePart(index); + } else { + index += 1; + } + len -= amount; + offset = 0; + } + } + + _addText(pos, str, actions) { + let {index, offset} = pos; + const part = this._parts[index]; + if (part) { + if (part.insertAll(offset, str)) { + str = null; + } else { + // console.log("splitting", offset, [part.text]); + const splitPart = part.split(offset); + // console.log("splitted", [part.text, splitPart.text]); + index += 1; + this._insertPart(index, splitPart); + } + } + while (str) { + let newPart; + switch (str[0]) { + case "#": + newPart = new RoomPillPart(); + break; + case "@": + newPart = new UserPillPart(); + break; + default: + newPart = new PlainPart(); + } + str = newPart.appendUntilRejected(str); + this._insertPart(index, newPart); + index += 1; + } + } + + _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..be5326d98f --- /dev/null +++ b/src/editor/parts.js @@ -0,0 +1,174 @@ +/* +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. +*/ + +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. + + // TODO: this should probably return the Part and caret position within this should be replaced with + 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; + } + + + trim(len) { + const remaining = this._text.substr(len); + this._text = this._text.substr(0, len); + return remaining; + } + + get text() { + return this._text; + } +} + +export class PlainPart extends BasePart { + acceptsInsertion(chr) { + return chr !== "@" && chr !== "#"; + } + + 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 { + 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; + } +} + +export class RoomPillPart extends PillPart { + get type() { + return "room-pill"; + } +} + +export class UserPillPart extends PillPart { + get type() { + return "user-pill"; + } +} From 76bb56a2bf4b9a9da482480349c29fbe6680f52b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 May 2019 16:27:09 +0200 Subject: [PATCH 04/86] initial hookup editor code with react component --- res/css/views/elements/_MessageEditor.scss | 26 +++++++++++++- .../views/elements/MessageEditor.js | 35 +++++++++++++++++-- src/editor/caret.js | 24 ++++++------- src/editor/model.js | 8 +++-- 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 57ae79da8c..eefb45afe5 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -17,12 +17,28 @@ limitations under the License. .mx_MessageEditor { border-radius: 4px; background-color: #f3f8fd; - padding: 10px; + padding: 11px 13px 7px 56px; .editor { border-radius: 4px; border: solid 1px #e9edf1; background-color: #ffffff; + padding: 10px; + + span { + display: inline-block; + padding: 0 5px; + border-radius: 4px; + color: white; + } + + span.user-pill { + background: red; + } + + span.room-pill { + background: green; + } } .buttons { @@ -39,4 +55,12 @@ limitations under the License. font-weight: 600; } } + + .model { + background: lightgrey; + padding: 5px; + display: block; + white-space: pre; + font-size: 12px; + } } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index f57521dbe9..026f92238b 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -18,6 +18,9 @@ import sdk from '../../../index'; import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; +import EditorModel from '../../../editor/model'; +import {PlainPart} from '../../../editor/parts'; +import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -34,8 +37,24 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - this.state = {}; + const body = this.props.event.getContent().body; + this.model = new EditorModel(); + this.model.update(body, undefined, {offset: body.length}); + this.state = { + parts: this.model.serializeParts(), + }; this._onCancelClicked = this._onCancelClicked.bind(this); + this._onInput = this._onInput.bind(this); + } + + _onInput(event) { + const editor = event.target; + const caretOffset = getCaretOffset(editor); + const caret = this.model.update(editor.textContent, event.inputType, caretOffset); + const parts = this.model.serializeParts(); + this.setState({parts}, () => { + setCaretPosition(editor, caret); + }); } _onCancelClicked() { @@ -43,14 +62,24 @@ export default class MessageEditor extends React.Component { } render() { + const parts = this.state.parts.map((p, i) => { + const key = `${i}-${p.type}`; + switch (p.type) { + case "plain": return p.text; + case "room-pill": return ({p.text}); + case "user-pill": return ({p.text}); + } + }); + const modelOutput = JSON.stringify(this.state.parts, undefined, 2); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return
-
- {this.props.event.getContent().body} +
+ {parts}
{_t("Cancel")}
+ {modelOutput}
; } } diff --git a/src/editor/caret.js b/src/editor/caret.js index 3b803f35c3..a252ebddc6 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function getCaretPosition(editor) { +export function getCaretOffset(editor) { const sel = document.getSelection(); const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; - let position = sel.focusOffset; + let offset = sel.focusOffset; let node = sel.focusNode; // when deleting the last character of a node, // the caret gets reported as being after the focusOffset-th node, // with the focusNode being the editor if (node === editor) { - let position = 0; + let offset = 0; for (let i = 0; i < sel.focusOffset; ++i) { - position += editor.childNodes[i].textContent.length; + offset += editor.childNodes[i].textContent.length; } - return {position, atNodeEnd: false}; + return {offset, atNodeEnd: false}; } // first make sure we're at the level of a direct child of editor @@ -36,7 +36,7 @@ export function getCaretPosition(editor) { // include all preceding siblings of the non-direct editor children while (node.previousSibling) { node = node.previousSibling; - position += node.textContent.length; + offset += node.textContent.length; } // then move up // I guess technically there could be preceding text nodes in the parents here as well, @@ -48,13 +48,13 @@ export function getCaretPosition(editor) { // now include the text length of all preceding direct editor children while (node.previousSibling) { node = node.previousSibling; - position += node.textContent.length; + offset += node.textContent.length; } - { - const {focusOffset, focusNode} = sel; - console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); - } - return {position, atNodeEnd}; + // { + // const {focusOffset, focusNode} = sel; + // console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); + // } + return {offset, atNodeEnd}; } export function setCaretPosition(editor, caretPosition) { diff --git a/src/editor/model.js b/src/editor/model.js index ffd2e17c01..b3d6682f79 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -40,18 +40,22 @@ export default class EditorModel { return this._parts; } + serializeParts() { + return this._parts.map(({type, text}) => {return {type, text};}); + } + _diff(newValue, inputType, caret) { if (inputType === "deleteByDrag") { return diffDeletion(this._previousValue, newValue); } else { - return diffAtCaret(this._previousValue, newValue, caret.position); + return diffAtCaret(this._previousValue, newValue, caret.offset); } } update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - console.log("update at", {position, diff}); + console.log("update at", {position, diff, newValue, prevValue: this._previousValue}); if (diff.removed) { this._removeText(position, diff.removed.length); } From 6be6492cd285c8627305ccdaba85881bb4253cbd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 May 2019 17:31:37 +0200 Subject: [PATCH 05/86] initial parsing of pills for editor --- .../views/elements/MessageEditor.js | 6 +- src/editor/parse-event.js | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 src/editor/parse-event.js diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 026f92238b..104e805c05 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -19,8 +19,8 @@ import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; -import {PlainPart} from '../../../editor/parts'; import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; +import parseEvent from '../../../editor/parse-event'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -37,9 +37,7 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - const body = this.props.event.getContent().body; - this.model = new EditorModel(); - this.model.update(body, undefined, {offset: body.length}); + this.model = new EditorModel(parseEvent(this.props.event)); this.state = { parts: this.model.serializeParts(), }; diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js new file mode 100644 index 0000000000..711529defe --- /dev/null +++ b/src/editor/parse-event.js @@ -0,0 +1,57 @@ +/* +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 } from "./parts"; + +function parseHtmlMessage(html) { + const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); + 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); + case "#": return new RoomPillPart(resourceId); + default: return new PlainPart(n.innerText); + } + } + default: + return new PlainPart(n.innerText); + } + } + }).filter(p => !!p); + return parts; +} + +export default function parseEvent(event) { + const content = event.getContent(); + if (content.format === "org.matrix.custom.html") { + return parseHtmlMessage(content.formatted_body); + } else { + return [new PlainPart(content.body)]; + } +} From 8f0074f824ecd198072c81aceeadec5ca06dd5b1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:12:47 +0200 Subject: [PATCH 06/86] ignore react comment nodes when locating/setting caret --- src/editor/caret.js | 67 +++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/editor/caret.js b/src/editor/caret.js index a252ebddc6..1d437dd083 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -26,7 +26,10 @@ export function getCaretOffset(editor) { if (node === editor) { let offset = 0; for (let i = 0; i < sel.focusOffset; ++i) { - offset += editor.childNodes[i].textContent.length; + const node = editor.childNodes[i]; + if (isVisibleNode(node)) { + offset += node.textContent.length; + } } return {offset, atNodeEnd: false}; } @@ -36,7 +39,9 @@ export function getCaretOffset(editor) { // include all preceding siblings of the non-direct editor children while (node.previousSibling) { node = node.previousSibling; - offset += node.textContent.length; + if (isVisibleNode(node)) { + offset += node.textContent.length; + } } // then move up // I guess technically there could be preceding text nodes in the parents here as well, @@ -48,7 +53,9 @@ export function getCaretOffset(editor) { // now include the text length of all preceding direct editor children while (node.previousSibling) { node = node.previousSibling; - offset += node.textContent.length; + if (isVisibleNode(node)) { + offset += node.textContent.length; + } } // { // const {focusOffset, focusNode} = sel; @@ -57,22 +64,40 @@ export function getCaretOffset(editor) { return {offset, atNodeEnd}; } -export function setCaretPosition(editor, caretPosition) { - if (caretPosition) { - let focusNode = editor.childNodes[caretPosition.index]; - if (!focusNode) { - focusNode = editor; - } else { - // make sure we have a text node - if (focusNode.nodeType === Node.ELEMENT_NODE) { - focusNode = focusNode.childNodes[0]; - } - } - const sel = document.getSelection(); - sel.removeAllRanges(); - const range = document.createRange(); - range.setStart(focusNode, caretPosition.offset); - range.collapse(true); - sel.addRange(range); - } +function isVisibleNode(node) { + return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE; +} + +function untilVisibleNode(node) { + // need to ignore comment nodes that react uses + while (node && !isVisibleNode(node)) { + node = node.nextSibling; + } + return node; +} + +export function setCaretPosition(editor, caretPosition) { + let node = untilVisibleNode(editor.firstChild); + if (!node) { + node = editor; + } else { + let {index} = caretPosition; + while (node && index) { + node = untilVisibleNode(node.nextSibling); + --index; + } + if (!node) { + node = editor; + } else if (node.nodeType === Node.ELEMENT_NODE) { + // make sure we have a text node + node = node.childNodes[0]; + } + } + console.log("setting caret", caretPosition, node); + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(node, caretPosition.offset); + range.collapse(true); + sel.addRange(range); } From ebdb9fcb9c8c0b3eb6cba8ab9cd63e114bcfef39 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:13:13 +0200 Subject: [PATCH 07/86] don't collapse whitespace in editor --- res/css/views/elements/_MessageEditor.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index eefb45afe5..b3b73e88e3 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -24,6 +24,7 @@ limitations under the License. border: solid 1px #e9edf1; background-color: #ffffff; padding: 10px; + white-space: pre; span { display: inline-block; From 0f38753dba3848a9a190805103795710fbb15f7a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:13:36 +0200 Subject: [PATCH 08/86] some comments --- src/editor/model.js | 1 + src/editor/parse-event.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/editor/model.js b/src/editor/model.js index b3d6682f79..e4170d88dd 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -45,6 +45,7 @@ export default class EditorModel { } _diff(newValue, inputType, caret) { + // can't use caret position with drag and drop if (inputType === "deleteByDrag") { return diffDeletion(this._previousValue, newValue); } else { diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index 711529defe..b4dc22ee4e 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -19,6 +19,9 @@ import { PlainPart, UserPillPart, RoomPillPart } 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) { From 85adc8953f68dd99abca86197f488f3d8872e911 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:14:08 +0200 Subject: [PATCH 09/86] remove logging --- src/editor/caret.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/editor/caret.js b/src/editor/caret.js index 1d437dd083..a8fe3ddc68 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -93,7 +93,6 @@ export function setCaretPosition(editor, caretPosition) { node = node.childNodes[0]; } } - console.log("setting caret", caretPosition, node); const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); From a2f1f49972dddf268dd24f9d8c3fb8753a4ffbd6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 14:31:43 +0200 Subject: [PATCH 10/86] update the DOM manually as opposed through react rendering react messes up the DOM sometimes because of, I assume, not being aware of the changes to the real DOM by contenteditable. --- .../views/elements/MessageEditor.js | 73 ++++++++++++------- src/editor/render.js | 52 +++++++++++++ 2 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 src/editor/render.js diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 104e805c05..be44d4ffa8 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -21,6 +21,7 @@ import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; import parseEvent from '../../../editor/parse-event'; +import {renderModel, rerenderModel} from '../../../editor/render'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -38,46 +39,66 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); this.model = new EditorModel(parseEvent(this.props.event)); - this.state = { - parts: this.model.serializeParts(), - }; - this._onCancelClicked = this._onCancelClicked.bind(this); - this._onInput = this._onInput.bind(this); + this.state = {}; + this._editorRef = null; } - _onInput(event) { - const editor = event.target; - const caretOffset = getCaretOffset(editor); - const caret = this.model.update(editor.textContent, event.inputType, caretOffset); - const parts = this.model.serializeParts(); - this.setState({parts}, () => { - setCaretPosition(editor, caret); - }); + _onInput = (event) => { + const caretOffset = getCaretOffset(this._editorRef); + const caret = this.model.update(this._editorRef.textContent, event.inputType, caretOffset); + // const parts = this.model.serializeParts(); + const shouldRerender = event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; + if (shouldRerender) { + rerenderModel(this._editorRef, this.model); + } else { + renderModel(this._editorRef, this.model); + } + setCaretPosition(this._editorRef, caret); + + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } - _onCancelClicked() { + _onCancelClicked = () => { dis.dispatch({action: "edit_event", event: null}); } + _collectEditorRef = (ref) => { + this._editorRef = ref; + } + + componentDidMount() { + const editor = this._editorRef; + rerenderModel(editor, this.model); + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + } + render() { - const parts = this.state.parts.map((p, i) => { - const key = `${i}-${p.type}`; - switch (p.type) { - case "plain": return p.text; - case "room-pill": return ({p.text}); - case "user-pill": return ({p.text}); - } - }); - const modelOutput = JSON.stringify(this.state.parts, undefined, 2); + // const parts = this.state.parts.map((p, i) => { + // const key = `${i}-${p.type}`; + // switch (p.type) { + // case "plain": return p.text; + // case "room-pill": return ({p.text}); + // case "user-pill": return ({p.text}); + // } + // }); + // const modelOutput = JSON.stringify(this.state.parts, undefined, 2); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return
-
- {parts} +
{_t("Cancel")}
- {modelOutput} +
; } } diff --git a/src/editor/render.js b/src/editor/render.js new file mode 100644 index 0000000000..f7eb5d5c2b --- /dev/null +++ b/src/editor/render.js @@ -0,0 +1,52 @@ +/* +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 rerenderModel(editor, model) { + while (editor.firstChild) { + editor.removeChild(editor.firstChild); + } + for (const part of model.parts) { + editor.appendChild(part.toDOMNode()); + } +} + +export function renderModel(editor, model) { + // remove unwanted nodes, like
s + for (let i = 0; i < model.parts.length; ++i) { + const part = model.parts[i]; + let node = editor.childNodes[i]; + while (node && !part.canUpdateDOMNode(node)) { + editor.removeChild(node); + node = editor.childNodes[i]; + } + } + for (let i = 0; i < model.parts.length; ++i) { + const part = model.parts[i]; + const node = editor.childNodes[i]; + if (node && part) { + part.updateDOMNode(node); + } else if (part) { + editor.appendChild(part.toDOMNode()); + } else if (node) { + editor.removeChild(node); + } + } + let surplusElementCount = Math.max(0, editor.childNodes.length - model.parts.length); + while (surplusElementCount) { + editor.removeChild(editor.lastChild); + --surplusElementCount; + } +} From a765fdf98abc6100fa39570bacfc53f2082cdb4a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 14:57:09 +0200 Subject: [PATCH 11/86] run autocomplete after mounting componentWillReceiveProps doesn't run after mount, and is deprecated as well. Update state after both on componentDidMount and componentDidUpdate --- src/components/views/rooms/Autocomplete.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index e75456ea50..253e2b411a 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) { From 7507d0d7e15ef807203a0a48b346cec457122bff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 14:58:13 +0200 Subject: [PATCH 12/86] complete proptypes --- src/components/views/rooms/Autocomplete.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 253e2b411a..a19a4eaad0 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -303,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), }; From 1330b438d640d3ad05110cb03f3164230fdbf0e8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 14:59:52 +0200 Subject: [PATCH 13/86] initial support for auto complete in model and parts also move part creation out of model, into partcreator, which can then also contain dependencies for creating the auto completer. --- src/editor/model.js | 83 ++++++++++++++++++++++++++++++++++++--------- src/editor/parts.js | 70 ++++++++++++++++++++++++++++++++++---- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index e4170d88dd..2bf78026b3 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -14,22 +14,36 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {PlainPart, RoomPillPart, UserPillPart} from "./parts"; import {diffAtCaret, diffDeletion} from "./diff"; export default class EditorModel { - constructor(parts = []) { + constructor(parts, partCreator) { this._parts = parts; - this.actions = null; + this._partCreator = partCreator; this._previousValue = parts.reduce((text, p) => text + p.text, ""); + this._activePartIdx = null; + this._autoComplete = null; + this._autoCompletePartIdx = null; } _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) { @@ -40,11 +54,21 @@ export default class EditorModel { 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) { + // handle deleteContentForward (Delete key) + // and deleteContentBackward (Backspace) + // can't use caret position with drag and drop if (inputType === "deleteByDrag") { return diffDeletion(this._previousValue, newValue); @@ -66,9 +90,38 @@ export default class EditorModel { this._mergeAdjacentParts(); this._previousValue = newValue; const caretOffset = diff.at + (diff.added ? diff.added.length : 0); - return this._positionForOffset(caretOffset, true); + const newPosition = this._positionForOffset(caretOffset, true); + this._setActivePart(newPosition); + return newPosition; } + _setActivePart(pos) { + const {index} = pos; + const part = this._parts[index]; + if (pos.index !== this._activePartIdx) { + this._activePartIdx = index; + // if there is a hidden autocomplete for this part, show it again + 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; + } + } + } + if (this._autoComplete) { + this._autoComplete.onPartUpdate(part, pos.offset); + } + } + + /* + updateCaret(caret) { + // update active part here as well, hiding/showing autocomplete if needed + } + */ + _mergeAdjacentParts(docPos) { let prevPart = this._parts[0]; for (let i = 1; i < this._parts.length; ++i) { @@ -94,7 +147,7 @@ export default class EditorModel { const amount = Math.min(len, part.text.length - offset); const replaceWith = part.remove(offset, amount); if (typeof replaceWith === "string") { - this._replacePart(index, new PlainPart(replaceWith)); + this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); } part = this._parts[index]; // remove empty part @@ -123,17 +176,7 @@ export default class EditorModel { } } while (str) { - let newPart; - switch (str[0]) { - case "#": - newPart = new RoomPillPart(); - break; - case "@": - newPart = new UserPillPart(); - break; - default: - newPart = new PlainPart(); - } + const newPart = this._partCreator.createPartForInput(str); str = newPart.appendUntilRejected(str); this._insertPart(index, newPart); index += 1; @@ -156,6 +199,14 @@ export default class EditorModel { return new DocumentPosition(index, totalOffset - currentOffset); } + + _onAutoComplete = ({replacePart, replaceCaret, close}) => { + this._replacePart(this._autoCompletePartIdx, replacePart); + if (close) { + this._autoComplete = null; + this._autoCompletePartIdx = null; + } + } } class DocumentPosition { diff --git a/src/editor/parts.js b/src/editor/parts.js index be5326d98f..e060be716e 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -14,6 +14,8 @@ 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; @@ -39,12 +41,10 @@ class BasePart { // removes len chars, or returns the plain text this part should be replaced with // if the part would become invalid if it removed everything. - - // TODO: this should probably return the Part and caret position within this should be replaced with remove(offset, len) { // validate const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len); - for(let i = offset; i < (len + offset); ++i) { + for (let i = offset; i < (len + offset); ++i) { const chr = this.text.charAt(i); if (!this.acceptsRemoval(i, chr)) { return strWithRemoval; @@ -55,7 +55,7 @@ class BasePart { // append str, returns the remaining string if a character was rejected. appendUntilRejected(str) { - for(let i = 0; i < str.length; ++i) { + 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); @@ -68,7 +68,7 @@ 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. insertAll(offset, str) { - for(let i = 0; i < str.length; ++i) { + for (let i = 0; i < str.length; ++i) { const chr = str.charAt(i); if (!this.acceptsInsertion(chr)) { return false; @@ -80,6 +80,7 @@ class BasePart { return true; } + createAutoComplete() {} trim(len) { const remaining = this._text.substr(len); @@ -94,7 +95,7 @@ class BasePart { export class PlainPart extends BasePart { acceptsInsertion(chr) { - return chr !== "@" && chr !== "#"; + return chr !== "@" && chr !== "#" && chr !== ":"; } toDOMNode() { @@ -126,6 +127,11 @@ export class PlainPart extends BasePart { } class PillPart extends BasePart { + constructor(resourceId, label) { + super(label); + this.resourceId = resourceId; + } + acceptsInsertion(chr) { return chr !== " "; } @@ -162,6 +168,10 @@ class PillPart extends BasePart { } export class RoomPillPart extends PillPart { + constructor(displayAlias) { + super(displayAlias, displayAlias); + } + get type() { return "room-pill"; } @@ -172,3 +182,51 @@ export class UserPillPart extends PillPart { 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); + default: + return new PlainPart(); + } + } + + createDefaultPart(text) { + return new PlainPart(text); + } +} + From 317e88bef2472f25cceb8cc913c86dda87f2c681 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:00:48 +0200 Subject: [PATCH 14/86] initial hacky hookup of Autocomplete menu in MessageEditor --- res/css/views/elements/_MessageEditor.scss | 5 ++ .../views/elements/MessageEditor.js | 87 +++++++++++++++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index b3b73e88e3..487a3a0b06 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -64,4 +64,9 @@ limitations under the License. white-space: pre; font-size: 12px; } + + .mx_MessageEditor_AutoCompleteWrapper { + position: relative; + height: 0; + } } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index be44d4ffa8..ff0a705111 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -21,6 +21,9 @@ import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; import parseEvent from '../../../editor/parse-event'; +import Autocomplete from '../rooms/Autocomplete'; +// import AutocompleteModel from '../../../editor/autocomplete'; +import {PartCreator} from '../../../editor/parts'; import {renderModel, rerenderModel} from '../../../editor/render'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; @@ -28,7 +31,6 @@ export default class MessageEditor extends React.Component { static propTypes = { // the latest event in this chain of replies event: PropTypes.instanceOf(MatrixEvent).isRequired, - // called when the ReplyThread contents has changed, including EventTiles thereof // onHeightChanged: PropTypes.func.isRequired, }; @@ -38,9 +40,18 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - this.model = new EditorModel(parseEvent(this.props.event)); - this.state = {}; + const partCreator = new PartCreator( + () => this._autocompleteRef, + query => this.setState({query}), + ); + this.model = new EditorModel(parseEvent(this.props.event), partCreator); + const room = this.context.matrixClient.getRoom(this.props.event.getRoomId()); + this.state = { + autoComplete: null, + room, + }; this._editorRef = null; + this._autocompleteRef = null; } _onInput = (event) => { @@ -55,8 +66,33 @@ export default class MessageEditor extends React.Component { } setCaretPosition(this._editorRef, caret); - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + this.setState({autoComplete: this.model.autoComplete}); + this._updateModelOutput(); + } + + _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 = () => { @@ -67,11 +103,31 @@ export default class MessageEditor extends React.Component { this._editorRef = ref; } + _collectAutocompleteRef = (ref) => { + this._autocompleteRef = ref; + } + + _onAutoCompleteConfirm = (completion) => { + this.model.autoComplete.onComponentConfirm(completion); + renderModel(this._editorRef, this.model); + this._updateModelOutput(); + } + + _onAutoCompleteSelectionChange = (completion) => { + this.model.autoComplete.onComponentSelectionChange(completion); + renderModel(this._editorRef, this.model); + this._updateModelOutput(); + } + + _updateModelOutput() { + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + } + componentDidMount() { const editor = this._editorRef; rerenderModel(editor, this.model); - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + this._updateModelOutput(); } render() { @@ -84,14 +140,31 @@ export default class MessageEditor extends React.Component { // } // }); // const modelOutput = JSON.stringify(this.state.parts, undefined, 2); + let autoComplete; + if (this.state.autoComplete) { + const query = this.state.query; + const queryLen = query.length; + autoComplete =
+ +
; + } const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return
+ { autoComplete }
From bb73521f0c8fb5999c421218cf90dfb7124845bf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:01:17 +0200 Subject: [PATCH 15/86] prefer textContent over innerText as it's faster and transforms the text less --- src/editor/parse-event.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index b4dc22ee4e..51b96a58e7 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -37,14 +37,16 @@ function parseHtmlMessage(html) { const resourceId = pillMatch[1]; // The room/user ID const prefix = pillMatch[2]; // The first character of prefix switch (prefix) { - case "@": return new UserPillPart(resourceId); - case "#": return new RoomPillPart(resourceId); - default: return new PlainPart(n.innerText); + case "@": return new UserPillPart(resourceId, n.textContent); + case "#": return new RoomPillPart(resourceId, n.textContent); + default: return new PlainPart(n.textContent); } } default: - return new PlainPart(n.innerText); + return new PlainPart(n.textContent); } + default: + return null; } }).filter(p => !!p); return parts; From 4bb8b799427f1f2a4688d382792999593b28f841 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:01:47 +0200 Subject: [PATCH 16/86] initial auto complete wrapper, make existing autocompleter work w/ model --- src/editor/autocomplete.js | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/editor/autocomplete.js diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js new file mode 100644 index 0000000000..18b7cdee57 --- /dev/null +++ b/src/editor/autocomplete.js @@ -0,0 +1,93 @@ +/* +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); + } + + onEnter() { + + } + + onTab() { + //forceCompletion here? + } + + onUpArrow() { + console.log("onUpArrow"); + this._getAutocompleterComponent().onUpArrow(); + } + + onDownArrow() { + console.log("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, + replaceCaret: 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); + } + default: + return new PlainPart(completion.completion); + } + } +} From fc87a27c5d25cb7c2fe12fd9694ce0cb90200c08 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:02:43 +0200 Subject: [PATCH 17/86] make editor nicer --- res/css/views/elements/_MessageEditor.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 487a3a0b06..d1d06389a5 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -24,7 +24,9 @@ limitations under the License. border: solid 1px #e9edf1; background-color: #ffffff; padding: 10px; - white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; + outline: none; span { display: inline-block; From 5e6367ab5718ee8f17f0e12ee4de2f61853a6e3f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:42:10 +0200 Subject: [PATCH 18/86] basic support for non-editable parts e.g. pills, they get deleted when any character of them is removed later on we also shouldn't allow the caret to be set inside of them --- src/editor/model.js | 41 ++++++++++++++++++++++------------------- src/editor/parts.js | 8 ++++++++ 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 2bf78026b3..6834d31c4d 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -20,7 +20,6 @@ export default class EditorModel { constructor(parts, partCreator) { this._parts = parts; this._partCreator = partCreator; - this._previousValue = parts.reduce((text, p) => text + p.text, ""); this._activePartIdx = null; this._autoComplete = null; this._autoCompletePartIdx = null; @@ -68,19 +67,19 @@ export default class EditorModel { _diff(newValue, inputType, caret) { // handle deleteContentForward (Delete key) // and deleteContentBackward (Backspace) - + const previousValue = this.parts.reduce((text, p) => text + p.text, ""); // can't use caret position with drag and drop if (inputType === "deleteByDrag") { - return diffDeletion(this._previousValue, newValue); + return diffDeletion(previousValue, newValue); } else { - return diffAtCaret(this._previousValue, newValue, caret.offset); + 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); - console.log("update at", {position, diff, newValue, prevValue: this._previousValue}); + console.log("update at", {position, diff, newValue, prevValue: this.parts.reduce((text, p) => text + p.text, "")}); if (diff.removed) { this._removeText(position, diff.removed.length); } @@ -88,7 +87,6 @@ export default class EditorModel { this._addText(position, diff.added); } this._mergeAdjacentParts(); - this._previousValue = newValue; const caretOffset = diff.at + (diff.added ? diff.added.length : 0); const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); @@ -141,23 +139,28 @@ export default class EditorModel { _removeText(pos, len) { let {index, offset} = pos; - while (len !== 0) { + while (len > 0) { // part might be undefined here let part = this._parts[index]; - const amount = Math.min(len, part.text.length - offset); - 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); + if (part.canEdit) { + const amount = Math.min(len, part.text.length - offset); + 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; + } + len -= amount; + offset = 0; } else { - index += 1; + len = part.length - (offset + len); + this._removePart(index); } - len -= amount; - offset = 0; } } diff --git a/src/editor/parts.js b/src/editor/parts.js index e060be716e..619bdfba9b 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -91,6 +91,10 @@ class BasePart { get text() { return this._text; } + + get canEdit() { + return true; + } } export class PlainPart extends BasePart { @@ -165,6 +169,10 @@ class PillPart extends BasePart { node.childNodes.length === 1 && node.childNodes[0].nodeType === Node.TEXT_NODE; } + + get canEdit() { + return false; + } } export class RoomPillPart extends PillPart { From aa1b4bb91e50b097392ce3bc2d957f577e926a45 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:43:10 +0200 Subject: [PATCH 19/86] keep auto complete code close to each other --- src/editor/model.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 6834d31c4d..fb1e4801ba 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -114,6 +114,14 @@ export default class EditorModel { } } + _onAutoComplete = ({replacePart, replaceCaret, close}) => { + this._replacePart(this._autoCompletePartIdx, replacePart); + if (close) { + this._autoComplete = null; + this._autoCompletePartIdx = null; + } + } + /* updateCaret(caret) { // update active part here as well, hiding/showing autocomplete if needed @@ -202,14 +210,6 @@ export default class EditorModel { return new DocumentPosition(index, totalOffset - currentOffset); } - - _onAutoComplete = ({replacePart, replaceCaret, close}) => { - this._replacePart(this._autoCompletePartIdx, replacePart); - if (close) { - this._autoComplete = null; - this._autoCompletePartIdx = null; - } - } } class DocumentPosition { From 64b171198c87b41749d0e742d969266d1e01ec04 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:58:32 +0200 Subject: [PATCH 20/86] rerender through callback instead of after modifying model this way rendering is centralized and we can better rerender from interaction in the autocompleter (we didn't have access to caret before) --- .../views/elements/MessageEditor.js | 38 +++++++++---------- src/editor/model.js | 7 +++- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index ff0a705111..c863f8baf4 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -44,7 +44,11 @@ export default class MessageEditor extends React.Component { () => this._autocompleteRef, query => this.setState({query}), ); - this.model = new EditorModel(parseEvent(this.props.event), partCreator); + 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, @@ -54,20 +58,25 @@ export default class MessageEditor extends React.Component { this._autocompleteRef = null; } - _onInput = (event) => { - const caretOffset = getCaretOffset(this._editorRef); - const caret = this.model.update(this._editorRef.textContent, event.inputType, caretOffset); - // const parts = this.model.serializeParts(); - const shouldRerender = event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; + _updateEditorState = (caret) => { + const shouldRerender = false; //event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; if (shouldRerender) { rerenderModel(this._editorRef, this.model); } else { renderModel(this._editorRef, this.model); } - setCaretPosition(this._editorRef, caret); + if (caret) { + setCaretPosition(this._editorRef, caret); + } this.setState({autoComplete: this.model.autoComplete}); - this._updateModelOutput(); + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + } + + _onInput = (event) => { + const caretOffset = getCaretOffset(this._editorRef); + this.model.update(this._editorRef.textContent, event.inputType, caretOffset); } _onKeyDown = (event) => { @@ -109,25 +118,14 @@ export default class MessageEditor extends React.Component { _onAutoCompleteConfirm = (completion) => { this.model.autoComplete.onComponentConfirm(completion); - renderModel(this._editorRef, this.model); - this._updateModelOutput(); } _onAutoCompleteSelectionChange = (completion) => { this.model.autoComplete.onComponentSelectionChange(completion); - renderModel(this._editorRef, this.model); - this._updateModelOutput(); - } - - _updateModelOutput() { - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } componentDidMount() { - const editor = this._editorRef; - rerenderModel(editor, this.model); - this._updateModelOutput(); + this._updateEditorState(); } render() { diff --git a/src/editor/model.js b/src/editor/model.js index fb1e4801ba..999d37efca 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -17,12 +17,13 @@ limitations under the License. import {diffAtCaret, diffDeletion} from "./diff"; export default class EditorModel { - constructor(parts, partCreator) { + 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) { @@ -90,7 +91,7 @@ export default class EditorModel { const caretOffset = diff.at + (diff.added ? diff.added.length : 0); const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); - return newPosition; + this._updateCallback(newPosition); } _setActivePart(pos) { @@ -116,10 +117,12 @@ export default class EditorModel { _onAutoComplete = ({replacePart, replaceCaret, close}) => { this._replacePart(this._autoCompletePartIdx, replacePart); + const index = this._autoCompletePartIdx; if (close) { this._autoComplete = null; this._autoCompletePartIdx = null; } + this._updateCallback(new DocumentPosition(index, replaceCaret)); } /* From ffff66a92d894fa6e6ce47f142467ef680be888c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:59:48 +0200 Subject: [PATCH 21/86] handle Escape properly close autocomplete, and also replace with plain text part. also remove leftover logging --- src/editor/autocomplete.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index 18b7cdee57..0512a4ac49 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -26,6 +26,11 @@ export default class AutocompleteWrapperModel { onEscape(e) { this._getAutocompleterComponent().onEscape(e); + this._updateCallback({ + replacePart: new PlainPart(this._queryPart.text), + replaceCaret: this._queryOffset, + close: true, + }); } onEnter() { @@ -37,12 +42,10 @@ export default class AutocompleteWrapperModel { } onUpArrow() { - console.log("onUpArrow"); this._getAutocompleterComponent().onUpArrow(); } onDownArrow() { - console.log("onDownArrow"); this._getAutocompleterComponent().onDownArrow(); } From 22587da5ff12e71ca55696f200c4ca581323deb4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:07:00 +0200 Subject: [PATCH 22/86] close autocomplete on enter --- src/editor/autocomplete.js | 2 +- src/editor/model.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index 0512a4ac49..d7ad211de2 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -34,7 +34,7 @@ export default class AutocompleteWrapperModel { } onEnter() { - + this._updateCallback({close: true}); } onTab() { diff --git a/src/editor/model.js b/src/editor/model.js index 999d37efca..2fa1541b99 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -116,7 +116,9 @@ export default class EditorModel { } _onAutoComplete = ({replacePart, replaceCaret, close}) => { - this._replacePart(this._autoCompletePartIdx, replacePart); + if (replacePart) { + this._replacePart(this._autoCompletePartIdx, replacePart); + } const index = this._autoCompletePartIdx; if (close) { this._autoComplete = null; From bc14d4f58f23de495fab6ca6f82d2a8e5e6d10d9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:07:10 +0200 Subject: [PATCH 23/86] comment --- src/editor/autocomplete.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index d7ad211de2..a322d5e6ce 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -89,6 +89,7 @@ export default class AutocompleteWrapperModel { const displayAlias = completion.completionId; return new RoomPillPart(displayAlias); } + // also used for emoji completion default: return new PlainPart(completion.completion); } From 580a89875adac78f7bb21851ce129d2f1df30b68 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:54:58 +0200 Subject: [PATCH 24/86] fix autocompl. not always appearing/being updated when there is no part --- src/editor/model.js | 50 ++++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 2fa1541b99..ab808877a0 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -88,6 +88,7 @@ export default class EditorModel { this._addText(position, diff.added); } this._mergeAdjacentParts(); + // TODO: now that parts can be outright deleted, this doesn't make sense anymore const caretOffset = diff.at + (diff.added ? diff.added.length : 0); const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); @@ -97,21 +98,27 @@ export default class EditorModel { _setActivePart(pos) { const {index} = pos; const part = this._parts[index]; - if (pos.index !== this._activePartIdx) { - this._activePartIdx = index; - // if there is a hidden autocomplete for this part, show it again - 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; + 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; + } } } - } - if (this._autoComplete) { - this._autoComplete.onPartUpdate(part, pos.offset); + // 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; } } @@ -181,14 +188,19 @@ export default class EditorModel { let {index, offset} = pos; const part = this._parts[index]; if (part) { - if (part.insertAll(offset, str)) { - str = null; + if (part.canEdit) { + if (part.insertAll(offset, str)) { + str = null; + } else { + // console.log("splitting", offset, [part.text]); + const splitPart = part.split(offset); + // console.log("splitted", [part.text, splitPart.text]); + index += 1; + this._insertPart(index, splitPart); + } } else { - // console.log("splitting", offset, [part.text]); - const splitPart = part.split(offset); - // console.log("splitted", [part.text, splitPart.text]); + // insert str after this part index += 1; - this._insertPart(index, splitPart); } } while (str) { From 8d97c0033e770369b2179ab1334f7ee6bf1489b6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:55:35 +0200 Subject: [PATCH 25/86] catch this for now as caret behaviour is still a bit flaky --- src/components/views/elements/MessageEditor.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index c863f8baf4..32171391f9 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -66,9 +66,13 @@ export default class MessageEditor extends React.Component { renderModel(this._editorRef, this.model); } if (caret) { - setCaretPosition(this._editorRef, caret); + try { + setCaretPosition(this._editorRef, caret); + } catch (err) { + console.error(err); + } } - + console.log("_updateEditorState", this.state.autoComplete, this.model.autoComplete); this.setState({autoComplete: this.model.autoComplete}); const modelOutput = this._editorRef.parentElement.querySelector(".model"); modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); From 1a577eed1195c845528b14415e64775e86517009 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 19:55:24 +0200 Subject: [PATCH 26/86] take non-editable parts into account for new caret position --- src/editor/model.js | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index ab808877a0..e7284da005 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -81,15 +81,16 @@ export default class EditorModel { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); console.log("update at", {position, diff, newValue, prevValue: this.parts.reduce((text, p) => text + p.text, "")}); + let removedOffsetDecrease = 0; if (diff.removed) { - this._removeText(position, diff.removed.length); + removedOffsetDecrease = this._removeText(position, diff.removed.length); } + let addedLen = 0; if (diff.added) { - this._addText(position, diff.added); + addedLen = this._addText(position, diff.added); } this._mergeAdjacentParts(); - // TODO: now that parts can be outright deleted, this doesn't make sense anymore - const caretOffset = diff.at + (diff.added ? diff.added.length : 0); + const caretOffset = diff.at - removedOffsetDecrease + addedLen; const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); this._updateCallback(newPosition); @@ -157,13 +158,19 @@ export default class EditorModel { } } + /** + * removes `len` amount of characters at `pos`. + * @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 amount = Math.min(len, part.text.length - offset); const replaceWith = part.remove(offset, amount); if (typeof replaceWith === "string") { this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); @@ -175,31 +182,38 @@ export default class EditorModel { } else { index += 1; } - len -= amount; - offset = 0; } else { - len = part.length - (offset + len); + removedOffsetDecrease += offset; this._removePart(index); } + len -= amount; + offset = 0; } + return removedOffsetDecrease; } + /** + * inserts `str` into the model at `pos`. + * @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, actions) { - let {index, offset} = pos; + 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 { - // console.log("splitting", offset, [part.text]); const splitPart = part.split(offset); - // console.log("splitted", [part.text, splitPart.text]); index += 1; this._insertPart(index, splitPart); } } else { - // insert str after this part + // not-editable, insert str after this part + addLen += part.text.length - offset; index += 1; } } @@ -209,6 +223,7 @@ export default class EditorModel { this._insertPart(index, newPart); index += 1; } + return addLen; } _positionForOffset(totalOffset, atPartEnd) { From 2c3453d307c3f55d805ea6025cfa55ca6db48a0f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 20:37:45 +0200 Subject: [PATCH 27/86] put caret after replaced part if no caretOffset is given by autocomplete --- src/editor/autocomplete.js | 4 ++-- src/editor/model.js | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index a322d5e6ce..d2f73b1dff 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -28,7 +28,7 @@ export default class AutocompleteWrapperModel { this._getAutocompleterComponent().onEscape(e); this._updateCallback({ replacePart: new PlainPart(this._queryPart.text), - replaceCaret: this._queryOffset, + caretOffset: this._queryOffset, close: true, }); } @@ -61,7 +61,7 @@ export default class AutocompleteWrapperModel { if (!completion) { this._updateCallback({ replacePart: this._queryPart, - replaceCaret: this._queryOffset, + caretOffset: this._queryOffset, }); } else { this._updateCallback({ diff --git a/src/editor/model.js b/src/editor/model.js index e7284da005..ed350b1337 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -123,7 +123,7 @@ export default class EditorModel { } } - _onAutoComplete = ({replacePart, replaceCaret, close}) => { + _onAutoComplete = ({replacePart, caretOffset, close}) => { if (replacePart) { this._replacePart(this._autoCompletePartIdx, replacePart); } @@ -132,7 +132,10 @@ export default class EditorModel { this._autoComplete = null; this._autoCompletePartIdx = null; } - this._updateCallback(new DocumentPosition(index, replaceCaret)); + if (caretOffset === undefined) { + caretOffset = replacePart.text.length; + } + this._updateCallback(new DocumentPosition(index, caretOffset)); } /* From 7a85dd4e61f06317b7a4c40354e290d9eb60f9d3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 10 May 2019 14:24:50 +0200 Subject: [PATCH 28/86] after completion, set caret in next part at start instead of end of current part --- src/editor/model.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index ed350b1337..fb3658a0be 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -124,18 +124,24 @@ export default class EditorModel { } _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); } - const index = this._autoCompletePartIdx; if (close) { this._autoComplete = null; this._autoCompletePartIdx = null; } - if (caretOffset === undefined) { - caretOffset = replacePart.text.length; - } - this._updateCallback(new DocumentPosition(index, caretOffset)); + // rerender even if editor contents didn't change + // to make sure the MessageEditor checks + // model.autoComplete being empty and closes it + this._updateCallback(pos); } /* From 9f597c7ec0b30c8392a91967e3b4a19e72696368 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 10 May 2019 14:25:13 +0200 Subject: [PATCH 29/86] no comment nodes without react,so can bring this back to simpler version --- src/editor/caret.js | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/editor/caret.js b/src/editor/caret.js index a8fe3ddc68..f0359bed2d 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -68,35 +68,22 @@ function isVisibleNode(node) { return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE; } -function untilVisibleNode(node) { - // need to ignore comment nodes that react uses - while (node && !isVisibleNode(node)) { - node = node.nextSibling; - } - return node; -} - export function setCaretPosition(editor, caretPosition) { - let node = untilVisibleNode(editor.firstChild); - if (!node) { - node = editor; - } else { - let {index} = caretPosition; - while (node && index) { - node = untilVisibleNode(node.nextSibling); - --index; - } - if (!node) { - node = editor; - } else if (node.nodeType === Node.ELEMENT_NODE) { - // make sure we have a text node - node = node.childNodes[0]; - } - } const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); - range.setStart(node, caretPosition.offset); - range.collapse(true); + let focusNode = editor.childNodes[caretPosition.index]; + // node not found, set caret at end + if (!focusNode) { + range.selectNodeContents(editor); + range.collapse(false); + } else { + // make sure we have a text node + if (focusNode.nodeType === Node.ELEMENT_NODE) { + focusNode = focusNode.childNodes[0]; + } + range.setStart(focusNode, caretPosition.offset); + range.collapse(true); + } sel.addRange(range); } From 7ebb6ce621d5175007b0ba3ddc49dce023362ea9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 15:21:57 +0100 Subject: [PATCH 30/86] WIP commit, newlines sort of working --- .../views/elements/MessageEditor.js | 28 +++++- src/editor/caret.js | 81 ++++++++------- src/editor/model.js | 3 +- src/editor/parts.js | 36 ++++++- src/editor/render.js | 98 ++++++++++++++----- 5 files changed, 183 insertions(+), 63 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 32171391f9..b047e210e0 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -56,6 +56,7 @@ export default class MessageEditor extends React.Component { }; this._editorRef = null; this._autocompleteRef = null; + // document.execCommand("insertBrOnReturn", undefined, true); } _updateEditorState = (caret) => { @@ -72,15 +73,38 @@ export default class MessageEditor extends React.Component { console.error(err); } } - console.log("_updateEditorState", this.state.autoComplete, this.model.autoComplete); this.setState({autoComplete: this.model.autoComplete}); const modelOutput = this._editorRef.parentElement.querySelector(".model"); modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } _onInput = (event) => { + console.log("finding newValue", this._editorRef.innerHTML); + let newValue = ""; + let node = this._editorRef.firstChild; + while (node && node !== this._editorRef) { + if (node.nodeType === Node.TEXT_NODE) { + newValue += node.nodeValue; + } + + if (node.firstChild) { + node = node.firstChild; + } else if (node.nextSibling) { + node = node.nextSibling; + } else { + while (!node.nextSibling && node !== this._editorRef) { + node = node.parentElement; + if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV" && node !== this._editorRef) { + newValue += "\n"; + } + } + if (node !== this._editorRef) { + node = node.nextSibling; + } + } + } const caretOffset = getCaretOffset(this._editorRef); - this.model.update(this._editorRef.textContent, event.inputType, caretOffset); + this.model.update(newValue, event.inputType, caretOffset); } _onKeyDown = (event) => { diff --git a/src/editor/caret.js b/src/editor/caret.js index f0359bed2d..e9081ee05d 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -16,56 +16,67 @@ limitations under the License. export function getCaretOffset(editor) { const sel = document.getSelection(); - const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; - let offset = sel.focusOffset; - let node = sel.focusNode; - + console.info("getCaretOffset", sel.focusNode, sel.focusOffset); // when deleting the last character of a node, // the caret gets reported as being after the focusOffset-th node, // with the focusNode being the editor - if (node === editor) { - let offset = 0; - for (let i = 0; i < sel.focusOffset; ++i) { - const node = editor.childNodes[i]; - if (isVisibleNode(node)) { - offset += node.textContent.length; - } - } - return {offset, atNodeEnd: false}; + let offset = 0; + let node; + let atNodeEnd = true; + if (sel.focusNode.nodeType === Node.TEXT_NODE) { + node = sel.focusNode; + offset = sel.focusOffset; + atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; + } else if (sel.focusNode.nodeType === Node.ELEMENT_NODE) { + node = sel.focusNode.childNodes[sel.focusOffset]; + offset = nodeLength(node); } - // first make sure we're at the level of a direct child of editor - if (node.parentElement !== editor) { - // include all preceding siblings of the non-direct editor children + while (node !== editor) { while (node.previousSibling) { node = node.previousSibling; - if (isVisibleNode(node)) { - offset += node.textContent.length; - } - } - // then move up - // I guess technically there could be preceding text nodes in the parents here as well, - // but we're assuming there are no mixed text and element nodes - while (node.parentElement !== editor) { - node = node.parentElement; - } - } - // now include the text length of all preceding direct editor children - while (node.previousSibling) { - node = node.previousSibling; - if (isVisibleNode(node)) { - offset += node.textContent.length; + offset += nodeLength(node); } + // then 1 move up + node = node.parentElement; } + + return {offset, atNodeEnd}; + + + // // first make sure we're at the level of a direct child of editor + // if (node.parentElement !== editor) { + // // include all preceding siblings of the non-direct editor children + // while (node.previousSibling) { + // node = node.previousSibling; + // offset += nodeLength(node); + // } + // // then move up + // // I guess technically there could be preceding text nodes in the parents here as well, + // // but we're assuming there are no mixed text and element nodes + // while (node.parentElement !== editor) { + // node = node.parentElement; + // } + // } + // // now include the text length of all preceding direct editor children + // while (node.previousSibling) { + // node = node.previousSibling; + // offset += nodeLength(node); + // } // { // const {focusOffset, focusNode} = sel; // console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); // } - return {offset, atNodeEnd}; } -function isVisibleNode(node) { - return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE; +function nodeLength(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + const isBlock = node.tagName === "DIV"; + const isLastDiv = !node.nextSibling || node.nextSibling.tagName !== "DIV"; + return node.textContent.length + ((isBlock && !isLastDiv) ? 1 : 0); + } else { + return node.textContent.length; + } } export function setCaretPosition(editor, caretPosition) { diff --git a/src/editor/model.js b/src/editor/model.js index fb3658a0be..e7184ad3d3 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -80,7 +80,8 @@ export default class EditorModel { update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - console.log("update at", {position, diff, newValue, prevValue: this.parts.reduce((text, p) => text + p.text, "")}); + const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); + console.log("update at", {diff, valueWithCaret}); let removedOffsetDecrease = 0; if (diff.removed) { removedOffsetDecrease = this._removeText(position, diff.removed.length); diff --git a/src/editor/parts.js b/src/editor/parts.js index 619bdfba9b..a20b857fee 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -95,11 +95,15 @@ class BasePart { get canEdit() { return true; } + + toString() { + return `${this.type}(${this.text})`; + } } export class PlainPart extends BasePart { acceptsInsertion(chr) { - return chr !== "@" && chr !== "#" && chr !== ":"; + return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; } toDOMNode() { @@ -175,6 +179,34 @@ class PillPart extends BasePart { } } +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); @@ -228,6 +260,8 @@ export class PartCreator { case "@": case ":": return new PillCandidatePart("", this._autoCompleteCreator); + case "\n": + return new NewlinePart(); default: return new PlainPart(); } diff --git a/src/editor/render.js b/src/editor/render.js index f7eb5d5c2b..ae39f62c41 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -18,35 +18,85 @@ export function rerenderModel(editor, model) { while (editor.firstChild) { editor.removeChild(editor.firstChild); } + let lineContainer = document.createElement("div"); + editor.appendChild(lineContainer); for (const part of model.parts) { - editor.appendChild(part.toDOMNode()); + if (part.type === "newline") { + lineContainer = document.createElement("div"); + editor.appendChild(lineContainer); + } else { + lineContainer.appendChild(part.toDOMNode()); + } } } export function renderModel(editor, model) { - // remove unwanted nodes, like
s - for (let i = 0; i < model.parts.length; ++i) { - const part = model.parts[i]; - let node = editor.childNodes[i]; - while (node && !part.canUpdateDOMNode(node)) { - editor.removeChild(node); - node = editor.childNodes[i]; + const lines = model.parts.reduce((lines, part) => { + if (part.type === "newline") { + lines.push([]); + } else { + const lastLine = lines[lines.length - 1]; + lastLine.push(part); } - } - for (let i = 0; i < model.parts.length; ++i) { - const part = model.parts[i]; - const node = editor.childNodes[i]; - if (node && part) { - part.updateDOMNode(node); - } else if (part) { - editor.appendChild(part.toDOMNode()); - } else if (node) { - editor.removeChild(node); + return lines; + }, [[]]); + + console.log(lines.map(parts => parts.map(p => p.toString()))); + + lines.forEach((parts, i) => { + let lineContainer = editor.childNodes[i]; + while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) { + editor.removeChild(lineContainer); + lineContainer = editor.childNodes[i]; } - } - let surplusElementCount = Math.max(0, editor.childNodes.length - model.parts.length); - while (surplusElementCount) { - editor.removeChild(editor.lastChild); - --surplusElementCount; - } + 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 + let foundBR = false; + let partNode = lineContainer.firstChild; + console.log("partNode", partNode, editor.innerHTML); + while (partNode) { + console.log("partNode(in loop)", partNode); + if (!foundBR && partNode.tagName === "BR") { + foundBR = true; + } else { + lineContainer.removeChild(partNode); + } + partNode = partNode.nextSibling; + } + if (!foundBR) { + console.log("adding a BR in an empty div because there was none already"); + lineContainer.appendChild(document.createElement("br")); + } + } + + let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length); + while (surplusElementCount) { + editor.removeChild(editor.lastChild); + --surplusElementCount; + } + }); } From 9e0816c51c903dede994064c212a2e7e4daf0f63 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 16:42:00 +0100 Subject: [PATCH 31/86] find caret offset and calculate editor text in same tree-walking algo instead of having the same logic twice --- .../views/elements/MessageEditor.js | 39 ++------ src/editor/caret.js | 96 +++++-------------- src/editor/dom.js | 83 ++++++++++++++++ 3 files changed, 115 insertions(+), 103 deletions(-) create mode 100644 src/editor/dom.js diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index b047e210e0..803b06455f 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -19,7 +19,8 @@ import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; -import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; +import {setCaretPosition} from '../../../editor/caret'; +import {getCaretOffsetAndText} from '../../../editor/dom'; import parseEvent from '../../../editor/parse-event'; import Autocomplete from '../rooms/Autocomplete'; // import AutocompleteModel from '../../../editor/autocomplete'; @@ -60,15 +61,10 @@ export default class MessageEditor extends React.Component { } _updateEditorState = (caret) => { - const shouldRerender = false; //event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; - if (shouldRerender) { - rerenderModel(this._editorRef, this.model); - } else { - renderModel(this._editorRef, this.model); - } + renderModel(this._editorRef, this.model); if (caret) { try { - setCaretPosition(this._editorRef, caret); + setCaretPosition(this._editorRef, this.model, caret); } catch (err) { console.error(err); } @@ -80,31 +76,8 @@ export default class MessageEditor extends React.Component { _onInput = (event) => { console.log("finding newValue", this._editorRef.innerHTML); - let newValue = ""; - let node = this._editorRef.firstChild; - while (node && node !== this._editorRef) { - if (node.nodeType === Node.TEXT_NODE) { - newValue += node.nodeValue; - } - - if (node.firstChild) { - node = node.firstChild; - } else if (node.nextSibling) { - node = node.nextSibling; - } else { - while (!node.nextSibling && node !== this._editorRef) { - node = node.parentElement; - if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV" && node !== this._editorRef) { - newValue += "\n"; - } - } - if (node !== this._editorRef) { - node = node.nextSibling; - } - } - } - const caretOffset = getCaretOffset(this._editorRef); - this.model.update(newValue, event.inputType, caretOffset); + const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); + this.model.update(text, event.inputType, caret); } _onKeyDown = (event) => { diff --git a/src/editor/caret.js b/src/editor/caret.js index e9081ee05d..3a784aa8eb 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -14,85 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function getCaretOffset(editor) { - const sel = document.getSelection(); - console.info("getCaretOffset", sel.focusNode, sel.focusOffset); - // when deleting the last character of a node, - // the caret gets reported as being after the focusOffset-th node, - // with the focusNode being the editor - let offset = 0; - let node; - let atNodeEnd = true; - if (sel.focusNode.nodeType === Node.TEXT_NODE) { - node = sel.focusNode; - offset = sel.focusOffset; - atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; - } else if (sel.focusNode.nodeType === Node.ELEMENT_NODE) { - node = sel.focusNode.childNodes[sel.focusOffset]; - offset = nodeLength(node); - } - - while (node !== editor) { - while (node.previousSibling) { - node = node.previousSibling; - offset += nodeLength(node); - } - // then 1 move up - node = node.parentElement; - } - - return {offset, atNodeEnd}; - - - // // first make sure we're at the level of a direct child of editor - // if (node.parentElement !== editor) { - // // include all preceding siblings of the non-direct editor children - // while (node.previousSibling) { - // node = node.previousSibling; - // offset += nodeLength(node); - // } - // // then move up - // // I guess technically there could be preceding text nodes in the parents here as well, - // // but we're assuming there are no mixed text and element nodes - // while (node.parentElement !== editor) { - // node = node.parentElement; - // } - // } - // // now include the text length of all preceding direct editor children - // while (node.previousSibling) { - // node = node.previousSibling; - // offset += nodeLength(node); - // } - // { - // const {focusOffset, focusNode} = sel; - // console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); - // } -} - -function nodeLength(node) { - if (node.nodeType === Node.ELEMENT_NODE) { - const isBlock = node.tagName === "DIV"; - const isLastDiv = !node.nextSibling || node.nextSibling.tagName !== "DIV"; - return node.textContent.length + ((isBlock && !isLastDiv) ? 1 : 0); - } else { - return node.textContent.length; - } -} - -export function setCaretPosition(editor, caretPosition) { +export function setCaretPosition(editor, model, caretPosition) { const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); - let focusNode = editor.childNodes[caretPosition.index]; + 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 - if (focusNode.nodeType === Node.ELEMENT_NODE) { - focusNode = focusNode.childNodes[0]; - } range.setStart(focusNode, caretPosition.offset); range.collapse(true); } diff --git a/src/editor/dom.js b/src/editor/dom.js new file mode 100644 index 0000000000..fd46c0820a --- /dev/null +++ b/src/editor/dom.js @@ -0,0 +1,83 @@ +/* +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 {focusOffset, focusNode} = 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}; +} From 4ff37ca046d8371b048744d3339e872d5c7acbb3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:45:35 +0100 Subject: [PATCH 32/86] don't show model for now --- res/css/views/elements/_MessageEditor.scss | 2 +- src/components/views/elements/MessageEditor.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index d1d06389a5..9d6fa6d064 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -62,7 +62,7 @@ limitations under the License. .model { background: lightgrey; padding: 5px; - display: block; + display: none; white-space: pre; font-size: 12px; } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 803b06455f..a0da298f06 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -70,8 +70,8 @@ export default class MessageEditor extends React.Component { } } this.setState({autoComplete: this.model.autoComplete}); - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + // const modelOutput = this._editorRef.parentElement.querySelector(".model"); + // modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } _onInput = (event) => { From a3b02cf0cc4e4f5db5df8f58c1f7c6b71096854c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:45:54 +0100 Subject: [PATCH 33/86] make logging quiet --- src/components/views/elements/MessageEditor.js | 6 +++--- src/editor/model.js | 5 +++-- src/editor/render.js | 5 +---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index a0da298f06..bc5cd021dd 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -57,7 +57,6 @@ export default class MessageEditor extends React.Component { }; this._editorRef = null; this._autocompleteRef = null; - // document.execCommand("insertBrOnReturn", undefined, true); } _updateEditorState = (caret) => { @@ -75,8 +74,9 @@ export default class MessageEditor extends React.Component { } _onInput = (event) => { - console.log("finding newValue", this._editorRef.innerHTML); - const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); + const sel = document.getSelection(); + // console.log("finding newValue", this._editorRef.innerHTML, sel); + const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); this.model.update(text, event.inputType, caret); } diff --git a/src/editor/model.js b/src/editor/model.js index e7184ad3d3..c35d55e309 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -80,8 +80,8 @@ export default class EditorModel { update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); - console.log("update at", {diff, valueWithCaret}); + // const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); + // console.log("update at", {diff, valueWithCaret}); let removedOffsetDecrease = 0; if (diff.removed) { removedOffsetDecrease = this._removeText(position, diff.removed.length); @@ -93,6 +93,7 @@ export default class EditorModel { this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; const newPosition = this._positionForOffset(caretOffset, true); + // console.log("caretOffset", {at: diff.at, removedOffsetDecrease, addedLen}, newPosition); this._setActivePart(newPosition); this._updateCallback(newPosition); } diff --git a/src/editor/render.js b/src/editor/render.js index ae39f62c41..bb04a4babd 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -74,12 +74,10 @@ export function renderModel(editor, model) { --surplusElementCount; } } else { - // empty div needs to have a BR in it + // empty div needs to have a BR in it to give it height let foundBR = false; let partNode = lineContainer.firstChild; - console.log("partNode", partNode, editor.innerHTML); while (partNode) { - console.log("partNode(in loop)", partNode); if (!foundBR && partNode.tagName === "BR") { foundBR = true; } else { @@ -88,7 +86,6 @@ export function renderModel(editor, model) { partNode = partNode.nextSibling; } if (!foundBR) { - console.log("adding a BR in an empty div because there was none already"); lineContainer.appendChild(document.createElement("br")); } } From c44fed4bea0bef0e5fe12bd5f0d0ff2dbfc3fce7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:51:20 +0100 Subject: [PATCH 34/86] even less logging --- src/editor/render.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/render.js b/src/editor/render.js index bb04a4babd..34643ff8b7 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -41,7 +41,7 @@ export function renderModel(editor, model) { return lines; }, [[]]); - console.log(lines.map(parts => parts.map(p => p.toString()))); + // console.log(lines.map(parts => parts.map(p => p.toString()))); lines.forEach((parts, i) => { let lineContainer = editor.childNodes[i]; From c98e716cbda12498b023acc63db22cfbf2749bab Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:56:30 +0100 Subject: [PATCH 35/86] some pill styling --- res/css/views/elements/_MessageEditor.scss | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 9d6fa6d064..da1dbb23cb 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -35,12 +35,13 @@ limitations under the License. color: white; } - span.user-pill { - background: red; - } - - span.room-pill { - background: green; + 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; } } From eaf43d7277a5239d53806a4155205b6ff43aa990 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 18:20:21 +0100 Subject: [PATCH 36/86] correctly parse BRs --- src/editor/parse-event.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index 51b96a58e7..455c51bcf4 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -15,7 +15,7 @@ limitations under the License. */ import { MATRIXTO_URL_PATTERN } from '../linkify-matrix'; -import { PlainPart, UserPillPart, RoomPillPart } from "./parts"; +import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts"; function parseHtmlMessage(html) { const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); @@ -42,6 +42,8 @@ function parseHtmlMessage(html) { default: return new PlainPart(n.textContent); } } + case "BR": + return new NewlinePart("\n"); default: return new PlainPart(n.textContent); } From 2fbe73e6581e6bc92a40156268efd4cde9ef3031 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 18:20:32 +0100 Subject: [PATCH 37/86] draft of formatting --- src/editor/html_serialize.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/editor/html_serialize.js diff --git a/src/editor/html_serialize.js b/src/editor/html_serialize.js new file mode 100644 index 0000000000..bd8842b01f --- /dev/null +++ b/src/editor/html_serialize.js @@ -0,0 +1,14 @@ +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}`; + } + }, ""); +} From 3abdf6b1001f79a15cf924310cc4fdd90aeec6de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:37:16 +0100 Subject: [PATCH 38/86] also serialize to text and method to tell us if we need html for model --- src/editor/html_serialize.js | 14 ------------ src/editor/serialize.js | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 14 deletions(-) delete mode 100644 src/editor/html_serialize.js create mode 100644 src/editor/serialize.js diff --git a/src/editor/html_serialize.js b/src/editor/html_serialize.js deleted file mode 100644 index bd8842b01f..0000000000 --- a/src/editor/html_serialize.js +++ /dev/null @@ -1,14 +0,0 @@ -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}`; - } - }, ""); -} 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; + } + }); +} From 34dbe5f314884fc34308ac6f3c687859e5cb6113 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:37:40 +0100 Subject: [PATCH 39/86] add newline parts for text messages as well --- src/editor/parse-event.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index 455c51bcf4..303f234f9e 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -59,6 +59,17 @@ export default function parseEvent(event) { if (content.format === "org.matrix.custom.html") { return parseHtmlMessage(content.formatted_body); } else { - return [new PlainPart(content.body)]; + const lines = content.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; } } From 759a4a54efc75ee0834de1d5d51b07a03ac5438a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:37:57 +0100 Subject: [PATCH 40/86] send the actual m.replace event from composer content --- res/css/views/elements/_MessageEditor.scss | 9 ++--- .../views/elements/MessageEditor.js | 39 ++++++++++++------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index da1dbb23cb..2829413a27 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -47,16 +47,13 @@ limitations under the License. .buttons { display: flex; - flex-direction: column; - align-items: end; + flex-direction: row; + justify-content: end; padding: 5px 0; .mx_AccessibleButton { - background-color: $button-bg-color; - border-radius: 4px; + margin-left: 5px; padding: 5px 40px; - color: $button-fg-color; - font-weight: 600; } } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index bc5cd021dd..62f4d442a7 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -21,11 +21,12 @@ 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/parse-event'; import Autocomplete from '../rooms/Autocomplete'; // import AutocompleteModel from '../../../editor/autocomplete'; import {PartCreator} from '../../../editor/parts'; -import {renderModel, rerenderModel} from '../../../editor/render'; +import {renderModel} from '../../../editor/render'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -109,6 +110,26 @@ export default class MessageEditor extends React.Component { dis.dispatch({action: "edit_event", event: null}); } + _onSaveClicked = () => { + const content = { + "msgtype": "m.text", + "body": textSerialize(this.model), + "m.relates_to": { + "rel_type": "m.replace", + "event_id": this.props.event.getId(), + }, + }; + if (requiresHtml(this.model)) { + content.format = "org.matrix.custom.html"; + content.formatted_body = htmlSerialize(this.model); + } + + const roomId = this.props.event.getRoomId(); + this.context.matrixClient.sendMessage(roomId, content); + + dis.dispatch({action: "edit_event", event: null}); + } + _collectEditorRef = (ref) => { this._editorRef = ref; } @@ -130,15 +151,6 @@ export default class MessageEditor extends React.Component { } render() { - // const parts = this.state.parts.map((p, i) => { - // const key = `${i}-${p.type}`; - // switch (p.type) { - // case "plain": return p.text; - // case "room-pill": return ({p.text}); - // case "user-pill": return ({p.text}); - // } - // }); - // const modelOutput = JSON.stringify(this.state.parts, undefined, 2); let autoComplete; if (this.state.autoComplete) { const query = this.state.query; @@ -161,14 +173,13 @@ export default class MessageEditor extends React.Component { className="editor" contentEditable="true" tabIndex="1" - // suppressContentEditableWarning={true} onInput={this._onInput} onKeyDown={this._onKeyDown} ref={this._collectEditorRef} - > -
+ >
- {_t("Cancel")} + {_t("Cancel")} + {_t("Save")}
; From 036cb02c0eab79dd73044e2073b7be1a6a2fafc7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:51:04 +0100 Subject: [PATCH 41/86] add feature flag --- src/components/views/messages/MessageActionBar.js | 14 ++++++++++---- src/i18n/strings/en_EN.json | 3 ++- src/settings/Settings.js | 6 ++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index c4b8c441bd..fe6c22ab1e 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -103,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; @@ -144,10 +148,12 @@ export default class MessageActionBar extends React.PureComponent { title={_t("Reply")} onClick={this.onReplyClick} />; - editButton = ; + if (this.isEditingEnabled()) { + editButton = ; + } } return
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e407d92630..1d524cbcbc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -300,6 +300,7 @@ "Show recent room avatars above the room list": "Show recent room avatars above the room list", "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", + "Edit messages after they have been sent": "Edit messages after they have been sent", "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", @@ -897,6 +898,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", @@ -973,7 +975,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 1c3ca4fd0f..76d220cf56 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -118,6 +118,12 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_message_editing": { + isFeature: true, + displayName: _td("Edit messages after they have been sent"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_reactions": { isFeature: true, displayName: _td("React to messages with emoji (refresh to apply changes)"), From e2388afb51c2e3f509268204a0426dabe3376fa7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:53:42 +0100 Subject: [PATCH 42/86] consistent naming between serialize and deserialize modules --- src/components/views/elements/MessageEditor.js | 2 +- src/editor/{parse-event.js => deserialize.js} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/editor/{parse-event.js => deserialize.js} (98%) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 62f4d442a7..db0ddb74b2 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -22,7 +22,7 @@ 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/parse-event'; +import {parseEvent} from '../../../editor/deserialize'; import Autocomplete from '../rooms/Autocomplete'; // import AutocompleteModel from '../../../editor/autocomplete'; import {PartCreator} from '../../../editor/parts'; diff --git a/src/editor/parse-event.js b/src/editor/deserialize.js similarity index 98% rename from src/editor/parse-event.js rename to src/editor/deserialize.js index 303f234f9e..969393b4d9 100644 --- a/src/editor/parse-event.js +++ b/src/editor/deserialize.js @@ -54,7 +54,7 @@ function parseHtmlMessage(html) { return parts; } -export default function parseEvent(event) { +export function parseEvent(event) { const content = event.getContent(); if (content.format === "org.matrix.custom.html") { return parseHtmlMessage(content.formatted_body); From 15df72e62921c2ab75d19cbaaca6c266d891b3cf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:20:34 +0100 Subject: [PATCH 43/86] reload events when event gets replaced in the timeline --- src/components/structures/TimelinePanel.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 6529e92256..350dcd72c3 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -204,6 +204,7 @@ const TimelinePanel = React.createClass({ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); + MatrixClientPeg.get().on("Room.replaceEvent", this.onRoomReplaceEvent); MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); MatrixClientPeg.get().on("Room.accountData", this.onAccountData); @@ -282,6 +283,7 @@ const TimelinePanel = React.createClass({ client.removeListener("Room.timeline", this.onRoomTimeline); client.removeListener("Room.timelineReset", this.onRoomTimelineReset); client.removeListener("Room.redaction", this.onRoomRedaction); + client.removeListener("Room.replaceEvent", this.onRoomReplaceEvent); client.removeListener("Room.receipt", this.onRoomReceipt); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.accountData", this.onAccountData); @@ -505,6 +507,17 @@ const TimelinePanel = React.createClass({ this.forceUpdate(); }, + onRoomReplaceEvent: function(replacedEvent, newEvent, room) { + if (this.unmounted) return; + + // ignore events for other rooms + if (room !== this.props.timelineSet.room) return; + + // we could skip an update if the event isn't in our timeline, + // but that's probably an early optimisation. + this._reloadEvents(); + }, + onRoomReceipt: function(ev, room) { if (this.unmounted) return; From 45991bc3de58cad3cc89b20541068fe1fc52e4c5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:20:52 +0100 Subject: [PATCH 44/86] replace original event if there have been previous edits --- src/components/views/elements/MessageEditor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index db0ddb74b2..13febbf5cc 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -116,7 +116,7 @@ export default class MessageEditor extends React.Component { "body": textSerialize(this.model), "m.relates_to": { "rel_type": "m.replace", - "event_id": this.props.event.getId(), + "event_id": this.props.event.getOriginalId(), }, }; if (requiresHtml(this.model)) { From 0b18ff52c528b343a248b595cd00f5e21c883417 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:41:55 +0100 Subject: [PATCH 45/86] pass feature flag to js-sdk --- src/MatrixClientPeg.js | 2 ++ src/i18n/strings/en_EN.json | 2 +- src/settings/Settings.js | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index cd40c7874e..8796d7fe30 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -176,6 +176,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 +188,7 @@ class MatrixClientPeg { forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), verificationMethods: [verificationMethods.SAS], unstableClientRelationAggregation: aggregateRelations, + unstableClientRelationReplacements: enableEdits, }; this.matrixClient = createMatrixClient(opts); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1d524cbcbc..393184a6c4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -300,7 +300,7 @@ "Show recent room avatars above the room list": "Show recent room avatars above the room list", "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", - "Edit messages after they have been sent": "Edit messages after they have been sent", + "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", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 76d220cf56..429030d862 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -120,7 +120,7 @@ export const SETTINGS = { }, "feature_message_editing": { isFeature: true, - displayName: _td("Edit messages after they have been sent"), + displayName: _td("Edit messages after they have been sent (refresh to apply changes)"), supportedLevels: LEVELS_FEATURE, default: false, }, From fd31e793d120ad0baa83c934506bac61b3c8749e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:49:53 +0100 Subject: [PATCH 46/86] fix lint --- src/editor/dom.js | 3 ++- src/editor/model.js | 6 +++++- src/editor/render.js | 4 +--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/editor/dom.js b/src/editor/dom.js index fd46c0820a..0899fd25b3 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -37,7 +37,8 @@ function walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback) { } export function getCaretOffsetAndText(editor, sel) { - let {focusOffset, focusNode} = sel; + let {focusNode} = sel; + const {focusOffset} = sel; let caretOffset = focusOffset; let foundCaret = false; let text = ""; diff --git a/src/editor/model.js b/src/editor/model.js index c35d55e309..ebf92c9a79 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -171,6 +171,8 @@ export default class EditorModel { /** * 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. */ @@ -205,10 +207,12 @@ export default class EditorModel { /** * 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, actions) { + _addText(pos, str) { let {index} = pos; const {offset} = pos; let addLen = str.length; diff --git a/src/editor/render.js b/src/editor/render.js index 34643ff8b7..052c11c320 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -40,9 +40,7 @@ export function renderModel(editor, model) { } return lines; }, [[]]); - - // console.log(lines.map(parts => parts.map(p => p.toString()))); - + // TODO: refactor this code, DRY it lines.forEach((parts, i) => { let lineContainer = editor.childNodes[i]; while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) { From dc21faa2403714b336e07a6a6a648939ab1e9240 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 16:32:08 +0100 Subject: [PATCH 47/86] send edit also in n.new_content field so we can have fallback content in the regular content for clients that don't support edits. Note that we're not reading m.new_content yet as it's going to be a bit of a headache to change this. So for now just sending the edit in both the normal content and the m.new_content subfield, so all events out there already are well-formed --- src/components/views/elements/MessageEditor.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 13febbf5cc..f44abd87e9 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -111,18 +111,21 @@ export default class MessageEditor extends React.Component { } _onSaveClicked = () => { - const content = { + 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.getOriginalId(), }, - }; - if (requiresHtml(this.model)) { - content.format = "org.matrix.custom.html"; - content.formatted_body = htmlSerialize(this.model); - } + }, newContent); const roomId = this.props.event.getRoomId(); this.context.matrixClient.sendMessage(roomId, content); From fd07b5c4767bdca3e49cbdfb68cbe9b08479b6a4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 May 2019 23:03:02 +0100 Subject: [PATCH 48/86] use scss variable for grey Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/views/rooms/_MemberList.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index c8d61c7cc5..0b9c7e2368 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -88,7 +88,7 @@ limitations under the License. } .mx_MemberList_invite.mx_AccessibleButton_disabled { - background-color: grey; + background-color: $greyed-fg-color;; cursor: not-allowed; } From 1421e8e06b244ea368477a6cfc45d21b4fb7da66 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 15 May 2019 09:12:53 +0100 Subject: [PATCH 49/86] Tweak reply arrow and hover color --- res/img/reply.svg | 8 ++++---- res/themes/light/css/_light.scss | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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; From d83e278f6b357ba49e05de82ead3632d8cd092a4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 09:46:08 +0100 Subject: [PATCH 50/86] PR feedback, cleanup --- res/css/views/elements/_MessageEditor.scss | 14 +++-------- .../views/elements/MessageEditor.js | 24 ++++--------------- src/editor/model.js | 11 --------- src/editor/render.js | 16 ------------- 4 files changed, 8 insertions(+), 57 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 2829413a27..b58e18c19b 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 Vector Creations Ltd +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. @@ -19,7 +19,7 @@ limitations under the License. background-color: #f3f8fd; padding: 11px 13px 7px 56px; - .editor { + .mx_MessageEditor_editor { border-radius: 4px; border: solid 1px #e9edf1; background-color: #ffffff; @@ -45,7 +45,7 @@ limitations under the License. } } - .buttons { + .mx_MessageEditor_buttons { display: flex; flex-direction: row; justify-content: end; @@ -57,14 +57,6 @@ limitations under the License. } } - .model { - background: lightgrey; - padding: 5px; - display: none; - white-space: pre; - font-size: 12px; - } - .mx_MessageEditor_AutoCompleteWrapper { position: relative; height: 0; diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index f44abd87e9..f8d08b313f 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -24,16 +24,14 @@ import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize'; import {parseEvent} from '../../../editor/deserialize'; import Autocomplete from '../rooms/Autocomplete'; -// import AutocompleteModel from '../../../editor/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 latest event in this chain of replies + // the message event being edited event: PropTypes.instanceOf(MatrixEvent).isRequired, - // onHeightChanged: PropTypes.func.isRequired, }; static contextTypes = { @@ -70,13 +68,10 @@ export default class MessageEditor extends React.Component { } } this.setState({autoComplete: this.model.autoComplete}); - // const modelOutput = this._editorRef.parentElement.querySelector(".model"); - // modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } _onInput = (event) => { const sel = document.getSelection(); - // console.log("finding newValue", this._editorRef.innerHTML, sel); const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); this.model.update(text, event.inputType, caret); } @@ -133,14 +128,6 @@ export default class MessageEditor extends React.Component { dis.dispatch({action: "edit_event", event: null}); } - _collectEditorRef = (ref) => { - this._editorRef = ref; - } - - _collectAutocompleteRef = (ref) => { - this._autocompleteRef = ref; - } - _onAutoCompleteConfirm = (completion) => { this.model.autoComplete.onComponentConfirm(completion); } @@ -160,7 +147,7 @@ export default class MessageEditor extends React.Component { const queryLen = query.length; autoComplete =
this._autocompleteRef = ref} query={query} onConfirm={this._onAutoCompleteConfirm} onSelectionChange={this._onAutoCompleteSelectionChange} @@ -173,18 +160,17 @@ export default class MessageEditor extends React.Component { return
{ autoComplete }
this._editorRef = ref} >
-
+
{_t("Cancel")} {_t("Save")}
-
; } } diff --git a/src/editor/model.js b/src/editor/model.js index ebf92c9a79..85dd425b0e 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -66,8 +66,6 @@ export default class EditorModel { } _diff(newValue, inputType, caret) { - // handle deleteContentForward (Delete key) - // and deleteContentBackward (Backspace) const previousValue = this.parts.reduce((text, p) => text + p.text, ""); // can't use caret position with drag and drop if (inputType === "deleteByDrag") { @@ -80,8 +78,6 @@ export default class EditorModel { update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - // const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); - // console.log("update at", {diff, valueWithCaret}); let removedOffsetDecrease = 0; if (diff.removed) { removedOffsetDecrease = this._removeText(position, diff.removed.length); @@ -93,7 +89,6 @@ export default class EditorModel { this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; const newPosition = this._positionForOffset(caretOffset, true); - // console.log("caretOffset", {at: diff.at, removedOffsetDecrease, addedLen}, newPosition); this._setActivePart(newPosition); this._updateCallback(newPosition); } @@ -146,12 +141,6 @@ export default class EditorModel { this._updateCallback(pos); } - /* - updateCaret(caret) { - // update active part here as well, hiding/showing autocomplete if needed - } - */ - _mergeAdjacentParts(docPos) { let prevPart = this._parts[0]; for (let i = 1; i < this._parts.length; ++i) { diff --git a/src/editor/render.js b/src/editor/render.js index 052c11c320..abc5d42fa1 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -14,22 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function rerenderModel(editor, model) { - while (editor.firstChild) { - editor.removeChild(editor.firstChild); - } - let lineContainer = document.createElement("div"); - editor.appendChild(lineContainer); - for (const part of model.parts) { - if (part.type === "newline") { - lineContainer = document.createElement("div"); - editor.appendChild(lineContainer); - } else { - lineContainer.appendChild(part.toDOMNode()); - } - } -} - export function renderModel(editor, model) { const lines = model.parts.reduce((lines, part) => { if (part.type === "newline") { From 6b932d58e6acb6311d8958989cdf9bc2f63c71a0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 10:10:29 +0100 Subject: [PATCH 51/86] remove cruft from edit icon --- res/img/edit.svg | 98 +----------------------------------------------- 1 file changed, 1 insertion(+), 97 deletions(-) diff --git a/res/img/edit.svg b/res/img/edit.svg index 15b5ef9563..95bd44f606 100644 --- a/res/img/edit.svg +++ b/res/img/edit.svg @@ -1,97 +1 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file From 22533ba2d4fb5e3e26568126b04c02f41c4097b6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 10:12:16 +0100 Subject: [PATCH 52/86] use theme var for bg color --- res/css/views/elements/_MessageEditor.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index b58e18c19b..ec6d903753 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_MessageEditor { border-radius: 4px; - background-color: #f3f8fd; + background-color: $header-panel-bg-color; padding: 11px 13px 7px 56px; .mx_MessageEditor_editor { From aa361a251b490350ddf563a63943837b763f9a11 Mon Sep 17 00:00:00 2001 From: Osoitz Date: Wed, 10 Apr 2019 16:51:58 +0000 Subject: [PATCH 53/86] Translated using Weblate (Basque) Currently translated at 97.7% (1556 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/eu/ --- src/i18n/strings/eu.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/eu.json b/src/i18n/strings/eu.json index 0f49dd152e..c4cb32f713 100644 --- a/src/i18n/strings/eu.json +++ b/src/i18n/strings/eu.json @@ -1824,5 +1824,6 @@ "Riot failed to get the public room list.": "Riot-ek ezin izan du du gelen zerrenda publikoa eskuratu.", "The homeserver may be unavailable or overloaded.": "Hasiera-zerbitzaria eskuraezin edo kargatuegia egon daiteke.", "You have %(count)s unread notifications in a prior version of this room.|other": "Irakurri gabeko %(count)s jakinarazpen dituzu gela honen aurreko bertsio batean.", - "You have %(count)s unread notifications in a prior version of this room.|one": "Irakurri gabeko %(count)s jakinarazpen duzu gela honen aurreko bertsio batean." + "You have %(count)s unread notifications in a prior version of this room.|one": "Irakurri gabeko %(count)s jakinarazpen duzu gela honen aurreko bertsio batean.", + "Replying With Files": "Fitxategiekin erantzutea" } From 6f393b5d91028c5187cb604180555e9262499e34 Mon Sep 17 00:00:00 2001 From: Slavi Pantaleev Date: Tue, 9 Apr 2019 06:44:03 +0000 Subject: [PATCH 54/86] Translated using Weblate (Bulgarian) Currently translated at 100.0% (1593 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/bg/ --- src/i18n/strings/bg.json | 56 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/bg.json b/src/i18n/strings/bg.json index aec953b40b..56e4933ae6 100644 --- a/src/i18n/strings/bg.json +++ b/src/i18n/strings/bg.json @@ -1821,5 +1821,59 @@ "Want more than a community? Get your own server": "Искате повече от общност? Сдобийте се със собствен сървър", "You are logged in to another account": "Влезли сте в друг акаунт", "Thank you for verifying your email! The account you're logged into here (%(sessionUserId)s) appears to be different from the account you've verified an email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, please log out first.": "Благодарим, че потвърждавате имейла си! Акаунтът, с които сте влезли тук (%(sessionUserId)s) изглежда е различен от акаунтът за който потвърждавате имейл адреса (%(verifiedUserId)s). Ако искате да влезете в акаунт %(verifiedUserId2)s, моля първо излезте.", - "Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Промяната на паролата Ви, ще анулира всички ключове за шифроване от-край-до-край по всички Ваши устройства, правейки историята на чата нечетима. Настройте резервно копие на ключовете или експортирайте ключовете от друго устройство преди да промените паролата си." + "Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Промяната на паролата Ви, ще анулира всички ключове за шифроване от-край-до-край по всички Ваши устройства, правейки историята на чата нечетима. Настройте резервно копие на ключовете или експортирайте ключовете от друго устройство преди да промените паролата си.", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Дали използвате 'breadcrumbs' функцията (аватари над списъка със стаи)", + "Replying With Files": "Отговаряне с файлове", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Все още не е възможно да отговорите с файл. Искате ли да качите файла без той да бъде отговор?", + "The file '%(fileName)s' failed to upload.": "Файлът '%(fileName)s' не можа да бъде качен.", + "Room upgrade confirmation": "Потвърждение на обновяването на стаята", + "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 usually only affect server-side processing of the room. If you're having problems with your Riot client, please file an issue with .": "Обновяванията на стаи обикновено повлияват само сървърната обработка. Ако имате проблем с Riot, моля съобщете за него със .", + "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Внимание: Обновяването на стаята няма автоматично да прехвърли членовете в новата версия на стаята. Ще изпратим съобщение в старата стая с връзка към новата - членовете на стаята ще трябва да кликнат на връзката за да влязат в новата стая.", + "Upgrade": "Обнови", + "Adds a custom widget by URL to the room": "Добавя собствено приспособление от URL в стаята", + "Please supply a https:// or http:// widget URL": "Моля, укажете https:// или http:// адрес на приспособление", + "You cannot modify widgets in this room.": "Не можете да модифицирате приспособления в тази стая.", + "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s премахна покана към %(targetDisplayName)s за присъединяване в стаята.", + "Show recent room avatars above the room list": "Показвай аватари на скоро-използваните стаи над списъка със стаи", + "Enable desktop notifications for this device": "Включи известия на работния плот за това устройство", + "Enable audible notifications for this device": "Включи звукови уведомления за това устройство", + "Upgrade this room to the recommended room version": "Обнови тази стая до препоръчаната версия на стаята", + "This room is running room version , which this homeserver has marked as unstable.": "Тази стая използва версия на стая , която сървърът счита за нестабилна.", + "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Обновяването на тази стая ще изключи текущата стая и ще създаде обновена стая със същото име.", + "Failed to revoke invite": "Неуспешно оттегляне на поканата", + "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Поканата не можа да бъде оттеглена. Или има временен проблем със сървъра, или нямате достатъчно права за да оттеглите поканата.", + "Revoke invite": "Оттегли поканата", + "Invited by %(sender)s": "Поканен от %(sender)s", + "Maximize apps": "Максимизирай приложенията", + "Rotate counter-clockwise": "Завърти обратно на часовниковата стрелка", + "Rotate clockwise": "Завърти по часовниковата стрелка", + "GitHub issue": "GitHub проблем", + "Notes": "Бележки", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Моля включете допълнителни сведения, които ще помогнат за анализиране на проблема, като например: какво правихте когато възникна проблема, идентификатори на стаи, идентификатори на потребители и т.н.", + "Sign out and remove encryption keys?": "Излизане и премахване на ключовете за шифроване?", + "To help us prevent this in future, please send us logs.": "За да ни помогнете да предотвратим това в бъдеще, моля изпратете логове.", + "Missing session data": "Липсват данни за сесията", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Липсват данни за сесията, като например ключове за шифровани съобщения. За да поправите това, излезте и влезте отново, възстановявайки ключовете от резервно копие.", + "Your browser likely removed this data when running low on disk space.": "Най-вероятно браузърът Ви е премахнал тези данни поради липса на дисково пространство.", + "Upload files (%(current)s of %(total)s)": "Качване на файлове (%(current)s от %(total)s)", + "Upload files": "Качи файлове", + "Upload": "Качи", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Файлът е прекалено голям за да се качи. Максималният допустим размер е %(limit)s, докато този файл е %(sizeOfThisFile)s.", + "These files are too large to upload. The file size limit is %(limit)s.": "Тези файлове са прекалено големи за да се качат. Максималният допустим размер е %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Някои файлове са прекалено големи за да се качат. Максималният допустим размер е %(limit)s.", + "Upload %(count)s other files|other": "Качи %(count)s други файла", + "Upload %(count)s other files|one": "Качи %(count)s друг файл", + "Cancel All": "Откажи всички", + "Upload Error": "Грешка при качване", + "A widget would like to verify your identity": "Приспособление иска да потвърди идентичността Ви", + "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "Приспособлението от адрес %(widgetUrl)s иска да потвърди идентичността Ви. Ако позволите това, приспособлението ще може да потвърди потребителския Ви идентификатор, без да може да извършва действия с него.", + "Remember my selection for this widget": "Запомни избора ми за това приспособление", + "Deny": "Откажи", + "Riot failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "Riot не успя да вземе списъка с протоколи от сървъра. Този сървър може да е прекалено стар за да поддържа чужди мрежи.", + "Riot failed to get the public room list.": "Riot не успя да вземе списъка с публични стаи.", + "The homeserver may be unavailable or overloaded.": "Сървърът може да не е наличен или претоварен.", + "You have %(count)s unread notifications in a prior version of this room.|other": "Имате %(count)s непрочетени известия в предишна версия на тази стая.", + "You have %(count)s unread notifications in a prior version of this room.|one": "Имате %(count)s непрочетено известие в предишна версия на тази стая." } From 40d9bc7b348ae6224870883b3d00dfc7d83029b7 Mon Sep 17 00:00:00 2001 From: Jeff Huang Date: Wed, 10 Apr 2019 00:37:51 +0000 Subject: [PATCH 55/86] Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (1593 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/zh_Hant/ --- src/i18n/strings/zh_Hant.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index bb3e1fd84a..a50182149a 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -1862,5 +1862,30 @@ "Riot failed to get the public room list.": "Riot 取得公開聊天室清單失敗。", "The homeserver may be unavailable or overloaded.": "家伺服器似乎不可用或超載。", "You have %(count)s unread notifications in a prior version of this room.|other": "您在此聊天室的先前版本有 %(count)s 個未讀的通知。", - "You have %(count)s unread notifications in a prior version of this room.|one": "您在此聊天室的先前版本有 %(count)s 個未讀的通知。" + "You have %(count)s unread notifications in a prior version of this room.|one": "您在此聊天室的先前版本有 %(count)s 個未讀的通知。", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "不論您是否使用「麵包屑」功能(大頭貼在聊天室清單上)", + "Replying With Files": "以檔案回覆", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "此時無法使用檔案回覆。您想要上傳此檔案而不回覆嗎?", + "The file '%(fileName)s' failed to upload.": "檔案「%(fileName)s」上傳失敗。", + "Show recent room avatars above the room list": "在聊天室清單上顯示聊天室大頭貼", + "Rotate counter-clockwise": "逆時針旋轉", + "Rotate clockwise": "順時針旋轉", + "GitHub issue": "GitHub 議題", + "Notes": "註記", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "如果有其他有助於釐清問題的情境,如您當時正在做什麼,聊天室 ID、使用者 ID 等等,請在這裡加入這些資訊。", + "Sign out and remove encryption keys?": "登出並移除加密金鑰?", + "To help us prevent this in future, please send us logs.": "要協助我們讓這個問題不再發生,請將紀錄檔傳送給我們。", + "Missing session data": "遺失工作階段資料", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "某些工作階段資料遺失了,其中包含加密訊息金鑰。登出再登入並從備份中復原金鑰可以修復這個問題。", + "Your browser likely removed this data when running low on disk space.": "當硬碟空間不足時,您的瀏覽器可能會移除這些資料。", + "Upload files (%(current)s of %(total)s)": "上傳檔案 (%(total)s 中的 %(current)s)", + "Upload files": "上傳檔案", + "Upload": "上傳", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "這個檔案太大了,所以沒辦法上傳。檔案大小限制為 %(limit)s 但這個檔案大小是 %(sizeOfThisFile)s。", + "These files are too large to upload. The file size limit is %(limit)s.": "這些檔案太大了,所以沒辦法上傳。檔案大小限制為 %(limit)s。", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "某些檔案太大了,所以沒辦法上傳。檔案大小限制為 %(limit)s。", + "Upload %(count)s other files|other": "上傳 %(count)s 個其他檔案", + "Upload %(count)s other files|one": "上傳 %(count)s 個其他檔案", + "Cancel All": "取消全部", + "Upload Error": "上傳錯誤" } From 8a94d00edc7b9fb11a601baa773432df599dd8ba Mon Sep 17 00:00:00 2001 From: Nathan Follens Date: Mon, 8 Apr 2019 16:17:02 +0000 Subject: [PATCH 56/86] Translated using Weblate (Dutch) Currently translated at 100.0% (1593 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/nl/ --- src/i18n/strings/nl.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json index 2b4e50a138..81a5fd0536 100644 --- a/src/i18n/strings/nl.json +++ b/src/i18n/strings/nl.json @@ -1771,5 +1771,30 @@ "Riot failed to get the public room list.": "Riot kon de lijst met openbare gesprekken niet verkrijgen.", "The homeserver may be unavailable or overloaded.": "De thuisserver is mogelijk onbereikbaar of overbelast.", "You have %(count)s unread notifications in a prior version of this room.|other": "U heeft %(count)s ongelezen meldingen in een voorgaande versie van dit gesprek.", - "You have %(count)s unread notifications in a prior version of this room.|one": "U heeft %(count)s ongelezen melding in een voorgaande versie van dit gesprek." + "You have %(count)s unread notifications in a prior version of this room.|one": "U heeft %(count)s ongelezen melding in een voorgaande versie van dit gesprek.", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Of u de 'broodkruimels'-functie al dan niet gebruikt (avatars boven de gesprekslijst)", + "Replying With Files": "Beantwoorden met bestanden", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Momenteel is het niet mogelijk om met een bestand te antwoorden. Wilt u dit bestand uploaden zonder te antwoorden?", + "The file '%(fileName)s' failed to upload.": "Het bestand ‘%(fileName)s’ kon niet geüpload worden.", + "Show recent room avatars above the room list": "Recente gespreksavatars weergeven boven de gesprekslijst", + "Rotate counter-clockwise": "Tegen de klok in draaien", + "Rotate clockwise": "Met de klok mee draaien", + "GitHub issue": "GitHub-melding", + "Notes": "Opmerkingen", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Indien er extra context zou kunnen helpen om het probleem te analyseren, zoals wat u aan het doen was, relevante gespreks-ID’s, gebruikers-ID’s, enz., gelieve deze informatie dan hier mee te geven.", + "Sign out and remove encryption keys?": "Afmelden en versleutelingssleutels verwijderen?", + "To help us prevent this in future, please send us logs.": "Gelieve ons logboeken te sturen om dit in de toekomst te helpen voorkomen.", + "Missing session data": "Sessiegegevens ontbreken", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Sommige sessiegegevens, inclusief sleutels voor versleutelde berichten, ontbreken. Meld u af en weer aan om dit op te lossen, en herstel de sleutels uit de back-up.", + "Your browser likely removed this data when running low on disk space.": "Uw browser heeft deze gegevens mogelijk verwijderd toen de beschikbare opslagruimte vol was.", + "Upload files (%(current)s of %(total)s)": "Bestanden worden geüpload (%(current)s van %(total)s)", + "Upload files": "Bestanden uploaden", + "Upload": "Uploaden", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Dit bestand is te groot om te uploaden. De bestandsgroottelimiet is %(limit)s, maar dit bestand is %(sizeOfThisFile)s.", + "These files are too large to upload. The file size limit is %(limit)s.": "Deze bestanden zijn te groot om te uploaden. De bestandsgroottelimiet is %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Sommige bestanden zijn te groot om te uploaden. De bestandsgroottelimiet is %(limit)s.", + "Upload %(count)s other files|other": "%(count)s overige bestanden uploaden", + "Upload %(count)s other files|one": "%(count)s overig bestand uploaden", + "Cancel All": "Alles annuleren", + "Upload Error": "Uploadfout" } From 129cb8ea29c8c5d2fd62155695cb96f4272f710a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20C?= Date: Mon, 8 Apr 2019 12:44:19 +0000 Subject: [PATCH 57/86] Translated using Weblate (French) Currently translated at 99.9% (1591 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/fr/ --- src/i18n/strings/fr.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 8afaeef2e6..4da12b701d 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -1867,5 +1867,30 @@ "Riot failed to get the public room list.": "Riot n’a pas pu récupérer la liste des salons publics.", "The homeserver may be unavailable or overloaded.": "Le serveur d’accueil est peut-être indisponible ou surchargé.", "You have %(count)s unread notifications in a prior version of this room.|other": "Vous avez %(count)s notifications non lues dans une version précédente de ce salon.", - "You have %(count)s unread notifications in a prior version of this room.|one": "Vous avez %(count)s notification non lue dans une version précédente de ce salon." + "You have %(count)s unread notifications in a prior version of this room.|one": "Vous avez %(count)s notification non lue dans une version précédente de ce salon.", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Si vous utilisez ou non la fonction « fil d’ariane » (les avatars au-dessus de la liste des salons)", + "Replying With Files": "Répondre avec des fichiers", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Pour le moment, il n’est pas possible de répondre avec un fichier. Souhaitez-vous envoyer ce fichier sans répondre ?", + "The file '%(fileName)s' failed to upload.": "Le fichier « %(fileName)s » n’a pas pu être envoyé.", + "Show recent room avatars above the room list": "Afficher les avatars des salons récents au-dessus de la liste des salons", + "Rotate counter-clockwise": "Pivoter dans le sens inverse des aiguilles d’une montre", + "Rotate clockwise": "Pivoter dans le sens des aiguilles d’une montre", + "GitHub issue": "Rapport GitHub", + "Notes": "Notes", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "S’il y a des informations supplémentaires qui pourraient nous aider à analyser le problème, comme ce que vous faisiez, l’identifiant du salon ou des utilisateurs etc, veuillez les préciser ici.", + "Sign out and remove encryption keys?": "Se déconnecter et supprimer les clés de chiffrement ?", + "To help us prevent this in future, please send us logs.": "Pour nous aider à éviter cela dans le futur, veuillez nous envoyer les journaux.", + "Missing session data": "Données de la session manquantes", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Des données de la session, dont les clés des messages chiffrés, sont manquantes. Déconnectez-vous et reconnectez-vous pour régler ce problème, en restaurant les clés depuis la sauvegarde.", + "Your browser likely removed this data when running low on disk space.": "Votre navigateur a sûrement supprimé ces données car il restait peu d’espace sur le disque.", + "Upload files (%(current)s of %(total)s)": "Envoi des fichiers (%(current)s sur %(total)s)", + "Upload files": "Envoyer les fichiers", + "Upload": "Envoyer", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Le fichier est trop lourd pour être envoyé. La taille limite est de %(limit)s mais la taille de ce fichier est de %(sizeOfThisFile)s.", + "These files are too large to upload. The file size limit is %(limit)s.": "Ces fichiers sont trop lourds pour être envoyés. La taille limite des fichiers est de %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Certains fichiers sont trop lourds pour être envoyés. La taille limite des fichiers est de %(limit)s.", + "Upload %(count)s other files|other": "Envoyer %(count)s autres fichiers", + "Upload %(count)s other files|one": "Envoyer %(count)s autre fichier", + "Cancel All": "Tout annuler", + "Upload Error": "Erreur d’envoi" } From b55b41257a26a894488aff9aa78cecd5d46595fc Mon Sep 17 00:00:00 2001 From: natowi Date: Mon, 8 Apr 2019 13:46:15 +0000 Subject: [PATCH 58/86] Translated using Weblate (German) Currently translated at 92.7% (1477 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/de/ --- src/i18n/strings/de_DE.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 3cfdb9c733..a21dae887a 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -1428,7 +1428,7 @@ "General failure": "Allgemeiner Fehler", "Failed to perform homeserver discovery": "Fehler beim Aufspüren des Heimservers", "Unknown failure discovering homeserver": "Unbekannter Fehler beim Aufspüren des Heimservers", - "Great! This passphrase looks strong enough.": "Gut! Diese Passphrase sieht start genug aus.", + "Great! This passphrase looks strong enough.": "Gut! Diese Passphrase sieht stark genug aus.", "Secure your encrypted message history with a Recovery Passphrase.": "Sichere deine sichere Nachrichtenhistorie mit einer Wiederherstellungspassphrase.", "If you don't want encrypted message history to be available on other devices, .": "Wenn du deine verschlüsselte Nachrichtenhistorie nicht auf anderen Geräten verfügbar haben möchtest, .", "Or, if you don't want to create a Recovery Passphrase, skip this step and .": "Oder, wenn du keine Wiederherstellungspassphrase erzeugen möchtest, überspringe diesen Schritt und .", @@ -1807,5 +1807,6 @@ "Thank you for verifying your email! The account you're logged into here (%(sessionUserId)s) appears to be different from the account you've verified an email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, please log out first.": "Danke für das Verifizieren deiner E-Mail! Das Konto, mit dem du angemeldet bist (%(sessionUserId)s) scheint ein anderes zu sein als das wofür die die E-Mail verifizierst (%(verifiedUserId)s). Wenn du dich als %(verifiedUserId2)s anmelden willst, melde dich zuerst ab.", "Could not load user profile": "Konnte Nutzerprofil nicht laden", "Your Matrix account": "Dein Matrixkonto", - "Your Matrix account on %(serverName)s": "Dein Matrixkonto auf %(serverName)s" + "Your Matrix account on %(serverName)s": "Dein Matrixkonto auf %(serverName)s", + "Show recent room avatars above the room list": "Zeige die letzten Avatare über der Raumliste an (neu laden um Änderungen zu übernehmen)" } From 6f63df5694403c35ab375fc02036ef2c604a4b15 Mon Sep 17 00:00:00 2001 From: Szimszon Date: Mon, 8 Apr 2019 19:26:46 +0000 Subject: [PATCH 59/86] Translated using Weblate (Hungarian) Currently translated at 100.0% (1593 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/hu/ --- src/i18n/strings/hu.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index e7f0164779..c83431ac44 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -1867,5 +1867,30 @@ "Riot failed to get the public room list.": "Riotnak nem sikerült beszereznie a nyilvános szoba listát.", "The homeserver may be unavailable or overloaded.": "A Matrix szerver elérhetetlen vagy túlterhelt.", "You have %(count)s unread notifications in a prior version of this room.|other": "%(count)s olvasatlan értesítésed van a régi verziójú szobában.", - "You have %(count)s unread notifications in a prior version of this room.|one": "%(count)s olvasatlan értesítésed van a régi verziójú szobában." + "You have %(count)s unread notifications in a prior version of this room.|one": "%(count)s olvasatlan értesítésed van a régi verziójú szobában.", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Használsz vagy nem „morzsákat” (profilképek a szobalista felett)", + "Replying With Files": "Válasz fájlokkal", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Egyenlőre nem lehet fájlal válaszolni. Szeretnéd feltölteni a fájlt úgy, hogy az nem egy válasz lesz?", + "The file '%(fileName)s' failed to upload.": "A %(fileName)s fájlt nem sikerült feltölteni.", + "Show recent room avatars above the room list": "A legfrissebb szoba profilképét mutassa a szoba lista felett", + "Rotate counter-clockwise": "Óramutató járásával ellentétesen fordít", + "Rotate clockwise": "Óramutató járásával megegyező irányba fordít", + "GitHub issue": "GitHub hibajegy", + "Notes": "Megjegyzések", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Ha a hiba felderítésében további adat is segítséget adhat, mint az, hogy mit csináltál éppen, mi a szoba-, felhasználó azonosítója, stb... itt add meg.", + "Sign out and remove encryption keys?": "Kilépés és a titkosítási kulcsok törlése?", + "To help us prevent this in future, please send us logs.": "Segíts abban, hogy ez később ne fordulhasson elő, kérlek küld el a naplókat.", + "Missing session data": "A kapcsolati adat hiányzik", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Néhány kapcsolati adat hiányzik, beleértve a titkosított üzenetek kulcsait. Lépj ki és jelentkezz vissza a hiba javításához és állítsd vissza mentésből a kulcsokat.", + "Your browser likely removed this data when running low on disk space.": "A böngésző valószínűleg törölte ezeket az adatokat amikor lecsökkent a szabad lemezterület.", + "Upload files (%(current)s of %(total)s)": "Fájlok feltöltése (%(current)s / %(total)s)", + "Upload files": "Fájlok feltöltése", + "Upload": "Feltöltés", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Ez a fájl túl nagy, hogy fel lehessen tölteni. A fájl méret korlát %(limit)s de a fájl %(sizeOfThisFile)s méretű.", + "These files are too large to upload. The file size limit is %(limit)s.": "A fájl túl nagy a feltöltéshez. A fájlméret korlát %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Néhány fájl túl nagy, hogy fel lehessen tölteni. A fájlméret korlát %(limit)s.", + "Upload %(count)s other files|other": "Feltölt %(count)s másik fájlt", + "Upload %(count)s other files|one": "Feltölt %(count)s másik fájlt", + "Cancel All": "Mindent megszakít", + "Upload Error": "Feltöltési hiba" } From e4b4da4f8d0a6c975bb38ab94ba9658753efacb1 Mon Sep 17 00:00:00 2001 From: Kenneth Larsson Date: Mon, 8 Apr 2019 19:50:53 +0000 Subject: [PATCH 60/86] Translated using Weblate (Swedish) Currently translated at 84.8% (1351 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/sv/ --- src/i18n/strings/sv.json | 58 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index cb60d31e48..15673d23e4 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -380,7 +380,7 @@ "Online": "Online", "Unnamed room": "Namnlöst rum", "World readable": "Alla kan läsa", - "Guests can join": "Gäster kan bli medlem i rummet", + "Guests can join": "Gäster kan gå med i rummet", "No rooms to show": "Inga fler rum att visa", "This phone number is already in use": "Detta telefonnummer används redan", "The version of Riot.im": "Versionen av Riot.im", @@ -1607,5 +1607,59 @@ "Unable to load backup status": "Det går inte att ladda backupstatus", "Guest": "Gäst", "Could not load user profile": "Kunde inte ladda användarprofil", - "Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Om du ändrar ditt lösenord återställs eventuella krypteringsnycklar på alla dina enheter, vilket gör att krypterad chatthistorik inte kan läsas. Aktivera nyckelsäkerhetskopiering eller exportera dina rumsnycklar från en annan enhet innan du återställer ditt lösenord." + "Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Om du ändrar ditt lösenord återställs eventuella krypteringsnycklar på alla dina enheter, vilket gör att krypterad chatthistorik inte kan läsas. Aktivera nyckelsäkerhetskopiering eller exportera dina rumsnycklar från en annan enhet innan du återställer ditt lösenord.", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Om du använder 'breadcrumbs' eller inte (avatarer ovanför rumslistan)", + "Replying With Files": "Svarar med filer", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Just nu är det inte möjligt att svara med en fil. Vill du ladda upp filen utan att svara?", + "The file '%(fileName)s' failed to upload.": "Filen '%(fileName)s' kunde inte laddas upp.", + "Room upgrade confirmation": "Bekräfta rumsuppgradering", + "Upgrading a room can be destructive and isn't always necessary.": "Uppgradering av ett rum kan vara destruktivt och är inte alltid nödvändigt.", + "Room upgrades are usually recommended when a room version is considered unstable. Unstable room versions might have bugs, missing features, or security vulnerabilities.": "Rumsuppgraderingar rekommenderas vanligtvis när en rumversion anses vara instabil. Instabila rumsversioner kan ha fel, sakna funktioner eller ha säkerhetsproblem.", + "Room upgrades usually only affect server-side processing of the room. If you're having problems with your Riot client, please file an issue with .": "", + "Upgrade": "Uppgradera", + "Close button should minimize window to tray": "Stängknappen ska minimera fönstret till systemfältet", + "Composer": "Meddelandefält", + "Key backup": "Nyckelsäkerhetskopiering", + "Never lose encrypted messages": "Förlora aldrig krypterade meddelanden", + "Securely back up your keys to avoid losing them. Learn more.": "Säkerhetskopiera dina nycklar på ett säkert sätt för att undvika att förlora dem. Läs mer.", + "Failed to load group members": "Det gick inte att ladda gruppmedlemmar", + "Maximize apps": "Maximera appar", + "Join": "Gå med", + "Rotate counter-clockwise": "Rotera moturs", + "Rotate clockwise": "Rotera medurs", + "Power level": "Behörighetsnivå", + "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Det gick inte att hitta profiler för de Matrix-IDn som anges nedan - vill du bjuda in dem ändå?", + "GitHub issue": "GitHub-ärende", + "Notes": "Noteringar", + "You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.": "Du har tidigare använt Riot på %(host)s med lazy loading av medlemmar aktiverat. I den här versionen är lazy loading inaktiverat. Eftersom den lokala cachen inte är kompatibel mellan dessa två inställningar behöver Riot synkronisera om ditt konto.", + "If the other version of Riot is still open in another tab, please close it as using Riot on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Om den andra versionen av Riot fortfarande är öppen i en annan flik, stäng den eftersom användning av Riot på samma värd med både lazy loading aktiverad och inaktiverad samtidigt kommer att orsaka problem.", + "Incompatible local cache": "Inkompatibel lokal cache", + "Clear cache and resync": "Töm cache och synkronisera om", + "Riot now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "Riot använder nu 3-5 gånger mindre minne, genom att bara ladda information om andra användare när det behövs. Vänta medan vi återsynkroniserar med servern!", + "I don't want my encrypted messages": "Jag vill inte ha mina krypterade meddelanden", + "Manually export keys": "Exportera nycklar manuellt", + "You'll lose access to your encrypted messages": "Du kommer att förlora åtkomst till dina krypterade meddelanden", + "Are you sure you want to sign out?": "Är du säker på att du vill logga ut?", + "If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "Om du stöter på några fel eller har feedback du vill dela, vänligen meddela oss på GitHub.", + "To help avoid duplicate issues, please view existing issues first (and add a +1) or create a new issue if you can't find it.": "För att undvika dubbla ärenden, vänligen granska befintliga ärenden först (och lägg till +1) eller skapa ett nytt ärende om du inte hittar det.", + "Report bugs & give feedback": "Rapportera fel och ge feedback", + "Go back": "Gå tillbaka", + "Room Settings - %(roomName)s": "Rumsinställningar - %(roomName)s", + "Sign out and remove encryption keys?": "Logga ut och ta bort krypteringsnycklar?", + "A username can only contain lower case letters, numbers and '=_-./'": "Ett användarnamn får endast innehålla små bokstäver, siffror och '=_-./'", + "Checking...": "Kontrollerar...", + "To help us prevent this in future, please send us logs.": "För att hjälpa oss att förhindra detta i framtiden, vänligen skicka oss loggar.", + "Missing session data": "Sessionsdata saknas", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Vissa sessionsdata, inklusive krypteringsnycklar för meddelanden, saknas. Logga ut och logga in för att åtgärda detta genom återställning av nycklarna från säkerhetskopia.", + "Your browser likely removed this data when running low on disk space.": "Din webbläsare har troligen tagit bort dessa data när det blev ont om diskutrymme.", + "Upload files (%(current)s of %(total)s)": "Ladda upp filer (%(current)s av %(total)s)", + "Upload files": "Ladda upp filer", + "Upload": "Ladda upp", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Den här filen är för stor för att ladda upp. Filstorleksgränsen är %(limit)s men den här filen är %(sizeOfThisFile)s.", + "These files are too large to upload. The file size limit is %(limit)s.": "Dessa filer är för stora för att laddas upp. Filstorleksgränsen är %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Vissa filer är för stora för att laddas upp. Filstorleksgränsen är %(limit)s.", + "Upload %(count)s other files|other": "Ladda upp %(count)s andra filer", + "Upload %(count)s other files|one": "Ladda upp %(count)s annan fil", + "Cancel All": "Avbryt alla", + "Upload Error": "Uppladdningsfel" } From 5805a88ab918fa9d46277212fec5836625e5b8e5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 11:49:43 +0100 Subject: [PATCH 61/86] adjust to js-sdk changes of marking original event as replaced --- src/components/structures/MessagePanel.js | 1 + src/components/structures/TimelinePanel.js | 4 ++-- src/components/views/elements/MessageEditor.js | 2 +- src/components/views/messages/MessageEvent.js | 1 + src/components/views/messages/TextualBody.js | 1 + src/components/views/rooms/EventTile.js | 1 + src/editor/deserialize.js | 5 +++-- src/shouldHideEvent.js | 1 + 8 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index adc78d7032..6f21bb6951 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -526,6 +526,7 @@ module.exports = React.createClass({ ; }, }); 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/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 } { const isLast = i === lines.length - 1; const text = new PlainPart(line); 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); From da1bff1c5db4dcd5755eb2866abca8929d6a2428 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 May 2019 13:47:48 +0100 Subject: [PATCH 62/86] Fix Single Sign-on https://github.com/matrix-org/matrix-react-sdk/pull/2826 checked that we had data in the crypto store if the had credentials in localStorage. However, SSO stores creds in localStorage and then redirects the browser to remove the loginToken parameter from the URL without starting crypto, so after the redirect, we see creds in localStorage but no crypto data, and error. Fix by marking when we've successfully initialised crypto and only erroring if that flag is set. Fixes https://github.com/vector-im/riot-web/issues/9695 --- src/Lifecycle.js | 2 +- src/MatrixClientPeg.js | 1 + src/utils/StorageManager.js | 22 ++++++++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) 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..7e93b1dd2e 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') { diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js index 1c0931273b..d0fcd58106 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,15 @@ 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. + */ +export function setCryptoInitialised(cryptoInited) { + localStorage.setItem("mx_crypto_initialised", cryptoInited); +} From fc636c6cb9ef1d154e9437c06938a03e401f666f Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 May 2019 14:00:37 +0100 Subject: [PATCH 63/86] lint --- src/utils/StorageManager.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js index d0fcd58106..49a120a470 100644 --- a/src/utils/StorageManager.js +++ b/src/utils/StorageManager.js @@ -169,6 +169,8 @@ export function trackStores(client) { * 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); From 00508367f6c90a8b9cd931aad70ae8d886941beb Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 May 2019 13:47:48 +0100 Subject: [PATCH 64/86] Fix Single Sign-on https://github.com/matrix-org/matrix-react-sdk/pull/2826 checked that we had data in the crypto store if the had credentials in localStorage. However, SSO stores creds in localStorage and then redirects the browser to remove the loginToken parameter from the URL without starting crypto, so after the redirect, we see creds in localStorage but no crypto data, and error. Fix by marking when we've successfully initialised crypto and only erroring if that flag is set. Fixes https://github.com/vector-im/riot-web/issues/9695 --- src/Lifecycle.js | 2 +- src/MatrixClientPeg.js | 1 + src/utils/StorageManager.js | 22 ++++++++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) 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 763eddbd5d..d3cf19ef48 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') { diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js index 1c0931273b..d0fcd58106 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,15 @@ 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. + */ +export function setCryptoInitialised(cryptoInited) { + localStorage.setItem("mx_crypto_initialised", cryptoInited); +} From 0cde4bf2cd7f50ec8c06a3a8a07884b570721c3f Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 May 2019 14:00:37 +0100 Subject: [PATCH 65/86] lint --- src/utils/StorageManager.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js index d0fcd58106..49a120a470 100644 --- a/src/utils/StorageManager.js +++ b/src/utils/StorageManager.js @@ -169,6 +169,8 @@ export function trackStores(client) { * 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); From 3ea187a524ca6eaa17417407043b4b9dc6a3d7df Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Mon, 13 May 2019 18:00:52 +0100 Subject: [PATCH 66/86] Save `content.info` as a local for readability --- src/components/views/messages/MImageBody.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 9fd42fb31d..cb12259c9b 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -198,9 +198,11 @@ export default class MImageBody extends React.Component { // so we'll need to download the original image for this to work // well for now. First, let's try a few cases that let us avoid // downloading the original: - if (pixelRatio === 1.0 || - (!content.info || !content.info.w || - !content.info.h || !content.info.size)) { + const info = content.info; + if ( + pixelRatio === 1.0 || + (!info || !info.w || !info.h || !info.size) + ) { // always thumbnail. it may look a bit worse, but it'll save bandwidth. // which is probably desirable on a lo-dpi device anyway. return this.context.matrixClient.mxcUrlToHttp(content.url, thumbWidth, thumbHeight); @@ -215,10 +217,10 @@ export default class MImageBody extends React.Component { // timeline (e.g. >1MB). const isLargerThanThumbnail = ( - content.info.w > thumbWidth || - content.info.h > thumbHeight + info.w > thumbWidth || + info.h > thumbHeight ); - const isLargeFileSize = content.info.size > 1*1024*1024; + const isLargeFileSize = info.size > 1*1024*1024; if (isLargeFileSize && isLargerThanThumbnail) { // image is too large physically and bytewise to clutter our timeline so From 6ea590cf1fef8ece21c532ba692e8bd5b1f6c2dc Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Mon, 13 May 2019 18:28:57 +0100 Subject: [PATCH 67/86] Always thumbnail for GIFs When displaying a GIF, we always want to thumbnail so that we can properly respect the user's GIF autoplay setting (which relies on thumbnailing to produce the static preview image). Fixes https://github.com/vector-im/riot-web/issues/9658 --- src/components/views/messages/MImageBody.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index cb12259c9b..4b5e1c20fa 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -200,6 +200,7 @@ export default class MImageBody extends React.Component { // downloading the original: const info = content.info; if ( + this._isGif() || pixelRatio === 1.0 || (!info || !info.w || !info.h || !info.size) ) { From ef5ac27ddc969a01eb90df474fa1484232cf8e98 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Tue, 14 May 2019 13:55:38 +0100 Subject: [PATCH 68/86] Add comment about thumbnailing for GIFs --- src/components/views/messages/MImageBody.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 4b5e1c20fa..2f12022140 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -197,15 +197,18 @@ export default class MImageBody extends React.Component { // synapse only supports 800x600 thumbnails for now though, // so we'll need to download the original image for this to work // well for now. First, let's try a few cases that let us avoid - // downloading the original: + // downloading the original, including: + // - When displaying a GIF, we always want to thumbnail so that we can + // properly respect the user's GIF autoplay setting (which relies on + // thumbnailing to produce the static preview image) + // - On a low DPI device, always thumbnail to save bandwidth + // - If there's no sizing info in the event, default to thumbnail const info = content.info; if ( this._isGif() || pixelRatio === 1.0 || (!info || !info.w || !info.h || !info.size) ) { - // always thumbnail. it may look a bit worse, but it'll save bandwidth. - // which is probably desirable on a lo-dpi device anyway. return this.context.matrixClient.mxcUrlToHttp(content.url, thumbWidth, thumbHeight); } else { // we should only request thumbnails if the image is bigger than 800x600 From 77384497b0b3934e4f9b6fa5884f6abcc6e83465 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 May 2019 14:30:20 +0100 Subject: [PATCH 69/86] Prepare changelog for v1.1.2 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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) From 4e3c39cd60f35fadbe87ed80b9a462e65affb63d Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 May 2019 14:30:21 +0100 Subject: [PATCH 70/86] v1.1.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df51ac6526..f008366e87 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": { From 52c4d7b6b09ea433f54cea2f1ca5875e992cf14d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 15:53:02 +0100 Subject: [PATCH 71/86] fixup edit icon --- res/img/edit.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/img/edit.svg b/res/img/edit.svg index 95bd44f606..9674b31690 100644 --- a/res/img/edit.svg +++ b/res/img/edit.svg @@ -1 +1 @@ - \ No newline at end of file + From b081a3156f86d2f3b952edaa1ddcb16f00c5cde5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 15:53:49 +0100 Subject: [PATCH 72/86] dont show edit button for messages that are not your own --- src/components/views/messages/MessageActionBar.js | 14 +++++++------- src/utils/EventUtils.js | 6 ++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index fe6c22ab1e..84474710cd 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -23,7 +23,7 @@ import dis from '../../../dispatcher'; import Modal from '../../../Modal'; import { createMenu } from '../../structures/ContextualMenu'; import SettingsStore from '../../../settings/SettingsStore'; -import { isContentActionable } from '../../../utils/EventUtils'; +import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; export default class MessageActionBar extends React.PureComponent { static propTypes = { @@ -148,12 +148,12 @@ export default class MessageActionBar extends React.PureComponent { title={_t("Reply")} onClick={this.onReplyClick} />; - if (this.isEditingEnabled()) { - editButton = ; - } + } + if (this.isEditingEnabled() && canEditContent(this.props.mxEvent)) { + editButton = ; } return
diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js index 911257f95c..ac415ca6de 100644 --- a/src/utils/EventUtils.js +++ b/src/utils/EventUtils.js @@ -15,6 +15,7 @@ limitations under the License. */ import { EventStatus } from 'matrix-js-sdk'; +import MatrixClientPeg from '../MatrixClientPeg'; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -43,3 +44,8 @@ export function isContentActionable(mxEvent) { return false; } + +export function canEditContent(mxEvent) { + return isContentActionable(mxEvent) && + mxEvent.getSender() === MatrixClientPeg.get().getUserId(); +} From 6366371c0db064860024aff6cfa2e1e229de9d0b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 15:54:05 +0100 Subject: [PATCH 73/86] add * to fallback messages for edits --- src/components/views/elements/MessageEditor.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index a2005feace..20d6dc6f66 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -110,9 +110,15 @@ export default class MessageEditor extends React.Component { "msgtype": "m.text", "body": textSerialize(this.model), }; + const contentBody = { + msgtype: newContent.msgtype, + body: ` * ${newContent.body}`, + }; if (requiresHtml(this.model)) { newContent.format = "org.matrix.custom.html"; newContent.formatted_body = htmlSerialize(this.model); + contentBody.format = newContent.format; + contentBody.formatted_body = ` * ${newContent.formatted_body}`; } const content = Object.assign({ "m.new_content": newContent, @@ -120,7 +126,7 @@ export default class MessageEditor extends React.Component { "rel_type": "m.replace", "event_id": this.props.event.getId(), }, - }, newContent); + }, contentBody); const roomId = this.props.event.getRoomId(); this.context.matrixClient.sendMessage(roomId, content); From d73f547f556d98013d76fefcc95069f321015e4c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 15:55:03 +0100 Subject: [PATCH 74/86] reapply pills, link preview, code highlighting, ... after edit --- src/components/views/messages/TextualBody.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index deb3c5cc0f..34769c060f 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -88,7 +88,10 @@ module.exports = React.createClass({ componentDidMount: function() { this._unmounted = false; + this._applyFormatting(); + }, + _applyFormatting() { // 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. @@ -123,7 +126,11 @@ module.exports = React.createClass({ } }, - componentDidUpdate: function() { + componentDidUpdate: function(prevProps) { + const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId; + if (messageWasEdited) { + this._applyFormatting(); + } this.calculateUrlPreview(); }, From 085f2d199de8468ae363428872d0a34101f695f0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 17:17:35 +0100 Subject: [PATCH 75/86] focus editor after clicking edit --- src/components/views/elements/MessageEditor.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 20d6dc6f66..b42923954b 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -144,6 +144,13 @@ export default class MessageEditor extends React.Component { componentDidMount() { this._updateEditorState(); + const sel = document.getSelection(); + const range = document.createRange(); + range.selectNodeContents(this._editorRef); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + this._editorRef.focus(); } render() { From bfd37d808942834524348326cffbbcb457c3eaa1 Mon Sep 17 00:00:00 2001 From: Aaron Raimist Date: Wed, 15 May 2019 22:05:54 -0500 Subject: [PATCH 76/86] Add tooltips to rotate and close buttons in ImageView (#9686) Signed-off-by: Aaron Raimist --- src/components/views/elements/ImageView.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/ImageView.js b/src/components/views/elements/ImageView.js index 2adc0df57f..ae481f0659 100644 --- a/src/components/views/elements/ImageView.js +++ b/src/components/views/elements/ImageView.js @@ -195,13 +195,13 @@ export default class ImageView extends React.Component {
- + { - + { - + {
From c38b5cabb1a3d8786309389c4b653573f7b01456 Mon Sep 17 00:00:00 2001 From: Aaron Raimist Date: Wed, 15 May 2019 22:11:47 -0500 Subject: [PATCH 77/86] Allow the strings to be translated Signed-off-by: Aaron Raimist --- src/components/views/elements/ImageView.js | 6 +++--- src/i18n/strings/en_EN.json | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/ImageView.js b/src/components/views/elements/ImageView.js index ae481f0659..9a47bce553 100644 --- a/src/components/views/elements/ImageView.js +++ b/src/components/views/elements/ImageView.js @@ -195,13 +195,13 @@ export default class ImageView extends React.Component {
- + { - + { - + {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e407d92630..88d78b1bde 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -985,7 +985,9 @@ "Communities": "Communities", "You cannot delete this image. (%(code)s)": "You cannot delete this image. (%(code)s)", "Uploaded on %(date)s by %(user)s": "Uploaded on %(date)s by %(user)s", + "Rotate Left": "Rotate Left", "Rotate counter-clockwise": "Rotate counter-clockwise", + "Rotate Right": "Rotate Right", "Rotate clockwise": "Rotate clockwise", "Download this file": "Download this file", "Integrations Error": "Integrations Error", From 33885cb864512e12df55d5dbb66a02565cfe9d4b Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 15 May 2019 13:46:32 +0100 Subject: [PATCH 78/86] Use `getRelation` helper Use the `getRelation` helper to ensure we always read relation info from the wire content as required in E2E rooms. --- src/components/views/elements/ReplyThread.js | 5 +++++ src/components/views/messages/ReactionDimension.js | 2 +- src/components/views/messages/ReactionsRow.js | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index f3cd6e144d..ab7b1abb1c 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -63,6 +63,11 @@ export default class ReplyThread extends React.Component { static getParentEventId(ev) { if (!ev || ev.isRedacted()) return; + // XXX: For newer relations (annotations, replacements, etc.), we now + // have a `getRelation` helper on the event, and you might assume it + // could be used here for replies as well... However, the helper + // currently assumes the relation has a `rel_type`, which older replies + // do not, so this block is left as-is for now. const mRelatesTo = ev.getWireContent()['m.relates_to']; if (mRelatesTo && mRelatesTo['m.in_reply_to']) { const mInReplyTo = mRelatesTo['m.in_reply_to']; diff --git a/src/components/views/messages/ReactionDimension.js b/src/components/views/messages/ReactionDimension.js index a0cf5a86ec..aa3813c30d 100644 --- a/src/components/views/messages/ReactionDimension.js +++ b/src/components/views/messages/ReactionDimension.js @@ -82,7 +82,7 @@ export default class ReactionDimension extends React.PureComponent { if (mxEvent.isRedacted()) { return false; } - return mxEvent.getContent()["m.relates_to"].key === option; + return mxEvent.getRelation().key === option; }); if (!reactionForOption) { continue; diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.js index ffb81e1a38..f4bd9781bc 100644 --- a/src/components/views/messages/ReactionsRow.js +++ b/src/components/views/messages/ReactionsRow.js @@ -101,7 +101,7 @@ export default class ReactionsRow extends React.PureComponent { if (mxEvent.isRedacted()) { return false; } - return mxEvent.getContent()["m.relates_to"].key === content; + return mxEvent.getRelation().key === content; }); return Date: Thu, 16 May 2019 07:47:13 +0000 Subject: [PATCH 79/86] Translated using Weblate (Hindi) Currently translated at 45.3% (722 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/hi/ --- src/i18n/strings/hi.json | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/i18n/strings/hi.json b/src/i18n/strings/hi.json index 162ddf08a4..7ff6c6a7cf 100644 --- a/src/i18n/strings/hi.json +++ b/src/i18n/strings/hi.json @@ -772,5 +772,25 @@ "To ban users, you must be a": "उपयोगकर्ताओं को प्रतिबंधित करने के लिए, आपको होना चाहिए", "To remove other users' messages, you must be a": "अन्य उपयोगकर्ताओं के संदेशों को हटाने के लिए, आपको होना चाहिए", "To notify everyone in the room, you must be a": "कमरे में सभी को सूचित करने के लिए, आपको होना चाहिए", - "No users have specific privileges in this room": "इस कमरे में किसी भी उपयोगकर्ता के विशेष विशेषाधिकार नहीं हैं" + "No users have specific privileges in this room": "इस कमरे में किसी भी उपयोगकर्ता के विशेष विशेषाधिकार नहीं हैं", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "आप 'ब्रेडक्रंब' सुविधा का उपयोग कर रहे हैं या नहीं (कमरे की सूची के ऊपर अवतार)", + "Replying With Files": "फाइलों के साथ उत्तर", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "इस समय किसी फ़ाइल के साथ उत्तर देना संभव नहीं है। क्या आप इस फ़ाइल को बिना उत्तर दिए अपलोड करना चाहेंगे?", + "The file '%(fileName)s' failed to upload.": "फ़ाइल '%(fileName)s' अपलोड करने में विफल रही।", + "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "एक सादे पाठ संदेश के लिए ¯\\_(ツ)_/¯ प्रस्तुत करता है", + "Room upgrade confirmation": "रूम के उन्नयन की पुष्टि", + "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 usually only affect server-side processing of the room. If you're having problems with your Riot client, please file an issue with .": "रूम का उन्नयन आमतौर पर केवल रूम के सर्वर-साइड को प्रभावित करता है। यदि आपको अपने रायट क्लाइंट के साथ समस्या हो रही है, तो कृपया के साथ एक समस्या दर्ज करें।", + "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "चेतावनी: किसी कमरे को अपग्रेड करना कमरे के सदस्यों को कमरे के नए संस्करण में स्वचालित रूप से माइग्रेट नहीं करना है। हम कमरे के पुराने संस्करण में नए कमरे के लिए एक लिंक पोस्ट करेंगे। नए कमरे में शामिल होने के लिए कमरे के सदस्यों को इस लिंक पर क्लिक करना होगा।", + "Upgrade": "अपग्रेड", + "Adds a custom widget by URL to the room": "रूम में URL द्वारा एक कस्टम विजेट जोड़ता है", + "Please supply a https:// or http:// widget URL": "कृपया एक https:// या http:// विजेट URL की आपूर्ति करें", + "You cannot modify widgets in this room.": "आप इस रूम में विजेट्स को संशोधित नहीं कर सकते।", + "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s ने कमरे में शामिल होने के लिए %(targetDisplayName)s के निमंत्रण को रद्द कर दिया।", + "User %(userId)s is already in the room": "उपयोगकर्ता %(userId)s पहले से ही रूम में है", + "The user must be unbanned before they can be invited.": "उपयोगकर्ता को आमंत्रित करने से पहले उन्हें प्रतिबंधित किया जाना चाहिए।", + "Show recent room avatars above the room list": "रूम की सूची के ऊपर हाल के अवतारों को दिखाएं", + "Enable desktop notifications for this device": "इस उपकरण के लिए डेस्कटॉप सूचनाएं सक्षम करें", + "Enable audible notifications for this device": "इस उपकरण के लिए श्रव्य सूचनाएँ सक्षम करें" } From 7f7e7221c5b2567b8579832ba5f2958f1f874ae1 Mon Sep 17 00:00:00 2001 From: Szimszon Date: Wed, 15 May 2019 19:29:45 +0000 Subject: [PATCH 80/86] Translated using Weblate (Hungarian) Currently translated at 100.0% (1593 of 1593 strings) Translation: Riot Web/matrix-react-sdk Translate-URL: https://translate.riot.im/projects/riot-web/matrix-react-sdk/hu/ --- src/i18n/strings/hu.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index c83431ac44..d0a4560dc0 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -961,7 +961,7 @@ "Failed to add tag %(tagName)s to room": "Nem sikerült hozzáadni a szobához ezt: %(tagName)s", "Clear filter": "Szűrő törlése", "Disable Community Filter Panel": "Közösség keresési panel tiltása", - "Did you know: you can use communities to filter your Riot.im experience!": "Tudtad, hogy a Riot.im élmény fokozásához használhatsz közösségeket?", + "Did you know: you can use communities to filter your Riot.im experience!": "Tudtad, hogy a Riot.im élmény fokozásához használhatsz közösségeket!", "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "A szűrő beállításához húzd a közösség avatarját a szűrő panel fölé a képernyő bal szélén. A szűrő panelen az avatarra kattintva bármikor leszűrheted azokat a szobákat és embereket akik a megadott közösséghez tartoznak.", "Your key share request has been sent - please check your other devices for key share requests.": "A kulcs megosztási kérést elküldtük - ellenőrizd a többi eszközödön a kulcs megosztási kéréseket.", "Key share requests are sent to your other devices automatically. If you rejected or dismissed the key share request on your other devices, click here to request the keys for this session again.": "A kulcs megosztási kérelem automatikusan el lett küldve a többi eszközödre. Ha elutasítottad vagy törölted a kérést a másik eszközön ide kattintva újra kérheted a kulcsokat.", @@ -1175,8 +1175,8 @@ "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "Kérlek töröld az összes általam küldött üzenetet amikor a fiókomat felfüggesztem (Figyelem: ez azt eredményezheti, hogy a jövőbeni felhasználók csak részleges beszélgetést látnak majd)", "e.g. %(exampleValue)s": "pl. %(exampleValue)s", "Reload widget": "Kisalkalmazás újratöltése", - "Please help improve Riot.im by sending anonymous usage data. This will use a cookie (please see our Cookie Policy).": "Kérlek segíts javítani a Riot.im-et azzal, hogy anonim felhasználási adatokat küldesz. Ez szütit (cookie) fog használni (lásd a sütire vonatkozó szabályozásunkat).", - "Please help improve Riot.im by sending anonymous usage data. This will use a cookie.": "Kérlek segíts javítani a Riot.im-et azzal, hogy anonim felhasználási adatokat küldesz. Ez szütit (cookie) fog használni.", + "Please help improve Riot.im by sending anonymous usage data. This will use a cookie (please see our Cookie Policy).": "Kérlek segíts javítani a Riot.im-et azzal, hogy anonim felhasználási adatokat küldesz. Ez sütit (cookie) fog használni (lásd a sütire vonatkozó szabályozásunkat).", + "Please help improve Riot.im by sending anonymous usage data. This will use a cookie.": "Kérlek segíts javítani a Riot.im-et azzal, hogy anonim felhasználási adatokat küldesz. Ez sütit (cookie) fog használni.", "Yes, I want to help!": "Igen, segítek!", "Can't leave Server Notices room": "Nem lehet elhagyni a Szerver Üzenetek szobát", "This room is used for important messages from the Homeserver, so you cannot leave it.": "Ez a szoba fontos szerverüzenetek közlésére jött létre, nem tudsz kilépni belőle.", From 2e7e71556b0df0ec04e6a6ad06ae4d8e9253ea0a Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 16 May 2019 12:45:41 +0100 Subject: [PATCH 81/86] Annotations by sender is now a Set --- src/components/views/messages/ReactionDimension.js | 2 +- src/components/views/messages/ReactionsRow.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/ReactionDimension.js b/src/components/views/messages/ReactionDimension.js index aa3813c30d..4ec50e3036 100644 --- a/src/components/views/messages/ReactionDimension.js +++ b/src/components/views/messages/ReactionDimension.js @@ -107,7 +107,7 @@ export default class ReactionDimension extends React.PureComponent { return null; } const userId = MatrixClientPeg.get().getUserId(); - return reactions.getAnnotationsBySender()[userId]; + return [...reactions.getAnnotationsBySender()[userId].values()]; } onOptionClick = (ev) => { diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.js index f4bd9781bc..780d38ceee 100644 --- a/src/components/views/messages/ReactionsRow.js +++ b/src/components/views/messages/ReactionsRow.js @@ -80,7 +80,7 @@ export default class ReactionsRow extends React.PureComponent { return null; } const userId = MatrixClientPeg.get().getUserId(); - return reactions.getAnnotationsBySender()[userId]; + return [...reactions.getAnnotationsBySender()[userId].values()]; } render() { From e942939be9dd8a60f10d450f1237b904bdcce103 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 16 May 2019 13:03:05 +0100 Subject: [PATCH 82/86] Listen for removed relations The JS SDK has learned how to remove relations when cancelled, so we should also listen for those as well. Part of https://github.com/vector-im/riot-web/issues/9731 --- src/components/views/messages/ReactionDimension.js | 6 ++++++ src/components/views/messages/ReactionsRow.js | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/components/views/messages/ReactionDimension.js b/src/components/views/messages/ReactionDimension.js index 4ec50e3036..0c91d0ac99 100644 --- a/src/components/views/messages/ReactionDimension.js +++ b/src/components/views/messages/ReactionDimension.js @@ -37,6 +37,7 @@ export default class ReactionDimension extends React.PureComponent { if (props.reactions) { props.reactions.on("Relations.add", this.onReactionsChange); + props.reactions.on("Relations.remove", this.onReactionsChange); props.reactions.on("Relations.redaction", this.onReactionsChange); } } @@ -44,6 +45,7 @@ export default class ReactionDimension extends React.PureComponent { componentDidUpdate(prevProps) { if (prevProps.reactions !== this.props.reactions) { this.props.reactions.on("Relations.add", this.onReactionsChange); + this.props.reactions.on("Relations.remove", this.onReactionsChange); this.props.reactions.on("Relations.redaction", this.onReactionsChange); this.onReactionsChange(); } @@ -55,6 +57,10 @@ export default class ReactionDimension extends React.PureComponent { "Relations.add", this.onReactionsChange, ); + this.props.reactions.removeListener( + "Relations.remove", + this.onReactionsChange, + ); this.props.reactions.removeListener( "Relations.redaction", this.onReactionsChange, diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.js index 780d38ceee..cd3ccb6809 100644 --- a/src/components/views/messages/ReactionsRow.js +++ b/src/components/views/messages/ReactionsRow.js @@ -34,6 +34,7 @@ export default class ReactionsRow extends React.PureComponent { if (props.reactions) { props.reactions.on("Relations.add", this.onReactionsChange); + props.reactions.on("Relations.remove", this.onReactionsChange); props.reactions.on("Relations.redaction", this.onReactionsChange); } @@ -45,6 +46,7 @@ export default class ReactionsRow extends React.PureComponent { componentDidUpdate(prevProps) { if (prevProps.reactions !== this.props.reactions) { this.props.reactions.on("Relations.add", this.onReactionsChange); + this.props.reactions.on("Relations.remove", this.onReactionsChange); this.props.reactions.on("Relations.redaction", this.onReactionsChange); this.onReactionsChange(); } @@ -56,6 +58,10 @@ export default class ReactionsRow extends React.PureComponent { "Relations.add", this.onReactionsChange, ); + this.props.reactions.removeListener( + "Relations.remove", + this.onReactionsChange, + ); this.props.reactions.removeListener( "Relations.redaction", this.onReactionsChange, From 006d5d7591279cfe19fd000a4599ca70c91c5570 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 16 May 2019 13:57:02 +0100 Subject: [PATCH 83/86] Fix crash for null reactions set --- src/components/views/messages/ReactionDimension.js | 6 +++++- src/components/views/messages/ReactionsRow.js | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/ReactionDimension.js b/src/components/views/messages/ReactionDimension.js index 0c91d0ac99..843254ade3 100644 --- a/src/components/views/messages/ReactionDimension.js +++ b/src/components/views/messages/ReactionDimension.js @@ -113,7 +113,11 @@ export default class ReactionDimension extends React.PureComponent { return null; } const userId = MatrixClientPeg.get().getUserId(); - return [...reactions.getAnnotationsBySender()[userId].values()]; + const myReactions = reactions.getAnnotationsBySender()[userId]; + if (!myReactions) { + return null; + } + return [...myReactions.values()]; } onOptionClick = (ev) => { diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.js index cd3ccb6809..d55ecd6578 100644 --- a/src/components/views/messages/ReactionsRow.js +++ b/src/components/views/messages/ReactionsRow.js @@ -86,7 +86,11 @@ export default class ReactionsRow extends React.PureComponent { return null; } const userId = MatrixClientPeg.get().getUserId(); - return [...reactions.getAnnotationsBySender()[userId].values()]; + const myReactions = reactions.getAnnotationsBySender()[userId]; + if (!myReactions) { + return null; + } + return [...myReactions.values()]; } render() { From f7de8d4f5862f186d9a443f15b966b5a68359e66 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 May 2019 14:43:32 +0100 Subject: [PATCH 84/86] use new events to find out about replacements --- src/components/structures/TimelinePanel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 3e79ea7f18..7c1afbe9c3 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -204,11 +204,11 @@ const TimelinePanel = React.createClass({ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); - MatrixClientPeg.get().on("Room.replaceEvent", this.onRoomReplaceEvent); MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); MatrixClientPeg.get().on("Room.accountData", this.onAccountData); MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted); + MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced); MatrixClientPeg.get().on("sync", this.onSync); this._initTimeline(this.props); @@ -283,11 +283,11 @@ const TimelinePanel = React.createClass({ client.removeListener("Room.timeline", this.onRoomTimeline); client.removeListener("Room.timelineReset", this.onRoomTimelineReset); client.removeListener("Room.redaction", this.onRoomRedaction); - client.removeListener("Room.replaceEvent", this.onRoomReplaceEvent); client.removeListener("Room.receipt", this.onRoomReceipt); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.accountData", this.onAccountData); client.removeListener("Event.decrypted", this.onEventDecrypted); + client.removeListener("Event.replaced", this.onEventReplaced); client.removeListener("sync", this.onSync); } }, @@ -507,7 +507,7 @@ const TimelinePanel = React.createClass({ this.forceUpdate(); }, - onRoomReplaceEvent: function(replacedEvent, room) { + onEventReplaced: function(replacedEvent, room) { if (this.unmounted) return; // ignore events for other rooms From 31817a91c6600ec53093b3e4212eeeae49225cbb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 May 2019 14:43:47 +0100 Subject: [PATCH 85/86] remove replacements flag --- src/MatrixClientPeg.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 82bd273846..391d089cc6 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -188,8 +188,7 @@ class MatrixClientPeg { timelineSupport: true, forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), verificationMethods: [verificationMethods.SAS], - unstableClientRelationAggregation: aggregateRelations, - unstableClientRelationReplacements: enableEdits, + unstableClientRelationAggregation: aggregateRelations || enableEdits, }; this.matrixClient = createMatrixClient(opts); From 3ef631191be8b5613e2a68b78d2a84afb7e4964b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 May 2019 14:44:00 +0100 Subject: [PATCH 86/86] show sending status for replacements as well on local echo --- src/components/structures/MessagePanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 6f21bb6951..dbaab57adf 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -532,7 +532,7 @@ module.exports = React.createClass({ readReceiptMap={this._readReceiptMap} showUrlPreview={this.props.showUrlPreview} checkUnmounting={this._isUnmounting} - eventSendStatus={mxEv.status} + eventSendStatus={mxEv.replacementOrOwnStatus()} tileShape={this.props.tileShape} isTwelveHour={this.props.isTwelveHour} permalinkCreator={this.props.permalinkCreator}