Merge pull request #3287 from matrix-org/bwindels/new-main-composer

Support editing composer to be used as main composer (feature flagged)
This commit is contained in:
Bruno Windels 2019-08-23 10:06:19 +00:00 committed by GitHub
commit f39dc6feab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1362 additions and 293 deletions

View file

@ -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

View file

@ -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.

View file

@ -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";

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}

60
src/SendHistoryManager.js Normal file
View file

@ -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<HistoryItem> = [];
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];
}
}

View file

@ -47,7 +47,7 @@ class HistoryItem {
}
}
export default class ComposerHistoryManager {
export default class SlateComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0; // used for indexing the storage

View file

@ -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 =
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
if (SettingsStore.isFeatureEnabled("feature_cider_composer")) {
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
messageComposer =
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
} else {
const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer');
messageComposer =
<SlateMessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
}
}
// TODO: Why aren't we storing the term/scope/count in this format

View file

@ -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 = <div className="mx_MessageEditor_AutoCompleteWrapper">
autoComplete = (<div className="mx_BasicMessageComposer_AutoCompleteWrapper">
<Autocomplete
ref={ref => 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}
/>
</div>;
</div>);
}
return <div className={this.props.className}>
{ autoComplete }
<div
className="mx_MessageEditor_editor"
contentEditable="true"
tabIndex="1"
onKeyDown={this._onKeyDown}
ref={ref => this._editorRef = ref}
aria-label={_t("Edit message")}
></div>
</div>;
return (<div className="mx_BasicMessageComposer">
{ autoComplete }
<div
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}
></div>
</div>);
}
focus() {
this._editorRef.focus();
}
}

View file

@ -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 <div className={classNames("mx_MessageEditor", this.props.className)} onKeyDown={this._onKeyDown}>
<BasicMessageComposer
ref={this._setEditorRef}
model={this.model}
room={this._getRoom()}
initialCaret={this.props.editState.getCaret()}
/>
<div className="mx_MessageEditor_buttons">
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={this._sendEdit}>{_t("Save")}</AccessibleButton>
</div>
</div>;
return (<div className={classNames("mx_EditMessageComposer", this.props.className)} onKeyDown={this._onKeyDown}>
<BasicMessageComposer
ref={this._setEditorRef}
model={this.model}
room={this._getRoom()}
initialCaret={this.props.editState.getCaret()}
label={_t("Edit message")}
/>
<div className="mx_EditMessageComposer_buttons">
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={this._sendEdit}>{_t("Save")}</AccessibleButton>
</div>
</div>);
}
}

View file

@ -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 <div className="mx_MessageComposer_avatar">
@ -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 <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>);
}
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 <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick}
title={_t('Hangup')}
/>;
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick}
title={_t('Hangup')}
/>);
}
HangupButton.propTypes = {
roomId: PropTypes.string.isRequired,
}
function FormattingButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <AccessibleButton
element="img"
className="mx_MessageComposer_formatting"
alt={_t("Show Text Formatting Toolbar")}
title={_t("Show Text Formatting Toolbar")}
src={require("../../../../res/img/button-text-formatting.svg")}
style={{visibility: props.showFormatting ? 'hidden' : 'visible'}}
onClick={props.onClickHandler}
/>;
}
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 (
<img className={className}
title={_t(name)}
onMouseDown={onFormatButtonClicked}
key={name}
src={require(`../../../../res/img/button-text-${name}${suffix}.svg`)}
height="17"
/>
);
})
return (
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{ flex: 1 }}></div>
<AccessibleButton
className="mx_MessageComposer_formatbar_markdown mx_MessageComposer_markdownDisabled"
onClick={this.onToggleMarkdownClicked}
title={_t("Markdown is disabled")}
/>
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src={require("../../../../res/img/icon-text-cancel.svg")}
/>
</div>
</div>
);
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ? <E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> : null,
this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
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(
<MessageComposerInput
<SendMessageComposer
ref={(c) => this.messageComposerInput = c}
key="controls_input"
room={this.props.room}
placeholder={this.renderPlaceholderText()}
onInputStateChanged={this.onInputStateChanged}
permalinkCreator={this.props.permalinkCreator} />,
showFormattingButton ? <FormattingButton key="controls_formatting"
showFormatting={this.state.showFormatting} onClickHandler={this.onToggleFormattingClicked} /> : null,
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : 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 }
</div>
</div>
{ showFormatBar ? this.renderFormatBar() : null }
</div>
);
}
@ -485,5 +377,5 @@ MessageComposer.propTypes = {
callState: PropTypes.string,
// string representing the current room app drawer state
showApps: PropTypes.bool
showApps: PropTypes.bool,
};

