diff --git a/CHANGELOG.md b/CHANGELOG.md
index 988a85fd43..245d0c7e60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,12 @@
+Changes in [1.1.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.2) (2019-05-15)
+===================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.1...v1.1.2)
+
+ * Always thumbnail for GIFs
+ [\#2976](https://github.com/matrix-org/matrix-react-sdk/pull/2976)
+ * Fix Single Sign-on
+ [\#2975](https://github.com/matrix-org/matrix-react-sdk/pull/2975)
+
Changes in [1.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.1) (2019-05-14)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0...v1.1.1)
diff --git a/package.json b/package.json
index bcbf6ea29f..9c55ff43c8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 6e681894e3..2e0c91bd8c 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -89,6 +89,7 @@
@import "./views/elements/_InlineSpinner.scss";
@import "./views/elements/_ManageIntegsButton.scss";
@import "./views/elements/_MemberEventListSummary.scss";
+@import "./views/elements/_MessageEditor.scss";
@import "./views/elements/_PowerSelector.scss";
@import "./views/elements/_ProgressBar.scss";
@import "./views/elements/_ReplyThread.scss";
diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss
new file mode 100644
index 0000000000..ec6d903753
--- /dev/null
+++ b/res/css/views/elements/_MessageEditor.scss
@@ -0,0 +1,64 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_MessageEditor {
+ border-radius: 4px;
+ background-color: $header-panel-bg-color;
+ padding: 11px 13px 7px 56px;
+
+ .mx_MessageEditor_editor {
+ border-radius: 4px;
+ border: solid 1px #e9edf1;
+ background-color: #ffffff;
+ padding: 10px;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ outline: none;
+
+ span {
+ display: inline-block;
+ padding: 0 5px;
+ border-radius: 4px;
+ color: white;
+ }
+
+ span.user-pill, span.room-pill {
+ border-radius: 16px;
+ display: inline-block;
+ color: $primary-fg-color;
+ background-color: $other-user-pill-bg-color;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+ }
+
+ .mx_MessageEditor_buttons {
+ display: flex;
+ flex-direction: row;
+ justify-content: end;
+ padding: 5px 0;
+
+ .mx_AccessibleButton {
+ margin-left: 5px;
+ padding: 5px 40px;
+ }
+ }
+
+ .mx_MessageEditor_AutoCompleteWrapper {
+ position: relative;
+ height: 0;
+ }
+}
diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss
index e66c99e95b..749cfeebe6 100644
--- a/res/css/views/messages/_MessageActionBar.scss
+++ b/res/css/views/messages/_MessageActionBar.scss
@@ -69,6 +69,10 @@ limitations under the License.
mask-image: url('$(res)/img/reply.svg');
}
+.mx_MessageActionBar_editButton::after {
+ mask-image: url('$(res)/img/edit.svg');
+}
+
.mx_MessageActionBar_optionsButton::after {
mask-image: url('$(res)/img/icon_context.svg');
}
diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss
index cac97cb60d..0b9c7e2368 100644
--- a/res/css/views/rooms/_MemberList.scss
+++ b/res/css/views/rooms/_MemberList.scss
@@ -87,6 +87,11 @@ limitations under the License.
}
}
+.mx_MemberList_invite.mx_AccessibleButton_disabled {
+ background-color: $greyed-fg-color;;
+ cursor: not-allowed;
+}
+
.mx_MemberList_invite span {
background-image: url('$(res)/img/feather-customised/user-add.svg');
background-repeat: no-repeat;
diff --git a/res/img/edit.svg b/res/img/edit.svg
new file mode 100644
index 0000000000..95bd44f606
--- /dev/null
+++ b/res/img/edit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/reply.svg b/res/img/reply.svg
index 8cbbad3550..540e228883 100644
--- a/res/img/reply.svg
+++ b/res/img/reply.svg
@@ -1,6 +1,6 @@
-
\ No newline at end of file
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index adadd39333..fc15170b87 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -257,11 +257,11 @@ $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1);
$message-action-bar-bg-color: $primary-bg-color;
$message-action-bar-fg-color: $primary-fg-color;
$message-action-bar-border-color: #e9edf1;
-$message-action-bar-hover-border-color: #b8c1d2;
+$message-action-bar-hover-border-color: $focus-bg-color;
$reaction-row-button-bg-color: $header-panel-bg-color;
$reaction-row-button-border-color: #e9edf1;
-$reaction-row-button-hover-border-color: #bebebe;
+$reaction-row-button-hover-border-color: $focus-bg-color;
$reaction-row-button-selected-bg-color: #e9fff9;
$reaction-row-button-selected-border-color: $accent-color;
diff --git a/src/Lifecycle.js b/src/Lifecycle.js
index 0e2389fd1c..a7f90f847d 100644
--- a/src/Lifecycle.js
+++ b/src/Lifecycle.js
@@ -369,7 +369,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
// If there's an inconsistency between account data in local storage and the
// crypto store, we'll be generally confused when handling encrypted data.
// Show a modal recommending a full reset of storage.
- if (results.dataInLocalStorage && !results.dataInCryptoStore) {
+ if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
const signOut = await _showStorageEvictedDialog();
if (signOut) {
await _clearStorage();
diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js
index cd40c7874e..82bd273846 100644
--- a/src/MatrixClientPeg.js
+++ b/src/MatrixClientPeg.js
@@ -121,6 +121,7 @@ class MatrixClientPeg {
// check that we have a version of the js-sdk which includes initCrypto
if (this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto();
+ StorageManager.setCryptoInitialised(true);
}
} catch (e) {
if (e && e.name === 'InvalidCryptoStoreError') {
@@ -176,6 +177,7 @@ class MatrixClientPeg {
_createClient(creds: MatrixClientCreds) {
const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
+ const enableEdits = SettingsStore.isFeatureEnabled("feature_message_editing");
const opts = {
baseUrl: creds.homeserverUrl,
@@ -187,6 +189,7 @@ class MatrixClientPeg {
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
verificationMethods: [verificationMethods.SAS],
unstableClientRelationAggregation: aggregateRelations,
+ unstableClientRelationReplacements: enableEdits,
};
this.matrixClient = createMatrixClient(opts);
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 2037217710..6f21bb6951 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -450,9 +450,14 @@ module.exports = React.createClass({
_getTilesForEvent: function(prevEvent, mxEv, last) {
const EventTile = sdk.getComponent('rooms.EventTile');
+ const MessageEditor = sdk.getComponent('elements.MessageEditor');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = [];
+ if (this.props.editEvent && this.props.editEvent.getId() === mxEv.getId()) {
+ return [];
+ }
+
// is this a continuation of the previous message?
let continuation = false;
@@ -521,6 +526,7 @@ module.exports = React.createClass({
);
},
diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js
new file mode 100644
index 0000000000..a2005feace
--- /dev/null
+++ b/src/components/views/elements/MessageEditor.js
@@ -0,0 +1,176 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import React from 'react';
+import sdk from '../../../index';
+import {_t} from '../../../languageHandler';
+import PropTypes from 'prop-types';
+import dis from '../../../dispatcher';
+import EditorModel from '../../../editor/model';
+import {setCaretPosition} from '../../../editor/caret';
+import {getCaretOffsetAndText} from '../../../editor/dom';
+import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize';
+import {parseEvent} from '../../../editor/deserialize';
+import Autocomplete from '../rooms/Autocomplete';
+import {PartCreator} from '../../../editor/parts';
+import {renderModel} from '../../../editor/render';
+import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
+
+export default class MessageEditor extends React.Component {
+ static propTypes = {
+ // the message event being edited
+ event: PropTypes.instanceOf(MatrixEvent).isRequired,
+ };
+
+ static contextTypes = {
+ matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
+ };
+
+ constructor(props, context) {
+ super(props, context);
+ const partCreator = new PartCreator(
+ () => this._autocompleteRef,
+ query => this.setState({query}),
+ );
+ this.model = new EditorModel(
+ parseEvent(this.props.event),
+ partCreator,
+ this._updateEditorState,
+ );
+ const room = this.context.matrixClient.getRoom(this.props.event.getRoomId());
+ this.state = {
+ autoComplete: null,
+ room,
+ };
+ this._editorRef = null;
+ this._autocompleteRef = null;
+ }
+
+ _updateEditorState = (caret) => {
+ renderModel(this._editorRef, this.model);
+ if (caret) {
+ try {
+ setCaretPosition(this._editorRef, this.model, caret);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ this.setState({autoComplete: this.model.autoComplete});
+ }
+
+ _onInput = (event) => {
+ const sel = document.getSelection();
+ const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
+ this.model.update(text, event.inputType, caret);
+ }
+
+ _onKeyDown = (event) => {
+ if (event.metaKey || event.altKey || event.shiftKey) {
+ return;
+ }
+ if (!this.model.autoComplete) {
+ return;
+ }
+ const autoComplete = this.model.autoComplete;
+ switch (event.key) {
+ case "Enter":
+ autoComplete.onEnter(event); break;
+ case "ArrowUp":
+ autoComplete.onUpArrow(event); break;
+ case "ArrowDown":
+ autoComplete.onDownArrow(event); break;
+ case "Tab":
+ autoComplete.onTab(event); break;
+ case "Escape":
+ autoComplete.onEscape(event); break;
+ default:
+ return; // don't preventDefault on anything else
+ }
+ event.preventDefault();
+ }
+
+ _onCancelClicked = () => {
+ dis.dispatch({action: "edit_event", event: null});
+ }
+
+ _onSaveClicked = () => {
+ const newContent = {
+ "msgtype": "m.text",
+ "body": textSerialize(this.model),
+ };
+ if (requiresHtml(this.model)) {
+ newContent.format = "org.matrix.custom.html";
+ newContent.formatted_body = htmlSerialize(this.model);
+ }
+ const content = Object.assign({
+ "m.new_content": newContent,
+ "m.relates_to": {
+ "rel_type": "m.replace",
+ "event_id": this.props.event.getId(),
+ },
+ }, newContent);
+
+ const roomId = this.props.event.getRoomId();
+ this.context.matrixClient.sendMessage(roomId, content);
+
+ dis.dispatch({action: "edit_event", event: null});
+ }
+
+ _onAutoCompleteConfirm = (completion) => {
+ this.model.autoComplete.onComponentConfirm(completion);
+ }
+
+ _onAutoCompleteSelectionChange = (completion) => {
+ this.model.autoComplete.onComponentSelectionChange(completion);
+ }
+
+ componentDidMount() {
+ this._updateEditorState();
+ }
+
+ render() {
+ let autoComplete;
+ if (this.state.autoComplete) {
+ const query = this.state.query;
+ const queryLen = query.length;
+ autoComplete =
+
this._autocompleteRef = ref}
+ query={query}
+ onConfirm={this._onAutoCompleteConfirm}
+ onSelectionChange={this._onAutoCompleteSelectionChange}
+ selection={{beginning: true, end: queryLen, start: queryLen}}
+ room={this.state.room}
+ />
+ ;
+ }
+ const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
+ return
+ { autoComplete }
+
this._editorRef = ref}
+ >
+
+
{_t("Cancel")}
+
{_t("Save")}
+
+
;
+ }
+}
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 52630d7b0e..fe6c22ab1e 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -58,6 +58,13 @@ export default class MessageActionBar extends React.PureComponent {
});
}
+ onEditClick = (ev) => {
+ dis.dispatch({
+ action: 'edit_event',
+ event: this.props.mxEvent,
+ });
+ }
+
onOptionsClick = (ev) => {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const buttonRect = ev.target.getBoundingClientRect();
@@ -96,6 +103,10 @@ export default class MessageActionBar extends React.PureComponent {
return SettingsStore.isFeatureEnabled("feature_reactions");
}
+ isEditingEnabled() {
+ return SettingsStore.isFeatureEnabled("feature_message_editing");
+ }
+
renderAgreeDimension() {
if (!this.isReactionsEnabled()) {
return null;
@@ -128,6 +139,7 @@ export default class MessageActionBar extends React.PureComponent {
let agreeDimensionReactionButtons;
let likeDimensionReactionButtons;
let replyButton;
+ let editButton;
if (isContentActionable(this.props.mxEvent)) {
agreeDimensionReactionButtons = this.renderAgreeDimension();
@@ -136,12 +148,19 @@ export default class MessageActionBar extends React.PureComponent {
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
+ if (this.isEditingEnabled()) {
+ editButton = ;
+ }
}
return
{agreeDimensionReactionButtons}
{likeDimensionReactionButtons}
{replyButton}
+ {editButton}
;
},
});
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js
index 404a0f0889..deb3c5cc0f 100644
--- a/src/components/views/messages/TextualBody.js
+++ b/src/components/views/messages/TextualBody.js
@@ -137,6 +137,7 @@ module.exports = React.createClass({
// exploit that events are immutable :)
return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
nextProps.highlights !== this.props.highlights ||
+ nextProps.replacingEventId !== this.props.replacingEventId ||
nextProps.highlightLink !== this.props.highlightLink ||
nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextState.links !== this.state.links ||
diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js
index e75456ea50..a19a4eaad0 100644
--- a/src/components/views/rooms/Autocomplete.js
+++ b/src/components/views/rooms/Autocomplete.js
@@ -60,18 +60,22 @@ export default class Autocomplete extends React.Component {
};
}
- componentWillReceiveProps(newProps, state) {
- if (this.props.room.roomId !== newProps.room.roomId) {
+ componentDidMount() {
+ this._applyNewProps();
+ }
+
+ _applyNewProps(oldQuery, oldRoom) {
+ if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy();
- this.autocompleter = new Autocompleter(newProps.room);
+ this.autocompleter = new Autocompleter(this.props.room);
}
// Query hasn't changed so don't try to complete it
- if (newProps.query === this.props.query) {
+ if (oldQuery === this.props.query) {
return;
}
- this.complete(newProps.query, newProps.selection);
+ this.complete(this.props.query, this.props.selection);
}
componentWillUnmount() {
@@ -233,7 +237,8 @@ export default class Autocomplete extends React.Component {
}
}
- componentDidUpdate() {
+ componentDidUpdate(prevProps) {
+ this._applyNewProps(prevProps.query, prevProps.room);
// this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) {
@@ -298,6 +303,9 @@ Autocomplete.propTypes = {
// method invoked with range and text content when completion is confirmed
onConfirm: PropTypes.func.isRequired,
+ // method invoked when selected (if any) completion changes
+ onSelectionChange: PropTypes.func,
+
// The room in which we're autocompleting
room: PropTypes.instanceOf(Room),
};
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 1706019e94..f38e3c3946 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -779,6 +779,7 @@ module.exports = withMatrixClient(React.createClass({
{ thread }
= powerLevels.kick;
can.ban = me.powerLevel >= powerLevels.ban;
+ can.invite = me.powerLevel >= powerLevels.invite;
can.mute = me.powerLevel >= editPowerLevel;
can.modifyLevel = me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel);
can.modifyLevelMax = me.powerLevel;
@@ -727,7 +728,7 @@ module.exports = withMatrixClient(React.createClass({
);
}
- if (!member || !member.membership || member.membership === 'leave') {
+ if (this.state.can.invite && (!member || !member.membership || member.membership === 'leave')) {
const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId();
const onInviteUserButton = async () => {
try {
diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js
index 8350001d01..5718276768 100644
--- a/src/components/views/rooms/MemberList.js
+++ b/src/components/views/rooms/MemberList.js
@@ -449,10 +449,23 @@ module.exports = React.createClass({
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
let inviteButton;
+
if (room && room.getMyMembership() === 'join') {
+ // assume we can invite until proven false
+ let canInvite = true;
+
+ const plEvent = room.currentState.getStateEvents("m.room.power_levels", "");
+ const me = room.getMember(cli.getUserId());
+ if (plEvent && me) {
+ const content = plEvent.getContent();
+ if (content && content.invite > me.powerLevel) {
+ canInvite = false;
+ }
+ }
+
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
-
+
{ _t('Invite to this room') }
;
}
diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js
new file mode 100644
index 0000000000..d2f73b1dff
--- /dev/null
+++ b/src/editor/autocomplete.js
@@ -0,0 +1,97 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
+
+export default class AutocompleteWrapperModel {
+ constructor(updateCallback, getAutocompleterComponent, updateQuery) {
+ this._updateCallback = updateCallback;
+ this._getAutocompleterComponent = getAutocompleterComponent;
+ this._updateQuery = updateQuery;
+ this._query = null;
+ }
+
+ onEscape(e) {
+ this._getAutocompleterComponent().onEscape(e);
+ this._updateCallback({
+ replacePart: new PlainPart(this._queryPart.text),
+ caretOffset: this._queryOffset,
+ close: true,
+ });
+ }
+
+ onEnter() {
+ this._updateCallback({close: true});
+ }
+
+ onTab() {
+ //forceCompletion here?
+ }
+
+ onUpArrow() {
+ this._getAutocompleterComponent().onUpArrow();
+ }
+
+ onDownArrow() {
+ this._getAutocompleterComponent().onDownArrow();
+ }
+
+ onPartUpdate(part, offset) {
+ // cache the typed value and caret here
+ // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
+ this._queryPart = part;
+ this._queryOffset = offset;
+ this._updateQuery(part.text);
+ }
+
+ onComponentSelectionChange(completion) {
+ if (!completion) {
+ this._updateCallback({
+ replacePart: this._queryPart,
+ caretOffset: this._queryOffset,
+ });
+ } else {
+ this._updateCallback({
+ replacePart: this._partForCompletion(completion),
+ });
+ }
+ }
+
+ onComponentConfirm(completion) {
+ this._updateCallback({
+ replacePart: this._partForCompletion(completion),
+ close: true,
+ });
+ }
+
+ _partForCompletion(completion) {
+ const firstChr = completion.completionId && completion.completionId[0];
+ switch (firstChr) {
+ case "@": {
+ const displayName = completion.completion;
+ const userId = completion.completionId;
+ return new UserPillPart(userId, displayName);
+ }
+ case "#": {
+ const displayAlias = completion.completionId;
+ return new RoomPillPart(displayAlias);
+ }
+ // also used for emoji completion
+ default:
+ return new PlainPart(completion.completion);
+ }
+ }
+}
diff --git a/src/editor/caret.js b/src/editor/caret.js
new file mode 100644
index 0000000000..3a784aa8eb
--- /dev/null
+++ b/src/editor/caret.js
@@ -0,0 +1,56 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export function setCaretPosition(editor, model, caretPosition) {
+ const sel = document.getSelection();
+ sel.removeAllRanges();
+ const range = document.createRange();
+ const {parts} = model;
+ let lineIndex = 0;
+ let nodeIndex = -1;
+ for (let i = 0; i <= caretPosition.index; ++i) {
+ const part = parts[i];
+ if (part && part.type === "newline") {
+ lineIndex += 1;
+ nodeIndex = -1;
+ } else {
+ nodeIndex += 1;
+ }
+ }
+ let focusNode;
+ const lineNode = editor.childNodes[lineIndex];
+ if (lineNode) {
+ if (lineNode.childNodes.length === 0 && caretPosition.offset === 0) {
+ focusNode = lineNode;
+ } else {
+ focusNode = lineNode.childNodes[nodeIndex];
+
+ if (focusNode && focusNode.nodeType === Node.ELEMENT_NODE) {
+ focusNode = focusNode.childNodes[0];
+ }
+ }
+ }
+ // node not found, set caret at end
+ if (!focusNode) {
+ range.selectNodeContents(editor);
+ range.collapse(false);
+ } else {
+ // make sure we have a text node
+ range.setStart(focusNode, caretPosition.offset);
+ range.collapse(true);
+ }
+ sel.addRange(range);
+}
diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js
new file mode 100644
index 0000000000..a7f28badb1
--- /dev/null
+++ b/src/editor/deserialize.js
@@ -0,0 +1,76 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
+import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts";
+
+function parseHtmlMessage(html) {
+ const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
+ // no nodes from parsing here should be inserted in the document,
+ // as scripts in event handlers, etc would be executed then.
+ // we're only taking text, so that is fine
+ const nodes = Array.from(new DOMParser().parseFromString(html, "text/html").body.childNodes);
+ const parts = nodes.map(n => {
+ switch (n.nodeType) {
+ case Node.TEXT_NODE:
+ return new PlainPart(n.nodeValue);
+ case Node.ELEMENT_NODE:
+ switch (n.nodeName) {
+ case "MX-REPLY":
+ return null;
+ case "A": {
+ const {href} = n;
+ const pillMatch = REGEX_MATRIXTO.exec(href) || [];
+ const resourceId = pillMatch[1]; // The room/user ID
+ const prefix = pillMatch[2]; // The first character of prefix
+ switch (prefix) {
+ case "@": return new UserPillPart(resourceId, n.textContent);
+ case "#": return new RoomPillPart(resourceId, n.textContent);
+ default: return new PlainPart(n.textContent);
+ }
+ }
+ case "BR":
+ return new NewlinePart("\n");
+ default:
+ return new PlainPart(n.textContent);
+ }
+ default:
+ return null;
+ }
+ }).filter(p => !!p);
+ return parts;
+}
+
+export function parseEvent(event) {
+ const content = event.getContent();
+ if (content.format === "org.matrix.custom.html") {
+ return parseHtmlMessage(content.formatted_body || "");
+ } else {
+ const body = content.body || "";
+ const lines = body.split("\n");
+ const parts = lines.reduce((parts, line, i) => {
+ const isLast = i === lines.length - 1;
+ const text = new PlainPart(line);
+ const newLine = !isLast && new NewlinePart("\n");
+ if (newLine) {
+ return parts.concat(text, newLine);
+ } else {
+ return parts.concat(text);
+ }
+ }, []);
+ return parts;
+ }
+}
diff --git a/src/editor/diff.js b/src/editor/diff.js
new file mode 100644
index 0000000000..6dc8b746e4
--- /dev/null
+++ b/src/editor/diff.js
@@ -0,0 +1,78 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+function firstDiff(a, b) {
+ const compareLen = Math.min(a.length, b.length);
+ for (let i = 0; i < compareLen; ++i) {
+ if (a[i] !== b[i]) {
+ return i;
+ }
+ }
+ return compareLen;
+}
+
+function lastDiff(a, b) {
+ const compareLen = Math.min(a.length, b.length);
+ for (let i = 0; i < compareLen; ++i) {
+ if (a[a.length - i] !== b[b.length - i]) {
+ return i;
+ }
+ }
+ return compareLen;
+}
+
+function diffStringsAtEnd(oldStr, newStr) {
+ const len = Math.min(oldStr.length, newStr.length);
+ const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
+ if (startInCommon && oldStr.length > newStr.length) {
+ return {removed: oldStr.substr(len), at: len};
+ } else if (startInCommon && oldStr.length < newStr.length) {
+ return {added: newStr.substr(len), at: len};
+ } else {
+ const commonStartLen = firstDiff(oldStr, newStr);
+ return {
+ removed: oldStr.substr(commonStartLen),
+ added: newStr.substr(commonStartLen),
+ at: commonStartLen,
+ };
+ }
+}
+
+export function diffDeletion(oldStr, newStr) {
+ if (oldStr === newStr) {
+ return {};
+ }
+ const firstDiffIdx = firstDiff(oldStr, newStr);
+ const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1;
+ return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)};
+}
+
+export function diffInsertion(oldStr, newStr) {
+ const diff = diffDeletion(newStr, oldStr);
+ if (diff.removed) {
+ return {at: diff.at, added: diff.removed};
+ } else {
+ return diff;
+ }
+}
+
+export function diffAtCaret(oldValue, newValue, caretPosition) {
+ const diffLen = newValue.length - oldValue.length;
+ const caretPositionBeforeInput = caretPosition - diffLen;
+ const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput);
+ const newValueBeforeCaret = newValue.substr(0, caretPosition);
+ return diffStringsAtEnd(oldValueBeforeCaret, newValueBeforeCaret);
+}
diff --git a/src/editor/dom.js b/src/editor/dom.js
new file mode 100644
index 0000000000..0899fd25b3
--- /dev/null
+++ b/src/editor/dom.js
@@ -0,0 +1,84 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+function walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback) {
+ let node = editor.firstChild;
+ while (node && node !== editor) {
+ enterNodeCallback(node);
+ if (node.firstChild) {
+ node = node.firstChild;
+ } else if (node.nextSibling) {
+ node = node.nextSibling;
+ } else {
+ while (!node.nextSibling && node !== editor) {
+ node = node.parentElement;
+ if (node !== editor) {
+ leaveNodeCallback(node);
+ }
+ }
+ if (node !== editor) {
+ node = node.nextSibling;
+ }
+ }
+ }
+}
+
+export function getCaretOffsetAndText(editor, sel) {
+ let {focusNode} = sel;
+ const {focusOffset} = sel;
+ let caretOffset = focusOffset;
+ let foundCaret = false;
+ let text = "";
+
+ if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) {
+ focusNode = focusNode.childNodes[focusOffset - 1];
+ caretOffset = focusNode.textContent.length;
+ }
+
+ function enterNodeCallback(node) {
+ const nodeText = node.nodeType === Node.TEXT_NODE && node.nodeValue;
+ if (!foundCaret) {
+ if (node === focusNode) {
+ foundCaret = true;
+ }
+ }
+ if (nodeText) {
+ if (!foundCaret) {
+ caretOffset += nodeText.length;
+ }
+ text += nodeText;
+ }
+ }
+
+ function leaveNodeCallback(node) {
+ // if this is not the last DIV (which are only used as line containers atm)
+ // we don't just check if there is a nextSibling because sometimes the caret ends up
+ // after the last DIV and it creates a newline if you type then,
+ // whereas you just want it to be appended to the current line
+ if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") {
+ text += "\n";
+ if (!foundCaret) {
+ caretOffset += 1;
+ }
+ }
+ }
+
+ walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback);
+
+ const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length;
+ const caret = {atNodeEnd, offset: caretOffset};
+ return {caret, text};
+}
diff --git a/src/editor/model.js b/src/editor/model.js
new file mode 100644
index 0000000000..85dd425b0e
--- /dev/null
+++ b/src/editor/model.js
@@ -0,0 +1,264 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {diffAtCaret, diffDeletion} from "./diff";
+
+export default class EditorModel {
+ constructor(parts, partCreator, updateCallback) {
+ this._parts = parts;
+ this._partCreator = partCreator;
+ this._activePartIdx = null;
+ this._autoComplete = null;
+ this._autoCompletePartIdx = null;
+ this._updateCallback = updateCallback;
+ }
+
+ _insertPart(index, part) {
+ this._parts.splice(index, 0, part);
+ if (this._activePartIdx >= index) {
+ ++this._activePartIdx;
+ }
+ if (this._autoCompletePartIdx >= index) {
+ ++this._autoCompletePartIdx;
+ }
+ }
+
+ _removePart(index) {
+ this._parts.splice(index, 1);
+ if (this._activePartIdx >= index) {
+ --this._activePartIdx;
+ }
+ if (this._autoCompletePartIdx >= index) {
+ --this._autoCompletePartIdx;
+ }
+ }
+
+ _replacePart(index, part) {
+ this._parts.splice(index, 1, part);
+ }
+
+ get parts() {
+ return this._parts;
+ }
+
+ get autoComplete() {
+ if (this._activePartIdx === this._autoCompletePartIdx) {
+ return this._autoComplete;
+ }
+ return null;
+ }
+
+ serializeParts() {
+ return this._parts.map(({type, text}) => {return {type, text};});
+ }
+
+ _diff(newValue, inputType, caret) {
+ const previousValue = this.parts.reduce((text, p) => text + p.text, "");
+ // can't use caret position with drag and drop
+ if (inputType === "deleteByDrag") {
+ return diffDeletion(previousValue, newValue);
+ } else {
+ return diffAtCaret(previousValue, newValue, caret.offset);
+ }
+ }
+
+ update(newValue, inputType, caret) {
+ const diff = this._diff(newValue, inputType, caret);
+ const position = this._positionForOffset(diff.at, caret.atNodeEnd);
+ let removedOffsetDecrease = 0;
+ if (diff.removed) {
+ removedOffsetDecrease = this._removeText(position, diff.removed.length);
+ }
+ let addedLen = 0;
+ if (diff.added) {
+ addedLen = this._addText(position, diff.added);
+ }
+ this._mergeAdjacentParts();
+ const caretOffset = diff.at - removedOffsetDecrease + addedLen;
+ const newPosition = this._positionForOffset(caretOffset, true);
+ this._setActivePart(newPosition);
+ this._updateCallback(newPosition);
+ }
+
+ _setActivePart(pos) {
+ const {index} = pos;
+ const part = this._parts[index];
+ if (part) {
+ if (index !== this._activePartIdx) {
+ this._activePartIdx = index;
+ if (this._activePartIdx !== this._autoCompletePartIdx) {
+ // else try to create one
+ const ac = part.createAutoComplete(this._onAutoComplete);
+ if (ac) {
+ // make sure that react picks up the difference between both acs
+ this._autoComplete = ac;
+ this._autoCompletePartIdx = index;
+ }
+ }
+ }
+ // not _autoComplete, only there if active part is autocomplete part
+ if (this.autoComplete) {
+ this.autoComplete.onPartUpdate(part, pos.offset);
+ }
+ } else {
+ this._activePartIdx = null;
+ this._autoComplete = null;
+ this._autoCompletePartIdx = null;
+ }
+ }
+
+ _onAutoComplete = ({replacePart, caretOffset, close}) => {
+ let pos;
+ if (replacePart) {
+ this._replacePart(this._autoCompletePartIdx, replacePart);
+ let index = this._autoCompletePartIdx;
+ if (caretOffset === undefined) {
+ caretOffset = 0;
+ index += 1;
+ }
+ pos = new DocumentPosition(index, caretOffset);
+ }
+ if (close) {
+ this._autoComplete = null;
+ this._autoCompletePartIdx = null;
+ }
+ // rerender even if editor contents didn't change
+ // to make sure the MessageEditor checks
+ // model.autoComplete being empty and closes it
+ this._updateCallback(pos);
+ }
+
+ _mergeAdjacentParts(docPos) {
+ let prevPart = this._parts[0];
+ for (let i = 1; i < this._parts.length; ++i) {
+ let part = this._parts[i];
+ const isEmpty = !part.text.length;
+ const isMerged = !isEmpty && prevPart.merge(part);
+ if (isEmpty || isMerged) {
+ // remove empty or merged part
+ part = prevPart;
+ this._removePart(i);
+ //repeat this index, as it's removed now
+ --i;
+ }
+ prevPart = part;
+ }
+ }
+
+ /**
+ * removes `len` amount of characters at `pos`.
+ * @param {Object} pos
+ * @param {Number} len
+ * @return {Number} how many characters before pos were also removed,
+ * usually because of non-editable parts that can only be removed in their entirety.
+ */
+ _removeText(pos, len) {
+ let {index, offset} = pos;
+ let removedOffsetDecrease = 0;
+ while (len > 0) {
+ // part might be undefined here
+ let part = this._parts[index];
+ const amount = Math.min(len, part.text.length - offset);
+ if (part.canEdit) {
+ const replaceWith = part.remove(offset, amount);
+ if (typeof replaceWith === "string") {
+ this._replacePart(index, this._partCreator.createDefaultPart(replaceWith));
+ }
+ part = this._parts[index];
+ // remove empty part
+ if (!part.text.length) {
+ this._removePart(index);
+ } else {
+ index += 1;
+ }
+ } else {
+ removedOffsetDecrease += offset;
+ this._removePart(index);
+ }
+ len -= amount;
+ offset = 0;
+ }
+ return removedOffsetDecrease;
+ }
+
+ /**
+ * inserts `str` into the model at `pos`.
+ * @param {Object} pos
+ * @param {string} str
+ * @return {Number} how far from position (in characters) the insertion ended.
+ * This can be more than the length of `str` when crossing non-editable parts, which are skipped.
+ */
+ _addText(pos, str) {
+ let {index} = pos;
+ const {offset} = pos;
+ let addLen = str.length;
+ const part = this._parts[index];
+ if (part) {
+ if (part.canEdit) {
+ if (part.insertAll(offset, str)) {
+ str = null;
+ } else {
+ const splitPart = part.split(offset);
+ index += 1;
+ this._insertPart(index, splitPart);
+ }
+ } else {
+ // not-editable, insert str after this part
+ addLen += part.text.length - offset;
+ index += 1;
+ }
+ }
+ while (str) {
+ const newPart = this._partCreator.createPartForInput(str);
+ str = newPart.appendUntilRejected(str);
+ this._insertPart(index, newPart);
+ index += 1;
+ }
+ return addLen;
+ }
+
+ _positionForOffset(totalOffset, atPartEnd) {
+ let currentOffset = 0;
+ const index = this._parts.findIndex(part => {
+ const partLen = part.text.length;
+ if (
+ (atPartEnd && (currentOffset + partLen) >= totalOffset) ||
+ (!atPartEnd && (currentOffset + partLen) > totalOffset)
+ ) {
+ return true;
+ }
+ currentOffset += partLen;
+ return false;
+ });
+
+ return new DocumentPosition(index, totalOffset - currentOffset);
+ }
+}
+
+class DocumentPosition {
+ constructor(index, offset) {
+ this._index = index;
+ this._offset = offset;
+ }
+
+ get index() {
+ return this._index;
+ }
+
+ get offset() {
+ return this._offset;
+ }
+}
diff --git a/src/editor/parts.js b/src/editor/parts.js
new file mode 100644
index 0000000000..a20b857fee
--- /dev/null
+++ b/src/editor/parts.js
@@ -0,0 +1,274 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import AutocompleteWrapperModel from "./autocomplete";
+
+class BasePart {
+ constructor(text = "") {
+ this._text = text;
+ }
+
+ acceptsInsertion(chr) {
+ return true;
+ }
+
+ acceptsRemoval(position, chr) {
+ return true;
+ }
+
+ merge(part) {
+ return false;
+ }
+
+ split(offset) {
+ const splitText = this.text.substr(offset);
+ this._text = this.text.substr(0, offset);
+ return new PlainPart(splitText);
+ }
+
+ // removes len chars, or returns the plain text this part should be replaced with
+ // if the part would become invalid if it removed everything.
+ remove(offset, len) {
+ // validate
+ const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
+ for (let i = offset; i < (len + offset); ++i) {
+ const chr = this.text.charAt(i);
+ if (!this.acceptsRemoval(i, chr)) {
+ return strWithRemoval;
+ }
+ }
+ this._text = strWithRemoval;
+ }
+
+ // append str, returns the remaining string if a character was rejected.
+ appendUntilRejected(str) {
+ for (let i = 0; i < str.length; ++i) {
+ const chr = str.charAt(i);
+ if (!this.acceptsInsertion(chr)) {
+ this._text = this._text + str.substr(0, i);
+ return str.substr(i);
+ }
+ }
+ this._text = this._text + str;
+ }
+
+ // inserts str at offset if all the characters in str were accepted, otherwise don't do anything
+ // return whether the str was accepted or not.
+ insertAll(offset, str) {
+ for (let i = 0; i < str.length; ++i) {
+ const chr = str.charAt(i);
+ if (!this.acceptsInsertion(chr)) {
+ return false;
+ }
+ }
+ const beforeInsert = this._text.substr(0, offset);
+ const afterInsert = this._text.substr(offset);
+ this._text = beforeInsert + str + afterInsert;
+ return true;
+ }
+
+ createAutoComplete() {}
+
+ trim(len) {
+ const remaining = this._text.substr(len);
+ this._text = this._text.substr(0, len);
+ return remaining;
+ }
+
+ get text() {
+ return this._text;
+ }
+
+ get canEdit() {
+ return true;
+ }
+
+ toString() {
+ return `${this.type}(${this.text})`;
+ }
+}
+
+export class PlainPart extends BasePart {
+ acceptsInsertion(chr) {
+ return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
+ }
+
+ toDOMNode() {
+ return document.createTextNode(this.text);
+ }
+
+ merge(part) {
+ if (part.type === this.type) {
+ this._text = this.text + part.text;
+ return true;
+ }
+ return false;
+ }
+
+ get type() {
+ return "plain";
+ }
+
+ updateDOMNode(node) {
+ if (node.textContent !== this.text) {
+ // console.log("changing plain text from", node.textContent, "to", this.text);
+ node.textContent = this.text;
+ }
+ }
+
+ canUpdateDOMNode(node) {
+ return node.nodeType === Node.TEXT_NODE;
+ }
+}
+
+class PillPart extends BasePart {
+ constructor(resourceId, label) {
+ super(label);
+ this.resourceId = resourceId;
+ }
+
+ acceptsInsertion(chr) {
+ return chr !== " ";
+ }
+
+ acceptsRemoval(position, chr) {
+ return position !== 0; //if you remove initial # or @, pill should become plain
+ }
+
+ toDOMNode() {
+ const container = document.createElement("span");
+ container.className = this.type;
+ container.appendChild(document.createTextNode(this.text));
+ return container;
+ }
+
+ updateDOMNode(node) {
+ const textNode = node.childNodes[0];
+ if (textNode.textContent !== this.text) {
+ // console.log("changing pill text from", textNode.textContent, "to", this.text);
+ textNode.textContent = this.text;
+ }
+ if (node.className !== this.type) {
+ // console.log("turning", node.className, "into", this.type);
+ node.className = this.type;
+ }
+ }
+
+ canUpdateDOMNode(node) {
+ return node.nodeType === Node.ELEMENT_NODE &&
+ node.nodeName === "SPAN" &&
+ node.childNodes.length === 1 &&
+ node.childNodes[0].nodeType === Node.TEXT_NODE;
+ }
+
+ get canEdit() {
+ return false;
+ }
+}
+
+export class NewlinePart extends BasePart {
+ acceptsInsertion(chr) {
+ return this.text.length === 0 && chr === "\n";
+ }
+
+ acceptsRemoval(position, chr) {
+ return true;
+ }
+
+ toDOMNode() {
+ return document.createElement("br");
+ }
+
+ merge() {
+ return false;
+ }
+
+ updateDOMNode() {}
+
+ canUpdateDOMNode(node) {
+ return node.tagName === "BR";
+ }
+
+ get type() {
+ return "newline";
+ }
+}
+
+export class RoomPillPart extends PillPart {
+ constructor(displayAlias) {
+ super(displayAlias, displayAlias);
+ }
+
+ get type() {
+ return "room-pill";
+ }
+}
+
+export class UserPillPart extends PillPart {
+ get type() {
+ return "user-pill";
+ }
+}
+
+
+export class PillCandidatePart extends PlainPart {
+ constructor(text, autoCompleteCreator) {
+ super(text);
+ this._autoCompleteCreator = autoCompleteCreator;
+ }
+
+ createAutoComplete(updateCallback) {
+ return this._autoCompleteCreator(updateCallback);
+ }
+
+ acceptsInsertion(chr) {
+ return true;
+ }
+
+ acceptsRemoval(position, chr) {
+ return true;
+ }
+
+ get type() {
+ return "pill-candidate";
+ }
+}
+
+export class PartCreator {
+ constructor(getAutocompleterComponent, updateQuery) {
+ this._autoCompleteCreator = (updateCallback) => {
+ return new AutocompleteWrapperModel(updateCallback, getAutocompleterComponent, updateQuery);
+ };
+ }
+
+ createPartForInput(input) {
+ switch (input[0]) {
+ case "#":
+ case "@":
+ case ":":
+ return new PillCandidatePart("", this._autoCompleteCreator);
+ case "\n":
+ return new NewlinePart();
+ default:
+ return new PlainPart();
+ }
+ }
+
+ createDefaultPart(text) {
+ return new PlainPart(text);
+ }
+}
+
diff --git a/src/editor/render.js b/src/editor/render.js
new file mode 100644
index 0000000000..abc5d42fa1
--- /dev/null
+++ b/src/editor/render.js
@@ -0,0 +1,81 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export function renderModel(editor, model) {
+ const lines = model.parts.reduce((lines, part) => {
+ if (part.type === "newline") {
+ lines.push([]);
+ } else {
+ const lastLine = lines[lines.length - 1];
+ lastLine.push(part);
+ }
+ return lines;
+ }, [[]]);
+ // TODO: refactor this code, DRY it
+ lines.forEach((parts, i) => {
+ let lineContainer = editor.childNodes[i];
+ while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) {
+ editor.removeChild(lineContainer);
+ lineContainer = editor.childNodes[i];
+ }
+ if (!lineContainer) {
+ lineContainer = document.createElement("div");
+ editor.appendChild(lineContainer);
+ }
+
+ if (parts.length) {
+ parts.forEach((part, j) => {
+ let partNode = lineContainer.childNodes[j];
+ while (partNode && !part.canUpdateDOMNode(partNode)) {
+ lineContainer.removeChild(partNode);
+ partNode = lineContainer.childNodes[j];
+ }
+ if (partNode && part) {
+ part.updateDOMNode(partNode);
+ } else if (part) {
+ lineContainer.appendChild(part.toDOMNode());
+ }
+ });
+
+ let surplusElementCount = Math.max(0, lineContainer.childNodes.length - parts.length);
+ while (surplusElementCount) {
+ lineContainer.removeChild(lineContainer.lastChild);
+ --surplusElementCount;
+ }
+ } else {
+ // empty div needs to have a BR in it to give it height
+ let foundBR = false;
+ let partNode = lineContainer.firstChild;
+ while (partNode) {
+ if (!foundBR && partNode.tagName === "BR") {
+ foundBR = true;
+ } else {
+ lineContainer.removeChild(partNode);
+ }
+ partNode = partNode.nextSibling;
+ }
+ if (!foundBR) {
+ lineContainer.appendChild(document.createElement("br"));
+ }
+ }
+
+ let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length);
+ while (surplusElementCount) {
+ editor.removeChild(editor.lastChild);
+ --surplusElementCount;
+ }
+ });
+}
diff --git a/src/editor/serialize.js b/src/editor/serialize.js
new file mode 100644
index 0000000000..57cc79b375
--- /dev/null
+++ b/src/editor/serialize.js
@@ -0,0 +1,43 @@
+export function htmlSerialize(model) {
+ return model.parts.reduce((html, part) => {
+ switch (part.type) {
+ case "newline":
+ return html + "
";
+ case "plain":
+ case "pill-candidate":
+ return html + part.text;
+ case "room-pill":
+ case "user-pill":
+ return html + `${part.text}`;
+ }
+ }, "");
+}
+
+export function textSerialize(model) {
+ return model.parts.reduce((text, part) => {
+ switch (part.type) {
+ case "newline":
+ return text + "\n";
+ case "plain":
+ case "pill-candidate":
+ return text + part.text;
+ case "room-pill":
+ case "user-pill":
+ return text + `${part.resourceId}`;
+ }
+ }, "");
+}
+
+export function requiresHtml(model) {
+ return model.parts.some(part => {
+ switch (part.type) {
+ case "newline":
+ case "plain":
+ case "pill-candidate":
+ return false;
+ case "room-pill":
+ case "user-pill":
+ return true;
+ }
+ });
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 91829a80b4..58719e216a 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -301,6 +301,7 @@
"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",
"Custom Notification Sounds": "Custom Notification Sounds",
+ "Edit messages after they have been sent (refresh to apply changes)": "Edit messages after they have been sent (refresh to apply changes)",
"React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout",
@@ -904,6 +905,7 @@
"Agree or Disagree": "Agree or Disagree",
"Like or Dislike": "Like or Dislike",
"Reply": "Reply",
+ "Edit": "Edit",
"Options": "Options",
"Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment",
@@ -980,7 +982,6 @@
"Reload widget": "Reload widget",
"Popout widget": "Popout widget",
"Picture": "Picture",
- "Edit": "Edit",
"Revoke widget access": "Revoke widget access",
"Create new room": "Create new room",
"Unblacklist": "Unblacklist",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index 60740bae31..0efc10233f 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -119,6 +119,12 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
+ "feature_message_editing": {
+ isFeature: true,
+ displayName: _td("Edit messages after they have been sent (refresh to apply changes)"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
"feature_notification_sounds": {
isFeature: true,
displayName: _td("Custom Notification Sounds"),
diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js
index 47c901cd9f..3a1e51c610 100644
--- a/src/shouldHideEvent.js
+++ b/src/shouldHideEvent.js
@@ -45,6 +45,7 @@ export default function shouldHideEvent(ev) {
// Hide redacted events
if (ev.isRedacted() && !isEnabled('showRedactions')) return true;
+ if (ev.isRelation("m.replace")) return true;
const eventDiff = memberEventDiff(ev);
diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js
index 1c0931273b..49a120a470 100644
--- a/src/utils/StorageManager.js
+++ b/src/utils/StorageManager.js
@@ -50,11 +50,15 @@ export async function checkConsistency() {
let dataInLocalStorage = false;
let dataInCryptoStore = false;
+ let cryptoInited = false;
let healthy = true;
if (localStorage) {
dataInLocalStorage = localStorage.length > 0;
log(`Local storage contains data? ${dataInLocalStorage}`);
+
+ cryptoInited = localStorage.getItem("mx_crypto_initialised");
+ log(`Crypto initialised? ${cryptoInited}`);
} else {
healthy = false;
error("Local storage cannot be used on this browser");
@@ -84,10 +88,11 @@ export async function checkConsistency() {
track("Crypto store disabled");
}
- if (dataInLocalStorage && !dataInCryptoStore) {
+ if (dataInLocalStorage && cryptoInited && !dataInCryptoStore) {
healthy = false;
error(
- "Data exists in local storage but not in crypto store. " +
+ "Data exists in local storage and crypto is marked as initialised " +
+ " but no data found in crypto store. " +
"IndexedDB storage has likely been evicted by the browser!",
);
track("Crypto store evicted");
@@ -104,6 +109,7 @@ export async function checkConsistency() {
return {
dataInLocalStorage,
dataInCryptoStore,
+ cryptoInited,
healthy,
};
}
@@ -155,3 +161,17 @@ export function trackStores(client) {
});
}
}
+
+/**
+ * Sets whether crypto has ever been successfully
+ * initialised on this client.
+ * StorageManager uses this to determine whether indexeddb
+ * has been wiped by the browser: this flag is saved to localStorage
+ * and if it is true and not crypto data is found, an error is
+ * presented to the user.
+ *
+ * @param {bool} cryptoInited True if crypto has been set up
+ */
+export function setCryptoInitialised(cryptoInited) {
+ localStorage.setItem("mx_crypto_initialised", cryptoInited);
+}