From 341fdcd76155fa552bdf6764cd147e0b6725665c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 5 Aug 2019 15:50:16 +0200 Subject: [PATCH 01/47] cleanup lint errors --- src/components/views/rooms/MessageComposer.js | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index adfc0a7999..a14bac5a2a 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -19,7 +19,6 @@ import PropTypes from 'prop-types'; import { _t, _td } from '../../../languageHandler'; import CallHandler from '../../../CallHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import Modal from '../../../Modal'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import RoomViewStore from '../../../stores/RoomViewStore'; @@ -28,7 +27,6 @@ import Stickerpicker from './Stickerpicker'; import { makeRoomPermalink } from '../../../matrix-to'; import ContentMessages from '../../../ContentMessages'; import classNames from 'classnames'; - import E2EIcon from './E2EIcon'; const formatButtonList = [ @@ -51,7 +49,7 @@ function ComposerAvatar(props) { ComposerAvatar.propTypes = { me: PropTypes.object.isRequired, -} +}; function CallButton(props) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -63,15 +61,15 @@ function CallButton(props) { }); }; - return + return (); } CallButton.propTypes = { - roomId: PropTypes.string.isRequired -} + roomId: PropTypes.string.isRequired, +}; function VideoCallButton(props) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -107,15 +105,15 @@ function HangupButton(props) { room_id: call.roomId, }); }; - return ; + return (); } HangupButton.propTypes = { roomId: PropTypes.string.isRequired, -} +}; function FormattingButton(props) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -133,7 +131,7 @@ function FormattingButton(props) { FormattingButton.propTypes = { showFormatting: PropTypes.bool.isRequired, onClickHandler: PropTypes.func.isRequired, -} +}; class UploadButton extends React.Component { static propTypes = { @@ -376,7 +374,7 @@ export default class MessageComposer extends React.Component { height="17" /> ); - }) + }); return (
@@ -401,7 +399,9 @@ export default class MessageComposer extends React.Component { render() { const controls = [ this.state.me ? : null, - this.props.e2eStatus ? : null, + this.props.e2eStatus ? + : + null, ]; if (!this.state.tombstone && this.state.canSendMessages) { @@ -421,8 +421,11 @@ export default class MessageComposer extends React.Component { placeholder={this.renderPlaceholderText()} onInputStateChanged={this.onInputStateChanged} permalinkCreator={this.props.permalinkCreator} />, - showFormattingButton ? : null, + showFormattingButton ? : + null, , , callInProgress ? : null, @@ -485,5 +488,5 @@ MessageComposer.propTypes = { callState: PropTypes.string, // string representing the current room app drawer state - showApps: PropTypes.bool + showApps: PropTypes.bool, }; From 505846ce5345d0cd53bdb5a675a2cbe9efa054e5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 16:31:21 +0200 Subject: [PATCH 02/47] split up css, update class names --- res/css/_components.scss | 3 +- .../views/rooms/_BasicMessageComposer.scss | 55 +++++++++++++++++++ .../_EditMessageComposer.scss} | 46 +++------------- .../views/rooms/BasicMessageComposer.js | 26 ++++----- .../views/rooms/EditMessageComposer.js | 24 ++++---- 5 files changed, 89 insertions(+), 65 deletions(-) create mode 100644 res/css/views/rooms/_BasicMessageComposer.scss rename res/css/views/{elements/_MessageEditor.scss => rooms/_EditMessageComposer.scss} (57%) diff --git a/res/css/_components.scss b/res/css/_components.scss index b8811c742f..34d6f8a900 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -92,7 +92,6 @@ @import "./views/elements/_InteractiveTooltip.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"; @@ -135,7 +134,9 @@ @import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_Autocomplete.scss"; @import "./views/rooms/_AuxPanel.scss"; +@import "./views/rooms/_BasicMessageComposer.scss"; @import "./views/rooms/_E2EIcon.scss"; +@import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss new file mode 100644 index 0000000000..cfb957a8c5 --- /dev/null +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -0,0 +1,55 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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_BasicMessageComposer { + .mx_BasicMessageComposer_input { + padding: 3px 6px; + white-space: pre-wrap; + word-wrap: break-word; + outline: none; + overflow-x: auto; + + span.mx_UserPill, span.mx_RoomPill { + padding-left: 21px; + position: relative; + + // avatar psuedo element + &::before { + position: absolute; + left: 2px; + top: 2px; + content: var(--avatar-letter); + width: 16px; + height: 16px; + background: var(--avatar-background), $avatar-bg-color; + color: $avatar-initial-color; + background-repeat: no-repeat; + background-size: 16px; + border-radius: 8px; + text-align: center; + font-weight: normal; + line-height: 16px; + font-size: 10.4px; + } + } + } + + .mx_BasicMessageComposer_AutoCompleteWrapper { + position: relative; + height: 0; + } +} diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/rooms/_EditMessageComposer.scss similarity index 57% rename from res/css/views/elements/_MessageEditor.scss rename to res/css/views/rooms/_EditMessageComposer.scss index 7fd99bae17..cfb281b1a0 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MessageEditor { - border-radius: 4px; +.mx_EditMessageComposer { + padding: 3px; // this is to try not make the text move but still have some // padding around and in the editor. @@ -23,47 +24,19 @@ limitations under the License. margin: -7px -10px -5px -10px; overflow: visible !important; // override mx_EventTile_content - .mx_MessageEditor_editor { + + .mx_BasicMessageComposer_input { border-radius: 4px; border: solid 1px $primary-hairline-color; background-color: $primary-bg-color; - padding: 3px 6px; - white-space: pre-wrap; - word-wrap: break-word; - outline: none; max-height: 200px; - overflow-x: auto; &:focus { border-color: $accent-color-50pct; } - - span.mx_UserPill, span.mx_RoomPill { - padding-left: 21px; - position: relative; - - // avatar psuedo element - &::before { - position: absolute; - left: 2px; - top: 2px; - content: var(--avatar-letter); - width: 16px; - height: 16px; - background: var(--avatar-background), $avatar-bg-color; - color: $avatar-initial-color; - background-repeat: no-repeat; - background-size: 16px; - border-radius: 8px; - text-align: center; - font-weight: normal; - line-height: 16px; - font-size: 10.4px; - } - } } - .mx_MessageEditor_buttons { + .mx_EditMessageComposer_buttons { display: flex; flex-direction: row; justify-content: flex-end; @@ -81,14 +54,9 @@ limitations under the License. padding: 5px 40px; } } - - .mx_MessageEditor_AutoCompleteWrapper { - position: relative; - height: 0; - } } -.mx_EventTile_last .mx_MessageEditor_buttons { +.mx_EventTile_last .mx_EditMessageComposer_buttons { position: static; margin-right: -147px; } diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 76de9a6794..a5283cc15d 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -206,7 +206,7 @@ export default class BasicMessageEditor extends React.Component { if (this.state.autoComplete) { const query = this.state.query; const queryLen = query.length; - autoComplete =
+ autoComplete = (
this._autocompleteRef = ref} query={query} @@ -215,18 +215,18 @@ export default class BasicMessageEditor extends React.Component { selection={{beginning: true, end: queryLen, start: queryLen}} room={this.props.room} /> -
; +
); } - return
- { autoComplete } -
this._editorRef = ref} - aria-label={_t("Edit message")} - >
-
; + return (
+ { autoComplete } +
this._editorRef = ref} + aria-label={_t("Edit message")} + >
+
); } } diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 3ba14d9369..87e4a4a665 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -249,17 +249,17 @@ export default class EditMessageComposer extends React.Component { render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return
- -
- {_t("Cancel")} - {_t("Save")} -
-
; + return (
+ +
+ {_t("Cancel")} + {_t("Save")} +
+
); } } From 063eabed7108c1fa8d7e69b3553b4da79647d5e8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 16:32:01 +0200 Subject: [PATCH 03/47] don't return invalid indices from model, fix for #10358 --- src/editor/model.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor/model.js b/src/editor/model.js index 74546b9bf8..759e13aabb 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -80,7 +80,8 @@ export default class EditorModel { const part = this._parts[index]; return new DocumentPosition(index, part.text.length); } else { - return new DocumentPosition(0, 0); + // part index -1, as there are no parts to point at + return new DocumentPosition(-1, 0); } } From d22745a5b293fa1244e60efdcfce9fd58e3bcd34 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 16:32:43 +0200 Subject: [PATCH 04/47] make it obvious arguments are optional because now they have a setter --- src/editor/model.js | 2 +- src/editor/parts.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 759e13aabb..580085975f 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -18,7 +18,7 @@ limitations under the License. import {diffAtCaret, diffDeletion} from "./diff"; export default class EditorModel { - constructor(parts, partCreator, updateCallback) { + constructor(parts, partCreator, updateCallback = null) { this._parts = parts; this._partCreator = partCreator; this._activePartIdx = null; diff --git a/src/editor/parts.js b/src/editor/parts.js index 2a6ad81b9b..d3569a7347 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -363,7 +363,7 @@ export function autoCompleteCreator(getAutocompleterComponent, updateQuery) { } export class PartCreator { - constructor(room, client, autoCompleteCreator) { + constructor(room, client, autoCompleteCreator = null) { this._room = room; this._client = client; this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)}; From 92d72630469f4345cd8e5a0f92e1e51244e6dc91 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 17:02:47 +0200 Subject: [PATCH 05/47] move editor padding to edit specific style file as it will be different for the main composer --- res/css/views/rooms/_BasicMessageComposer.scss | 1 - res/css/views/rooms/_EditMessageComposer.scss | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index cfb957a8c5..a4f2afd795 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -17,7 +17,6 @@ limitations under the License. .mx_BasicMessageComposer { .mx_BasicMessageComposer_input { - padding: 3px 6px; white-space: pre-wrap; word-wrap: break-word; outline: none; diff --git a/res/css/views/rooms/_EditMessageComposer.scss b/res/css/views/rooms/_EditMessageComposer.scss index cfb281b1a0..214bfc4a1a 100644 --- a/res/css/views/rooms/_EditMessageComposer.scss +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -30,6 +30,7 @@ limitations under the License. border: solid 1px $primary-hairline-color; background-color: $primary-bg-color; max-height: 200px; + padding: 3px 6px; &:focus { border-color: $accent-color-50pct; From df8488e194cc83b1b366cb16943c16975ed69f8a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 17:03:16 +0200 Subject: [PATCH 06/47] pass label through props --- src/components/views/rooms/BasicMessageComposer.js | 2 +- src/components/views/rooms/EditMessageComposer.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index a5283cc15d..e498ff88ad 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -225,7 +225,7 @@ export default class BasicMessageEditor extends React.Component { tabIndex="1" onKeyDown={this._onKeyDown} ref={ref => this._editorRef = ref} - aria-label={_t("Edit message")} + aria-label={this.props.label} >
); } diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 87e4a4a665..7330405b81 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -255,6 +255,7 @@ export default class EditMessageComposer extends React.Component { model={this.model} room={this._getRoom()} initialCaret={this.props.editState.getCaret()} + label={_t("Edit message")} />
{_t("Cancel")} From cfbd2e9cc8863d6b64633edbb12415b75dea1845 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 17:03:44 +0200 Subject: [PATCH 07/47] support basic sending with new main composer this removes all formatting options, as the new editor doesn't have any. --- res/css/_components.scss | 1 + res/css/views/rooms/_SendMessageComposer.scss | 30 +++++ src/components/views/rooms/MessageComposer.js | 121 +----------------- .../views/rooms/SendMessageComposer.js | 105 +++++++++++++++ 4 files changed, 141 insertions(+), 116 deletions(-) create mode 100644 res/css/views/rooms/_SendMessageComposer.scss create mode 100644 src/components/views/rooms/SendMessageComposer.js diff --git a/res/css/_components.scss b/res/css/_components.scss index 34d6f8a900..d19d07132c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -159,6 +159,7 @@ @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SearchableEntityList.scss"; +@import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss new file mode 100644 index 0000000000..7bb20a443b --- /dev/null +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -0,0 +1,30 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SendMessageComposer { + flex: 1; + min-height: 50px; + display: flex; + flex-direction: column; + justify-content: center; + + .mx_BasicMessageComposer { + .mx_BasicMessageComposer_input { + padding: 3px 0; + } + } +} + diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index a14bac5a2a..022d45e60e 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -16,30 +16,18 @@ limitations under the License. */ import React from 'react'; import PropTypes from 'prop-types'; -import { _t, _td } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import CallHandler from '../../../CallHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import RoomViewStore from '../../../stores/RoomViewStore'; -import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import Stickerpicker from './Stickerpicker'; import { makeRoomPermalink } from '../../../matrix-to'; import ContentMessages from '../../../ContentMessages'; import classNames from 'classnames'; import E2EIcon from './E2EIcon'; -const formatButtonList = [ - _td("bold"), - _td("italic"), - _td("deleted"), - _td("underlined"), - _td("inline-code"), - _td("block-quote"), - _td("bulleted-list"), - _td("numbered-list"), -]; - function ComposerAvatar(props) { const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); return
@@ -115,28 +103,11 @@ HangupButton.propTypes = { roomId: PropTypes.string.isRequired, }; -function FormattingButton(props) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return ; -} - -FormattingButton.propTypes = { - showFormatting: PropTypes.bool.isRequired, - onClickHandler: PropTypes.func.isRequired, -}; - class UploadButton extends React.Component { static propTypes = { roomId: PropTypes.string.isRequired, } + constructor(props, context) { super(props, context); this.onUploadClick = this.onUploadClick.bind(this); @@ -193,24 +164,14 @@ class UploadButton extends React.Component { export default class MessageComposer extends React.Component { constructor(props, context) { super(props, context); - this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); - this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this); - this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this); this.onInputStateChanged = this.onInputStateChanged.bind(this); this.onEvent = this.onEvent.bind(this); this._onRoomStateEvents = this._onRoomStateEvents.bind(this); this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this); this.renderPlaceholderText = this.renderPlaceholderText.bind(this); - this.renderFormatBar = this.renderFormatBar.bind(this); this.state = { - inputState: { - marks: [], - blockType: null, - isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'), - }, - showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'), isQuoting: Boolean(RoomViewStore.getQuotingEvent()), tombstone: this._getRoomTombstone(), canSendMessages: this.props.room.maySendMessage(), @@ -257,6 +218,7 @@ export default class MessageComposer extends React.Component { onEvent(event) { if (event.getType() !== 'm.room.encryption') return; if (event.getRoomId() !== this.props.room.roomId) return; + // TODO: put (encryption state??) in state this.forceUpdate(); } @@ -281,34 +243,12 @@ export default class MessageComposer extends React.Component { this.setState({ isQuoting }); } - onInputStateChanged(inputState) { // Merge the new input state with old to support partial updates inputState = Object.assign({}, this.state.inputState, inputState); this.setState({inputState}); } - _onAutocompleteConfirm(range, completion) { - if (this.messageComposerInput) { - this.messageComposerInput.setDisplayedCompletion(range, completion); - } - } - - onFormatButtonClicked(name, event) { - event.preventDefault(); - this.messageComposerInput.onFormatButtonClicked(name, event); - } - - onToggleFormattingClicked() { - SettingsStore.setValue("MessageComposer.showFormatting", null, SettingLevel.DEVICE, !this.state.showFormatting); - this.setState({showFormatting: !this.state.showFormatting}); - } - - onToggleMarkdownClicked(e) { - e.preventDefault(); // don't steal focus from the editor! - this.messageComposerInput.enableRichtext(!this.state.inputState.isRichTextEnabled); - } - _onTombstoneClick(ev) { ev.preventDefault(); @@ -355,47 +295,6 @@ export default class MessageComposer extends React.Component { } } - renderFormatBar() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const {marks, blockType} = this.state.inputState; - const formatButtons = formatButtonList.map((name) => { - // special-case to match the md serializer and the special-case in MessageComposerInput.js - const markName = name === 'inline-code' ? 'code' : name; - const active = marks.some(mark => mark.type === markName) || blockType === name; - const suffix = active ? '-on' : ''; - const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); - const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; - return ( - - ); - }); - - return ( -
-
- { formatButtons } -
- - -
-
- ); - } - render() { const controls = [ this.state.me ? : null, @@ -409,23 +308,16 @@ export default class MessageComposer extends React.Component { // check separately for whether we can call, but this is slightly // complex because of conference calls. - const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput"); - const showFormattingButton = this.state.inputState.isRichTextEnabled; + const SendMessageComposer = sdk.getComponent("rooms.SendMessageComposer"); const callInProgress = this.props.callState && this.props.callState !== 'ended'; controls.push( - this.messageComposerInput = c} key="controls_input" room={this.props.room} placeholder={this.renderPlaceholderText()} - onInputStateChanged={this.onInputStateChanged} permalinkCreator={this.props.permalinkCreator} />, - showFormattingButton ? : - null, , , callInProgress ? : null, @@ -461,8 +353,6 @@ export default class MessageComposer extends React.Component { ); } - const showFormatBar = this.state.showFormatting && this.state.inputState.isRichTextEnabled; - const wrapperClasses = classNames({ mx_MessageComposer_wrapper: true, mx_MessageComposer_hasE2EIcon: !!this.props.e2eStatus, @@ -474,7 +364,6 @@ export default class MessageComposer extends React.Component { { controls }
- { showFormatBar ? this.renderFormatBar() : null } ); } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js new file mode 100644 index 0000000000..50a09eb894 --- /dev/null +++ b/src/components/views/rooms/SendMessageComposer.js @@ -0,0 +1,105 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 PropTypes from 'prop-types'; +import dis from '../../../dispatcher'; +import EditorModel from '../../../editor/model'; +import {getCaretOffsetAndText} from '../../../editor/dom'; +import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize'; +import {PartCreator} from '../../../editor/parts'; +import EditorStateTransfer from '../../../utils/EditorStateTransfer'; +import {MatrixClient} from 'matrix-js-sdk'; +import BasicMessageComposer from "./BasicMessageComposer"; + +function createMessageContent(model, editedEvent) { + const body = textSerialize(model); + const content = { + msgtype: "m.text", + body, + }; + const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: false}); + if (formattedBody) { + content.format = "org.matrix.custom.html"; + content.formatted_body = formattedBody; + } + return content; +} + +export default class SendMessageComposer extends React.Component { + static propTypes = { + // the message event being edited + editState: PropTypes.instanceOf(EditorStateTransfer).isRequired, + }; + + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }; + + constructor(props, context) { + super(props, context); + this.model = null; + this._editorRef = null; + } + + _setEditorRef = ref => { + this._editorRef = ref; + }; + + _onKeyDown = (event) => { + if (event.metaKey || event.altKey || event.shiftKey) { + return; + } + if (event.key === "Enter") { + this._sendMessage(); + event.preventDefault(); + } + } + + _sendMessage = () => { + const {roomId} = this.props.room; + this.context.matrixClient.sendMessage(roomId, createMessageContent(this.model)); + this.model.reset([]); + dis.dispatch({action: 'focus_composer'}); + } + + componentWillUnmount() { + const sel = document.getSelection(); + const {caret} = getCaretOffsetAndText(this._editorRef, sel); + const parts = this.model.serializeParts(); + this.props.editState.setEditorState(caret, parts); + } + + componentWillMount() { + const partCreator = new PartCreator(this.props.room, this.context.matrixClient); + this.model = new EditorModel([], partCreator); + } + + render() { + //
+ //
+ // + return ( +
+ +
+ ); + } +} From e2e4ea493fa239583681b4aee76a2378fe368ebb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 17:22:26 +0200 Subject: [PATCH 08/47] set aria label on main composer too --- src/components/views/rooms/SendMessageComposer.js | 4 +++- src/i18n/strings/en_EN.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 50a09eb894..7f17ceba6e 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -24,6 +24,7 @@ import {PartCreator} from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import {MatrixClient} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; +import { _t } from '../../../languageHandler'; function createMessageContent(model, editedEvent) { const body = textSerialize(model); @@ -69,7 +70,7 @@ export default class SendMessageComposer extends React.Component { } } - _sendMessage = () => { + _sendMessage() { const {roomId} = this.props.room; this.context.matrixClient.sendMessage(roomId, createMessageContent(this.model)); this.model.reset([]); @@ -98,6 +99,7 @@ export default class SendMessageComposer extends React.Component { ref={this._setEditorRef} model={this.model} room={this.props.room} + label={_t("Send message")} /> ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index fd5e42bcb4..53d8529c65 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -923,6 +923,7 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", + "Send message": "Send message", "Failed to connect to integrations server": "Failed to connect to integrations server", "No integrations server is configured to manage stickers with": "No integrations server is configured to manage stickers with", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", From f9992a1fc685cb325d5560f5a2cb9998579bd21b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 17:52:47 +0200 Subject: [PATCH 09/47] implement editor placeholder --- res/css/views/rooms/_BasicMessageComposer.scss | 11 +++++++++++ src/components/views/rooms/BasicMessageComposer.js | 10 ++++++++++ src/components/views/rooms/SendMessageComposer.js | 1 + src/editor/model.js | 4 ++++ 4 files changed, 26 insertions(+) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index a4f2afd795..acec4de952 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -16,6 +16,17 @@ limitations under the License. */ .mx_BasicMessageComposer { + .mx_BasicMessageComposer_inputEmpty > :first-child:before { + content: var(--placeholder); + opacity: 0.333; + width: 0; + height: 0; + overflow: visible; + display: inline-block; + pointer-events: none; + white-space: nowrap; + } + .mx_BasicMessageComposer_input { white-space: pre-wrap; word-wrap: break-word; diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index e498ff88ad..ca8681bf4d 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -54,6 +54,16 @@ export default class BasicMessageEditor extends React.Component { console.error(err); } } + if (this.props.placeholder) { + const {isEmpty} = this.props.model; + if (isEmpty) { + this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`); + this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty"); + } else { + this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty"); + this._editorRef.style.removeProperty("--placeholder"); + } + } this.setState({autoComplete: this.props.model.autoComplete}); this.historyManager.tryPush(this.props.model, caret, inputType, diff); } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 7f17ceba6e..8cc988f6a1 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -100,6 +100,7 @@ export default class SendMessageComposer extends React.Component { model={this.model} room={this.props.room} label={_t("Send message")} + placeholder={this.props.placeholder} /> ); diff --git a/src/editor/model.js b/src/editor/model.js index 580085975f..64986cdaf2 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -35,6 +35,10 @@ export default class EditorModel { return this._partCreator; } + get isEmpty() { + return this._parts.reduce((len, part) => len + part.text.length, 0) === 0; + } + clone() { return new EditorModel(this._parts, this._partCreator, this._updateCallback); } From 64e83a81111cbf909b765604c79559b6756a8857 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 17:53:06 +0200 Subject: [PATCH 10/47] font-size --- res/css/views/rooms/_SendMessageComposer.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss index 7bb20a443b..bdc96489d4 100644 --- a/res/css/views/rooms/_SendMessageComposer.scss +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -20,6 +20,7 @@ limitations under the License. display: flex; flex-direction: column; justify-content: center; + font-size: 14px; .mx_BasicMessageComposer { .mx_BasicMessageComposer_input { From 2cff486ec0c0f2aa7de9ef91375ab0adcae4b167 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Aug 2019 17:53:23 +0200 Subject: [PATCH 11/47] set placeholder same as label --- src/components/views/rooms/SendMessageComposer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 8cc988f6a1..0bdb901dc9 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -99,7 +99,7 @@ export default class SendMessageComposer extends React.Component { ref={this._setEditorRef} model={this.model} room={this.props.room} - label={_t("Send message")} + label={this.props.placeholder} placeholder={this.props.placeholder} /> From 33c6945fc4f5f9a39ed1527a125e17e0e051cb0b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Aug 2019 14:06:44 +0200 Subject: [PATCH 12/47] align autocomplete at top of composer --- res/css/views/rooms/_SendMessageComposer.scss | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss index bdc96489d4..ce5043a5bb 100644 --- a/res/css/views/rooms/_SendMessageComposer.scss +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -19,12 +19,22 @@ limitations under the License. min-height: 50px; display: flex; flex-direction: column; - justify-content: center; font-size: 14px; + justify-content: center; + display: flex; .mx_BasicMessageComposer { + flex: 1; + display: flex; + flex-direction: column; + .mx_BasicMessageComposer_input { padding: 3px 0; + // this will center the contenteditable + // in it's parent vertically + // while keeping the autocomplete at the top + // of the composer. The parent needs to be a flex container for this to work. + margin: auto 0; } } } From fdf5fca6284b06a4ab6ca845ac6047ef374bd244 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Aug 2019 14:07:48 +0200 Subject: [PATCH 13/47] add all props to proptypes --- src/components/views/rooms/BasicMessageComposer.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index ca8681bf4d..bf006ca815 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -15,7 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; @@ -33,6 +32,9 @@ export default class BasicMessageEditor extends React.Component { static propTypes = { model: PropTypes.instanceOf(EditorModel).isRequired, room: PropTypes.instanceOf(Room).isRequired, + placeholder: PropTypes.string, + label: PropTypes.string, // the aria label + initialCaret: PropTypes.object, // See DocumentPosition in editor/model.js }; constructor(props, context) { From d4ca087abe7c866704093d0b9b2f9e51f907413a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Aug 2019 14:51:49 +0200 Subject: [PATCH 14/47] fix styling issues - grow/shrink between min and max height correctly - don't grow wider than available space - some space between editor and buttons --- res/css/views/rooms/_SendMessageComposer.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss index ce5043a5bb..ffa20d29fa 100644 --- a/res/css/views/rooms/_SendMessageComposer.scss +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -16,17 +16,22 @@ limitations under the License. .mx_SendMessageComposer { flex: 1; - min-height: 50px; display: flex; flex-direction: column; font-size: 14px; justify-content: center; display: flex; + margin-right: 6px; + // don't grow wider than available space + min-width: 0; .mx_BasicMessageComposer { flex: 1; display: flex; flex-direction: column; + // min-height at this level so the mx_BasicMessageComposer_input + // still stays vertically centered when less than 50px + min-height: 50px; .mx_BasicMessageComposer_input { padding: 3px 0; @@ -35,6 +40,9 @@ limitations under the License. // while keeping the autocomplete at the top // of the composer. The parent needs to be a flex container for this to work. margin: auto 0; + // max-height at this level so autocomplete doesn't get scrolled too + max-height: 140px; + overflow-y: auto; } } } From e39c405c55ed58113218b6d5b2167bacc21c413b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Aug 2019 15:14:16 +0200 Subject: [PATCH 15/47] restore focus_composer action --- src/components/views/rooms/BasicMessageComposer.js | 4 ++++ src/components/views/rooms/SendMessageComposer.js | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index bf006ca815..4a17ec6066 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -241,4 +241,8 @@ export default class BasicMessageEditor extends React.Component { > ); } + + focus() { + this._editorRef.focus(); + } } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 0bdb901dc9..301a0f08d1 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -82,13 +82,23 @@ export default class SendMessageComposer extends React.Component { const {caret} = getCaretOffsetAndText(this._editorRef, sel); const parts = this.model.serializeParts(); this.props.editState.setEditorState(caret, parts); + dis.unregister(this.dispatcherRef); } componentWillMount() { const partCreator = new PartCreator(this.props.room, this.context.matrixClient); this.model = new EditorModel([], partCreator); + this.dispatcherRef = dis.register(this.onAction); } + onAction = (payload) => { + switch (payload.action) { + case 'focus_composer': + this._editorRef.focus(); + break; + } + }; + render() { //
//
From 71286b5610454dfdf8cb1c8282a1a61361d9db3b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Aug 2019 15:14:50 +0200 Subject: [PATCH 16/47] restore reply_to_event action --- res/css/views/rooms/_SendMessageComposer.scss | 5 +++++ src/components/views/rooms/SendMessageComposer.js | 9 +++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss index ffa20d29fa..3304003d84 100644 --- a/res/css/views/rooms/_SendMessageComposer.scss +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -45,5 +45,10 @@ limitations under the License. overflow-y: auto; } } + + .mx_SendMessageComposer_overlayWrapper { + position: relative; + height: 0; + } } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 301a0f08d1..c45ecaa9f3 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -24,7 +24,7 @@ import {PartCreator} from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import {MatrixClient} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; -import { _t } from '../../../languageHandler'; +import ReplyPreview from "./ReplyPreview"; function createMessageContent(model, editedEvent) { const body = textSerialize(model); @@ -93,6 +93,7 @@ export default class SendMessageComposer extends React.Component { onAction = (payload) => { switch (payload.action) { + case 'reply_to_event': case 'focus_composer': this._editorRef.focus(); break; @@ -100,11 +101,11 @@ export default class SendMessageComposer extends React.Component { }; render() { - //
- //
- // return (
+
+ +
Date: Wed, 7 Aug 2019 17:44:49 +0200 Subject: [PATCH 17/47] restore insert mention for this, we need to store the last caret in the editor, to know where to insert the user pill. Because clicking on a member blurs the editor, and the selection is moved away from the editor. For this reason, we keep as cache of the last caretOffset object, invalidated by a selection with different values. The selection needs to be cloned because apparently the browser mutates the object instead of returning a new one. --- .../views/rooms/BasicMessageComposer.js | 93 +++++++++++++++---- .../views/rooms/SendMessageComposer.js | 9 ++ src/editor/model.js | 26 ++++++ 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 4a17ec6066..62e136000f 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -28,6 +28,28 @@ import {Room} from 'matrix-js-sdk'; const IS_MAC = navigator.platform.indexOf("Mac") !== -1; +function cloneSelection(selection) { + return { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset, + isCollapsed: selection.isCollapsed, + rangeCount: selection.rangeCount, + type: selection.type, + }; +} + +function selectionEquals(a: Selection, b: Selection): boolean { + return a.anchorNode === b.anchorNode && + a.anchorOffset === b.anchorOffset && + a.focusNode === b.focusNode && + a.focusOffset === b.focusOffset && + a.isCollapsed === b.isCollapsed && + a.rangeCount === b.rangeCount && + a.type === b.type; +} + export default class BasicMessageEditor extends React.Component { static propTypes = { model: PropTypes.instanceOf(EditorModel).isRequired, @@ -74,6 +96,7 @@ export default class BasicMessageEditor extends React.Component { this._modifiedFlag = true; const sel = document.getSelection(); const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); + this._setLastCaret(caret, text, sel); this.props.model.update(text, event.inputType, caret); } @@ -85,14 +108,59 @@ export default class BasicMessageEditor extends React.Component { this.props.model.update(newText, inputType, caret); } - _isCaretAtStart() { - const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === 0; + // this is used later to see if we need to recalculate the caret + // on selectionchange. If it is just a consequence of typing + // we don't need to. But if the user is navigating the caret without input + // we need to recalculate it, to be able to know where to insert content after + // losing focus + _setLastCaret(caret, text, selection) { + this._lastSelection = cloneSelection(selection); + this._lastCaret = caret; + this._lastTextLength = text.length; } - _isCaretAtEnd() { - const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === text.length; + _refreshLastCaretIfNeeded() { + // TODO: needed when going up and down in editing messages ... not sure why yet + // because the editors should stop doing this when when blurred ... + // maybe it's on focus and the _editorRef isn't available yet or something. + if (!this._editorRef) { + return; + } + const selection = document.getSelection(); + if (!this._lastSelection || !selectionEquals(this._lastSelection, selection)) { + this._lastSelection = cloneSelection(selection); + const {caret, text} = getCaretOffsetAndText(this._editorRef, selection); + this._lastCaret = caret; + this._lastTextLength = text.length; + } + return this._lastCaret; + } + + getCaret() { + return this._lastCaret; + } + + isCaretAtStart() { + return this.getCaret().offset === 0; + } + + isCaretAtEnd() { + return this.getCaret().offset === this._lastTextLength; + } + + _onBlur = () => { + document.removeEventListener("selectionchange", this._onSelectionChange); + } + + _onFocus = () => { + document.addEventListener("selectionchange", this._onSelectionChange); + // force to recalculate + this._lastSelection = null; + this._refreshLastCaretIfNeeded(); + } + + _onSelectionChange = () => { + this._refreshLastCaretIfNeeded(); } _onKeyDown = (event) => { @@ -202,17 +270,6 @@ export default class BasicMessageEditor extends React.Component { return caretPosition; } - - isCaretAtStart() { - const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === 0; - } - - isCaretAtEnd() { - const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === text.length; - } - render() { let autoComplete; if (this.state.autoComplete) { @@ -235,6 +292,8 @@ export default class BasicMessageEditor extends React.Component { className="mx_BasicMessageComposer_input" contentEditable="true" tabIndex="1" + onBlur={this._onBlur} + onFocus={this._onFocus} onKeyDown={this._onKeyDown} ref={ref => this._editorRef = ref} aria-label={this.props.label} diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index c45ecaa9f3..fdc5fdd9e2 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -97,6 +97,15 @@ export default class SendMessageComposer extends React.Component { case 'focus_composer': this._editorRef.focus(); break; + case 'insert_mention': { + const userId = payload.user_id; + const member = this.props.room.getMember(userId); + const displayName = member ? + member.rawDisplayName : payload.user_id; + const userPillPart = this.model.partCreator.userPill(displayName, userId); + this.model.insertPartAt(userPillPart, this._editorRef.getCaret()); + break; + } } }; diff --git a/src/editor/model.js b/src/editor/model.js index 64986cdaf2..3a8c02da1b 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -108,6 +108,15 @@ export default class EditorModel { this._updateCallback(caret, inputType); } + insertPartAt(part, caret) { + const position = this.positionForOffset(caret.offset, caret.atNodeEnd); + const insertIndex = this._splitAt(position); + this._insertPart(insertIndex, part); + // want to put caret after new part? + const newPosition = new DocumentPosition(insertIndex, part.text.length); + this._updateCallback(newPosition); + } + update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this.positionForOffset(diff.at, caret.atNodeEnd); @@ -232,6 +241,23 @@ export default class EditorModel { } return removedOffsetDecrease; } + // return part index where insertion will insert between at offset + _splitAt(pos) { + if (pos.index === -1) { + return 0; + } + if (pos.offset === 0) { + return pos.index; + } + const part = this._parts[pos.index]; + if (pos.offset >= part.text.length) { + return pos.index + 1; + } + + const secondPart = part.split(pos.offset); + this._insertPart(pos.index + 1, secondPart); + return pos.index + 1; + } /** * inserts `str` into the model at `pos`. From 7b3282185ab2a82bd2508a585f91e234d1d593b5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Aug 2019 17:47:24 +0200 Subject: [PATCH 18/47] update proptypes --- src/components/views/rooms/SendMessageComposer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index fdc5fdd9e2..b364ebdcc7 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -21,7 +21,6 @@ import EditorModel from '../../../editor/model'; import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize'; import {PartCreator} from '../../../editor/parts'; -import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import {MatrixClient} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; import ReplyPreview from "./ReplyPreview"; @@ -42,8 +41,9 @@ function createMessageContent(model, editedEvent) { export default class SendMessageComposer extends React.Component { static propTypes = { - // the message event being edited - editState: PropTypes.instanceOf(EditorStateTransfer).isRequired, + room: PropTypes.object.isRequired, + placeholder: PropTypes.string, + permalinkCreator: PropTypes.object.isRequired, }; static contextTypes = { From a9d6d01f1036f9855ce9f1a7b7c47feaaccc18c8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 11:41:13 +0200 Subject: [PATCH 19/47] add reply fields to message content to be sent when replying --- .../views/rooms/SendMessageComposer.js | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index b364ebdcc7..916f93d544 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -24,18 +24,49 @@ import {PartCreator} from '../../../editor/parts'; import {MatrixClient} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; import ReplyPreview from "./ReplyPreview"; +import RoomViewStore from '../../../stores/RoomViewStore'; +import ReplyThread from "../elements/ReplyThread"; + +function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { + const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); + Object.assign(content, replyContent); + + // Part of Replies fallback support - prepend the text we're sending + // with the text we're replying to + const nestedReply = ReplyThread.getNestedReplyText(repliedToEvent, permalinkCreator); + if (nestedReply) { + if (content.formatted_body) { + content.formatted_body = nestedReply.html + content.formatted_body; + } + content.body = nestedReply.body + content.body; + } + + // Clear reply_to_event as we put the message into the queue + // if the send fails, retry will handle resending. + dis.dispatch({ + action: 'reply_to_event', + event: null, + }); +} + +function createMessageContent(model, permalinkCreator) { + const repliedToEvent = RoomViewStore.getQuotingEvent(); -function createMessageContent(model, editedEvent) { const body = textSerialize(model); const content = { msgtype: "m.text", - body, + body: body, }; - const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: false}); + const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: !!repliedToEvent}); if (formattedBody) { content.format = "org.matrix.custom.html"; content.formatted_body = formattedBody; } + + if (repliedToEvent) { + addReplyToMessageContent(content, repliedToEvent, permalinkCreator); + } + return content; } @@ -72,7 +103,7 @@ export default class SendMessageComposer extends React.Component { _sendMessage() { const {roomId} = this.props.room; - this.context.matrixClient.sendMessage(roomId, createMessageContent(this.model)); + this.context.matrixClient.sendMessage(roomId, createMessageContent(this.model, this.props.permalinkCreator)); this.model.reset([]); dis.dispatch({action: 'focus_composer'}); } From d4fbe7ed691fc98840aed30b5648ef4aa45b01e8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 12:34:35 +0200 Subject: [PATCH 20/47] make editor event parsing suitable for parsing messages to be quoted --- src/editor/deserialize.js | 41 +++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js index e8fd8fb888..08827ca89a 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.js @@ -130,29 +130,29 @@ function checkIgnored(n) { return true; } +const QUOTE_LINE_PREFIX = "> "; function prefixQuoteLines(isFirstNode, parts, partCreator) { - const PREFIX = "> "; // a newline (to append a > to) wouldn't be added to parts for the first line // if there was no content before the BLOCKQUOTE, so handle that if (isFirstNode) { - parts.splice(0, 0, partCreator.plain(PREFIX)); + parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX)); } for (let i = 0; i < parts.length; i += 1) { if (parts[i].type === "newline") { - parts.splice(i + 1, 0, partCreator.plain(PREFIX)); + parts.splice(i + 1, 0, partCreator.plain(QUOTE_LINE_PREFIX)); i += 1; } } } -function parseHtmlMessage(html, partCreator) { +function parseHtmlMessage(html, partCreator, isQuotedMessage) { // 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 rootNode = new DOMParser().parseFromString(html, "text/html").body; const parts = []; let lastNode; - let inQuote = false; + let inQuote = isQuotedMessage; const state = {}; function onNodeEnter(n) { @@ -220,22 +220,29 @@ function parseHtmlMessage(html, partCreator) { return parts; } -export function parseEvent(event, partCreator) { +function parsePlainTextMessage(body, partCreator, isQuotedMessage) { + const lines = body.split("\n"); + const parts = lines.reduce((parts, line, i) => { + const isLast = i === lines.length - 1; + if (!isLast) { + parts.push(partCreator.newline()); + } + if (isQuotedMessage) { + parts.push(partCreator.plain(QUOTE_LINE_PREFIX)); + } + parts.push(...parseAtRoomMentions(line, partCreator)); + return parts; + }, []); + return parts; +} + +export function parseEvent(event, partCreator, {isQuotedMessage = false}) { const content = event.getContent(); let parts; if (content.format === "org.matrix.custom.html") { - parts = parseHtmlMessage(content.formatted_body || "", partCreator); + parts = parseHtmlMessage(content.formatted_body || "", partCreator, isQuotedMessage); } else { - const body = content.body || ""; - const lines = body.split("\n"); - parts = lines.reduce((parts, line, i) => { - const isLast = i === lines.length - 1; - const newParts = parseAtRoomMentions(line, partCreator); - if (!isLast) { - newParts.push(partCreator.newline()); - } - return parts.concat(newParts); - }, []); + parts = parsePlainTextMessage(content.body || "", partCreator, isQuotedMessage); } if (content.msgtype === "m.emote") { parts.unshift(partCreator.plain("/me ")); From ce44c651d04afd1cdf6f653b4116e82187f30975 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 12:35:29 +0200 Subject: [PATCH 21/47] keep deserialized parts compatible with part api, to avoid breakage when passing real parts --- src/editor/parts.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/parts.js b/src/editor/parts.js index d3569a7347..cf11dc74dc 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -312,7 +312,7 @@ class UserPillPart extends PillPart { serialize() { const obj = super.serialize(); - obj.userId = this.resourceId; + obj.resourceId = this.resourceId; return obj; } } @@ -403,7 +403,7 @@ export class PartCreator { case "room-pill": return this.roomPill(part.text); case "user-pill": - return this.userPill(part.text, part.userId); + return this.userPill(part.text, part.resourceId); } } From 10c218825bc4b7033be989c28576003e9a8dfad0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 12:36:05 +0200 Subject: [PATCH 22/47] allow inserting multiple parts at a position --- src/components/views/rooms/SendMessageComposer.js | 2 +- src/editor/model.js | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 916f93d544..b3c65b5a97 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -134,7 +134,7 @@ export default class SendMessageComposer extends React.Component { const displayName = member ? member.rawDisplayName : payload.user_id; const userPillPart = this.model.partCreator.userPill(displayName, userId); - this.model.insertPartAt(userPillPart, this._editorRef.getCaret()); + this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); break; } } diff --git a/src/editor/model.js b/src/editor/model.js index 3a8c02da1b..99d590bf26 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -108,12 +108,18 @@ export default class EditorModel { this._updateCallback(caret, inputType); } - insertPartAt(part, caret) { + insertPartsAt(parts, caret) { const position = this.positionForOffset(caret.offset, caret.atNodeEnd); const insertIndex = this._splitAt(position); - this._insertPart(insertIndex, part); - // want to put caret after new part? - const newPosition = new DocumentPosition(insertIndex, part.text.length); + let newTextLength = 0; + for (let i = 0; i < parts.length; ++i) { + const part = parts[i]; + newTextLength += part.text.length; + this._insertPart(insertIndex + i, part); + } + // put caret after new part + const lastPartIndex = insertIndex + parts.length - 1; + const newPosition = new DocumentPosition(lastPartIndex, newTextLength); this._updateCallback(newPosition); } From 60e10364b0642a477e3fefeb213ec412e6125174 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 12:36:19 +0200 Subject: [PATCH 23/47] add quoting functionality to new composer --- src/components/views/rooms/SendMessageComposer.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index b3c65b5a97..259e32682a 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -26,6 +26,7 @@ import BasicMessageComposer from "./BasicMessageComposer"; import ReplyPreview from "./ReplyPreview"; import RoomViewStore from '../../../stores/RoomViewStore'; import ReplyThread from "../elements/ReplyThread"; +import {parseEvent} from '../../../editor/deserialize'; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -137,6 +138,17 @@ export default class SendMessageComposer extends React.Component { this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); break; } + case 'quote': { + const {partCreator} = this.model; + const quoteParts = parseEvent(payload.event, partCreator, { isQuotedMessage: true }); + // add two newlines + quoteParts.push(partCreator.newline()); + quoteParts.push(partCreator.newline()); + this.model.insertPartsAt(quoteParts, {offset: 0}); + // refocus on composer, as we just clicked "Quote" + this._editorRef.focus(); + break; + } } }; From 2e71dd3ea8d15ce5e785e04942b49acf8410d7e2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 13:51:06 +0200 Subject: [PATCH 24/47] cleanup, move code out of big switch statement --- .../views/rooms/SendMessageComposer.js | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 259e32682a..167615c891 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -129,29 +129,36 @@ export default class SendMessageComposer extends React.Component { case 'focus_composer': this._editorRef.focus(); break; - case 'insert_mention': { - const userId = payload.user_id; - const member = this.props.room.getMember(userId); - const displayName = member ? - member.rawDisplayName : payload.user_id; - const userPillPart = this.model.partCreator.userPill(displayName, userId); - this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); + case 'insert_mention': + this._insertMention(payload.user_id); break; - } - case 'quote': { - const {partCreator} = this.model; - const quoteParts = parseEvent(payload.event, partCreator, { isQuotedMessage: true }); - // add two newlines - quoteParts.push(partCreator.newline()); - quoteParts.push(partCreator.newline()); - this.model.insertPartsAt(quoteParts, {offset: 0}); - // refocus on composer, as we just clicked "Quote" - this._editorRef.focus(); + case 'quote': + this._insertQuotedMessage(payload.event); break; - } } }; + _insertMention(userId) { + const member = this.props.room.getMember(userId); + const displayName = member ? + member.rawDisplayName : userId; + const userPillPart = this.model.partCreator.userPill(displayName, userId); + this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); + // refocus on composer, as we just clicked "Mention" + this._editorRef.focus(); + } + + _insertQuotedMessage(event) { + const {partCreator} = this.model; + const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); + // add two newlines + quoteParts.push(partCreator.newline()); + quoteParts.push(partCreator.newline()); + this.model.insertPartsAt(quoteParts, {offset: 0}); + // refocus on composer, as we just clicked "Quote" + this._editorRef.focus(); + } + render() { return (
From 9003a8836a9be95753277d6820fe256cd92a5a5b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 13:56:11 +0200 Subject: [PATCH 25/47] put dispatches together --- .../views/rooms/SendMessageComposer.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 167615c891..4c6d763411 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -41,13 +41,6 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { } content.body = nestedReply.body + content.body; } - - // Clear reply_to_event as we put the message into the queue - // if the send fails, retry will handle resending. - dis.dispatch({ - action: 'reply_to_event', - event: null, - }); } function createMessageContent(model, permalinkCreator) { @@ -103,9 +96,18 @@ export default class SendMessageComposer extends React.Component { } _sendMessage() { + const isReply = !!RoomViewStore.getQuotingEvent(); const {roomId} = this.props.room; this.context.matrixClient.sendMessage(roomId, createMessageContent(this.model, this.props.permalinkCreator)); this.model.reset([]); + if (isReply) { + // Clear reply_to_event as we put the message into the queue + // if the send fails, retry will handle resending. + dis.dispatch({ + action: 'reply_to_event', + event: null, + }); + } dis.dispatch({action: 'focus_composer'}); } From ea1faacd8bc5178026fe5bcf138bfaa5e6670e86 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 14:14:07 +0200 Subject: [PATCH 26/47] support alt+enter on mac, like slate composer --- src/components/views/rooms/BasicMessageComposer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 62e136000f..9ada9df720 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -186,7 +186,7 @@ export default class BasicMessageEditor extends React.Component { } handled = true; // insert newline on Shift+Enter - } else if (event.shiftKey && event.key === "Enter") { + } else if (event.key === "Enter" && (event.shiftKey || (IS_MAC && event.altKey))) { this._insertText("\n"); handled = true; // autocomplete or enter to send below shouldn't have any modifier keys pressed. From 0adca10f9fd4cad7217e828882d74ac09856d1a0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 17:15:12 +0200 Subject: [PATCH 27/47] make named options argument optional --- src/editor/deserialize.js | 2 +- src/editor/serialize.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js index 08827ca89a..d5efef5d1a 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.js @@ -236,7 +236,7 @@ function parsePlainTextMessage(body, partCreator, isQuotedMessage) { return parts; } -export function parseEvent(event, partCreator, {isQuotedMessage = false}) { +export function parseEvent(event, partCreator, {isQuotedMessage = false} = {}) { const content = event.getContent(); let parts; if (content.format === "org.matrix.custom.html") { diff --git a/src/editor/serialize.js b/src/editor/serialize.js index cb06eede6c..0746f6788e 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.js @@ -33,7 +33,7 @@ export function mdSerialize(model) { }, ""); } -export function htmlSerializeIfNeeded(model, {forceHTML = false}) { +export function htmlSerializeIfNeeded(model, {forceHTML = false} = {}) { const md = mdSerialize(model); const parser = new Markdown(md); if (!parser.isPlainText() || forceHTML) { From 9bc8ff7e1eb98cda655bd1cadff8bfebfec09f6d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 17:15:52 +0200 Subject: [PATCH 28/47] clear composer undo history when sending a message --- src/components/views/rooms/BasicMessageComposer.js | 4 ++++ src/components/views/rooms/SendMessageComposer.js | 2 ++ src/editor/history.js | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 9ada9df720..a488990210 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -136,6 +136,10 @@ export default class BasicMessageEditor extends React.Component { return this._lastCaret; } + clearUndoHistory() { + this.historyManager.clear(); + } + getCaret() { return this._lastCaret; } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 4c6d763411..3b65b3e83d 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -100,6 +100,8 @@ export default class SendMessageComposer extends React.Component { const {roomId} = this.props.room; this.context.matrixClient.sendMessage(roomId, createMessageContent(this.model, this.props.permalinkCreator)); this.model.reset([]); + this._editorRef.clearUndoHistory(); + if (isReply) { // Clear reply_to_event as we put the message into the queue // if the send fails, retry will handle resending. diff --git a/src/editor/history.js b/src/editor/history.js index 6fd67d067c..de052cf682 100644 --- a/src/editor/history.js +++ b/src/editor/history.js @@ -18,6 +18,10 @@ export const MAX_STEP_LENGTH = 10; export default class HistoryManager { constructor() { + this.clear(); + } + + clear() { this._stack = []; this._newlyTypedCharCount = 0; this._currentIndex = -1; From ca3539d53e6b6f8e1181b71305f61b190eb5401e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 17:16:12 +0200 Subject: [PATCH 29/47] remove dead code --- src/components/views/rooms/BasicMessageComposer.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index a488990210..ccee439237 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -220,11 +220,6 @@ export default class BasicMessageEditor extends React.Component { } } - _cancelEdit = () => { - dis.dispatch({action: "edit_event", event: null}); - dis.dispatch({action: 'focus_composer'}); - } - isModified() { return this._modifiedFlag; } From cc82353d8fa2a4f65ea66b801d9c328dce1a55fd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Aug 2019 17:18:46 +0200 Subject: [PATCH 30/47] bring back composer send history and arrow up to edit previous message --- src/ComposerHistoryManager.js | 56 ++++----------- .../views/rooms/BasicMessageComposer.js | 4 ++ .../views/rooms/SendMessageComposer.js | 70 +++++++++++++++++-- 3 files changed, 84 insertions(+), 46 deletions(-) diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js index 1b3fb588eb..33030ed6cf 100644 --- a/src/ComposerHistoryManager.js +++ b/src/ComposerHistoryManager.js @@ -15,38 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Value} from 'slate'; - import _clamp from 'lodash/clamp'; -type MessageFormat = 'rich' | 'markdown'; - -class HistoryItem { - // We store history items in their native format to ensure history is accurate - // and then convert them if our RTE has subsequently changed format. - value: Value; - format: MessageFormat = 'rich'; - - constructor(value: ?Value, format: ?MessageFormat) { - this.value = value; - this.format = format; - } - - static fromJSON(obj: Object): HistoryItem { - return new HistoryItem( - Value.fromJSON(obj.value), - obj.format, - ); - } - - toJSON(): Object { - return { - value: this.value.toJSON(), - format: this.format, - }; - } -} - export default class ComposerHistoryManager { history: Array = []; prefix: string; @@ -57,26 +27,30 @@ export default class ComposerHistoryManager { this.prefix = prefix + roomId; // TODO: Performance issues? - let item; - for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { + let index = 0; + let itemJSON; + + while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) { try { - this.history.push( - HistoryItem.fromJSON(JSON.parse(item)), - ); + const serializedParts = JSON.parse(itemJSON); + this.history.push(serializedParts); } catch (e) { console.warn("Throwing away unserialisable history", e); + break; } + ++index; } - this.lastIndex = this.currentIndex; + this.lastIndex = this.history.length - 1; // reset currentIndex to account for any unserialisable history - this.currentIndex = this.history.length; + this.currentIndex = this.lastIndex + 1; } - save(value: Value, format: MessageFormat) { - const item = new HistoryItem(value, format); - this.history.push(item); + save(editorModel: Object) { + const serializedParts = editorModel.serializeParts(); + this.history.push(serializedParts); this.currentIndex = this.history.length; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); + this.lastIndex += 1; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts)); } getItem(offset: number): ?HistoryItem { diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index ccee439237..92abefa117 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -144,6 +144,10 @@ export default class BasicMessageEditor extends React.Component { return this._lastCaret; } + isSelectionCollapsed() { + return !this._lastSelection || this._lastSelection.isCollapsed; + } + isCaretAtStart() { return this.getCaret().offset === 0; } diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 3b65b3e83d..a400433aef 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -27,6 +27,8 @@ import ReplyPreview from "./ReplyPreview"; import RoomViewStore from '../../../stores/RoomViewStore'; import ReplyThread from "../elements/ReplyThread"; import {parseEvent} from '../../../editor/deserialize'; +import {findEditableEvent} from '../../../utils/EventUtils'; +import ComposerHistoryManager from "../../../ComposerHistoryManager"; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -79,6 +81,7 @@ export default class SendMessageComposer extends React.Component { super(props, context); this.model = null; this._editorRef = null; + this.currentlyComposedEditorState = null; } _setEditorRef = ref => { @@ -86,19 +89,75 @@ export default class SendMessageComposer extends React.Component { }; _onKeyDown = (event) => { - if (event.metaKey || event.altKey || event.shiftKey) { - return; - } - if (event.key === "Enter") { + const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; + if (event.key === "Enter" && !hasModifier) { this._sendMessage(); event.preventDefault(); + } else if (event.key === "ArrowUp") { + this.onVerticalArrow(event, true); + } else if (event.key === "ArrowDown") { + this.onVerticalArrow(event, false); + } + } + + onVerticalArrow(e, up) { + if (e.ctrlKey || e.shiftKey || e.metaKey) return; + + const shouldSelectHistory = e.altKey; + const shouldEditLastMessage = !e.altKey && up && !RoomViewStore.getQuotingEvent(); + + if (shouldSelectHistory) { + // Try select composer history + const selected = this.selectSendHistory(up); + if (selected) { + // We're selecting history, so prevent the key event from doing anything else + e.preventDefault(); + } + } else if (shouldEditLastMessage) { + // selection must be collapsed and caret at start + if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) { + const editEvent = findEditableEvent(this.props.room, false); + if (editEvent) { + // We're selecting history, so prevent the key event from doing anything else + e.preventDefault(); + dis.dispatch({ + action: 'edit_event', + event: editEvent, + }); + } + } + } + } + + selectSendHistory(up) { + const delta = up ? -1 : 1; + + // True if we are not currently selecting history, but composing a message + if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) { + // We can't go any further - there isn't any more history, so nop. + if (!up) { + return; + } + this.currentlyComposedEditorState = this.model.serializeParts(); + } else if (this.sendHistoryManager.currentIndex + delta === this.sendHistoryManager.history.length) { + // True when we return to the message being composed currently + this.model.reset(this.currentlyComposedEditorState); + this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length; + return; + } + const serializedParts = this.sendHistoryManager.getItem(delta); + if (serializedParts) { + this.model.reset(serializedParts); + this._editorRef.focus(); } } _sendMessage() { const isReply = !!RoomViewStore.getQuotingEvent(); const {roomId} = this.props.room; - this.context.matrixClient.sendMessage(roomId, createMessageContent(this.model, this.props.permalinkCreator)); + const content = createMessageContent(this.model, this.props.permalinkCreator); + this.context.matrixClient.sendMessage(roomId, content); + this.sendHistoryManager.save(this.model); this.model.reset([]); this._editorRef.clearUndoHistory(); @@ -125,6 +184,7 @@ export default class SendMessageComposer extends React.Component { const partCreator = new PartCreator(this.props.room, this.context.matrixClient); this.model = new EditorModel([], partCreator); this.dispatcherRef = dis.register(this.onAction); + this.sendHistoryManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); } onAction = (payload) => { From 88cc1c428dd5df6318228bd0b3ec836f8876694c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 11:26:21 +0200 Subject: [PATCH 31/47] add support for emotes and running /commands this does not yet include autocomplete for commands --- .../views/rooms/EditMessageComposer.js | 13 +-- .../views/rooms/SendMessageComposer.js | 88 +++++++++++++++---- src/editor/serialize.js | 12 +++ 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 7330405b81..d58279436d 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -21,7 +21,7 @@ import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {getCaretOffsetAndText} from '../../../editor/dom'; -import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize'; +import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import {parseEvent} from '../../../editor/deserialize'; import {PartCreator} from '../../../editor/parts'; @@ -56,17 +56,10 @@ function getTextReplyFallback(mxEvent) { return ""; } -function _isEmote(model) { - const firstPart = model.parts[0]; - return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me "); -} - function createEditContent(model, editedEvent) { - const isEmote = _isEmote(model); + const isEmote = containsEmote(model); if (isEmote) { - // trim "/me " - model = model.clone(); - model.removeText({index: 0, offset: 0}, 4); + model = stripEmoteCommand(model); } const isReply = _isReply(editedEvent); let plainPrefix = ""; diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index a400433aef..bd5b5b9102 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -19,7 +19,7 @@ import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {getCaretOffsetAndText} from '../../../editor/dom'; -import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize'; +import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; import {PartCreator} from '../../../editor/parts'; import {MatrixClient} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; @@ -29,6 +29,10 @@ import ReplyThread from "../elements/ReplyThread"; import {parseEvent} from '../../../editor/deserialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import ComposerHistoryManager from "../../../ComposerHistoryManager"; +import {processCommandInput} from '../../../SlashCommands'; +import sdk from '../../../index'; +import Modal from '../../../Modal'; +import { _t } from '../../../languageHandler'; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -46,11 +50,15 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { } function createMessageContent(model, permalinkCreator) { + const isEmote = containsEmote(model); + if (isEmote) { + model = stripEmoteCommand(model); + } const repliedToEvent = RoomViewStore.getQuotingEvent(); const body = textSerialize(model); const content = { - msgtype: "m.text", + msgtype: isEmote ? "m.emote" : "m.text", body: body, }; const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: !!repliedToEvent}); @@ -129,9 +137,10 @@ export default class SendMessageComposer extends React.Component { } } + // we keep sent messages/commands in a separate history (separate from undo history) + // so you can alt+up/down in them selectSendHistory(up) { const delta = up ? -1 : 1; - // True if we are not currently selecting history, but composing a message if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) { // We can't go any further - there isn't any more history, so nop. @@ -152,24 +161,69 @@ export default class SendMessageComposer extends React.Component { } } + _isSlashCommand() { + const parts = this.model.parts; + const isPlain = parts.reduce((isPlain, part) => { + return isPlain && (part.type === "plain" || part.type === "newline"); + }, true); + return isPlain && parts.length > 0 && parts[0].text.startsWith("/"); + } + + async _runSlashCommand() { + const commandText = this.model.parts.reduce((text, part) => { + return text + part.text; + }, ""); + const cmd = processCommandInput(this.props.room.roomId, commandText); + + if (cmd) { + let error = cmd.error; + if (cmd.promise) { + try { + await cmd.promise; + } catch (err) { + error = err; + } + } + if (error) { + console.error("Command failure: %s", error); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + // assume the error is a server error when the command is async + const isServerError = !!cmd.promise; + const title = isServerError ? "Server error" : "Command error"; + Modal.createTrackedDialog(title, '', ErrorDialog, { + title: isServerError ? _t("Server error") : _t("Command error"), + description: error.message ? error.message : _t( + "Server unavailable, overloaded, or something else went wrong.", + ), + }); + } else { + console.log("Command success."); + } + } + } + _sendMessage() { - const isReply = !!RoomViewStore.getQuotingEvent(); - const {roomId} = this.props.room; - const content = createMessageContent(this.model, this.props.permalinkCreator); - this.context.matrixClient.sendMessage(roomId, content); + if (!containsEmote(this.model) && this._isSlashCommand()) { + this._runSlashCommand(); + } else { + const isReply = !!RoomViewStore.getQuotingEvent(); + const {roomId} = this.props.room; + const content = createMessageContent(this.model, this.props.permalinkCreator); + this.context.matrixClient.sendMessage(roomId, content); + if (isReply) { + // Clear reply_to_event as we put the message into the queue + // if the send fails, retry will handle resending. + dis.dispatch({ + action: 'reply_to_event', + event: null, + }); + } + } this.sendHistoryManager.save(this.model); + // clear composer this.model.reset([]); this._editorRef.clearUndoHistory(); - - if (isReply) { - // Clear reply_to_event as we put the message into the queue - // if the send fails, retry will handle resending. - dis.dispatch({ - action: 'reply_to_event', - event: null, - }); - } - dis.dispatch({action: 'focus_composer'}); + this._editorRef.focus(); } componentWillUnmount() { diff --git a/src/editor/serialize.js b/src/editor/serialize.js index 0746f6788e..756a27dd03 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.js @@ -56,3 +56,15 @@ export function textSerialize(model) { } }, ""); } + +export function containsEmote(model) { + const firstPart = model.parts[0]; + return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me "); +} + +export function stripEmoteCommand(model) { + // trim "/me " + model = model.clone(); + model.removeText({index: 0, offset: 0}, 4); + return model; +} From c5cd8b943aa20471c58f15ec4ce86f30354343e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 15:27:50 +0200 Subject: [PATCH 32/47] support auto complete for /commands --- .../views/rooms/SendMessageComposer.js | 10 ++----- src/editor/model.js | 2 +- src/editor/parts.js | 30 +++++++++++++++++++ src/editor/serialize.js | 8 ++++- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index bd5b5b9102..da858e9029 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -20,7 +20,7 @@ import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; -import {PartCreator} from '../../../editor/parts'; +import {CommandPartCreator} from '../../../editor/parts'; import {MatrixClient} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; import ReplyPreview from "./ReplyPreview"; @@ -164,7 +164,7 @@ export default class SendMessageComposer extends React.Component { _isSlashCommand() { const parts = this.model.parts; const isPlain = parts.reduce((isPlain, part) => { - return isPlain && (part.type === "plain" || part.type === "newline"); + return isPlain && (part.type === "command" || part.type === "plain" || part.type === "newline"); }, true); return isPlain && parts.length > 0 && parts[0].text.startsWith("/"); } @@ -227,15 +227,11 @@ export default class SendMessageComposer extends React.Component { } componentWillUnmount() { - const sel = document.getSelection(); - const {caret} = getCaretOffsetAndText(this._editorRef, sel); - const parts = this.model.serializeParts(); - this.props.editState.setEditorState(caret, parts); dis.unregister(this.dispatcherRef); } componentWillMount() { - const partCreator = new PartCreator(this.props.room, this.context.matrixClient); + const partCreator = new CommandPartCreator(this.props.room, this.context.matrixClient); this.model = new EditorModel([], partCreator); this.dispatcherRef = dis.register(this.onAction); this.sendHistoryManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); diff --git a/src/editor/model.js b/src/editor/model.js index 99d590bf26..91b724cf9e 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -303,7 +303,7 @@ export default class EditorModel { index = 0; } while (str) { - const newPart = this._partCreator.createPartForInput(str); + const newPart = this._partCreator.createPartForInput(str, index); if (validate) { str = newPart.appendUntilRejected(str); } else { diff --git a/src/editor/parts.js b/src/editor/parts.js index cf11dc74dc..f9b4243de4 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -441,3 +441,33 @@ export class PartCreator { } } +// part creator that support auto complete for /commands, +// used in SendMessageComposer +export class CommandPartCreator extends PartCreator { + createPartForInput(text, partIndex) { + // at beginning and starts with /? create + if (partIndex === 0 && text[0] === "/") { + return new CommandPart("", this._autoCompleteCreator); + } else { + return super.createPartForInput(text, partIndex); + } + } + + deserializePart(part) { + if (part.type === "command") { + return new CommandPart(part.text, this._autoCompleteCreator); + } else { + return super.deserializePart(part); + } + } +} + +class CommandPart extends PillCandidatePart { + acceptsInsertion(chr, i) { + return PlainPart.prototype.acceptsInsertion.call(this, chr, i); + } + + get type() { + return "command"; + } +} diff --git a/src/editor/serialize.js b/src/editor/serialize.js index 756a27dd03..5a1a941309 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.js @@ -23,6 +23,7 @@ export function mdSerialize(model) { case "newline": return html + "\n"; case "plain": + case "command": case "pill-candidate": case "at-room-pill": return html + part.text; @@ -47,6 +48,7 @@ export function textSerialize(model) { case "newline": return text + "\n"; case "plain": + case "command": case "pill-candidate": case "at-room-pill": return text + part.text; @@ -59,7 +61,11 @@ export function textSerialize(model) { export function containsEmote(model) { const firstPart = model.parts[0]; - return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me "); + // part type will be "plain" while editing, + // and "command" while composing a message. + return firstPart && + (firstPart.type === "plain" || firstPart.type === "command") && + firstPart.text.startsWith("/me "); } export function stripEmoteCommand(model) { From 3c5cf3e778c61722bdaaa00841cc31a9031ce3d1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 15:34:49 +0200 Subject: [PATCH 33/47] rename ComposerHistoryManager to SendHistoryManager to avoid confusion ...with the undo history manager for the composer. --- src/{ComposerHistoryManager.js => SendHistoryManager.js} | 2 +- src/components/views/rooms/SendMessageComposer.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) rename src/{ComposerHistoryManager.js => SendHistoryManager.js} (97%) diff --git a/src/ComposerHistoryManager.js b/src/SendHistoryManager.js similarity index 97% rename from src/ComposerHistoryManager.js rename to src/SendHistoryManager.js index 33030ed6cf..c838f1246b 100644 --- a/src/ComposerHistoryManager.js +++ b/src/SendHistoryManager.js @@ -17,7 +17,7 @@ limitations under the License. import _clamp from 'lodash/clamp'; -export default class ComposerHistoryManager { +export default class SendHistoryManager { history: Array = []; prefix: string; lastIndex: number = 0; // used for indexing the storage diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index da858e9029..0d1d24c282 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -18,7 +18,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; -import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; import {CommandPartCreator} from '../../../editor/parts'; import {MatrixClient} from 'matrix-js-sdk'; @@ -28,7 +27,7 @@ import RoomViewStore from '../../../stores/RoomViewStore'; import ReplyThread from "../elements/ReplyThread"; import {parseEvent} from '../../../editor/deserialize'; import {findEditableEvent} from '../../../utils/EventUtils'; -import ComposerHistoryManager from "../../../ComposerHistoryManager"; +import SendHistoryManager from "../../../SendHistoryManager"; import {processCommandInput} from '../../../SlashCommands'; import sdk from '../../../index'; import Modal from '../../../Modal'; @@ -234,7 +233,7 @@ export default class SendMessageComposer extends React.Component { const partCreator = new CommandPartCreator(this.props.room, this.context.matrixClient); this.model = new EditorModel([], partCreator); this.dispatcherRef = dis.register(this.onAction); - this.sendHistoryManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); + this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); } onAction = (payload) => { From e2dfe888ccae21e8eeec83686fa374cc24704967 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 16:22:04 +0200 Subject: [PATCH 34/47] only capture Enter when autocompletion list has selection this is the old behaviour and makes sense IMO also close the auto complete when resetting the composer model, in case it was still open --- .../views/rooms/BasicMessageComposer.js | 25 ++++++++++++++----- src/editor/autocomplete.js | 6 ++++- src/editor/model.js | 7 ++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 92abefa117..d4b9cd87c9 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -203,19 +203,32 @@ export default class BasicMessageEditor extends React.Component { const autoComplete = model.autoComplete; switch (event.key) { case "Enter": - autoComplete.onEnter(event); break; + // only capture enter when something is selected in the list, + // otherwise don't handle so the contents of the composer gets sent + if (autoComplete.hasSelection()) { + autoComplete.onEnter(event); + handled = true; + } + break; case "ArrowUp": - autoComplete.onUpArrow(event); break; + autoComplete.onUpArrow(event); + handled = true; + break; case "ArrowDown": - autoComplete.onDownArrow(event); break; + autoComplete.onDownArrow(event); + handled = true; + break; case "Tab": - autoComplete.onTab(event); break; + autoComplete.onTab(event); + handled = true; + break; case "Escape": - autoComplete.onEscape(event); break; + autoComplete.onEscape(event); + handled = true; + break; default: return; // don't preventDefault on anything else } - handled = true; } } if (handled) { diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index 2aedf8d7f5..ac662c32d8 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -33,6 +33,10 @@ export default class AutocompleteWrapperModel { }); } + hasSelection() { + return this._getAutocompleterComponent().hasSelection(); + } + onEnter() { this._updateCallback({close: true}); } @@ -103,7 +107,7 @@ export default class AutocompleteWrapperModel { } case "#": return this._partCreator.roomPill(completionId); - // also used for emoji completion + // used for emoji and command completion replacement default: return this._partCreator.plain(text); } diff --git a/src/editor/model.js b/src/editor/model.js index 91b724cf9e..2f1e5218d8 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -105,6 +105,13 @@ export default class EditorModel { reset(serializedParts, caret, inputType) { this._parts = serializedParts.map(p => this._partCreator.deserializePart(p)); + // close auto complete if open + // this would happen when clearing the composer after sending + // a message with the autocomplete still open + if (this._autoComplete) { + this._autoComplete = null; + this._autoCompletePartIdx = null; + } this._updateCallback(caret, inputType); } From 6df46cc319b5b95cc32437920328d6c048d4b339 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 16:40:16 +0200 Subject: [PATCH 35/47] send typing notifs in new composer (both send and edit) --- src/components/views/rooms/BasicMessageComposer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index d4b9cd87c9..19111f1f45 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; import PropTypes from 'prop-types'; -import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; import {setCaretPosition} from '../../../editor/caret'; @@ -25,6 +24,7 @@ import Autocomplete from '../rooms/Autocomplete'; import {autoCompleteCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; import {Room} from 'matrix-js-sdk'; +import TypingStore from "../../../stores/TypingStore"; const IS_MAC = navigator.platform.indexOf("Mac") !== -1; @@ -90,6 +90,7 @@ export default class BasicMessageEditor extends React.Component { } this.setState({autoComplete: this.props.model.autoComplete}); this.historyManager.tryPush(this.props.model, caret, inputType, diff); + TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, !this.props.model.isEmpty); } _onInput = (event) => { From 9f72268df7c72f79e0e4983700a6dd5e640d2efa Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 16:40:35 +0200 Subject: [PATCH 36/47] avoid null-refs when receiving an action before initial rendering --- src/components/views/rooms/SendMessageComposer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 0d1d24c282..4137de54c5 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -240,7 +240,7 @@ export default class SendMessageComposer extends React.Component { switch (payload.action) { case 'reply_to_event': case 'focus_composer': - this._editorRef.focus(); + this._editorRef && this._editorRef.focus(); break; case 'insert_mention': this._insertMention(payload.user_id); @@ -258,7 +258,7 @@ export default class SendMessageComposer extends React.Component { const userPillPart = this.model.partCreator.userPill(displayName, userId); this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); // refocus on composer, as we just clicked "Mention" - this._editorRef.focus(); + this._editorRef && this._editorRef.focus(); } _insertQuotedMessage(event) { @@ -269,7 +269,7 @@ export default class SendMessageComposer extends React.Component { quoteParts.push(partCreator.newline()); this.model.insertPartsAt(quoteParts, {offset: 0}); // refocus on composer, as we just clicked "Quote" - this._editorRef.focus(); + this._editorRef && this._editorRef.focus(); } render() { From b366b0b3d845b5e4d0cc43933d398874dc55c751 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 17:42:09 +0200 Subject: [PATCH 37/47] store composer state when typing in new composer this doesn't use the MessageComposerStore on purpose so that both the new and old composer don't overwrite each others state, as the format is different. --- .../views/rooms/BasicMessageComposer.js | 5 ++++ .../views/rooms/SendMessageComposer.js | 30 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 19111f1f45..e4179d9c3b 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -52,6 +52,7 @@ function selectionEquals(a: Selection, b: Selection): boolean { export default class BasicMessageEditor extends React.Component { static propTypes = { + onChange: PropTypes.func, model: PropTypes.instanceOf(EditorModel).isRequired, room: PropTypes.instanceOf(Room).isRequired, placeholder: PropTypes.string, @@ -91,6 +92,10 @@ export default class BasicMessageEditor extends React.Component { this.setState({autoComplete: this.props.model.autoComplete}); this.historyManager.tryPush(this.props.model, caret, inputType, diff); TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, !this.props.model.isEmpty); + + if (this.props.onChange) { + this.props.onChange(); + } } _onInput = (event) => { diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 4137de54c5..7d831dad4a 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -223,6 +223,7 @@ export default class SendMessageComposer extends React.Component { this.model.reset([]); this._editorRef.clearUndoHistory(); this._editorRef.focus(); + this._clearStoredEditorState(); } componentWillUnmount() { @@ -231,11 +232,37 @@ export default class SendMessageComposer extends React.Component { componentWillMount() { const partCreator = new CommandPartCreator(this.props.room, this.context.matrixClient); - this.model = new EditorModel([], partCreator); + const parts = this._restoreStoredEditorState(partCreator) || []; + this.model = new EditorModel(parts, partCreator); this.dispatcherRef = dis.register(this.onAction); this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); } + get _editorStateKey() { + return `cider_editor_state_${this.props.room.roomId}`; + } + + _clearStoredEditorState() { + localStorage.removeItem(this._editorStateKey); + } + + _restoreStoredEditorState(partCreator) { + const json = localStorage.getItem(this._editorStateKey); + if (json) { + const serializedParts = JSON.parse(json); + const parts = serializedParts.map(p => partCreator.deserializePart(p)); + return parts; + } + } + + _saveStoredEditorState = () => { + if (this.model.isEmpty) { + this._clearStoredEditorState(); + } else { + localStorage.setItem(this._editorStateKey, JSON.stringify(this.model.serializeParts())); + } + } + onAction = (payload) => { switch (payload.action) { case 'reply_to_event': @@ -284,6 +311,7 @@ export default class SendMessageComposer extends React.Component { room={this.props.room} label={this.props.placeholder} placeholder={this.props.placeholder} + onChange={this._saveStoredEditorState} />
); From 36390da634bd6408afa549722c91594bcced0217 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Aug 2019 17:43:26 +0200 Subject: [PATCH 38/47] some doc improvements --- docs/ciderEditor.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md index 2448be852a..e67c74a95c 100644 --- a/docs/ciderEditor.md +++ b/docs/ciderEditor.md @@ -17,7 +17,7 @@ The parts are then reconciled with the DOM. When typing in the `contenteditable` element, the `input` event fires and the DOM of the editor is turned into a string. The way this is done has some logic to it to deal with adding newlines for block elements, to make sure -the caret offset is calculated in the same way as the content string, and the ignore +the caret offset is calculated in the same way as the content string, and to ignore caret nodes (more on that later). For these reasons it doesn't use `innerText`, `textContent` or anything similar. The model addresses any content in the editor within as an offset within this string. @@ -25,13 +25,13 @@ The caret position is thus also converted from a position in the DOM tree to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.js`. Once the content string and caret offset is calculated, it is passed to the `update()` -method of the model. The model first calculates the same content string its current parts, +method of the model. The model first calculates the same content string of its current parts, basically just concatenating their text. It then looks for differences between the current and the new content string. The diffing algorithm is very basic, and assumes there is only one change around the caret offset, so this should be very inexpensive. See `diff.js` for details. -The result of the diffing is the strings that was added and/or removed from +The result of the diffing is the strings that were added and/or removed from the current content. These differences are then applied to the parts, where parts can apply validation logic to these changes. @@ -48,7 +48,8 @@ to leave the parts it intersects alone. The benefit of this is that we can use the `input` event, which is broadly supported, to find changes in the editor. We don't have to rely on keyboard events, -which relate poorly to text input or changes. +which relate poorly to text input or changes, and don't need the `beforeinput` event, +which isn't broadly supported yet. Once the parts of the model are updated, the DOM of the editor is then reconciled with the new model state, see `renderModel` in `render.js` for this. From 6a3ecde4e6b3fd515738a5a93ccb51b5a7bfa4a0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:05:54 +0100 Subject: [PATCH 39/47] duplicate slate code where we changed it drastically to still make it work when the feature flag will be turned off --- .eslintignore.errorfiles | 2 +- src/SlateComposerHistoryManager.js | 86 +++ .../views/rooms/MessageComposerInput.js | 6 +- .../views/rooms/SlateMessageComposer.js | 489 ++++++++++++++++++ 4 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 src/SlateComposerHistoryManager.js create mode 100644 src/components/views/rooms/SlateMessageComposer.js diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index c129f801a1..02629ea169 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -34,7 +34,7 @@ src/components/views/rooms/LinkPreviewWidget.js src/components/views/rooms/MemberDeviceInfo.js src/components/views/rooms/MemberInfo.js src/components/views/rooms/MemberList.js -src/components/views/rooms/MessageComposer.js +src/components/views/rooms/SlateMessageComposer.js src/components/views/rooms/PinnedEventTile.js src/components/views/rooms/RoomList.js src/components/views/rooms/RoomPreviewBar.js diff --git a/src/SlateComposerHistoryManager.js b/src/SlateComposerHistoryManager.js new file mode 100644 index 0000000000..948dcf64ff --- /dev/null +++ b/src/SlateComposerHistoryManager.js @@ -0,0 +1,86 @@ +//@flow +/* +Copyright 2017 Aviral Dasgupta + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Value} from 'slate'; + +import _clamp from 'lodash/clamp'; + +type MessageFormat = 'rich' | 'markdown'; + +class HistoryItem { + // We store history items in their native format to ensure history is accurate + // and then convert them if our RTE has subsequently changed format. + value: Value; + format: MessageFormat = 'rich'; + + constructor(value: ?Value, format: ?MessageFormat) { + this.value = value; + this.format = format; + } + + static fromJSON(obj: Object): HistoryItem { + return new HistoryItem( + Value.fromJSON(obj.value), + obj.format, + ); + } + + toJSON(): Object { + return { + value: this.value.toJSON(), + format: this.format, + }; + } +} + +export default class SlateComposerHistoryManager { + history: Array = []; + prefix: string; + lastIndex: number = 0; // used for indexing the storage + currentIndex: number = 0; // used for indexing the loaded validated history Array + + constructor(roomId: string, prefix: string = 'mx_composer_history_') { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + let item; + for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { + try { + this.history.push( + HistoryItem.fromJSON(JSON.parse(item)), + ); + } catch (e) { + console.warn("Throwing away unserialisable history", e); + } + } + this.lastIndex = this.currentIndex; + // reset currentIndex to account for any unserialisable history + this.currentIndex = this.history.length; + } + + save(value: Value, format: MessageFormat) { + const item = new HistoryItem(value, format); + this.history.push(item); + this.currentIndex = this.history.length; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); + } + + getItem(offset: number): ?HistoryItem { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + return this.history[this.currentIndex]; + } +} diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index ca25ada12d..df7ba27493 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -61,7 +61,7 @@ import ReplyThread from "../elements/ReplyThread"; import {ContentHelpers} from 'matrix-js-sdk'; import AccessibleButton from '../elements/AccessibleButton'; import {findEditableEvent} from '../../../utils/EventUtils'; -import ComposerHistoryManager from "../../../ComposerHistoryManager"; +import SlateComposerHistoryManager from "../../../SlateComposerHistoryManager"; import TypingStore from "../../../stores/TypingStore"; const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -141,7 +141,7 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; - historyManager: ComposerHistoryManager; + historyManager: SlateComposerHistoryManager; constructor(props, context) { super(props, context); @@ -331,7 +331,7 @@ export default class MessageComposerInput extends React.Component { componentWillMount() { this.dispatcherRef = dis.register(this.onAction); - this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); + this.historyManager = new SlateComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); } componentWillUnmount() { diff --git a/src/components/views/rooms/SlateMessageComposer.js b/src/components/views/rooms/SlateMessageComposer.js new file mode 100644 index 0000000000..d7aa745753 --- /dev/null +++ b/src/components/views/rooms/SlateMessageComposer.js @@ -0,0 +1,489 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017, 2018 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 PropTypes from 'prop-types'; +import { _t, _td } from '../../../languageHandler'; +import CallHandler from '../../../CallHandler'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import RoomViewStore from '../../../stores/RoomViewStore'; +import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; +import Stickerpicker from './Stickerpicker'; +import { makeRoomPermalink } from '../../../matrix-to'; +import ContentMessages from '../../../ContentMessages'; +import classNames from 'classnames'; + +import E2EIcon from './E2EIcon'; + +const formatButtonList = [ + _td("bold"), + _td("italic"), + _td("deleted"), + _td("underlined"), + _td("inline-code"), + _td("block-quote"), + _td("bulleted-list"), + _td("numbered-list"), +]; + +function ComposerAvatar(props) { + const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); + return
+ +
; +} + +ComposerAvatar.propTypes = { + me: PropTypes.object.isRequired, +} + +function CallButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const onVoiceCallClick = (ev) => { + dis.dispatch({ + action: 'place_call', + type: "voice", + room_id: props.roomId, + }); + }; + + return +} + +CallButton.propTypes = { + roomId: PropTypes.string.isRequired +} + +function VideoCallButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const onCallClick = (ev) => { + dis.dispatch({ + action: 'place_call', + type: ev.shiftKey ? "screensharing" : "video", + room_id: props.roomId, + }); + }; + + return ; +} + +VideoCallButton.propTypes = { + roomId: PropTypes.string.isRequired, +}; + +function HangupButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const onHangupClick = () => { + const call = CallHandler.getCallForRoom(props.roomId); + if (!call) { + return; + } + dis.dispatch({ + action: 'hangup', + // hangup the call for this room, which may not be the room in props + // (e.g. conferences which will hangup the 1:1 room instead) + room_id: call.roomId, + }); + }; + return ; +} + +HangupButton.propTypes = { + roomId: PropTypes.string.isRequired, +} + +function FormattingButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ; +} + +FormattingButton.propTypes = { + showFormatting: PropTypes.bool.isRequired, + onClickHandler: PropTypes.func.isRequired, +} + +class UploadButton extends React.Component { + static propTypes = { + roomId: PropTypes.string.isRequired, + } + constructor(props, context) { + super(props, context); + this.onUploadClick = this.onUploadClick.bind(this); + this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this); + } + + onUploadClick(ev) { + if (MatrixClientPeg.get().isGuest()) { + dis.dispatch({action: 'require_registration'}); + return; + } + this.refs.uploadInput.click(); + } + + onUploadFileInputChange(ev) { + if (ev.target.files.length === 0) return; + + // take a copy so we can safely reset the value of the form control + // (Note it is a FileList: we can't use slice or sesnible iteration). + const tfiles = []; + for (let i = 0; i < ev.target.files.length; ++i) { + tfiles.push(ev.target.files[i]); + } + + ContentMessages.sharedInstance().sendContentListToRoom( + tfiles, this.props.roomId, MatrixClientPeg.get(), + ); + + // This is the onChange handler for a file form control, but we're + // not keeping any state, so reset the value of the form control + // to empty. + // NB. we need to set 'value': the 'files' property is immutable. + ev.target.value = ''; + } + + render() { + const uploadInputStyle = {display: 'none'}; + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + + + ); + } +} + +export default class SlateMessageComposer extends React.Component { + constructor(props, context) { + super(props, context); + this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); + this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this); + this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this); + this.onInputStateChanged = this.onInputStateChanged.bind(this); + this.onEvent = this.onEvent.bind(this); + this._onRoomStateEvents = this._onRoomStateEvents.bind(this); + this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); + this._onTombstoneClick = this._onTombstoneClick.bind(this); + this.renderPlaceholderText = this.renderPlaceholderText.bind(this); + this.renderFormatBar = this.renderFormatBar.bind(this); + + this.state = { + inputState: { + marks: [], + blockType: null, + isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'), + }, + showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'), + isQuoting: Boolean(RoomViewStore.getQuotingEvent()), + tombstone: this._getRoomTombstone(), + canSendMessages: this.props.room.maySendMessage(), + }; + } + + componentDidMount() { + // N.B. using 'event' rather than 'RoomEvents' otherwise the crypto handler + // for 'event' fires *after* 'RoomEvent', and our room won't have yet been + // marked as encrypted. + // XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something. + MatrixClientPeg.get().on("event", this.onEvent); + MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); + this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); + this._waitForOwnMember(); + } + + _waitForOwnMember() { + // if we have the member already, do that + const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()); + if (me) { + this.setState({me}); + return; + } + // Otherwise, wait for member loading to finish and then update the member for the avatar. + // The members should already be loading, and loadMembersIfNeeded + // will return the promise for the existing operation + this.props.room.loadMembersIfNeeded().then(() => { + const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()); + this.setState({me}); + }); + } + + componentWillUnmount() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("event", this.onEvent); + MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); + } + if (this._roomStoreToken) { + this._roomStoreToken.remove(); + } + } + + onEvent(event) { + if (event.getType() !== 'm.room.encryption') return; + if (event.getRoomId() !== this.props.room.roomId) return; + this.forceUpdate(); + } + + _onRoomStateEvents(ev, state) { + if (ev.getRoomId() !== this.props.room.roomId) return; + + if (ev.getType() === 'm.room.tombstone') { + this.setState({tombstone: this._getRoomTombstone()}); + } + if (ev.getType() === 'm.room.power_levels') { + this.setState({canSendMessages: this.props.room.maySendMessage()}); + } + } + + _getRoomTombstone() { + return this.props.room.currentState.getStateEvents('m.room.tombstone', ''); + } + + _onRoomViewStoreUpdate() { + const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); + if (this.state.isQuoting === isQuoting) return; + this.setState({ isQuoting }); + } + + + onInputStateChanged(inputState) { + // Merge the new input state with old to support partial updates + inputState = Object.assign({}, this.state.inputState, inputState); + this.setState({inputState}); + } + + _onAutocompleteConfirm(range, completion) { + if (this.messageComposerInput) { + this.messageComposerInput.setDisplayedCompletion(range, completion); + } + } + + onFormatButtonClicked(name, event) { + event.preventDefault(); + this.messageComposerInput.onFormatButtonClicked(name, event); + } + + onToggleFormattingClicked() { + SettingsStore.setValue("MessageComposer.showFormatting", null, SettingLevel.DEVICE, !this.state.showFormatting); + this.setState({showFormatting: !this.state.showFormatting}); + } + + onToggleMarkdownClicked(e) { + e.preventDefault(); // don't steal focus from the editor! + this.messageComposerInput.enableRichtext(!this.state.inputState.isRichTextEnabled); + } + + _onTombstoneClick(ev) { + ev.preventDefault(); + + const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; + const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId); + let createEventId = null; + if (replacementRoom) { + const createEvent = replacementRoom.currentState.getStateEvents('m.room.create', ''); + if (createEvent && createEvent.getId()) createEventId = createEvent.getId(); + } + + const viaServers = [this.state.tombstone.getSender().split(':').splice(1).join(':')]; + dis.dispatch({ + action: 'view_room', + highlighted: true, + event_id: createEventId, + room_id: replacementRoomId, + auto_join: true, + + // Try to join via the server that sent the event. This converts @something:example.org + // into a server domain by splitting on colons and ignoring the first entry ("@something"). + via_servers: viaServers, + opts: { + // These are passed down to the js-sdk's /join call + viaServers: viaServers, + }, + }); + } + + renderPlaceholderText() { + const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); + if (this.state.isQuoting) { + if (roomIsEncrypted) { + return _t('Send an encrypted reply…'); + } else { + return _t('Send a reply (unencrypted)…'); + } + } else { + if (roomIsEncrypted) { + return _t('Send an encrypted message…'); + } else { + return _t('Send a message (unencrypted)…'); + } + } + } + + renderFormatBar() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const {marks, blockType} = this.state.inputState; + const formatButtons = formatButtonList.map((name) => { + // special-case to match the md serializer and the special-case in MessageComposerInput.js + const markName = name === 'inline-code' ? 'code' : name; + const active = marks.some(mark => mark.type === markName) || blockType === name; + const suffix = active ? '-on' : ''; + const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); + const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; + return ( + + ); + }) + + return ( +
+
+ { formatButtons } +
+ + +
+
+ ); + } + + render() { + const controls = [ + this.state.me ? : null, + this.props.e2eStatus ? : null, + ]; + + if (!this.state.tombstone && this.state.canSendMessages) { + // This also currently includes the call buttons. Really we should + // check separately for whether we can call, but this is slightly + // complex because of conference calls. + + const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput"); + const showFormattingButton = this.state.inputState.isRichTextEnabled; + const callInProgress = this.props.callState && this.props.callState !== 'ended'; + + controls.push( + this.messageComposerInput = c} + key="controls_input" + room={this.props.room} + placeholder={this.renderPlaceholderText()} + onInputStateChanged={this.onInputStateChanged} + permalinkCreator={this.props.permalinkCreator} />, + showFormattingButton ? : null, + , + , + callInProgress ? : null, + callInProgress ? null : , + callInProgress ? null : , + ); + } else if (this.state.tombstone) { + const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; + + const continuesLink = replacementRoomId ? ( + + {_t("The conversation continues here.")} + + ) : ''; + + controls.push(
+
+ + + {_t("This room has been replaced and is no longer active.")} +
+ { continuesLink } +
+
); + } else { + controls.push( +
+ { _t('You do not have permission to post to this room') } +
, + ); + } + + const showFormatBar = this.state.showFormatting && this.state.inputState.isRichTextEnabled; + + const wrapperClasses = classNames({ + mx_MessageComposer_wrapper: true, + mx_MessageComposer_hasE2EIcon: !!this.props.e2eStatus, + }); + return ( +
+
+
+ { controls } +
+
+ { showFormatBar ? this.renderFormatBar() : null } +
+ ); + } +} + +SlateMessageComposer.propTypes = { + // js-sdk Room object + room: PropTypes.object.isRequired, + + // string representing the current voip call state + callState: PropTypes.string, + + // string representing the current room app drawer state + showApps: PropTypes.bool +}; From 944c56d09bf5c67b1c565aee98ba75980a58f09b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:07:03 +0100 Subject: [PATCH 40/47] prevent cider history overlapping with slate composer history --- src/components/views/rooms/SendMessageComposer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 7d831dad4a..c8fac0b667 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -235,7 +235,7 @@ export default class SendMessageComposer extends React.Component { const parts = this._restoreStoredEditorState(partCreator) || []; this.model = new EditorModel(parts, partCreator); this.dispatcherRef = dis.register(this.onAction); - this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); + this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_composer_history_'); } get _editorStateKey() { From 6e54bb8e5122ef9eb8aa088eb08d2b080f4092e3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:07:31 +0100 Subject: [PATCH 41/47] default is unused here --- src/SendHistoryManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SendHistoryManager.js b/src/SendHistoryManager.js index c838f1246b..794a58ad6f 100644 --- a/src/SendHistoryManager.js +++ b/src/SendHistoryManager.js @@ -23,7 +23,7 @@ export default class SendHistoryManager { lastIndex: number = 0; // used for indexing the storage currentIndex: number = 0; // used for indexing the loaded validated history Array - constructor(roomId: string, prefix: string = 'mx_composer_history_') { + constructor(roomId: string, prefix: string) { this.prefix = prefix + roomId; // TODO: Performance issues? From b395fad8348410a2f957fc37ba263687ad02ec77 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:07:43 +0100 Subject: [PATCH 42/47] add feature flag, allowing to revert to old slate editor --- src/components/structures/RoomView.js | 33 +++++++++++++++++++-------- src/settings/Settings.js | 6 +++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 535a1f3df3..5edf19f3ef 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1550,7 +1550,6 @@ module.exports = React.createClass({ render: function() { const RoomHeader = sdk.getComponent('rooms.RoomHeader'); - const MessageComposer = sdk.getComponent('rooms.MessageComposer'); const ForwardMessage = sdk.getComponent("rooms.ForwardMessage"); const AuxPanel = sdk.getComponent("rooms.AuxPanel"); const SearchBar = sdk.getComponent("rooms.SearchBar"); @@ -1778,15 +1777,29 @@ module.exports = React.createClass({ myMembership === 'join' && !this.state.searchResults ); if (canSpeak) { - messageComposer = - ; + if (SettingsStore.isFeatureEnabled("feature_cider_composer")) { + const MessageComposer = sdk.getComponent('rooms.MessageComposer'); + messageComposer = + ; + } else { + const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer'); + messageComposer = + ; + } } // TODO: Why aren't we storing the term/scope/count in this format diff --git a/src/settings/Settings.js b/src/settings/Settings.js index b33ef3f8d7..fd6f2bcdb1 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -114,6 +114,12 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_cider_composer": { + isFeature: true, + displayName: _td("Use the new, faster, but still experimental composer for writing messages (requires refresh)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "MessageComposerInput.suggestEmoji": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Enable Emoji suggestions while typing'), From 8c9846efc1bd25d0daeb3576aef5e62aeace8bc3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:10:08 +0100 Subject: [PATCH 43/47] update i18n --- src/i18n/strings/en_EN.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 53d8529c65..83a9602a51 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -326,6 +326,7 @@ "Custom user status messages": "Custom user status messages", "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", + "Use the new, faster, but still experimental composer for writing messages (requires refresh)": "Use the new, faster, but still experimental composer for writing messages (requires refresh)", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "Use compact timeline layout": "Use compact timeline layout", "Show a placeholder for removed messages": "Show a placeholder for removed messages", @@ -747,11 +748,11 @@ " (unsupported)": " (unsupported)", "Join as voice or video.": "Join as voice or video.", "Ongoing conference call%(supportedText)s.": "Ongoing conference call%(supportedText)s.", - "Edit message": "Edit message", "Some devices for this user are not trusted": "Some devices for this user are not trusted", "Some devices in this encrypted room are not trusted": "Some devices in this encrypted room are not trusted", "All devices for this user are trusted": "All devices for this user are trusted", "All devices in this encrypted room are trusted": "All devices in this encrypted room are trusted", + "Edit message": "Edit message", "This event could not be displayed": "This event could not be displayed", "%(senderName)s sent an image": "%(senderName)s sent an image", "%(senderName)s sent a video": "%(senderName)s sent a video", @@ -804,25 +805,14 @@ "Invited": "Invited", "Filter room members": "Filter room members", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", - "bold": "bold", - "italic": "italic", - "deleted": "deleted", - "underlined": "underlined", - "inline-code": "inline-code", - "block-quote": "block-quote", - "bulleted-list": "bulleted-list", - "numbered-list": "numbered-list", "Voice call": "Voice call", "Video call": "Video call", "Hangup": "Hangup", - "Show Text Formatting Toolbar": "Show Text Formatting Toolbar", "Upload file": "Upload file", "Send an encrypted reply…": "Send an encrypted reply…", "Send a reply (unencrypted)…": "Send a reply (unencrypted)…", "Send an encrypted message…": "Send an encrypted message…", "Send a message (unencrypted)…": "Send a message (unencrypted)…", - "Markdown is disabled": "Markdown is disabled", - "Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar", "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "You do not have permission to post to this room": "You do not have permission to post to this room", @@ -831,6 +821,7 @@ "Command error": "Command error", "Unable to reply": "Unable to reply", "At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.", + "Markdown is disabled": "Markdown is disabled", "Markdown is enabled": "Markdown is enabled", "No pinned messages.": "No pinned messages.", "Loading...": "Loading...", @@ -923,7 +914,16 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", - "Send message": "Send message", + "bold": "bold", + "italic": "italic", + "deleted": "deleted", + "underlined": "underlined", + "inline-code": "inline-code", + "block-quote": "block-quote", + "bulleted-list": "bulleted-list", + "numbered-list": "numbered-list", + "Show Text Formatting Toolbar": "Show Text Formatting Toolbar", + "Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar", "Failed to connect to integrations server": "Failed to connect to integrations server", "No integrations server is configured to manage stickers with": "No integrations server is configured to manage stickers with", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", From 9c6953f176e42911f585267fb9d5f4678fb939fe Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:11:04 +0100 Subject: [PATCH 44/47] lint --- src/settings/Settings.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/settings/Settings.js b/src/settings/Settings.js index fd6f2bcdb1..37a777913b 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -116,7 +116,8 @@ export const SETTINGS = { }, "feature_cider_composer": { isFeature: true, - displayName: _td("Use the new, faster, but still experimental composer for writing messages (requires refresh)"), + displayName: _td("Use the new, faster, but still experimental composer " + + "for writing messages (requires refresh)"), supportedLevels: LEVELS_FEATURE, default: false, }, From 1dd052d9dd79318c1eb53d616c959d55abac755e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:38:24 +0100 Subject: [PATCH 45/47] fix test after refactoring --- test/editor/deserialize-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index c7e0278f52..46deb14ce3 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -144,7 +144,7 @@ describe('editor/deserialize', function() { const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); - expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice", userId: "@alice:hs.tld"}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice", resourceId: "@alice:hs.tld"}); expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); }); it('room pill', function() { From 5cebce9bbf557dd5079902d4bcdb5fd34b1caf41 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 14:41:40 +0100 Subject: [PATCH 46/47] fix bug detected by tests --- src/editor/deserialize.js | 8 ++++---- test/editor/deserialize-test.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js index d5efef5d1a..d59e4ca123 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.js @@ -223,14 +223,14 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) { function parsePlainTextMessage(body, partCreator, isQuotedMessage) { const lines = body.split("\n"); const parts = lines.reduce((parts, line, i) => { - const isLast = i === lines.length - 1; - if (!isLast) { - parts.push(partCreator.newline()); - } if (isQuotedMessage) { parts.push(partCreator.plain(QUOTE_LINE_PREFIX)); } parts.push(...parseAtRoomMentions(line, partCreator)); + const isLast = i === lines.length - 1; + if (!isLast) { + parts.push(partCreator.newline()); + } return parts; }, []); return parts; diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index 46deb14ce3..8a79b4101f 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -71,10 +71,10 @@ describe('editor/deserialize', function() { describe('text messages', function() { it('test with newlines', function() { const parts = normalize(parseEvent(textMessage("hello\nworld"), createPartCreator())); - expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); expect(parts[2]).toStrictEqual({type: "plain", text: "world"}); + expect(parts.length).toBe(3); }); it('@room pill', function() { const parts = normalize(parseEvent(textMessage("text message for @room"), createPartCreator())); From 4a27abb13149feacab54dfb55b49f66c1c3e087c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Aug 2019 15:11:31 +0100 Subject: [PATCH 47/47] fix css lint --- res/css/views/rooms/_BasicMessageComposer.scss | 2 +- res/css/views/rooms/_SendMessageComposer.scss | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index acec4de952..b6035e5859 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -16,7 +16,7 @@ limitations under the License. */ .mx_BasicMessageComposer { - .mx_BasicMessageComposer_inputEmpty > :first-child:before { + .mx_BasicMessageComposer_inputEmpty > :first-child::before { content: var(--placeholder); opacity: 0.333; width: 0; diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss index 3304003d84..d20f7107b3 100644 --- a/res/css/views/rooms/_SendMessageComposer.scss +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -20,7 +20,6 @@ limitations under the License. flex-direction: column; font-size: 14px; justify-content: center; - display: flex; margin-right: 6px; // don't grow wider than available space min-width: 0;