View file

@ -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() {

View file

@ -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 (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
<div className="mx_SendMessageComposer_overlayWrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
</div>
<BasicMessageComposer
ref={this._setEditorRef}
model={this.model}
room={this.props.room}
label={this.props.placeholder}
placeholder={this.props.placeholder}
onChange={this._saveStoredEditorState}
/>
</div>
);
}
}

View file

@ -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 <div className="mx_MessageComposer_avatar">
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
</div>;
}
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 <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>
}
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 <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_videocall"
onClick={onCallClick}
title={_t('Video call')}
/>;
}
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 <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick}
title={_t('Hangup')}
/>;
}
HangupButton.propTypes = {
roomId: PropTypes.string.isRequired,
}
function FormattingButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <AccessibleButton
element="img"
className="mx_MessageComposer_formatting"
alt={_t("Show Text Formatting Toolbar")}
title={_t("Show Text Formatting Toolbar")}
src={require("../../../../res/img/button-text-formatting.svg")}
style={{visibility: props.showFormatting ? 'hidden' : 'visible'}}
onClick={props.onClickHandler}
/>;
}
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 (
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={this.onUploadClick}
title={_t('Upload file')}
>
<input ref="uploadInput" type="file"
style={uploadInputStyle}
multiple
onChange={this.onUploadFileInputChange}
/>
</AccessibleButton>
);
}
}
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 (
<img className={className}
title={_t(name)}
onMouseDown={onFormatButtonClicked}
key={name}
src={require(`../../../../res/img/button-text-${name}${suffix}.svg`)}
height="17"
/>
);
})
return (
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{ flex: 1 }}></div>
<AccessibleButton
className="mx_MessageComposer_formatbar_markdown mx_MessageComposer_markdownDisabled"
onClick={this.onToggleMarkdownClicked}
title={_t("Markdown is disabled")}
/>
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src={require("../../../../res/img/icon-text-cancel.svg")}
/>
</div>
</div>
);
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ? <E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> : 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(
<MessageComposerInput
ref={(c) => this.messageComposerInput = c}
key="controls_input"
room={this.props.room}
placeholder={this.renderPlaceholderText()}
onInputStateChanged={this.onInputStateChanged}
permalinkCreator={this.props.permalinkCreator} />,
showFormattingButton ? <FormattingButton key="controls_formatting"
showFormatting={this.state.showFormatting} onClickHandler={this.onToggleFormattingClicked} /> : null,
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : null,
callInProgress ? null : <CallButton key="controls_call" roomId={this.props.room.roomId} />,
callInProgress ? null : <VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
const continuesLink = replacementRoomId ? (
<a href={makeRoomPermalink(replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link"
onClick={this._onTombstoneClick}
>
{_t("The conversation continues here.")}
</a>
) : '';
controls.push(<div className="mx_MessageComposer_replaced_wrapper">
<div className="mx_MessageComposer_replaced_valign">
<img className="mx_MessageComposer_roomReplaced_icon" src={require("../../../../res/img/room_replaced.svg")} />
<span className="mx_MessageComposer_roomReplaced_header">
{_t("This room has been replaced and is no longer active.")}
</span><br />
{ continuesLink }
</div>
</div>);
} else {
controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') }
</div>,
);
}
const showFormatBar = this.state.showFormatting && this.state.inputState.isRichTextEnabled;
const wrapperClasses = classNames({
mx_MessageComposer_wrapper: true,
mx_MessageComposer_hasE2EIcon: !!this.props.e2eStatus,
});
return (
<div className="mx_MessageComposer">
<div className={wrapperClasses}>
<div className="mx_MessageComposer_row">
{ controls }
</div>
</div>
{ showFormatBar ? this.renderFormatBar() : null }
</div>
);
}
}
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
};

View file

@ -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);
}

View file

@ -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 "));

View file

@ -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;

View file

@ -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 {

View file

@ -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";
}
}

View file

@ -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;
}

View file

@ -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 <voiceText>voice</voiceText> or <videoText>video</videoText>.": "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
"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",

View file

@ -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'),

View file

@ -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() {