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/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. diff --git a/res/css/_components.scss b/res/css/_components.scss index b8811c742f..d19d07132c 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"; @@ -158,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/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss new file mode 100644 index 0000000000..b6035e5859 --- /dev/null +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -0,0 +1,65 @@ +/* +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_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; + 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 58% rename from res/css/views/elements/_MessageEditor.scss rename to res/css/views/rooms/_EditMessageComposer.scss index 7fd99bae17..214bfc4a1a 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,20 @@ 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; + padding: 3px 6px; &: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 +55,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/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss new file mode 100644 index 0000000000..d20f7107b3 --- /dev/null +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -0,0 +1,53 @@ +/* +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; + display: flex; + flex-direction: column; + font-size: 14px; + justify-content: center; + 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; + // 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; + // max-height at this level so autocomplete doesn't get scrolled too + max-height: 140px; + overflow-y: auto; + } + } + + .mx_SendMessageComposer_overlayWrapper { + position: relative; + height: 0; + } +} + diff --git a/src/SendHistoryManager.js b/src/SendHistoryManager.js new file mode 100644 index 0000000000..794a58ad6f --- /dev/null +++ b/src/SendHistoryManager.js @@ -0,0 +1,60 @@ +//@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 _clamp from 'lodash/clamp'; + +export default class SendHistoryManager { + 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) { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + let index = 0; + let itemJSON; + + while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) { + try { + const serializedParts = JSON.parse(itemJSON); + this.history.push(serializedParts); + } catch (e) { + console.warn("Throwing away unserialisable history", e); + break; + } + ++index; + } + this.lastIndex = this.history.length - 1; + // reset currentIndex to account for any unserialisable history + this.currentIndex = this.lastIndex + 1; + } + + save(editorModel: Object) { + const serializedParts = editorModel.serializeParts(); + this.history.push(serializedParts); + this.currentIndex = this.history.length; + this.lastIndex += 1; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts)); + } + + getItem(offset: number): ?HistoryItem { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + return this.history[this.currentIndex]; + } +} diff --git a/src/ComposerHistoryManager.js b/src/SlateComposerHistoryManager.js similarity index 98% rename from src/ComposerHistoryManager.js rename to src/SlateComposerHistoryManager.js index 1b3fb588eb..948dcf64ff 100644 --- a/src/ComposerHistoryManager.js +++ b/src/SlateComposerHistoryManager.js @@ -47,7 +47,7 @@ class HistoryItem { } } -export default class ComposerHistoryManager { +export default class SlateComposerHistoryManager { history: Array = []; prefix: string; lastIndex: number = 0; // used for indexing the storage 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/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 76de9a6794..e4179d9c3b 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -15,9 +15,7 @@ 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'; import HistoryManager from '../../../editor/history'; import {setCaretPosition} from '../../../editor/caret'; @@ -26,13 +24,40 @@ 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; +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 = { + onChange: PropTypes.func, 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) { @@ -54,14 +79,30 @@ 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); + TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, !this.props.model.isEmpty); + + if (this.props.onChange) { + this.props.onChange(); + } } _onInput = (event) => { 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); } @@ -73,14 +114,67 @@ 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; + } + + clearUndoHistory() { + this.historyManager.clear(); + } + + getCaret() { + return this._lastCaret; + } + + isSelectionCollapsed() { + return !this._lastSelection || this._lastSelection.isCollapsed; + } + + 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) => { @@ -106,7 +200,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. @@ -115,19 +209,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) { @@ -136,11 +243,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; } @@ -190,23 +292,12 @@ 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) { const query = this.state.query; const queryLen = query.length; - autoComplete =
+ autoComplete = (
this._autocompleteRef = ref} query={query} @@ -215,18 +306,24 @@ 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={this.props.label} + >
+
); + } + + focus() { + this._editorRef.focus(); } } diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 3ba14d9369..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 = ""; @@ -249,17 +242,18 @@ export default class EditMessageComposer extends React.Component { render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return
- -
- {_t("Cancel")} - {_t("Save")} -
-
; + return (
+ +
+ {_t("Cancel")} + {_t("Save")} +
+
); } } diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index adfc0a7999..022d45e60e 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -16,32 +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 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
@@ -51,7 +37,7 @@ function ComposerAvatar(props) { ComposerAvatar.propTypes = { me: PropTypes.object.isRequired, -} +}; function CallButton(props) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -63,15 +49,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,38 +93,21 @@ function HangupButton(props) { room_id: call.roomId, }); }; - return ; + 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); @@ -195,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(), @@ -259,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(); } @@ -283,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(); @@ -357,51 +295,12 @@ 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, - this.props.e2eStatus ? : null, + this.props.e2eStatus ? + : + null, ]; if (!this.state.tombstone && this.state.canSendMessages) { @@ -409,20 +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, @@ -458,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, @@ -471,7 +364,6 @@ export default class MessageComposer extends React.Component { { controls }
- { showFormatBar ? this.renderFormatBar() : null } ); } @@ -485,5 +377,5 @@ MessageComposer.propTypes = { callState: PropTypes.string, // string representing the current room app drawer state - showApps: PropTypes.bool + showApps: PropTypes.bool, }; 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/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js new file mode 100644 index 0000000000..c8fac0b667 --- /dev/null +++ b/src/components/views/rooms/SendMessageComposer.js @@ -0,0 +1,319 @@ +/* +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 {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; +import {CommandPartCreator} 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"; +import {parseEvent} from '../../../editor/deserialize'; +import {findEditableEvent} from '../../../utils/EventUtils'; +import SendHistoryManager from "../../../SendHistoryManager"; +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); + 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; + } +} + +function createMessageContent(model, permalinkCreator) { + const isEmote = containsEmote(model); + if (isEmote) { + model = stripEmoteCommand(model); + } + const repliedToEvent = RoomViewStore.getQuotingEvent(); + + const body = textSerialize(model); + const content = { + msgtype: isEmote ? "m.emote" : "m.text", + body: body, + }; + 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; +} + +export default class SendMessageComposer extends React.Component { + static propTypes = { + room: PropTypes.object.isRequired, + placeholder: PropTypes.string, + permalinkCreator: PropTypes.object.isRequired, + }; + + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }; + + constructor(props, context) { + super(props, context); + this.model = null; + this._editorRef = null; + this.currentlyComposedEditorState = null; + } + + _setEditorRef = ref => { + this._editorRef = ref; + }; + + _onKeyDown = (event) => { + 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, + }); + } + } + } + } + + // 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. + 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(); + } + } + + _isSlashCommand() { + const parts = this.model.parts; + const isPlain = parts.reduce((isPlain, part) => { + return isPlain && (part.type === "command" || 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() { + 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(); + this._editorRef.focus(); + this._clearStoredEditorState(); + } + + componentWillUnmount() { + dis.unregister(this.dispatcherRef); + } + + componentWillMount() { + const partCreator = new CommandPartCreator(this.props.room, this.context.matrixClient); + 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_cider_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': + case 'focus_composer': + this._editorRef && this._editorRef.focus(); + break; + case 'insert_mention': + this._insertMention(payload.user_id); + break; + 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 && 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 && this._editorRef.focus(); + } + + render() { + return ( +
+
+ +
+ +
+ ); + } +} 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 +}; 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/deserialize.js b/src/editor/deserialize.js index e8fd8fb888..d59e4ca123 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) => { + 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; +} + +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 ")); 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; diff --git a/src/editor/model.js b/src/editor/model.js index 74546b9bf8..2f1e5218d8 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; @@ -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); } @@ -80,7 +84,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); } } @@ -100,9 +105,31 @@ 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); } + insertPartsAt(parts, caret) { + const position = this.positionForOffset(caret.offset, caret.atNodeEnd); + const insertIndex = this._splitAt(position); + 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); + } + update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this.positionForOffset(diff.at, caret.atNodeEnd); @@ -227,6 +254,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`. @@ -266,7 +310,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 2a6ad81b9b..f9b4243de4 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; } } @@ -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)}; @@ -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); } } @@ -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 cb06eede6c..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; @@ -33,7 +34,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) { @@ -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; @@ -56,3 +58,19 @@ export function textSerialize(model) { } }, ""); } + +export function containsEmote(model) { + const firstPart = model.parts[0]; + // 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) { + // trim "/me " + model = model.clone(); + model.removeText({index: 0, offset: 0}, 4); + return model; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index fd5e42bcb4..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,6 +914,16 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", + "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", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index b33ef3f8d7..37a777913b 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -114,6 +114,13 @@ 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'), diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index c7e0278f52..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())); @@ -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() {