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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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 5805a88ab918fa9d46277212fec5836625e5b8e5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 11:49:43 +0100 Subject: [PATCH 53/62] 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 54/62] 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 55/62] 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 56/62] 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 57/62] 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 58/62] 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 59/62] 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 60/62] 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 61/62] 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 62/62] 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": {