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:
commit
f39dc6feab
24 changed files with 1362 additions and 293 deletions
|
@ -34,7 +34,7 @@ src/components/views/rooms/LinkPreviewWidget.js
|
||||||
src/components/views/rooms/MemberDeviceInfo.js
|
src/components/views/rooms/MemberDeviceInfo.js
|
||||||
src/components/views/rooms/MemberInfo.js
|
src/components/views/rooms/MemberInfo.js
|
||||||
src/components/views/rooms/MemberList.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/PinnedEventTile.js
|
||||||
src/components/views/rooms/RoomList.js
|
src/components/views/rooms/RoomList.js
|
||||||
src/components/views/rooms/RoomPreviewBar.js
|
src/components/views/rooms/RoomPreviewBar.js
|
||||||
|
|
|
@ -17,7 +17,7 @@ The parts are then reconciled with the DOM.
|
||||||
When typing in the `contenteditable` element, the `input` event fires and
|
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
|
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
|
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).
|
caret nodes (more on that later).
|
||||||
For these reasons it doesn't use `innerText`, `textContent` or anything similar.
|
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.
|
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`.
|
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()`
|
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
|
basically just concatenating their text. It then looks for differences between
|
||||||
the current and the new content string. The diffing algorithm is very basic,
|
the current and the new content string. The diffing algorithm is very basic,
|
||||||
and assumes there is only one change around the caret offset,
|
and assumes there is only one change around the caret offset,
|
||||||
so this should be very inexpensive. See `diff.js` for details.
|
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,
|
the current content. These differences are then applied to the parts,
|
||||||
where parts can apply validation logic to these changes.
|
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,
|
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,
|
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
|
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.
|
with the new model state, see `renderModel` in `render.js` for this.
|
||||||
|
|
|
@ -92,7 +92,6 @@
|
||||||
@import "./views/elements/_InteractiveTooltip.scss";
|
@import "./views/elements/_InteractiveTooltip.scss";
|
||||||
@import "./views/elements/_ManageIntegsButton.scss";
|
@import "./views/elements/_ManageIntegsButton.scss";
|
||||||
@import "./views/elements/_MemberEventListSummary.scss";
|
@import "./views/elements/_MemberEventListSummary.scss";
|
||||||
@import "./views/elements/_MessageEditor.scss";
|
|
||||||
@import "./views/elements/_PowerSelector.scss";
|
@import "./views/elements/_PowerSelector.scss";
|
||||||
@import "./views/elements/_ProgressBar.scss";
|
@import "./views/elements/_ProgressBar.scss";
|
||||||
@import "./views/elements/_ReplyThread.scss";
|
@import "./views/elements/_ReplyThread.scss";
|
||||||
|
@ -135,7 +134,9 @@
|
||||||
@import "./views/rooms/_AppsDrawer.scss";
|
@import "./views/rooms/_AppsDrawer.scss";
|
||||||
@import "./views/rooms/_Autocomplete.scss";
|
@import "./views/rooms/_Autocomplete.scss";
|
||||||
@import "./views/rooms/_AuxPanel.scss";
|
@import "./views/rooms/_AuxPanel.scss";
|
||||||
|
@import "./views/rooms/_BasicMessageComposer.scss";
|
||||||
@import "./views/rooms/_E2EIcon.scss";
|
@import "./views/rooms/_E2EIcon.scss";
|
||||||
|
@import "./views/rooms/_EditMessageComposer.scss";
|
||||||
@import "./views/rooms/_EntityTile.scss";
|
@import "./views/rooms/_EntityTile.scss";
|
||||||
@import "./views/rooms/_EventTile.scss";
|
@import "./views/rooms/_EventTile.scss";
|
||||||
@import "./views/rooms/_JumpToBottomButton.scss";
|
@import "./views/rooms/_JumpToBottomButton.scss";
|
||||||
|
@ -158,6 +159,7 @@
|
||||||
@import "./views/rooms/_RoomUpgradeWarningBar.scss";
|
@import "./views/rooms/_RoomUpgradeWarningBar.scss";
|
||||||
@import "./views/rooms/_SearchBar.scss";
|
@import "./views/rooms/_SearchBar.scss";
|
||||||
@import "./views/rooms/_SearchableEntityList.scss";
|
@import "./views/rooms/_SearchableEntityList.scss";
|
||||||
|
@import "./views/rooms/_SendMessageComposer.scss";
|
||||||
@import "./views/rooms/_Stickers.scss";
|
@import "./views/rooms/_Stickers.scss";
|
||||||
@import "./views/rooms/_TopUnreadMessagesBar.scss";
|
@import "./views/rooms/_TopUnreadMessagesBar.scss";
|
||||||
@import "./views/rooms/_WhoIsTypingTile.scss";
|
@import "./views/rooms/_WhoIsTypingTile.scss";
|
||||||
|
|
65
res/css/views/rooms/_BasicMessageComposer.scss
Normal file
65
res/css/views/rooms/_BasicMessageComposer.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 New Vector Ltd
|
Copyright 2019 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_MessageEditor {
|
.mx_EditMessageComposer {
|
||||||
border-radius: 4px;
|
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
// this is to try not make the text move but still have some
|
// this is to try not make the text move but still have some
|
||||||
// padding around and in the editor.
|
// padding around and in the editor.
|
||||||
|
@ -23,47 +24,20 @@ limitations under the License.
|
||||||
margin: -7px -10px -5px -10px;
|
margin: -7px -10px -5px -10px;
|
||||||
overflow: visible !important; // override mx_EventTile_content
|
overflow: visible !important; // override mx_EventTile_content
|
||||||
|
|
||||||
.mx_MessageEditor_editor {
|
|
||||||
|
.mx_BasicMessageComposer_input {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: solid 1px $primary-hairline-color;
|
border: solid 1px $primary-hairline-color;
|
||||||
background-color: $primary-bg-color;
|
background-color: $primary-bg-color;
|
||||||
padding: 3px 6px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
outline: none;
|
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-x: auto;
|
padding: 3px 6px;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: $accent-color-50pct;
|
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;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
@ -81,14 +55,9 @@ limitations under the License.
|
||||||
padding: 5px 40px;
|
padding: 5px 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MessageEditor_AutoCompleteWrapper {
|
|
||||||
position: relative;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_EventTile_last .mx_MessageEditor_buttons {
|
.mx_EventTile_last .mx_EditMessageComposer_buttons {
|
||||||
position: static;
|
position: static;
|
||||||
margin-right: -147px;
|
margin-right: -147px;
|
||||||
}
|
}
|
53
res/css/views/rooms/_SendMessageComposer.scss
Normal file
53
res/css/views/rooms/_SendMessageComposer.scss
Normal 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
60
src/SendHistoryManager.js
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,7 +47,7 @@ class HistoryItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ComposerHistoryManager {
|
export default class SlateComposerHistoryManager {
|
||||||
history: Array<HistoryItem> = [];
|
history: Array<HistoryItem> = [];
|
||||||
prefix: string;
|
prefix: string;
|
||||||
lastIndex: number = 0; // used for indexing the storage
|
lastIndex: number = 0; // used for indexing the storage
|
|
@ -1550,7 +1550,6 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
render: function() {
|
render: function() {
|
||||||
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
|
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
|
||||||
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
|
|
||||||
const ForwardMessage = sdk.getComponent("rooms.ForwardMessage");
|
const ForwardMessage = sdk.getComponent("rooms.ForwardMessage");
|
||||||
const AuxPanel = sdk.getComponent("rooms.AuxPanel");
|
const AuxPanel = sdk.getComponent("rooms.AuxPanel");
|
||||||
const SearchBar = sdk.getComponent("rooms.SearchBar");
|
const SearchBar = sdk.getComponent("rooms.SearchBar");
|
||||||
|
@ -1778,6 +1777,8 @@ module.exports = React.createClass({
|
||||||
myMembership === 'join' && !this.state.searchResults
|
myMembership === 'join' && !this.state.searchResults
|
||||||
);
|
);
|
||||||
if (canSpeak) {
|
if (canSpeak) {
|
||||||
|
if (SettingsStore.isFeatureEnabled("feature_cider_composer")) {
|
||||||
|
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
|
||||||
messageComposer =
|
messageComposer =
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
room={this.state.room}
|
room={this.state.room}
|
||||||
|
@ -1787,6 +1788,18 @@ module.exports = React.createClass({
|
||||||
e2eStatus={this.state.e2eStatus}
|
e2eStatus={this.state.e2eStatus}
|
||||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
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
|
// TODO: Why aren't we storing the term/scope/count in this format
|
||||||
|
|
|
@ -15,9 +15,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {_t} from '../../../languageHandler';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import dis from '../../../dispatcher';
|
|
||||||
import EditorModel from '../../../editor/model';
|
import EditorModel from '../../../editor/model';
|
||||||
import HistoryManager from '../../../editor/history';
|
import HistoryManager from '../../../editor/history';
|
||||||
import {setCaretPosition} from '../../../editor/caret';
|
import {setCaretPosition} from '../../../editor/caret';
|
||||||
|
@ -26,13 +24,40 @@ import Autocomplete from '../rooms/Autocomplete';
|
||||||
import {autoCompleteCreator} from '../../../editor/parts';
|
import {autoCompleteCreator} from '../../../editor/parts';
|
||||||
import {renderModel} from '../../../editor/render';
|
import {renderModel} from '../../../editor/render';
|
||||||
import {Room} from 'matrix-js-sdk';
|
import {Room} from 'matrix-js-sdk';
|
||||||
|
import TypingStore from "../../../stores/TypingStore";
|
||||||
|
|
||||||
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
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 {
|
export default class BasicMessageEditor extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
onChange: PropTypes.func,
|
||||||
model: PropTypes.instanceOf(EditorModel).isRequired,
|
model: PropTypes.instanceOf(EditorModel).isRequired,
|
||||||
room: PropTypes.instanceOf(Room).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) {
|
constructor(props, context) {
|
||||||
|
@ -54,14 +79,30 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
console.error(err);
|
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.setState({autoComplete: this.props.model.autoComplete});
|
||||||
this.historyManager.tryPush(this.props.model, caret, inputType, diff);
|
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) => {
|
_onInput = (event) => {
|
||||||
this._modifiedFlag = true;
|
this._modifiedFlag = true;
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
|
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
|
||||||
|
this._setLastCaret(caret, text, sel);
|
||||||
this.props.model.update(text, event.inputType, caret);
|
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);
|
this.props.model.update(newText, inputType, caret);
|
||||||
}
|
}
|
||||||
|
|
||||||
_isCaretAtStart() {
|
// this is used later to see if we need to recalculate the caret
|
||||||
const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection());
|
// on selectionchange. If it is just a consequence of typing
|
||||||
return caret.offset === 0;
|
// 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() {
|
_refreshLastCaretIfNeeded() {
|
||||||
const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection());
|
// TODO: needed when going up and down in editing messages ... not sure why yet
|
||||||
return caret.offset === text.length;
|
// 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) => {
|
_onKeyDown = (event) => {
|
||||||
|
@ -106,7 +200,7 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
}
|
}
|
||||||
handled = true;
|
handled = true;
|
||||||
// insert newline on Shift+Enter
|
// 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");
|
this._insertText("\n");
|
||||||
handled = true;
|
handled = true;
|
||||||
// autocomplete or enter to send below shouldn't have any modifier keys pressed.
|
// 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;
|
const autoComplete = model.autoComplete;
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case "Enter":
|
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":
|
case "ArrowUp":
|
||||||
autoComplete.onUpArrow(event); break;
|
autoComplete.onUpArrow(event);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
autoComplete.onDownArrow(event); break;
|
autoComplete.onDownArrow(event);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
case "Tab":
|
case "Tab":
|
||||||
autoComplete.onTab(event); break;
|
autoComplete.onTab(event);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
autoComplete.onEscape(event); break;
|
autoComplete.onEscape(event);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return; // don't preventDefault on anything else
|
return; // don't preventDefault on anything else
|
||||||
}
|
}
|
||||||
handled = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (handled) {
|
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() {
|
isModified() {
|
||||||
return this._modifiedFlag;
|
return this._modifiedFlag;
|
||||||
}
|
}
|
||||||
|
@ -190,23 +292,12 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
return caretPosition;
|
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() {
|
render() {
|
||||||
let autoComplete;
|
let autoComplete;
|
||||||
if (this.state.autoComplete) {
|
if (this.state.autoComplete) {
|
||||||
const query = this.state.query;
|
const query = this.state.query;
|
||||||
const queryLen = query.length;
|
const queryLen = query.length;
|
||||||
autoComplete = <div className="mx_MessageEditor_AutoCompleteWrapper">
|
autoComplete = (<div className="mx_BasicMessageComposer_AutoCompleteWrapper">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
ref={ref => this._autocompleteRef = ref}
|
ref={ref => this._autocompleteRef = ref}
|
||||||
query={query}
|
query={query}
|
||||||
|
@ -215,18 +306,24 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
selection={{beginning: true, end: queryLen, start: queryLen}}
|
selection={{beginning: true, end: queryLen, start: queryLen}}
|
||||||
room={this.props.room}
|
room={this.props.room}
|
||||||
/>
|
/>
|
||||||
</div>;
|
</div>);
|
||||||
}
|
}
|
||||||
return <div className={this.props.className}>
|
return (<div className="mx_BasicMessageComposer">
|
||||||
{ autoComplete }
|
{ autoComplete }
|
||||||
<div
|
<div
|
||||||
className="mx_MessageEditor_editor"
|
className="mx_BasicMessageComposer_input"
|
||||||
contentEditable="true"
|
contentEditable="true"
|
||||||
tabIndex="1"
|
tabIndex="1"
|
||||||
|
onBlur={this._onBlur}
|
||||||
|
onFocus={this._onFocus}
|
||||||
onKeyDown={this._onKeyDown}
|
onKeyDown={this._onKeyDown}
|
||||||
ref={ref => this._editorRef = ref}
|
ref={ref => this._editorRef = ref}
|
||||||
aria-label={_t("Edit message")}
|
aria-label={this.props.label}
|
||||||
></div>
|
></div>
|
||||||
</div>;
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this._editorRef.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ import PropTypes from 'prop-types';
|
||||||
import dis from '../../../dispatcher';
|
import dis from '../../../dispatcher';
|
||||||
import EditorModel from '../../../editor/model';
|
import EditorModel from '../../../editor/model';
|
||||||
import {getCaretOffsetAndText} from '../../../editor/dom';
|
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 {findEditableEvent} from '../../../utils/EventUtils';
|
||||||
import {parseEvent} from '../../../editor/deserialize';
|
import {parseEvent} from '../../../editor/deserialize';
|
||||||
import {PartCreator} from '../../../editor/parts';
|
import {PartCreator} from '../../../editor/parts';
|
||||||
|
@ -56,17 +56,10 @@ function getTextReplyFallback(mxEvent) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function _isEmote(model) {
|
|
||||||
const firstPart = model.parts[0];
|
|
||||||
return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEditContent(model, editedEvent) {
|
function createEditContent(model, editedEvent) {
|
||||||
const isEmote = _isEmote(model);
|
const isEmote = containsEmote(model);
|
||||||
if (isEmote) {
|
if (isEmote) {
|
||||||
// trim "/me "
|
model = stripEmoteCommand(model);
|
||||||
model = model.clone();
|
|
||||||
model.removeText({index: 0, offset: 0}, 4);
|
|
||||||
}
|
}
|
||||||
const isReply = _isReply(editedEvent);
|
const isReply = _isReply(editedEvent);
|
||||||
let plainPrefix = "";
|
let plainPrefix = "";
|
||||||
|
@ -249,17 +242,18 @@ export default class EditMessageComposer extends React.Component {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||||
return <div className={classNames("mx_MessageEditor", this.props.className)} onKeyDown={this._onKeyDown}>
|
return (<div className={classNames("mx_EditMessageComposer", this.props.className)} onKeyDown={this._onKeyDown}>
|
||||||
<BasicMessageComposer
|
<BasicMessageComposer
|
||||||
ref={this._setEditorRef}
|
ref={this._setEditorRef}
|
||||||
model={this.model}
|
model={this.model}
|
||||||
room={this._getRoom()}
|
room={this._getRoom()}
|
||||||
initialCaret={this.props.editState.getCaret()}
|
initialCaret={this.props.editState.getCaret()}
|
||||||
|
label={_t("Edit message")}
|
||||||
/>
|
/>
|
||||||
<div className="mx_MessageEditor_buttons">
|
<div className="mx_EditMessageComposer_buttons">
|
||||||
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton>
|
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton>
|
||||||
<AccessibleButton kind="primary" onClick={this._sendEdit}>{_t("Save")}</AccessibleButton>
|
<AccessibleButton kind="primary" onClick={this._sendEdit}>{_t("Save")}</AccessibleButton>
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,32 +16,18 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { _t, _td } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import CallHandler from '../../../CallHandler';
|
import CallHandler from '../../../CallHandler';
|
||||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||||
import Modal from '../../../Modal';
|
|
||||||
import sdk from '../../../index';
|
import sdk from '../../../index';
|
||||||
import dis from '../../../dispatcher';
|
import dis from '../../../dispatcher';
|
||||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
|
||||||
import Stickerpicker from './Stickerpicker';
|
import Stickerpicker from './Stickerpicker';
|
||||||
import { makeRoomPermalink } from '../../../matrix-to';
|
import { makeRoomPermalink } from '../../../matrix-to';
|
||||||
import ContentMessages from '../../../ContentMessages';
|
import ContentMessages from '../../../ContentMessages';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import E2EIcon from './E2EIcon';
|
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) {
|
function ComposerAvatar(props) {
|
||||||
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
|
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
|
||||||
return <div className="mx_MessageComposer_avatar">
|
return <div className="mx_MessageComposer_avatar">
|
||||||
|
@ -51,7 +37,7 @@ function ComposerAvatar(props) {
|
||||||
|
|
||||||
ComposerAvatar.propTypes = {
|
ComposerAvatar.propTypes = {
|
||||||
me: PropTypes.object.isRequired,
|
me: PropTypes.object.isRequired,
|
||||||
}
|
};
|
||||||
|
|
||||||
function CallButton(props) {
|
function CallButton(props) {
|
||||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||||
|
@ -63,15 +49,15 @@ function CallButton(props) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
|
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
|
||||||
onClick={onVoiceCallClick}
|
onClick={onVoiceCallClick}
|
||||||
title={_t('Voice call')}
|
title={_t('Voice call')}
|
||||||
/>
|
/>);
|
||||||
}
|
}
|
||||||
|
|
||||||
CallButton.propTypes = {
|
CallButton.propTypes = {
|
||||||
roomId: PropTypes.string.isRequired
|
roomId: PropTypes.string.isRequired,
|
||||||
}
|
};
|
||||||
|
|
||||||
function VideoCallButton(props) {
|
function VideoCallButton(props) {
|
||||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||||
|
@ -107,38 +93,21 @@ function HangupButton(props) {
|
||||||
room_id: call.roomId,
|
room_id: call.roomId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
|
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
|
||||||
onClick={onHangupClick}
|
onClick={onHangupClick}
|
||||||
title={_t('Hangup')}
|
title={_t('Hangup')}
|
||||||
/>;
|
/>);
|
||||||
}
|
}
|
||||||
|
|
||||||
HangupButton.propTypes = {
|
HangupButton.propTypes = {
|
||||||
roomId: PropTypes.string.isRequired,
|
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 {
|
class UploadButton extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
roomId: PropTypes.string.isRequired,
|
roomId: PropTypes.string.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
this.onUploadClick = this.onUploadClick.bind(this);
|
this.onUploadClick = this.onUploadClick.bind(this);
|
||||||
|
@ -195,24 +164,14 @@ class UploadButton extends React.Component {
|
||||||
export default class MessageComposer extends React.Component {
|
export default class MessageComposer extends React.Component {
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
super(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.onInputStateChanged = this.onInputStateChanged.bind(this);
|
||||||
this.onEvent = this.onEvent.bind(this);
|
this.onEvent = this.onEvent.bind(this);
|
||||||
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
|
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
|
||||||
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
|
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
|
||||||
this._onTombstoneClick = this._onTombstoneClick.bind(this);
|
this._onTombstoneClick = this._onTombstoneClick.bind(this);
|
||||||
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
|
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
|
||||||
this.renderFormatBar = this.renderFormatBar.bind(this);
|
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
inputState: {
|
|
||||||
marks: [],
|
|
||||||
blockType: null,
|
|
||||||
isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'),
|
|
||||||
},
|
|
||||||
showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'),
|
|
||||||
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
|
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
|
||||||
tombstone: this._getRoomTombstone(),
|
tombstone: this._getRoomTombstone(),
|
||||||
canSendMessages: this.props.room.maySendMessage(),
|
canSendMessages: this.props.room.maySendMessage(),
|
||||||
|
@ -259,6 +218,7 @@ export default class MessageComposer extends React.Component {
|
||||||
onEvent(event) {
|
onEvent(event) {
|
||||||
if (event.getType() !== 'm.room.encryption') return;
|
if (event.getType() !== 'm.room.encryption') return;
|
||||||
if (event.getRoomId() !== this.props.room.roomId) return;
|
if (event.getRoomId() !== this.props.room.roomId) return;
|
||||||
|
// TODO: put (encryption state??) in state
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,34 +243,12 @@ export default class MessageComposer extends React.Component {
|
||||||
this.setState({ isQuoting });
|
this.setState({ isQuoting });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onInputStateChanged(inputState) {
|
onInputStateChanged(inputState) {
|
||||||
// Merge the new input state with old to support partial updates
|
// Merge the new input state with old to support partial updates
|
||||||
inputState = Object.assign({}, this.state.inputState, inputState);
|
inputState = Object.assign({}, this.state.inputState, inputState);
|
||||||
this.setState({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) {
|
_onTombstoneClick(ev) {
|
||||||
ev.preventDefault();
|
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() {
|
render() {
|
||||||
const controls = [
|
const controls = [
|
||||||
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
|
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) {
|
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
|
// check separately for whether we can call, but this is slightly
|
||||||
// complex because of conference calls.
|
// complex because of conference calls.
|
||||||
|
|
||||||
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
|
const SendMessageComposer = sdk.getComponent("rooms.SendMessageComposer");
|
||||||
const showFormattingButton = this.state.inputState.isRichTextEnabled;
|
|
||||||
const callInProgress = this.props.callState && this.props.callState !== 'ended';
|
const callInProgress = this.props.callState && this.props.callState !== 'ended';
|
||||||
|
|
||||||
controls.push(
|
controls.push(
|
||||||
<MessageComposerInput
|
<SendMessageComposer
|
||||||
ref={(c) => this.messageComposerInput = c}
|
ref={(c) => this.messageComposerInput = c}
|
||||||
key="controls_input"
|
key="controls_input"
|
||||||
room={this.props.room}
|
room={this.props.room}
|
||||||
placeholder={this.renderPlaceholderText()}
|
placeholder={this.renderPlaceholderText()}
|
||||||
onInputStateChanged={this.onInputStateChanged}
|
|
||||||
permalinkCreator={this.props.permalinkCreator} />,
|
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} />,
|
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
|
||||||
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
|
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
|
||||||
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : null,
|
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({
|
const wrapperClasses = classNames({
|
||||||
mx_MessageComposer_wrapper: true,
|
mx_MessageComposer_wrapper: true,
|
||||||
mx_MessageComposer_hasE2EIcon: !!this.props.e2eStatus,
|
mx_MessageComposer_hasE2EIcon: !!this.props.e2eStatus,
|
||||||
|
@ -471,7 +364,6 @@ export default class MessageComposer extends React.Component {
|
||||||
{ controls }
|
{ controls }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ showFormatBar ? this.renderFormatBar() : null }
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -485,5 +377,5 @@ MessageComposer.propTypes = {
|
||||||
callState: PropTypes.string,
|
callState: PropTypes.string,
|
||||||
|
|
||||||
// string representing the current room app drawer state
|
// string representing the current room app drawer state
|
||||||
showApps: PropTypes.bool
|
showApps: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
|
@ -61,7 +61,7 @@ import ReplyThread from "../elements/ReplyThread";
|
||||||
import {ContentHelpers} from 'matrix-js-sdk';
|
import {ContentHelpers} from 'matrix-js-sdk';
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import {findEditableEvent} from '../../../utils/EventUtils';
|
import {findEditableEvent} from '../../../utils/EventUtils';
|
||||||
import ComposerHistoryManager from "../../../ComposerHistoryManager";
|
import SlateComposerHistoryManager from "../../../SlateComposerHistoryManager";
|
||||||
import TypingStore from "../../../stores/TypingStore";
|
import TypingStore from "../../../stores/TypingStore";
|
||||||
|
|
||||||
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
||||||
|
@ -141,7 +141,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
autocomplete: Autocomplete;
|
autocomplete: Autocomplete;
|
||||||
historyManager: ComposerHistoryManager;
|
historyManager: SlateComposerHistoryManager;
|
||||||
|
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
|
@ -331,7 +331,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
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() {
|
componentWillUnmount() {
|
||||||
|
|
319
src/components/views/rooms/SendMessageComposer.js
Normal file
319
src/components/views/rooms/SendMessageComposer.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
489
src/components/views/rooms/SlateMessageComposer.js
Normal file
489
src/components/views/rooms/SlateMessageComposer.js
Normal 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
|
||||||
|
};
|
|
@ -33,6 +33,10 @@ export default class AutocompleteWrapperModel {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasSelection() {
|
||||||
|
return this._getAutocompleterComponent().hasSelection();
|
||||||
|
}
|
||||||
|
|
||||||
onEnter() {
|
onEnter() {
|
||||||
this._updateCallback({close: true});
|
this._updateCallback({close: true});
|
||||||
}
|
}
|
||||||
|
@ -103,7 +107,7 @@ export default class AutocompleteWrapperModel {
|
||||||
}
|
}
|
||||||
case "#":
|
case "#":
|
||||||
return this._partCreator.roomPill(completionId);
|
return this._partCreator.roomPill(completionId);
|
||||||
// also used for emoji completion
|
// used for emoji and command completion replacement
|
||||||
default:
|
default:
|
||||||
return this._partCreator.plain(text);
|
return this._partCreator.plain(text);
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,29 +130,29 @@ function checkIgnored(n) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QUOTE_LINE_PREFIX = "> ";
|
||||||
function prefixQuoteLines(isFirstNode, parts, partCreator) {
|
function prefixQuoteLines(isFirstNode, parts, partCreator) {
|
||||||
const PREFIX = "> ";
|
|
||||||
// a newline (to append a > to) wouldn't be added to parts for the first line
|
// 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 there was no content before the BLOCKQUOTE, so handle that
|
||||||
if (isFirstNode) {
|
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) {
|
for (let i = 0; i < parts.length; i += 1) {
|
||||||
if (parts[i].type === "newline") {
|
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;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHtmlMessage(html, partCreator) {
|
function parseHtmlMessage(html, partCreator, isQuotedMessage) {
|
||||||
// no nodes from parsing here should be inserted in the document,
|
// no nodes from parsing here should be inserted in the document,
|
||||||
// as scripts in event handlers, etc would be executed then.
|
// as scripts in event handlers, etc would be executed then.
|
||||||
// we're only taking text, so that is fine
|
// we're only taking text, so that is fine
|
||||||
const rootNode = new DOMParser().parseFromString(html, "text/html").body;
|
const rootNode = new DOMParser().parseFromString(html, "text/html").body;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let lastNode;
|
let lastNode;
|
||||||
let inQuote = false;
|
let inQuote = isQuotedMessage;
|
||||||
const state = {};
|
const state = {};
|
||||||
|
|
||||||
function onNodeEnter(n) {
|
function onNodeEnter(n) {
|
||||||
|
@ -220,22 +220,29 @@ function parseHtmlMessage(html, partCreator) {
|
||||||
return parts;
|
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();
|
const content = event.getContent();
|
||||||
let parts;
|
let parts;
|
||||||
if (content.format === "org.matrix.custom.html") {
|
if (content.format === "org.matrix.custom.html") {
|
||||||
parts = parseHtmlMessage(content.formatted_body || "", partCreator);
|
parts = parseHtmlMessage(content.formatted_body || "", partCreator, isQuotedMessage);
|
||||||
} else {
|
} else {
|
||||||
const body = content.body || "";
|
parts = parsePlainTextMessage(content.body || "", partCreator, isQuotedMessage);
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
}
|
}
|
||||||
if (content.msgtype === "m.emote") {
|
if (content.msgtype === "m.emote") {
|
||||||
parts.unshift(partCreator.plain("/me "));
|
parts.unshift(partCreator.plain("/me "));
|
||||||
|
|
|
@ -18,6 +18,10 @@ export const MAX_STEP_LENGTH = 10;
|
||||||
|
|
||||||
export default class HistoryManager {
|
export default class HistoryManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
this._stack = [];
|
this._stack = [];
|
||||||
this._newlyTypedCharCount = 0;
|
this._newlyTypedCharCount = 0;
|
||||||
this._currentIndex = -1;
|
this._currentIndex = -1;
|
||||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
||||||
import {diffAtCaret, diffDeletion} from "./diff";
|
import {diffAtCaret, diffDeletion} from "./diff";
|
||||||
|
|
||||||
export default class EditorModel {
|
export default class EditorModel {
|
||||||
constructor(parts, partCreator, updateCallback) {
|
constructor(parts, partCreator, updateCallback = null) {
|
||||||
this._parts = parts;
|
this._parts = parts;
|
||||||
this._partCreator = partCreator;
|
this._partCreator = partCreator;
|
||||||
this._activePartIdx = null;
|
this._activePartIdx = null;
|
||||||
|
@ -35,6 +35,10 @@ export default class EditorModel {
|
||||||
return this._partCreator;
|
return this._partCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isEmpty() {
|
||||||
|
return this._parts.reduce((len, part) => len + part.text.length, 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
clone() {
|
clone() {
|
||||||
return new EditorModel(this._parts, this._partCreator, this._updateCallback);
|
return new EditorModel(this._parts, this._partCreator, this._updateCallback);
|
||||||
}
|
}
|
||||||
|
@ -80,7 +84,8 @@ export default class EditorModel {
|
||||||
const part = this._parts[index];
|
const part = this._parts[index];
|
||||||
return new DocumentPosition(index, part.text.length);
|
return new DocumentPosition(index, part.text.length);
|
||||||
} else {
|
} 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) {
|
reset(serializedParts, caret, inputType) {
|
||||||
this._parts = serializedParts.map(p => this._partCreator.deserializePart(p));
|
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);
|
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) {
|
update(newValue, inputType, caret) {
|
||||||
const diff = this._diff(newValue, inputType, caret);
|
const diff = this._diff(newValue, inputType, caret);
|
||||||
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
|
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
|
||||||
|
@ -227,6 +254,23 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
return removedOffsetDecrease;
|
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`.
|
* inserts `str` into the model at `pos`.
|
||||||
|
@ -266,7 +310,7 @@ export default class EditorModel {
|
||||||
index = 0;
|
index = 0;
|
||||||
}
|
}
|
||||||
while (str) {
|
while (str) {
|
||||||
const newPart = this._partCreator.createPartForInput(str);
|
const newPart = this._partCreator.createPartForInput(str, index);
|
||||||
if (validate) {
|
if (validate) {
|
||||||
str = newPart.appendUntilRejected(str);
|
str = newPart.appendUntilRejected(str);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -312,7 +312,7 @@ class UserPillPart extends PillPart {
|
||||||
|
|
||||||
serialize() {
|
serialize() {
|
||||||
const obj = super.serialize();
|
const obj = super.serialize();
|
||||||
obj.userId = this.resourceId;
|
obj.resourceId = this.resourceId;
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -363,7 +363,7 @@ export function autoCompleteCreator(getAutocompleterComponent, updateQuery) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PartCreator {
|
export class PartCreator {
|
||||||
constructor(room, client, autoCompleteCreator) {
|
constructor(room, client, autoCompleteCreator = null) {
|
||||||
this._room = room;
|
this._room = room;
|
||||||
this._client = client;
|
this._client = client;
|
||||||
this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
|
this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
|
||||||
|
@ -403,7 +403,7 @@ export class PartCreator {
|
||||||
case "room-pill":
|
case "room-pill":
|
||||||
return this.roomPill(part.text);
|
return this.roomPill(part.text);
|
||||||
case "user-pill":
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ export function mdSerialize(model) {
|
||||||
case "newline":
|
case "newline":
|
||||||
return html + "\n";
|
return html + "\n";
|
||||||
case "plain":
|
case "plain":
|
||||||
|
case "command":
|
||||||
case "pill-candidate":
|
case "pill-candidate":
|
||||||
case "at-room-pill":
|
case "at-room-pill":
|
||||||
return html + part.text;
|
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 md = mdSerialize(model);
|
||||||
const parser = new Markdown(md);
|
const parser = new Markdown(md);
|
||||||
if (!parser.isPlainText() || forceHTML) {
|
if (!parser.isPlainText() || forceHTML) {
|
||||||
|
@ -47,6 +48,7 @@ export function textSerialize(model) {
|
||||||
case "newline":
|
case "newline":
|
||||||
return text + "\n";
|
return text + "\n";
|
||||||
case "plain":
|
case "plain":
|
||||||
|
case "command":
|
||||||
case "pill-candidate":
|
case "pill-candidate":
|
||||||
case "at-room-pill":
|
case "at-room-pill":
|
||||||
return text + part.text;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -326,6 +326,7 @@
|
||||||
"Custom user status messages": "Custom user status messages",
|
"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)",
|
"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",
|
"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",
|
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
|
||||||
"Use compact timeline layout": "Use compact timeline layout",
|
"Use compact timeline layout": "Use compact timeline layout",
|
||||||
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
|
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
|
||||||
|
@ -747,11 +748,11 @@
|
||||||
" (unsupported)": " (unsupported)",
|
" (unsupported)": " (unsupported)",
|
||||||
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.": "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
|
"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.",
|
"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 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",
|
"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 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",
|
"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",
|
"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 an image": "%(senderName)s sent an image",
|
||||||
"%(senderName)s sent a video": "%(senderName)s sent a video",
|
"%(senderName)s sent a video": "%(senderName)s sent a video",
|
||||||
|
@ -804,25 +805,14 @@
|
||||||
"Invited": "Invited",
|
"Invited": "Invited",
|
||||||
"Filter room members": "Filter room members",
|
"Filter room members": "Filter room members",
|
||||||
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)",
|
"%(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",
|
"Voice call": "Voice call",
|
||||||
"Video call": "Video call",
|
"Video call": "Video call",
|
||||||
"Hangup": "Hangup",
|
"Hangup": "Hangup",
|
||||||
"Show Text Formatting Toolbar": "Show Text Formatting Toolbar",
|
|
||||||
"Upload file": "Upload file",
|
"Upload file": "Upload file",
|
||||||
"Send an encrypted reply…": "Send an encrypted reply…",
|
"Send an encrypted reply…": "Send an encrypted reply…",
|
||||||
"Send a reply (unencrypted)…": "Send a reply (unencrypted)…",
|
"Send a reply (unencrypted)…": "Send a reply (unencrypted)…",
|
||||||
"Send an encrypted message…": "Send an encrypted message…",
|
"Send an encrypted message…": "Send an encrypted message…",
|
||||||
"Send a message (unencrypted)…": "Send a message (unencrypted)…",
|
"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.",
|
"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.",
|
"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",
|
"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",
|
"Command error": "Command error",
|
||||||
"Unable to reply": "Unable to reply",
|
"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.",
|
"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",
|
"Markdown is enabled": "Markdown is enabled",
|
||||||
"No pinned messages.": "No pinned messages.",
|
"No pinned messages.": "No pinned messages.",
|
||||||
"Loading...": "Loading...",
|
"Loading...": "Loading...",
|
||||||
|
@ -923,6 +914,16 @@
|
||||||
"This Room": "This Room",
|
"This Room": "This Room",
|
||||||
"All Rooms": "All Rooms",
|
"All Rooms": "All Rooms",
|
||||||
"Search…": "Search…",
|
"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",
|
"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",
|
"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",
|
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
|
||||||
|
|
|
@ -114,6 +114,13 @@ export const SETTINGS = {
|
||||||
supportedLevels: LEVELS_FEATURE,
|
supportedLevels: LEVELS_FEATURE,
|
||||||
default: false,
|
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": {
|
"MessageComposerInput.suggestEmoji": {
|
||||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||||
displayName: _td('Enable Emoji suggestions while typing'),
|
displayName: _td('Enable Emoji suggestions while typing'),
|
||||||
|
|
|
@ -71,10 +71,10 @@ describe('editor/deserialize', function() {
|
||||||
describe('text messages', function() {
|
describe('text messages', function() {
|
||||||
it('test with newlines', function() {
|
it('test with newlines', function() {
|
||||||
const parts = normalize(parseEvent(textMessage("hello\nworld"), createPartCreator()));
|
const parts = normalize(parseEvent(textMessage("hello\nworld"), createPartCreator()));
|
||||||
expect(parts.length).toBe(3);
|
|
||||||
expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
|
expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
|
||||||
expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
|
expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
|
||||||
expect(parts[2]).toStrictEqual({type: "plain", text: "world"});
|
expect(parts[2]).toStrictEqual({type: "plain", text: "world"});
|
||||||
|
expect(parts.length).toBe(3);
|
||||||
});
|
});
|
||||||
it('@room pill', function() {
|
it('@room pill', function() {
|
||||||
const parts = normalize(parseEvent(textMessage("text message for @room"), createPartCreator()));
|
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()));
|
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||||
expect(parts.length).toBe(3);
|
expect(parts.length).toBe(3);
|
||||||
expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "});
|
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: "!"});
|
expect(parts[2]).toStrictEqual({type: "plain", text: "!"});
|
||||||
});
|
});
|
||||||
it('room pill', function() {
|
it('room pill', function() {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue