Typescript conversion of Composer components and more
This commit is contained in:
parent
29904a7ffc
commit
e768ecb3d0
15 changed files with 492 additions and 444 deletions
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
import { IContent } from "matrix-js-sdk/src/models/event";
|
||||||
|
|
||||||
import { getCurrentLanguage } from './languageHandler';
|
import { getCurrentLanguage } from './languageHandler';
|
||||||
import PlatformPeg from './PlatformPeg';
|
import PlatformPeg from './PlatformPeg';
|
||||||
|
@ -868,7 +869,7 @@ export default class CountlyAnalytics {
|
||||||
roomId: string,
|
roomId: string,
|
||||||
isEdit: boolean,
|
isEdit: boolean,
|
||||||
isReply: boolean,
|
isReply: boolean,
|
||||||
content: {format?: string, msgtype: string},
|
content: IContent,
|
||||||
) {
|
) {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
|
|
|
@ -358,11 +358,11 @@ interface IOpts {
|
||||||
stripReplyFallback?: boolean;
|
stripReplyFallback?: boolean;
|
||||||
returnString?: boolean;
|
returnString?: boolean;
|
||||||
forComposerQuote?: boolean;
|
forComposerQuote?: boolean;
|
||||||
ref?: React.Ref<any>;
|
ref?: React.Ref<HTMLSpanElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOptsReturnNode extends IOpts {
|
export interface IOptsReturnNode extends IOpts {
|
||||||
returnString: false;
|
returnString: false | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOptsReturnString extends IOpts {
|
export interface IOptsReturnString extends IOpts {
|
||||||
|
|
|
@ -1181,7 +1181,7 @@ export const Commands = [
|
||||||
];
|
];
|
||||||
|
|
||||||
// build a map from names and aliases to the Command objects.
|
// build a map from names and aliases to the Command objects.
|
||||||
export const CommandMap = new Map();
|
export const CommandMap = new Map<string, Command>();
|
||||||
Commands.forEach(cmd => {
|
Commands.forEach(cmd => {
|
||||||
CommandMap.set(cmd.command, cmd);
|
CommandMap.set(cmd.command, cmd);
|
||||||
cmd.aliases.forEach(alias => {
|
cmd.aliases.forEach(alias => {
|
||||||
|
@ -1189,15 +1189,15 @@ Commands.forEach(cmd => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseCommandString(input: string) {
|
export function parseCommandString(input: string): { cmd?: string, args?: string } {
|
||||||
// trim any trailing whitespace, as it can confuse the parser for
|
// trim any trailing whitespace, as it can confuse the parser for
|
||||||
// IRC-style commands
|
// IRC-style commands
|
||||||
input = input.replace(/\s+$/, '');
|
input = input.replace(/\s+$/, '');
|
||||||
if (input[0] !== '/') return {}; // not a command
|
if (input[0] !== '/') return {}; // not a command
|
||||||
|
|
||||||
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
|
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
|
||||||
let cmd;
|
let cmd: string;
|
||||||
let args;
|
let args: string;
|
||||||
if (bits) {
|
if (bits) {
|
||||||
cmd = bits[1].substring(1).toLowerCase();
|
cmd = bits[1].substring(1).toLowerCase();
|
||||||
args = bits[2];
|
args = bits[2];
|
||||||
|
@ -1208,6 +1208,11 @@ export function parseCommandString(input: string) {
|
||||||
return { cmd, args };
|
return { cmd, args };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ICmd {
|
||||||
|
cmd?: Command;
|
||||||
|
args?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process the given text for /commands and return a bound method to perform them.
|
* Process the given text for /commands and return a bound method to perform them.
|
||||||
* @param {string} roomId The room in which the command was performed.
|
* @param {string} roomId The room in which the command was performed.
|
||||||
|
@ -1216,7 +1221,7 @@ export function parseCommandString(input: string) {
|
||||||
* processing the command, or 'promise' if a request was sent out.
|
* processing the command, or 'promise' if a request was sent out.
|
||||||
* Returns null if the input didn't match a command.
|
* Returns null if the input didn't match a command.
|
||||||
*/
|
*/
|
||||||
export function getCommand(input: string) {
|
export function getCommand(input: string): ICmd {
|
||||||
const { cmd, args } = parseCommandString(input);
|
const { cmd, args } = parseCommandString(input);
|
||||||
|
|
||||||
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {
|
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
Copyright 2017 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.
|
||||||
|
@ -16,134 +14,151 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createRef } from 'react';
|
import React, { createRef, SyntheticEvent } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import highlight from 'highlight.js';
|
import highlight from 'highlight.js';
|
||||||
|
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
|
import { MsgType } from "matrix-js-sdk/lib/@types/event";
|
||||||
|
|
||||||
import * as HtmlUtils from '../../../HtmlUtils';
|
import * as HtmlUtils from '../../../HtmlUtils';
|
||||||
import { formatDate } from '../../../DateUtils';
|
import { formatDate } from '../../../DateUtils';
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import * as ContextMenu from '../../structures/ContextMenu';
|
import * as ContextMenu from '../../structures/ContextMenu';
|
||||||
|
import { toRightOf } from '../../structures/ContextMenu';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import ReplyThread from "../elements/ReplyThread";
|
import ReplyThread from "../elements/ReplyThread";
|
||||||
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
||||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||||
import { isPermalinkHost } from "../../../utils/permalinks/Permalinks";
|
import { isPermalinkHost } from "../../../utils/permalinks/Permalinks";
|
||||||
import { toRightOf } from "../../structures/ContextMenu";
|
|
||||||
import { copyPlaintext } from "../../../utils/strings";
|
import { copyPlaintext } from "../../../utils/strings";
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import UIStore from "../../../stores/UIStore";
|
import UIStore from "../../../stores/UIStore";
|
||||||
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import { TileShape } from '../rooms/EventTile';
|
||||||
|
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||||
|
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
|
||||||
|
import Spoiler from "../elements/Spoiler";
|
||||||
|
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||||
|
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
||||||
|
import EditMessageComposer from '../rooms/EditMessageComposer';
|
||||||
|
import LinkPreviewWidget from '../rooms/LinkPreviewWidget';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/* the MatrixEvent to show */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
|
||||||
|
/* a list of words to highlight */
|
||||||
|
highlights?: string[];
|
||||||
|
|
||||||
|
/* link URL for the highlights */
|
||||||
|
highlightLink?: string;
|
||||||
|
|
||||||
|
/* should show URL previews for this event */
|
||||||
|
showUrlPreview?: boolean;
|
||||||
|
|
||||||
|
/* the shape of the tile, used */
|
||||||
|
tileShape?: TileShape;
|
||||||
|
|
||||||
|
editState?: EditorStateTransfer;
|
||||||
|
replacingEventId?: string;
|
||||||
|
|
||||||
|
/* callback for when our widget has loaded */
|
||||||
|
onHeightChanged(): void,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
|
||||||
|
links: string[];
|
||||||
|
|
||||||
|
// track whether the preview widget is hidden
|
||||||
|
widgetHidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.messages.TextualBody")
|
@replaceableComponent("views.messages.TextualBody")
|
||||||
export default class TextualBody extends React.Component {
|
export default class TextualBody extends React.Component<IProps, IState> {
|
||||||
static propTypes = {
|
private readonly contentRef = createRef<HTMLSpanElement>();
|
||||||
/* the MatrixEvent to show */
|
|
||||||
mxEvent: PropTypes.object.isRequired,
|
|
||||||
|
|
||||||
/* a list of words to highlight */
|
private unmounted = false;
|
||||||
highlights: PropTypes.array,
|
private pills: Element[] = [];
|
||||||
|
|
||||||
/* link URL for the highlights */
|
|
||||||
highlightLink: PropTypes.string,
|
|
||||||
|
|
||||||
/* should show URL previews for this event */
|
|
||||||
showUrlPreview: PropTypes.bool,
|
|
||||||
|
|
||||||
/* callback for when our widget has loaded */
|
|
||||||
onHeightChanged: PropTypes.func,
|
|
||||||
|
|
||||||
/* the shape of the tile, used */
|
|
||||||
tileShape: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this._content = createRef();
|
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
// the URLs (if any) to be previewed with a LinkPreviewWidget
|
|
||||||
// inside this TextualBody.
|
|
||||||
links: [],
|
links: [],
|
||||||
|
|
||||||
// track whether the preview widget is hidden
|
|
||||||
widgetHidden: false,
|
widgetHidden: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this._unmounted = false;
|
|
||||||
this._pills = [];
|
|
||||||
if (!this.props.editState) {
|
if (!this.props.editState) {
|
||||||
this._applyFormatting();
|
this.applyFormatting();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyFormatting() {
|
private applyFormatting(): void {
|
||||||
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
|
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
|
||||||
this.activateSpoilers([this._content.current]);
|
this.activateSpoilers([this.contentRef.current]);
|
||||||
|
|
||||||
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
|
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
|
||||||
// are still sent as plaintext URLs. If these are ever pillified in the composer,
|
// are still sent as plaintext URLs. If these are ever pillified in the composer,
|
||||||
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
|
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
|
||||||
pillifyLinks([this._content.current], this.props.mxEvent, this._pills);
|
pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills);
|
||||||
HtmlUtils.linkifyElement(this._content.current);
|
HtmlUtils.linkifyElement(this.contentRef.current);
|
||||||
this.calculateUrlPreview();
|
this.calculateUrlPreview();
|
||||||
|
|
||||||
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
|
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
|
||||||
// Handle expansion and add buttons
|
// Handle expansion and add buttons
|
||||||
const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre");
|
const pres = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("pre");
|
||||||
if (pres.length > 0) {
|
if (pres.length > 0) {
|
||||||
for (let i = 0; i < pres.length; i++) {
|
for (let i = 0; i < pres.length; i++) {
|
||||||
// If there already is a div wrapping the codeblock we want to skip this.
|
// If there already is a div wrapping the codeblock we want to skip this.
|
||||||
// This happens after the codeblock was edited.
|
// This happens after the codeblock was edited.
|
||||||
if (pres[i].parentNode.className == "mx_EventTile_pre_container") continue;
|
if (pres[i].parentElement.className == "mx_EventTile_pre_container") continue;
|
||||||
// Add code element if it's missing since we depend on it
|
// Add code element if it's missing since we depend on it
|
||||||
if (pres[i].getElementsByTagName("code").length == 0) {
|
if (pres[i].getElementsByTagName("code").length == 0) {
|
||||||
this._addCodeElement(pres[i]);
|
this.addCodeElement(pres[i]);
|
||||||
}
|
}
|
||||||
// Wrap a div around <pre> so that the copy button can be correctly positioned
|
// Wrap a div around <pre> so that the copy button can be correctly positioned
|
||||||
// when the <pre> overflows and is scrolled horizontally.
|
// when the <pre> overflows and is scrolled horizontally.
|
||||||
const div = this._wrapInDiv(pres[i]);
|
const div = this.wrapInDiv(pres[i]);
|
||||||
this._handleCodeBlockExpansion(pres[i]);
|
this.handleCodeBlockExpansion(pres[i]);
|
||||||
this._addCodeExpansionButton(div, pres[i]);
|
this.addCodeExpansionButton(div, pres[i]);
|
||||||
this._addCodeCopyButton(div);
|
this.addCodeCopyButton(div);
|
||||||
if (showLineNumbers) {
|
if (showLineNumbers) {
|
||||||
this._addLineNumbers(pres[i]);
|
this.addLineNumbers(pres[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Highlight code
|
// Highlight code
|
||||||
const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
|
const codes = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("code");
|
||||||
if (codes.length > 0) {
|
if (codes.length > 0) {
|
||||||
// Do this asynchronously: parsing code takes time and we don't
|
// Do this asynchronously: parsing code takes time and we don't
|
||||||
// need to block the DOM update on it.
|
// need to block the DOM update on it.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this._unmounted) return;
|
if (this.unmounted) return;
|
||||||
for (let i = 0; i < codes.length; i++) {
|
for (let i = 0; i < codes.length; i++) {
|
||||||
// If the code already has the hljs class we want to skip this.
|
// If the code already has the hljs class we want to skip this.
|
||||||
// This happens after the codeblock was edited.
|
// This happens after the codeblock was edited.
|
||||||
if (codes[i].className.includes("hljs")) continue;
|
if (codes[i].className.includes("hljs")) continue;
|
||||||
this._highlightCode(codes[i]);
|
this.highlightCode(codes[i]);
|
||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_addCodeElement(pre) {
|
private addCodeElement(pre: HTMLPreElement): void {
|
||||||
const code = document.createElement("code");
|
const code = document.createElement("code");
|
||||||
code.append(...pre.childNodes);
|
code.append(...pre.childNodes);
|
||||||
pre.appendChild(code);
|
pre.appendChild(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
_addCodeExpansionButton(div, pre) {
|
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
|
||||||
// Calculate how many percent does the pre element take up.
|
// Calculate how many percent does the pre element take up.
|
||||||
// If it's less than 30% we don't add the expansion button.
|
// If it's less than 30% we don't add the expansion button.
|
||||||
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
|
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
|
||||||
|
@ -175,7 +190,7 @@ export default class TextualBody extends React.Component {
|
||||||
div.appendChild(button);
|
div.appendChild(button);
|
||||||
}
|
}
|
||||||
|
|
||||||
_addCodeCopyButton(div) {
|
private addCodeCopyButton(div: HTMLDivElement): void {
|
||||||
const button = document.createElement("span");
|
const button = document.createElement("span");
|
||||||
button.className = "mx_EventTile_button mx_EventTile_copyButton ";
|
button.className = "mx_EventTile_button mx_EventTile_copyButton ";
|
||||||
|
|
||||||
|
@ -185,11 +200,10 @@ export default class TextualBody extends React.Component {
|
||||||
if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
|
if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
|
||||||
|
|
||||||
button.onclick = async () => {
|
button.onclick = async () => {
|
||||||
const copyCode = button.parentNode.getElementsByTagName("code")[0];
|
const copyCode = button.parentElement.getElementsByTagName("code")[0];
|
||||||
const successful = await copyPlaintext(copyCode.textContent);
|
const successful = await copyPlaintext(copyCode.textContent);
|
||||||
|
|
||||||
const buttonRect = button.getBoundingClientRect();
|
const buttonRect = button.getBoundingClientRect();
|
||||||
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
|
|
||||||
const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
|
const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
|
||||||
...toRightOf(buttonRect, 2),
|
...toRightOf(buttonRect, 2),
|
||||||
message: successful ? _t('Copied!') : _t('Failed to copy'),
|
message: successful ? _t('Copied!') : _t('Failed to copy'),
|
||||||
|
@ -200,7 +214,7 @@ export default class TextualBody extends React.Component {
|
||||||
div.appendChild(button);
|
div.appendChild(button);
|
||||||
}
|
}
|
||||||
|
|
||||||
_wrapInDiv(pre) {
|
private wrapInDiv(pre: HTMLPreElement): HTMLDivElement {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "mx_EventTile_pre_container";
|
div.className = "mx_EventTile_pre_container";
|
||||||
|
|
||||||
|
@ -212,13 +226,13 @@ export default class TextualBody extends React.Component {
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleCodeBlockExpansion(pre) {
|
private handleCodeBlockExpansion(pre: HTMLPreElement): void {
|
||||||
if (!SettingsStore.getValue("expandCodeByDefault")) {
|
if (!SettingsStore.getValue("expandCodeByDefault")) {
|
||||||
pre.className = "mx_EventTile_collapsedCodeBlock";
|
pre.className = "mx_EventTile_collapsedCodeBlock";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_addLineNumbers(pre) {
|
private addLineNumbers(pre: HTMLPreElement): void {
|
||||||
// Calculate number of lines in pre
|
// Calculate number of lines in pre
|
||||||
const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
|
const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
|
||||||
pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>';
|
pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>';
|
||||||
|
@ -229,7 +243,7 @@ export default class TextualBody extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_highlightCode(code) {
|
private highlightCode(code: HTMLElement): void {
|
||||||
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
|
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
|
||||||
highlight.highlightBlock(code);
|
highlight.highlightBlock(code);
|
||||||
} else {
|
} else {
|
||||||
|
@ -249,14 +263,14 @@ export default class TextualBody extends React.Component {
|
||||||
const stoppedEditing = prevProps.editState && !this.props.editState;
|
const stoppedEditing = prevProps.editState && !this.props.editState;
|
||||||
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
|
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
|
||||||
if (messageWasEdited || stoppedEditing) {
|
if (messageWasEdited || stoppedEditing) {
|
||||||
this._applyFormatting();
|
this.applyFormatting();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._unmounted = true;
|
this.unmounted = true;
|
||||||
unmountPills(this._pills);
|
unmountPills(this.pills);
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps, nextState) {
|
shouldComponentUpdate(nextProps, nextState) {
|
||||||
|
@ -273,12 +287,12 @@ export default class TextualBody extends React.Component {
|
||||||
nextState.widgetHidden !== this.state.widgetHidden);
|
nextState.widgetHidden !== this.state.widgetHidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
calculateUrlPreview() {
|
private calculateUrlPreview(): void {
|
||||||
//console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
|
//console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
|
||||||
|
|
||||||
if (this.props.showUrlPreview) {
|
if (this.props.showUrlPreview) {
|
||||||
// pass only the first child which is the event tile otherwise this recurses on edited events
|
// pass only the first child which is the event tile otherwise this recurses on edited events
|
||||||
let links = this.findLinks([this._content.current]);
|
let links = this.findLinks([this.contentRef.current]);
|
||||||
if (links.length) {
|
if (links.length) {
|
||||||
// de-duplicate the links after stripping hashes as they don't affect the preview
|
// de-duplicate the links after stripping hashes as they don't affect the preview
|
||||||
// using a set here maintains the order
|
// using a set here maintains the order
|
||||||
|
@ -291,8 +305,8 @@ export default class TextualBody extends React.Component {
|
||||||
this.setState({ links });
|
this.setState({ links });
|
||||||
|
|
||||||
// lazy-load the hidden state of the preview widget from localstorage
|
// lazy-load the hidden state of the preview widget from localstorage
|
||||||
if (global.localStorage) {
|
if (window.localStorage) {
|
||||||
const hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
|
const hidden = !!window.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
|
||||||
this.setState({ widgetHidden: hidden });
|
this.setState({ widgetHidden: hidden });
|
||||||
}
|
}
|
||||||
} else if (this.state.links.length) {
|
} else if (this.state.links.length) {
|
||||||
|
@ -301,19 +315,15 @@ export default class TextualBody extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
activateSpoilers(nodes) {
|
private activateSpoilers(nodes: ArrayLike<Element>): void {
|
||||||
let node = nodes[0];
|
let node = nodes[0];
|
||||||
while (node) {
|
while (node) {
|
||||||
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
|
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
|
||||||
const spoilerContainer = document.createElement('span');
|
const spoilerContainer = document.createElement('span');
|
||||||
|
|
||||||
const reason = node.getAttribute("data-mx-spoiler");
|
const reason = node.getAttribute("data-mx-spoiler");
|
||||||
const Spoiler = sdk.getComponent('elements.Spoiler');
|
|
||||||
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
|
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
|
||||||
const spoiler = <Spoiler
|
const spoiler = <Spoiler reason={reason} contentHtml={node.outerHTML} />;
|
||||||
reason={reason}
|
|
||||||
contentHtml={node.outerHTML}
|
|
||||||
/>;
|
|
||||||
|
|
||||||
ReactDOM.render(spoiler, spoilerContainer);
|
ReactDOM.render(spoiler, spoilerContainer);
|
||||||
node.parentNode.replaceChild(spoilerContainer, node);
|
node.parentNode.replaceChild(spoilerContainer, node);
|
||||||
|
@ -322,15 +332,15 @@ export default class TextualBody extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.childNodes && node.childNodes.length) {
|
if (node.childNodes && node.childNodes.length) {
|
||||||
this.activateSpoilers(node.childNodes);
|
this.activateSpoilers(node.childNodes as NodeListOf<Element>);
|
||||||
}
|
}
|
||||||
|
|
||||||
node = node.nextSibling;
|
node = node.nextSibling as Element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
findLinks(nodes) {
|
private findLinks(nodes: ArrayLike<Element>): string[] {
|
||||||
let links = [];
|
let links: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
const node = nodes[i];
|
const node = nodes[i];
|
||||||
|
@ -348,7 +358,7 @@ export default class TextualBody extends React.Component {
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLinkPreviewable(node) {
|
private isLinkPreviewable(node: Element): boolean {
|
||||||
// don't try to preview relative links
|
// don't try to preview relative links
|
||||||
if (!node.getAttribute("href").startsWith("http://") &&
|
if (!node.getAttribute("href").startsWith("http://") &&
|
||||||
!node.getAttribute("href").startsWith("https://")) {
|
!node.getAttribute("href").startsWith("https://")) {
|
||||||
|
@ -381,7 +391,7 @@ export default class TextualBody extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCancelClick = event => {
|
private onCancelClick = (): void => {
|
||||||
this.setState({ widgetHidden: true });
|
this.setState({ widgetHidden: true });
|
||||||
// FIXME: persist this somewhere smarter than local storage
|
// FIXME: persist this somewhere smarter than local storage
|
||||||
if (global.localStorage) {
|
if (global.localStorage) {
|
||||||
|
@ -390,7 +400,7 @@ export default class TextualBody extends React.Component {
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
onEmoteSenderClick = event => {
|
private onEmoteSenderClick = (): void => {
|
||||||
const mxEvent = this.props.mxEvent;
|
const mxEvent = this.props.mxEvent;
|
||||||
dis.dispatch<ComposerInsertPayload>({
|
dis.dispatch<ComposerInsertPayload>({
|
||||||
action: Action.ComposerInsert,
|
action: Action.ComposerInsert,
|
||||||
|
@ -398,7 +408,7 @@ export default class TextualBody extends React.Component {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
getEventTileOps = () => ({
|
public getEventTileOps = () => ({
|
||||||
isWidgetHidden: () => {
|
isWidgetHidden: () => {
|
||||||
return this.state.widgetHidden;
|
return this.state.widgetHidden;
|
||||||
},
|
},
|
||||||
|
@ -411,7 +421,7 @@ export default class TextualBody extends React.Component {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
onStarterLinkClick = (starterLink, ev) => {
|
private onStarterLinkClick = (starterLink: string, ev: SyntheticEvent): void => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
// We need to add on our scalar token to the starter link, but we may not have one!
|
// We need to add on our scalar token to the starter link, but we may not have one!
|
||||||
// In addition, we can't fetch one on click and then go to it immediately as that
|
// In addition, we can't fetch one on click and then go to it immediately as that
|
||||||
|
@ -431,7 +441,6 @@ export default class TextualBody extends React.Component {
|
||||||
const scalarClient = integrationManager.getScalarClient();
|
const scalarClient = integrationManager.getScalarClient();
|
||||||
scalarClient.connect().then(() => {
|
scalarClient.connect().then(() => {
|
||||||
const completeUrl = scalarClient.getStarterLink(starterLink);
|
const completeUrl = scalarClient.getStarterLink(starterLink);
|
||||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
|
||||||
const integrationsUrl = integrationManager.uiUrl;
|
const integrationsUrl = integrationManager.uiUrl;
|
||||||
Modal.createTrackedDialog('Add an integration', '', QuestionDialog, {
|
Modal.createTrackedDialog('Add an integration', '', QuestionDialog, {
|
||||||
title: _t("Add an Integration"),
|
title: _t("Add an Integration"),
|
||||||
|
@ -458,12 +467,11 @@ export default class TextualBody extends React.Component {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
_openHistoryDialog = async () => {
|
private openHistoryDialog = async (): Promise<void> => {
|
||||||
const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
|
|
||||||
Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
|
Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
|
||||||
};
|
};
|
||||||
|
|
||||||
_renderEditedMarker() {
|
private renderEditedMarker() {
|
||||||
const date = this.props.mxEvent.replacingEventDate();
|
const date = this.props.mxEvent.replacingEventDate();
|
||||||
const dateString = date && formatDate(date);
|
const dateString = date && formatDate(date);
|
||||||
|
|
||||||
|
@ -479,7 +487,7 @@ export default class TextualBody extends React.Component {
|
||||||
return (
|
return (
|
||||||
<AccessibleTooltipButton
|
<AccessibleTooltipButton
|
||||||
className="mx_EventTile_edited"
|
className="mx_EventTile_edited"
|
||||||
onClick={this._openHistoryDialog}
|
onClick={this.openHistoryDialog}
|
||||||
title={_t("Edited at %(date)s. Click to view edits.", { date: dateString })}
|
title={_t("Edited at %(date)s. Click to view edits.", { date: dateString })}
|
||||||
tooltip={tooltip}
|
tooltip={tooltip}
|
||||||
>
|
>
|
||||||
|
@ -490,24 +498,25 @@ export default class TextualBody extends React.Component {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.props.editState) {
|
if (this.props.editState) {
|
||||||
const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer');
|
|
||||||
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
|
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
|
||||||
}
|
}
|
||||||
const mxEvent = this.props.mxEvent;
|
const mxEvent = this.props.mxEvent;
|
||||||
const content = mxEvent.getContent();
|
const content = mxEvent.getContent();
|
||||||
|
|
||||||
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
|
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
|
||||||
const stripReply = !mxEvent.replacingEvent() && ReplyThread.getParentEventId(mxEvent);
|
const stripReply = !mxEvent.replacingEvent() && !!ReplyThread.getParentEventId(mxEvent);
|
||||||
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
|
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
|
||||||
disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
|
disableBigEmoji: content.msgtype === MsgType.Emote
|
||||||
|
|| !SettingsStore.getValue<boolean>('TextualBody.enableBigEmoji'),
|
||||||
// Part of Replies fallback support
|
// Part of Replies fallback support
|
||||||
stripReplyFallback: stripReply,
|
stripReplyFallback: stripReply,
|
||||||
ref: this._content,
|
ref: this.contentRef,
|
||||||
|
returnString: false,
|
||||||
});
|
});
|
||||||
if (this.props.replacingEventId) {
|
if (this.props.replacingEventId) {
|
||||||
body = <>
|
body = <>
|
||||||
{body}
|
{body}
|
||||||
{this._renderEditedMarker()}
|
{this.renderEditedMarker()}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -521,7 +530,6 @@ export default class TextualBody extends React.Component {
|
||||||
|
|
||||||
let widgets;
|
let widgets;
|
||||||
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
|
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
|
||||||
const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
|
|
||||||
widgets = this.state.links.map((link)=>{
|
widgets = this.state.links.map((link)=>{
|
||||||
return <LinkPreviewWidget
|
return <LinkPreviewWidget
|
||||||
key={link}
|
key={link}
|
||||||
|
@ -534,7 +542,7 @@ export default class TextualBody extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (content.msgtype) {
|
switch (content.msgtype) {
|
||||||
case "m.emote":
|
case MsgType.Emote:
|
||||||
return (
|
return (
|
||||||
<span className="mx_MEmoteBody mx_EventTile_content">
|
<span className="mx_MEmoteBody mx_EventTile_content">
|
||||||
*
|
*
|
||||||
|
@ -549,7 +557,7 @@ export default class TextualBody extends React.Component {
|
||||||
{ widgets }
|
{ widgets }
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "m.notice":
|
case MsgType.Notice:
|
||||||
return (
|
return (
|
||||||
<span className="mx_MNoticeBody mx_EventTile_content">
|
<span className="mx_MNoticeBody mx_EventTile_content">
|
||||||
{ body }
|
{ body }
|
|
@ -1,6 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
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.
|
||||||
|
@ -16,20 +15,20 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
|
|
||||||
import * as TextForEvent from "../../../TextForEvent";
|
import * as TextForEvent from "../../../TextForEvent";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
|
||||||
@replaceableComponent("views.messages.TextualEvent")
|
interface IProps {
|
||||||
export default class TextualEvent extends React.Component {
|
mxEvent: MatrixEvent;
|
||||||
static propTypes = {
|
}
|
||||||
/* the MatrixEvent to show */
|
|
||||||
mxEvent: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
@replaceableComponent("views.messages.TextualEvent")
|
||||||
|
export default class TextualEvent extends React.Component<IProps> {
|
||||||
render() {
|
render() {
|
||||||
const text = TextForEvent.textForEvent(this.props.mxEvent, true);
|
const text = TextForEvent.textForEvent(this.props.mxEvent, true);
|
||||||
if (text == null || text.length === 0) return null;
|
if (!text || (text as string).length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mx_TextualEvent">{ text }</div>
|
<div className="mx_TextualEvent">{ text }</div>
|
||||||
);
|
);
|
|
@ -41,7 +41,7 @@ import { Key } from "../../../Keyboard";
|
||||||
import { EMOTICON_TO_EMOJI } from "../../../emoji";
|
import { EMOTICON_TO_EMOJI } from "../../../emoji";
|
||||||
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
|
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
|
||||||
import Range from "../../../editor/range";
|
import Range from "../../../editor/range";
|
||||||
import MessageComposerFormatBar from "./MessageComposerFormatBar";
|
import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar";
|
||||||
import DocumentOffset from "../../../editor/offset";
|
import DocumentOffset from "../../../editor/offset";
|
||||||
import { IDiff } from "../../../editor/diff";
|
import { IDiff } from "../../../editor/diff";
|
||||||
import AutocompleteWrapperModel from "../../../editor/autocomplete";
|
import AutocompleteWrapperModel from "../../../editor/autocomplete";
|
||||||
|
@ -55,7 +55,7 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
|
||||||
|
|
||||||
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
||||||
|
|
||||||
function ctrlShortcutLabel(key) {
|
function ctrlShortcutLabel(key: string): string {
|
||||||
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,14 +81,6 @@ function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
|
||||||
a.type === b.type;
|
a.type === b.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Formatting {
|
|
||||||
Bold = "bold",
|
|
||||||
Italics = "italics",
|
|
||||||
Strikethrough = "strikethrough",
|
|
||||||
Code = "code",
|
|
||||||
Quote = "quote",
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
model: EditorModel;
|
model: EditorModel;
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -111,9 +103,9 @@ interface IState {
|
||||||
|
|
||||||
@replaceableComponent("views.rooms.BasicMessageEditor")
|
@replaceableComponent("views.rooms.BasicMessageEditor")
|
||||||
export default class BasicMessageEditor extends React.Component<IProps, IState> {
|
export default class BasicMessageEditor extends React.Component<IProps, IState> {
|
||||||
private editorRef = createRef<HTMLDivElement>();
|
public readonly editorRef = createRef<HTMLDivElement>();
|
||||||
private autocompleteRef = createRef<Autocomplete>();
|
private autocompleteRef = createRef<Autocomplete>();
|
||||||
private formatBarRef = createRef<typeof MessageComposerFormatBar>();
|
private formatBarRef = createRef<MessageComposerFormatBar>();
|
||||||
|
|
||||||
private modifiedFlag = false;
|
private modifiedFlag = false;
|
||||||
private isIMEComposing = false;
|
private isIMEComposing = false;
|
||||||
|
@ -156,7 +148,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private replaceEmoticon = (caretPosition: DocumentPosition) => {
|
private replaceEmoticon = (caretPosition: DocumentPosition): number => {
|
||||||
const { model } = this.props;
|
const { model } = this.props;
|
||||||
const range = model.startRange(caretPosition);
|
const range = model.startRange(caretPosition);
|
||||||
// expand range max 8 characters backwards from caretPosition,
|
// expand range max 8 characters backwards from caretPosition,
|
||||||
|
@ -188,7 +180,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff) => {
|
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
|
||||||
renderModel(this.editorRef.current, this.props.model);
|
renderModel(this.editorRef.current, this.props.model);
|
||||||
if (selection) { // set the caret/selection
|
if (selection) { // set the caret/selection
|
||||||
try {
|
try {
|
||||||
|
@ -230,25 +222,25 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private showPlaceholder() {
|
private showPlaceholder(): void {
|
||||||
// escape single quotes
|
// escape single quotes
|
||||||
const placeholder = this.props.placeholder.replace(/'/g, '\\\'');
|
const placeholder = this.props.placeholder.replace(/'/g, '\\\'');
|
||||||
this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`);
|
this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`);
|
||||||
this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty");
|
this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty");
|
||||||
}
|
}
|
||||||
|
|
||||||
private hidePlaceholder() {
|
private hidePlaceholder(): void {
|
||||||
this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty");
|
this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty");
|
||||||
this.editorRef.current.style.removeProperty("--placeholder");
|
this.editorRef.current.style.removeProperty("--placeholder");
|
||||||
}
|
}
|
||||||
|
|
||||||
private onCompositionStart = () => {
|
private onCompositionStart = (): void => {
|
||||||
this.isIMEComposing = true;
|
this.isIMEComposing = true;
|
||||||
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
|
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
|
||||||
this.hidePlaceholder();
|
this.hidePlaceholder();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onCompositionEnd = () => {
|
private onCompositionEnd = (): void => {
|
||||||
this.isIMEComposing = false;
|
this.isIMEComposing = false;
|
||||||
// some browsers (Chrome) don't fire an input event after ending a composition,
|
// some browsers (Chrome) don't fire an input event after ending a composition,
|
||||||
// so trigger a model update after the composition is done by calling the input handler.
|
// so trigger a model update after the composition is done by calling the input handler.
|
||||||
|
@ -271,14 +263,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
isComposing(event: React.KeyboardEvent) {
|
public isComposing(event: React.KeyboardEvent): boolean {
|
||||||
// checking the event.isComposing flag just in case any browser out there
|
// checking the event.isComposing flag just in case any browser out there
|
||||||
// emits events related to the composition after compositionend
|
// emits events related to the composition after compositionend
|
||||||
// has been fired
|
// has been fired
|
||||||
return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
|
return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
|
||||||
}
|
}
|
||||||
|
|
||||||
private onCutCopy = (event: ClipboardEvent, type: string) => {
|
private onCutCopy = (event: ClipboardEvent, type: string): void => {
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const text = selection.toString();
|
const text = selection.toString();
|
||||||
if (text) {
|
if (text) {
|
||||||
|
@ -296,15 +288,15 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onCopy = (event: ClipboardEvent) => {
|
private onCopy = (event: ClipboardEvent): void => {
|
||||||
this.onCutCopy(event, "copy");
|
this.onCutCopy(event, "copy");
|
||||||
};
|
};
|
||||||
|
|
||||||
private onCut = (event: ClipboardEvent) => {
|
private onCut = (event: ClipboardEvent): void => {
|
||||||
this.onCutCopy(event, "cut");
|
this.onCutCopy(event, "cut");
|
||||||
};
|
};
|
||||||
|
|
||||||
private onPaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
|
||||||
event.preventDefault(); // we always handle the paste ourselves
|
event.preventDefault(); // we always handle the paste ourselves
|
||||||
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
|
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
|
||||||
// to prevent double handling, allow props.onPaste to skip internal onPaste
|
// to prevent double handling, allow props.onPaste to skip internal onPaste
|
||||||
|
@ -328,7 +320,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
replaceRangeAndMoveCaret(range, parts);
|
replaceRangeAndMoveCaret(range, parts);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onInput = (event: Partial<InputEvent>) => {
|
private onInput = (event: Partial<InputEvent>): void => {
|
||||||
// ignore any input while doing IME compositions
|
// ignore any input while doing IME compositions
|
||||||
if (this.isIMEComposing) {
|
if (this.isIMEComposing) {
|
||||||
return;
|
return;
|
||||||
|
@ -339,7 +331,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.props.model.update(text, event.inputType, caret);
|
this.props.model.update(text, event.inputType, caret);
|
||||||
};
|
};
|
||||||
|
|
||||||
private insertText(textToInsert: string, inputType = "insertText") {
|
private insertText(textToInsert: string, inputType = "insertText"): void {
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel);
|
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel);
|
||||||
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
|
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
|
||||||
|
@ -353,14 +345,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
// we don't need to. But if the user is navigating the caret without input
|
// 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
|
// we need to recalculate it, to be able to know where to insert content after
|
||||||
// losing focus
|
// losing focus
|
||||||
private setLastCaretFromPosition(position: DocumentPosition) {
|
private setLastCaretFromPosition(position: DocumentPosition): void {
|
||||||
const { model } = this.props;
|
const { model } = this.props;
|
||||||
this._isCaretAtEnd = position.isAtEnd(model);
|
this._isCaretAtEnd = position.isAtEnd(model);
|
||||||
this.lastCaret = position.asOffset(model);
|
this.lastCaret = position.asOffset(model);
|
||||||
this.lastSelection = cloneSelection(document.getSelection());
|
this.lastSelection = cloneSelection(document.getSelection());
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshLastCaretIfNeeded() {
|
private refreshLastCaretIfNeeded(): DocumentOffset {
|
||||||
// XXX: needed when going up and down in editing messages ... not sure why yet
|
// XXX: needed when going up and down in editing messages ... not sure why yet
|
||||||
// because the editors should stop doing this when when blurred ...
|
// because the editors should stop doing this when when blurred ...
|
||||||
// maybe it's on focus and the _editorRef isn't available yet or something.
|
// maybe it's on focus and the _editorRef isn't available yet or something.
|
||||||
|
@ -377,38 +369,38 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
return this.lastCaret;
|
return this.lastCaret;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearUndoHistory() {
|
public clearUndoHistory(): void {
|
||||||
this.historyManager.clear();
|
this.historyManager.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
getCaret() {
|
public getCaret(): DocumentOffset {
|
||||||
return this.lastCaret;
|
return this.lastCaret;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelectionCollapsed() {
|
public isSelectionCollapsed(): boolean {
|
||||||
return !this.lastSelection || this.lastSelection.isCollapsed;
|
return !this.lastSelection || this.lastSelection.isCollapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
isCaretAtStart() {
|
public isCaretAtStart(): boolean {
|
||||||
return this.getCaret().offset === 0;
|
return this.getCaret().offset === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
isCaretAtEnd() {
|
public isCaretAtEnd(): boolean {
|
||||||
return this._isCaretAtEnd;
|
return this._isCaretAtEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onBlur = () => {
|
private onBlur = (): void => {
|
||||||
document.removeEventListener("selectionchange", this.onSelectionChange);
|
document.removeEventListener("selectionchange", this.onSelectionChange);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onFocus = () => {
|
private onFocus = (): void => {
|
||||||
document.addEventListener("selectionchange", this.onSelectionChange);
|
document.addEventListener("selectionchange", this.onSelectionChange);
|
||||||
// force to recalculate
|
// force to recalculate
|
||||||
this.lastSelection = null;
|
this.lastSelection = null;
|
||||||
this.refreshLastCaretIfNeeded();
|
this.refreshLastCaretIfNeeded();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onSelectionChange = () => {
|
private onSelectionChange = (): void => {
|
||||||
const { isEmpty } = this.props.model;
|
const { isEmpty } = this.props.model;
|
||||||
|
|
||||||
this.refreshLastCaretIfNeeded();
|
this.refreshLastCaretIfNeeded();
|
||||||
|
@ -427,7 +419,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onKeyDown = (event: React.KeyboardEvent) => {
|
private onKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
const model = this.props.model;
|
const model = this.props.model;
|
||||||
let handled = false;
|
let handled = false;
|
||||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||||
|
@ -523,7 +515,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private async tabCompleteName() {
|
private async tabCompleteName(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
|
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
|
||||||
const { model } = this.props;
|
const { model } = this.props;
|
||||||
|
@ -557,27 +549,27 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isModified() {
|
public isModified(): boolean {
|
||||||
return this.modifiedFlag;
|
return this.modifiedFlag;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onAutoCompleteConfirm = (completion: ICompletion) => {
|
private onAutoCompleteConfirm = (completion: ICompletion): void => {
|
||||||
this.modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
this.props.model.autoComplete.onComponentConfirm(completion);
|
this.props.model.autoComplete.onComponentConfirm(completion);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => {
|
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
|
||||||
this.modifiedFlag = true;
|
this.modifiedFlag = true;
|
||||||
this.props.model.autoComplete.onComponentSelectionChange(completion);
|
this.props.model.autoComplete.onComponentSelectionChange(completion);
|
||||||
this.setState({ completionIndex });
|
this.setState({ completionIndex });
|
||||||
};
|
};
|
||||||
|
|
||||||
private configureEmoticonAutoReplace = () => {
|
private configureEmoticonAutoReplace = (): void => {
|
||||||
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
|
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
|
||||||
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
|
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
private configureShouldShowPillAvatar = () => {
|
private configureShouldShowPillAvatar = (): void => {
|
||||||
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
|
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
|
||||||
this.setState({ showPillAvatar });
|
this.setState({ showPillAvatar });
|
||||||
};
|
};
|
||||||
|
@ -611,8 +603,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.editorRef.current.focus();
|
this.editorRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInitialCaretPosition() {
|
private getInitialCaretPosition(): DocumentPosition {
|
||||||
let caretPosition;
|
let caretPosition: DocumentPosition;
|
||||||
if (this.props.initialCaret) {
|
if (this.props.initialCaret) {
|
||||||
// if restoring state from a previous editor,
|
// if restoring state from a previous editor,
|
||||||
// restore caret position from the state
|
// restore caret position from the state
|
||||||
|
@ -625,7 +617,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
return caretPosition;
|
return caretPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onFormatAction = (action: Formatting) => {
|
private onFormatAction = (action: Formatting): void => {
|
||||||
const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
|
const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
|
||||||
// trim the range as we want it to exclude leading/trailing spaces
|
// trim the range as we want it to exclude leading/trailing spaces
|
||||||
range.trim();
|
range.trim();
|
||||||
|
@ -680,9 +672,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
});
|
});
|
||||||
|
|
||||||
const shortcuts = {
|
const shortcuts = {
|
||||||
bold: ctrlShortcutLabel("B"),
|
[Formatting.Bold]: ctrlShortcutLabel("B"),
|
||||||
italics: ctrlShortcutLabel("I"),
|
[Formatting.Italics]: ctrlShortcutLabel("I"),
|
||||||
quote: ctrlShortcutLabel(">"),
|
[Formatting.Quote]: ctrlShortcutLabel(">"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { completionIndex } = this.state;
|
const { completionIndex } = this.state;
|
||||||
|
@ -714,11 +706,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|
||||||
focus() {
|
public focus(): void {
|
||||||
this.editorRef.current.focus();
|
this.editorRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public insertMention(userId: string) {
|
public insertMention(userId: string): void {
|
||||||
const { model } = this.props;
|
const { model } = this.props;
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
const member = this.props.room.getMember(userId);
|
const member = this.props.room.getMember(userId);
|
||||||
|
@ -736,7 +728,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.focus();
|
this.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public insertQuotedMessage(event: MatrixEvent) {
|
public insertQuotedMessage(event: MatrixEvent): void {
|
||||||
const { model } = this.props;
|
const { model } = this.props;
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
|
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
|
||||||
|
@ -751,7 +743,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.focus();
|
this.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public insertPlaintext(text: string) {
|
public insertPlaintext(text: string): void {
|
||||||
const { model } = this.props;
|
const { model } = this.props;
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
const caret = this.getCaret();
|
const caret = this.getCaret();
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 New Vector Ltd
|
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
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,37 +13,42 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
|
||||||
import * as sdk from '../../../index';
|
import React, { createRef, KeyboardEvent } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { EventStatus, IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
|
|
||||||
import { _t, _td } from '../../../languageHandler';
|
import { _t, _td } from '../../../languageHandler';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import EditorModel from '../../../editor/model';
|
import EditorModel from '../../../editor/model';
|
||||||
import { getCaretOffsetAndText } from '../../../editor/dom';
|
import { getCaretOffsetAndText } from '../../../editor/dom';
|
||||||
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } 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 { CommandPartCreator } from '../../../editor/parts';
|
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
|
||||||
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { EventStatus } from 'matrix-js-sdk/src/models/event';
|
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import { CommandCategories, getCommand } from '../../../SlashCommands';
|
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||||
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
|
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import SendHistoryManager from '../../../SendHistoryManager';
|
import SendHistoryManager from '../../../SendHistoryManager';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
|
import { MsgType } from 'matrix-js-sdk/src/@types/event';
|
||||||
|
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||||
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
|
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||||
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||||
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
|
|
||||||
function _isReply(mxEvent) {
|
function eventIsReply(mxEvent: MatrixEvent): boolean {
|
||||||
const relatesTo = mxEvent.getContent()["m.relates_to"];
|
const relatesTo = mxEvent.getContent()["m.relates_to"];
|
||||||
const isReply = !!(relatesTo && relatesTo["m.in_reply_to"]);
|
return !!(relatesTo && relatesTo["m.in_reply_to"]);
|
||||||
return isReply;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtmlReplyFallback(mxEvent) {
|
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
|
||||||
const html = mxEvent.getContent().formatted_body;
|
const html = mxEvent.getContent().formatted_body;
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return "";
|
return "";
|
||||||
|
@ -54,7 +58,7 @@ function getHtmlReplyFallback(mxEvent) {
|
||||||
return (mxReply && mxReply.outerHTML) || "";
|
return (mxReply && mxReply.outerHTML) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextReplyFallback(mxEvent) {
|
function getTextReplyFallback(mxEvent: MatrixEvent): string {
|
||||||
const body = mxEvent.getContent().body;
|
const body = mxEvent.getContent().body;
|
||||||
const lines = body.split("\n").map(l => l.trim());
|
const lines = body.split("\n").map(l => l.trim());
|
||||||
if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
|
if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
|
||||||
|
@ -63,12 +67,12 @@ function getTextReplyFallback(mxEvent) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEditContent(model, editedEvent) {
|
function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IContent {
|
||||||
const isEmote = containsEmote(model);
|
const isEmote = containsEmote(model);
|
||||||
if (isEmote) {
|
if (isEmote) {
|
||||||
model = stripEmoteCommand(model);
|
model = stripEmoteCommand(model);
|
||||||
}
|
}
|
||||||
const isReply = _isReply(editedEvent);
|
const isReply = eventIsReply(editedEvent);
|
||||||
let plainPrefix = "";
|
let plainPrefix = "";
|
||||||
let htmlPrefix = "";
|
let htmlPrefix = "";
|
||||||
|
|
||||||
|
@ -79,11 +83,11 @@ function createEditContent(model, editedEvent) {
|
||||||
|
|
||||||
const body = textSerialize(model);
|
const body = textSerialize(model);
|
||||||
|
|
||||||
const newContent = {
|
const newContent: IContent = {
|
||||||
"msgtype": isEmote ? "m.emote" : "m.text",
|
"msgtype": isEmote ? MsgType.Emote : MsgType.Text,
|
||||||
"body": body,
|
"body": body,
|
||||||
};
|
};
|
||||||
const contentBody = {
|
const contentBody: IContent = {
|
||||||
msgtype: newContent.msgtype,
|
msgtype: newContent.msgtype,
|
||||||
body: `${plainPrefix} * ${body}`,
|
body: `${plainPrefix} * ${body}`,
|
||||||
};
|
};
|
||||||
|
@ -105,55 +109,59 @@ function createEditContent(model, editedEvent) {
|
||||||
}, contentBody);
|
}, contentBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
editState: EditorStateTransfer;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
saveDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.rooms.EditMessageComposer")
|
@replaceableComponent("views.rooms.EditMessageComposer")
|
||||||
export default class EditMessageComposer extends React.Component {
|
export default class EditMessageComposer extends React.Component<IProps, IState> {
|
||||||
static propTypes = {
|
|
||||||
// the message event being edited
|
|
||||||
editState: PropTypes.instanceOf(EditorStateTransfer).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
static contextType = MatrixClientContext;
|
static contextType = MatrixClientContext;
|
||||||
|
context: React.ContextType<typeof MatrixClientContext>;
|
||||||
|
|
||||||
constructor(props, context) {
|
private readonly editorRef = createRef<BasicMessageComposer>();
|
||||||
|
private readonly dispatcherRef: string;
|
||||||
|
private model: EditorModel = null;
|
||||||
|
|
||||||
|
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
this.model = null;
|
|
||||||
this._editorRef = null;
|
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
saveDisabled: true,
|
saveDisabled: true,
|
||||||
};
|
};
|
||||||
this._createEditorModel();
|
|
||||||
window.addEventListener("beforeunload", this._saveStoredEditorState);
|
this.createEditorModel();
|
||||||
|
window.addEventListener("beforeunload", this.saveStoredEditorState);
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
_setEditorRef = ref => {
|
private getRoom(): Room {
|
||||||
this._editorRef = ref;
|
|
||||||
};
|
|
||||||
|
|
||||||
_getRoom() {
|
|
||||||
return this.context.getRoom(this.props.editState.getEvent().getRoomId());
|
return this.context.getRoom(this.props.editState.getEvent().getRoomId());
|
||||||
}
|
}
|
||||||
|
|
||||||
_onKeyDown = (event) => {
|
private onKeyDown = (event: KeyboardEvent): void => {
|
||||||
// ignore any keypress while doing IME compositions
|
// ignore any keypress while doing IME compositions
|
||||||
if (this._editorRef.isComposing(event)) {
|
if (this.editorRef.current?.isComposing(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case MessageComposerAction.Send:
|
case MessageComposerAction.Send:
|
||||||
this._sendEdit();
|
this.sendEdit();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
break;
|
break;
|
||||||
case MessageComposerAction.CancelEditing:
|
case MessageComposerAction.CancelEditing:
|
||||||
this._cancelEdit();
|
this.cancelEdit();
|
||||||
break;
|
break;
|
||||||
case MessageComposerAction.EditPrevMessage: {
|
case MessageComposerAction.EditPrevMessage: {
|
||||||
if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) {
|
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const previousEvent = findEditableEvent(this._getRoom(), false,
|
const previousEvent = findEditableEvent(this.getRoom(), false,
|
||||||
this.props.editState.getEvent().getId());
|
this.props.editState.getEvent().getId());
|
||||||
if (previousEvent) {
|
if (previousEvent) {
|
||||||
dis.dispatch({ action: 'edit_event', event: previousEvent });
|
dis.dispatch({ action: 'edit_event', event: previousEvent });
|
||||||
|
@ -162,14 +170,14 @@ export default class EditMessageComposer extends React.Component {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case MessageComposerAction.EditNextMessage: {
|
case MessageComposerAction.EditNextMessage: {
|
||||||
if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) {
|
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId());
|
const nextEvent = findEditableEvent(this.getRoom(), true, this.props.editState.getEvent().getId());
|
||||||
if (nextEvent) {
|
if (nextEvent) {
|
||||||
dis.dispatch({ action: 'edit_event', event: nextEvent });
|
dis.dispatch({ action: 'edit_event', event: nextEvent });
|
||||||
} else {
|
} else {
|
||||||
this._clearStoredEditorState();
|
this.clearStoredEditorState();
|
||||||
dis.dispatch({ action: 'edit_event', event: null });
|
dis.dispatch({ action: 'edit_event', event: null });
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusComposer);
|
||||||
}
|
}
|
||||||
|
@ -177,32 +185,32 @@ export default class EditMessageComposer extends React.Component {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private get editorRoomKey(): string {
|
||||||
|
return `mx_edit_room_${this.getRoom().roomId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get _editorRoomKey() {
|
private get editorStateKey(): string {
|
||||||
return `mx_edit_room_${this._getRoom().roomId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get _editorStateKey() {
|
|
||||||
return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
|
return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cancelEdit = () => {
|
private cancelEdit = (): void => {
|
||||||
this._clearStoredEditorState();
|
this.clearStoredEditorState();
|
||||||
dis.dispatch({ action: "edit_event", event: null });
|
dis.dispatch({ action: "edit_event", event: null });
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusComposer);
|
||||||
|
};
|
||||||
|
|
||||||
|
private get shouldSaveStoredEditorState(): boolean {
|
||||||
|
return localStorage.getItem(this.editorRoomKey) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get _shouldSaveStoredEditorState() {
|
private restoreStoredEditorState(partCreator: PartCreator): Part[] {
|
||||||
return localStorage.getItem(this._editorRoomKey) !== null;
|
const json = localStorage.getItem(this.editorStateKey);
|
||||||
}
|
|
||||||
|
|
||||||
_restoreStoredEditorState(partCreator) {
|
|
||||||
const json = localStorage.getItem(this._editorStateKey);
|
|
||||||
if (json) {
|
if (json) {
|
||||||
try {
|
try {
|
||||||
const { parts: serializedParts } = JSON.parse(json);
|
const { parts: serializedParts } = JSON.parse(json);
|
||||||
const parts = serializedParts.map(p => partCreator.deserializePart(p));
|
const parts: Part[] = serializedParts.map(p => partCreator.deserializePart(p));
|
||||||
return parts;
|
return parts;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error parsing editing state: ", e);
|
console.error("Error parsing editing state: ", e);
|
||||||
|
@ -210,25 +218,25 @@ export default class EditMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearStoredEditorState() {
|
private clearStoredEditorState(): void {
|
||||||
localStorage.removeItem(this._editorRoomKey);
|
localStorage.removeItem(this.editorRoomKey);
|
||||||
localStorage.removeItem(this._editorStateKey);
|
localStorage.removeItem(this.editorStateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearPreviousEdit() {
|
private clearPreviousEdit(): void {
|
||||||
if (localStorage.getItem(this._editorRoomKey)) {
|
if (localStorage.getItem(this.editorRoomKey)) {
|
||||||
localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`);
|
localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this.editorRoomKey)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveStoredEditorState() {
|
private saveStoredEditorState(): void {
|
||||||
const item = SendHistoryManager.createItem(this.model);
|
const item = SendHistoryManager.createItem(this.model);
|
||||||
this._clearPreviousEdit();
|
this.clearPreviousEdit();
|
||||||
localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId());
|
localStorage.setItem(this.editorRoomKey, this.props.editState.getEvent().getId());
|
||||||
localStorage.setItem(this._editorStateKey, JSON.stringify(item));
|
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
_isSlashCommand() {
|
private isSlashCommand(): boolean {
|
||||||
const parts = this.model.parts;
|
const parts = this.model.parts;
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
if (firstPart) {
|
if (firstPart) {
|
||||||
|
@ -244,10 +252,10 @@ export default class EditMessageComposer extends React.Component {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isContentModified(newContent) {
|
private isContentModified(newContent: IContent): boolean {
|
||||||
// if nothing has changed then bail
|
// if nothing has changed then bail
|
||||||
const oldContent = this.props.editState.getEvent().getContent();
|
const oldContent = this.props.editState.getEvent().getContent();
|
||||||
if (!this._editorRef.isModified() ||
|
if (!this.editorRef.current?.isModified() ||
|
||||||
(oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] &&
|
(oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] &&
|
||||||
oldContent["format"] === newContent["format"] &&
|
oldContent["format"] === newContent["format"] &&
|
||||||
oldContent["formatted_body"] === newContent["formatted_body"])) {
|
oldContent["formatted_body"] === newContent["formatted_body"])) {
|
||||||
|
@ -256,7 +264,7 @@ export default class EditMessageComposer extends React.Component {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSlashCommand() {
|
private getSlashCommand(): [Command, string, string] {
|
||||||
const commandText = this.model.parts.reduce((text, part) => {
|
const commandText = this.model.parts.reduce((text, part) => {
|
||||||
// use mxid to textify user pills in a command
|
// use mxid to textify user pills in a command
|
||||||
if (part.type === "user-pill") {
|
if (part.type === "user-pill") {
|
||||||
|
@ -268,7 +276,7 @@ export default class EditMessageComposer extends React.Component {
|
||||||
return [cmd, args, commandText];
|
return [cmd, args, commandText];
|
||||||
}
|
}
|
||||||
|
|
||||||
async _runSlashCommand(cmd, args, roomId) {
|
private async runSlashCommand(cmd: Command, args: string, roomId: string): Promise<void> {
|
||||||
const result = cmd.run(roomId, args);
|
const result = cmd.run(roomId, args);
|
||||||
let messageContent;
|
let messageContent;
|
||||||
let error = result.error;
|
let error = result.error;
|
||||||
|
@ -285,7 +293,6 @@ export default class EditMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Command failure: %s", 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
|
// assume the error is a server error when the command is async
|
||||||
const isServerError = !!result.promise;
|
const isServerError = !!result.promise;
|
||||||
const title = isServerError ? _td("Server error") : _td("Command error");
|
const title = isServerError ? _td("Server error") : _td("Command error");
|
||||||
|
@ -309,7 +316,7 @@ export default class EditMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_sendEdit = async () => {
|
private sendEdit = async (): Promise<void> => {
|
||||||
const startTime = CountlyAnalytics.getTimestamp();
|
const startTime = CountlyAnalytics.getTimestamp();
|
||||||
const editedEvent = this.props.editState.getEvent();
|
const editedEvent = this.props.editState.getEvent();
|
||||||
const editContent = createEditContent(this.model, editedEvent);
|
const editContent = createEditContent(this.model, editedEvent);
|
||||||
|
@ -318,20 +325,19 @@ export default class EditMessageComposer extends React.Component {
|
||||||
let shouldSend = true;
|
let shouldSend = true;
|
||||||
|
|
||||||
// If content is modified then send an updated event into the room
|
// If content is modified then send an updated event into the room
|
||||||
if (this._isContentModified(newContent)) {
|
if (this.isContentModified(newContent)) {
|
||||||
const roomId = editedEvent.getRoomId();
|
const roomId = editedEvent.getRoomId();
|
||||||
if (!containsEmote(this.model) && this._isSlashCommand()) {
|
if (!containsEmote(this.model) && this.isSlashCommand()) {
|
||||||
const [cmd, args, commandText] = this._getSlashCommand();
|
const [cmd, args, commandText] = this.getSlashCommand();
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
if (cmd.category === CommandCategories.messages) {
|
if (cmd.category === CommandCategories.messages) {
|
||||||
editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId);
|
editContent["m.new_content"] = await this.runSlashCommand(cmd, args, roomId);
|
||||||
} else {
|
} else {
|
||||||
this._runSlashCommand(cmd, args, roomId);
|
this.runSlashCommand(cmd, args, roomId);
|
||||||
shouldSend = false;
|
shouldSend = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ask the user if their unknown command should be sent as a message
|
// ask the user if their unknown command should be sent as a message
|
||||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
|
||||||
const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
||||||
title: _t("Unknown Command"),
|
title: _t("Unknown Command"),
|
||||||
description: <div>
|
description: <div>
|
||||||
|
@ -358,9 +364,9 @@ export default class EditMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
this._cancelPreviousPendingEdit();
|
this.cancelPreviousPendingEdit();
|
||||||
const prom = this.context.sendMessage(roomId, editContent);
|
const prom = this.context.sendMessage(roomId, editContent);
|
||||||
this._clearStoredEditorState();
|
this.clearStoredEditorState();
|
||||||
dis.dispatch({ action: "message_sent" });
|
dis.dispatch({ action: "message_sent" });
|
||||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
|
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
|
||||||
}
|
}
|
||||||
|
@ -371,7 +377,7 @@ export default class EditMessageComposer extends React.Component {
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusComposer);
|
||||||
};
|
};
|
||||||
|
|
||||||
_cancelPreviousPendingEdit() {
|
private cancelPreviousPendingEdit(): void {
|
||||||
const originalEvent = this.props.editState.getEvent();
|
const originalEvent = this.props.editState.getEvent();
|
||||||
const previousEdit = originalEvent.replacingEvent();
|
const previousEdit = originalEvent.replacingEvent();
|
||||||
if (previousEdit && (
|
if (previousEdit && (
|
||||||
|
@ -389,23 +395,23 @@ export default class EditMessageComposer extends React.Component {
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
let caret;
|
let caret;
|
||||||
if (sel.focusNode) {
|
if (sel.focusNode) {
|
||||||
caret = getCaretOffsetAndText(this._editorRef, sel).caret;
|
caret = getCaretOffsetAndText(this.editorRef.current, sel).caret;
|
||||||
}
|
}
|
||||||
const parts = this.model.serializeParts();
|
const parts = this.model.serializeParts();
|
||||||
// if caret is undefined because for some reason there isn't a valid selection,
|
// if caret is undefined because for some reason there isn't a valid selection,
|
||||||
// then when mounting the editor again with the same editor state,
|
// then when mounting the editor again with the same editor state,
|
||||||
// it will set the cursor at the end.
|
// it will set the cursor at the end.
|
||||||
this.props.editState.setEditorState(caret, parts);
|
this.props.editState.setEditorState(caret, parts);
|
||||||
window.removeEventListener("beforeunload", this._saveStoredEditorState);
|
window.removeEventListener("beforeunload", this.saveStoredEditorState);
|
||||||
if (this._shouldSaveStoredEditorState) {
|
if (this.shouldSaveStoredEditorState) {
|
||||||
this._saveStoredEditorState();
|
this.saveStoredEditorState();
|
||||||
}
|
}
|
||||||
dis.unregister(this.dispatcherRef);
|
dis.unregister(this.dispatcherRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
_createEditorModel() {
|
private createEditorModel(): void {
|
||||||
const { editState } = this.props;
|
const { editState } = this.props;
|
||||||
const room = this._getRoom();
|
const room = this.getRoom();
|
||||||
const partCreator = new CommandPartCreator(room, this.context);
|
const partCreator = new CommandPartCreator(room, this.context);
|
||||||
let parts;
|
let parts;
|
||||||
if (editState.hasEditorState()) {
|
if (editState.hasEditorState()) {
|
||||||
|
@ -414,13 +420,13 @@ export default class EditMessageComposer extends React.Component {
|
||||||
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
|
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
|
||||||
} else {
|
} else {
|
||||||
//otherwise, either restore serialized parts from localStorage or parse the body of the event
|
//otherwise, either restore serialized parts from localStorage or parse the body of the event
|
||||||
parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator);
|
parts = this.restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator);
|
||||||
}
|
}
|
||||||
this.model = new EditorModel(parts, partCreator);
|
this.model = new EditorModel(parts, partCreator);
|
||||||
this._saveStoredEditorState();
|
this.saveStoredEditorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
_getInitialCaretPosition() {
|
private getInitialCaretPosition(): CaretPosition {
|
||||||
const { editState } = this.props;
|
const { editState } = this.props;
|
||||||
let caretPosition;
|
let caretPosition;
|
||||||
if (editState.hasEditorState() && editState.getCaret()) {
|
if (editState.hasEditorState() && editState.getCaret()) {
|
||||||
|
@ -435,8 +441,8 @@ export default class EditMessageComposer extends React.Component {
|
||||||
return caretPosition;
|
return caretPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onChange = () => {
|
private onChange = (): void => {
|
||||||
if (!this.state.saveDisabled || !this._editorRef || !this._editorRef.isModified()) {
|
if (!this.state.saveDisabled || !this.editorRef.current?.isModified()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -445,33 +451,34 @@ export default class EditMessageComposer extends React.Component {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onAction = payload => {
|
private onAction = (payload: ActionPayload) => {
|
||||||
if (payload.action === "edit_composer_insert" && this._editorRef) {
|
if (payload.action === "edit_composer_insert" && this.editorRef.current) {
|
||||||
if (payload.userId) {
|
if (payload.userId) {
|
||||||
this._editorRef.insertMention(payload.userId);
|
this.editorRef.current?.insertMention(payload.userId);
|
||||||
} else if (payload.event) {
|
} else if (payload.event) {
|
||||||
this._editorRef.insertQuotedMessage(payload.event);
|
this.editorRef.current?.insertQuotedMessage(payload.event);
|
||||||
} else if (payload.text) {
|
} else if (payload.text) {
|
||||||
this._editorRef.insertPlaintext(payload.text);
|
this.editorRef.current?.insertPlaintext(payload.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
return (<div className={classNames("mx_EditMessageComposer", 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.editorRef}
|
||||||
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")}
|
label={_t("Edit message")}
|
||||||
onChange={this._onChange}
|
onChange={this.onChange}
|
||||||
/>
|
/>
|
||||||
<div className="mx_EditMessageComposer_buttons">
|
<div className="mx_EditMessageComposer_buttons">
|
||||||
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton>
|
<AccessibleButton kind="secondary" onClick={this.cancelEdit}>
|
||||||
<AccessibleButton kind="primary" onClick={this._sendEdit} disabled={this.state.saveDisabled}>
|
{ _t("Cancel") }
|
||||||
{_t("Save")}
|
</AccessibleButton>
|
||||||
|
<AccessibleButton kind="primary" onClick={this.sendEdit} disabled={this.state.saveDisabled}>
|
||||||
|
{ _t("Save") }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
|
@ -43,6 +43,7 @@ import { E2EStatus } from '../../../utils/ShieldUtils';
|
||||||
import SendMessageComposer from "./SendMessageComposer";
|
import SendMessageComposer from "./SendMessageComposer";
|
||||||
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import EditorModel from "../../../editor/model";
|
||||||
|
|
||||||
interface IComposerAvatarProps {
|
interface IComposerAvatarProps {
|
||||||
me: object;
|
me: object;
|
||||||
|
@ -318,14 +319,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addEmoji(emoji: string) {
|
private addEmoji(emoji: string) {
|
||||||
dis.dispatch<ComposerInsertPayload>({
|
dis.dispatch<ComposerInsertPayload>({
|
||||||
action: Action.ComposerInsert,
|
action: Action.ComposerInsert,
|
||||||
text: emoji,
|
text: emoji,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage = async () => {
|
private sendMessage = async () => {
|
||||||
if (this.state.haveRecording && this.voiceRecordingButton) {
|
if (this.state.haveRecording && this.voiceRecordingButton) {
|
||||||
// There shouldn't be any text message to send when a voice recording is active, so
|
// There shouldn't be any text message to send when a voice recording is active, so
|
||||||
// just send out the voice recording.
|
// just send out the voice recording.
|
||||||
|
@ -333,11 +334,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: Private function access
|
this.messageComposerInput.sendMessage();
|
||||||
this.messageComposerInput._sendMessage();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange = (model) => {
|
private onChange = (model: EditorModel) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
isComposerEmpty: model.isEmpty,
|
isComposerEmpty: model.isEmpty,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
Copyright 2019 - 2021 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,21 +14,35 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { createRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { _t } from '../../../languageHandler';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { _t } from '../../../languageHandler';
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
|
||||||
@replaceableComponent("views.rooms.MessageComposerFormatBar")
|
export enum Formatting {
|
||||||
export default class MessageComposerFormatBar extends React.PureComponent {
|
Bold = "bold",
|
||||||
static propTypes = {
|
Italics = "italics",
|
||||||
onAction: PropTypes.func.isRequired,
|
Strikethrough = "strikethrough",
|
||||||
shortcuts: PropTypes.object.isRequired,
|
Code = "code",
|
||||||
};
|
Quote = "quote",
|
||||||
|
}
|
||||||
|
|
||||||
constructor(props) {
|
interface IProps {
|
||||||
|
shortcuts: Partial<Record<Formatting, string>>;
|
||||||
|
onAction(action: Formatting): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@replaceableComponent("views.rooms.MessageComposerFormatBar")
|
||||||
|
export default class MessageComposerFormatBar extends React.PureComponent<IProps, IState> {
|
||||||
|
private readonly formatBarRef = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { visible: false };
|
this.state = { visible: false };
|
||||||
}
|
}
|
||||||
|
@ -37,49 +51,53 @@ export default class MessageComposerFormatBar extends React.PureComponent {
|
||||||
const classes = classNames("mx_MessageComposerFormatBar", {
|
const classes = classNames("mx_MessageComposerFormatBar", {
|
||||||
"mx_MessageComposerFormatBar_shown": this.state.visible,
|
"mx_MessageComposerFormatBar_shown": this.state.visible,
|
||||||
});
|
});
|
||||||
return (<div className={classes} ref={ref => this._formatBarRef = ref}>
|
return (<div className={classes} ref={this.formatBarRef}>
|
||||||
<FormatButton label={_t("Bold")} onClick={() => this.props.onAction("bold")} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} />
|
<FormatButton label={_t("Bold")} onClick={() => this.props.onAction(Formatting.Bold)} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} />
|
||||||
<FormatButton label={_t("Italics")} onClick={() => this.props.onAction("italics")} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} />
|
<FormatButton label={_t("Italics")} onClick={() => this.props.onAction(Formatting.Italics)} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} />
|
||||||
<FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction("strikethrough")} icon="Strikethrough" visible={this.state.visible} />
|
<FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} />
|
||||||
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction("code")} icon="Code" visible={this.state.visible} />
|
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} />
|
||||||
<FormatButton label={_t("Quote")} onClick={() => this.props.onAction("quote")} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
|
<FormatButton label={_t("Quote")} onClick={() => this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|
||||||
showAt(selectionRect) {
|
public showAt(selectionRect: DOMRect): void {
|
||||||
|
if (!this.formatBarRef.current) return;
|
||||||
|
|
||||||
this.setState({ visible: true });
|
this.setState({ visible: true });
|
||||||
const parentRect = this._formatBarRef.parentElement.getBoundingClientRect();
|
const parentRect = this.formatBarRef.current.parentElement.getBoundingClientRect();
|
||||||
this._formatBarRef.style.left = `${selectionRect.left - parentRect.left}px`;
|
this.formatBarRef.current.style.left = `${selectionRect.left - parentRect.left}px`;
|
||||||
// 12 is half the height of the bar (e.g. to center it) and 16 is an offset that felt ok.
|
// 12 is half the height of the bar (e.g. to center it) and 16 is an offset that felt ok.
|
||||||
this._formatBarRef.style.top = `${selectionRect.top - parentRect.top - 16 - 12}px`;
|
this.formatBarRef.current.style.top = `${selectionRect.top - parentRect.top - 16 - 12}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
public hide(): void {
|
||||||
this.setState({ visible: false });
|
this.setState({ visible: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FormatButton extends React.PureComponent {
|
interface IFormatButtonProps {
|
||||||
static propTypes = {
|
label: string;
|
||||||
label: PropTypes.string.isRequired,
|
icon: string;
|
||||||
onClick: PropTypes.func.isRequired,
|
shortcut?: string;
|
||||||
icon: PropTypes.string.isRequired,
|
visible?: boolean;
|
||||||
shortcut: PropTypes.string,
|
onClick(): void;
|
||||||
visible: PropTypes.bool,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
class FormatButton extends React.PureComponent<IFormatButtonProps> {
|
||||||
render() {
|
render() {
|
||||||
const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`;
|
const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`;
|
||||||
let shortcut;
|
let shortcut;
|
||||||
if (this.props.shortcut) {
|
if (this.props.shortcut) {
|
||||||
shortcut = <div className="mx_MessageComposerFormatBar_tooltipShortcut">{this.props.shortcut}</div>;
|
shortcut = <div className="mx_MessageComposerFormatBar_tooltipShortcut">
|
||||||
|
{ this.props.shortcut }
|
||||||
|
</div>;
|
||||||
}
|
}
|
||||||
const tooltip = <div>
|
const tooltip = <div>
|
||||||
<div className="mx_Tooltip_title">
|
<div className="mx_Tooltip_title">
|
||||||
{this.props.label}
|
{ this.props.label }
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_Tooltip_sub">
|
<div className="mx_Tooltip_sub">
|
||||||
{shortcut}
|
{ shortcut }
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 New Vector Ltd
|
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
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 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import React, { ClipboardEvent, createRef, KeyboardEvent } from 'react';
|
||||||
|
import EMOJI_REGEX from 'emojibase-regex';
|
||||||
|
import { IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
|
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import EditorModel from '../../../editor/model';
|
import EditorModel from '../../../editor/model';
|
||||||
import {
|
import {
|
||||||
|
@ -27,13 +29,12 @@ import {
|
||||||
startsWith,
|
startsWith,
|
||||||
stripPrefix,
|
stripPrefix,
|
||||||
} from '../../../editor/serialize';
|
} from '../../../editor/serialize';
|
||||||
import { CommandPartCreator } from '../../../editor/parts';
|
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
import ReplyThread from "../elements/ReplyThread";
|
import ReplyThread from "../elements/ReplyThread";
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
import SendHistoryManager from "../../../SendHistoryManager";
|
import SendHistoryManager from "../../../SendHistoryManager";
|
||||||
import { CommandCategories, getCommand } from '../../../SlashCommands';
|
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import { _t, _td } from '../../../languageHandler';
|
import { _t, _td } from '../../../languageHandler';
|
||||||
import ContentMessages from '../../../ContentMessages';
|
import ContentMessages from '../../../ContentMessages';
|
||||||
|
@ -44,12 +45,20 @@ import { containsEmoji } from "../../../effects/utils";
|
||||||
import { CHAT_EFFECTS } from '../../../effects';
|
import { CHAT_EFFECTS } from '../../../effects';
|
||||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import EMOJI_REGEX from 'emojibase-regex';
|
|
||||||
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
|
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import SettingsStore from '../../../settings/SettingsStore';
|
import SettingsStore from '../../../settings/SettingsStore';
|
||||||
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
|
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||||
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
|
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||||
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||||
|
|
||||||
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
function addReplyToMessageContent(
|
||||||
|
content: IContent,
|
||||||
|
repliedToEvent: MatrixEvent,
|
||||||
|
permalinkCreator: RoomPermalinkCreator,
|
||||||
|
): void {
|
||||||
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
||||||
Object.assign(content, replyContent);
|
Object.assign(content, replyContent);
|
||||||
|
|
||||||
|
@ -65,7 +74,11 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// exported for tests
|
// exported for tests
|
||||||
export function createMessageContent(model, permalinkCreator, replyToEvent) {
|
export function createMessageContent(
|
||||||
|
model: EditorModel,
|
||||||
|
permalinkCreator: RoomPermalinkCreator,
|
||||||
|
replyToEvent: MatrixEvent,
|
||||||
|
): IContent {
|
||||||
const isEmote = containsEmote(model);
|
const isEmote = containsEmote(model);
|
||||||
if (isEmote) {
|
if (isEmote) {
|
||||||
model = stripEmoteCommand(model);
|
model = stripEmoteCommand(model);
|
||||||
|
@ -76,7 +89,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
|
||||||
model = unescapeMessage(model);
|
model = unescapeMessage(model);
|
||||||
|
|
||||||
const body = textSerialize(model);
|
const body = textSerialize(model);
|
||||||
const content = {
|
const content: IContent = {
|
||||||
msgtype: isEmote ? "m.emote" : "m.text",
|
msgtype: isEmote ? "m.emote" : "m.text",
|
||||||
body: body,
|
body: body,
|
||||||
};
|
};
|
||||||
|
@ -94,7 +107,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// exported for tests
|
// exported for tests
|
||||||
export function isQuickReaction(model) {
|
export function isQuickReaction(model: EditorModel): boolean {
|
||||||
const parts = model.parts;
|
const parts = model.parts;
|
||||||
if (parts.length == 0) return false;
|
if (parts.length == 0) return false;
|
||||||
const text = textSerialize(model);
|
const text = textSerialize(model);
|
||||||
|
@ -111,46 +124,47 @@ export function isQuickReaction(model) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
room: Room;
|
||||||
|
placeholder?: string;
|
||||||
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
|
replyToEvent?: MatrixEvent;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?(model: EditorModel): void;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.rooms.SendMessageComposer")
|
@replaceableComponent("views.rooms.SendMessageComposer")
|
||||||
export default class SendMessageComposer extends React.Component {
|
export default class SendMessageComposer extends React.Component<IProps> {
|
||||||
static propTypes = {
|
|
||||||
room: PropTypes.object.isRequired,
|
|
||||||
placeholder: PropTypes.string,
|
|
||||||
permalinkCreator: PropTypes.object.isRequired,
|
|
||||||
replyToEvent: PropTypes.object,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
static contextType = MatrixClientContext;
|
static contextType = MatrixClientContext;
|
||||||
|
context: React.ContextType<typeof MatrixClientContext>;
|
||||||
|
|
||||||
constructor(props, context) {
|
private readonly prepareToEncrypt?: RateLimitedFunc;
|
||||||
|
private readonly editorRef = createRef<BasicMessageComposer>();
|
||||||
|
private model: EditorModel = null;
|
||||||
|
private currentlyComposedEditorState: SerializedPart[] = null;
|
||||||
|
private dispatcherRef: string;
|
||||||
|
private sendHistoryManager: SendHistoryManager;
|
||||||
|
|
||||||
|
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
this.model = null;
|
if (context.isCryptoEnabled() && context.isRoomEncrypted(this.props.room.roomId)) {
|
||||||
this._editorRef = null;
|
this.prepareToEncrypt = new RateLimitedFunc(() => {
|
||||||
this.currentlyComposedEditorState = null;
|
|
||||||
if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) {
|
|
||||||
this._prepareToEncrypt = new RateLimitedFunc(() => {
|
|
||||||
this.context.prepareToEncrypt(this.props.room);
|
this.context.prepareToEncrypt(this.props.room);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("beforeunload", this._saveStoredEditorState);
|
window.addEventListener("beforeunload", this.saveStoredEditorState);
|
||||||
}
|
}
|
||||||
|
|
||||||
_setEditorRef = ref => {
|
private onKeyDown = (event: KeyboardEvent): void => {
|
||||||
this._editorRef = ref;
|
|
||||||
};
|
|
||||||
|
|
||||||
_onKeyDown = (event) => {
|
|
||||||
// ignore any keypress while doing IME compositions
|
// ignore any keypress while doing IME compositions
|
||||||
if (this._editorRef.isComposing(event)) {
|
if (this.editorRef.current?.isComposing(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case MessageComposerAction.Send:
|
case MessageComposerAction.Send:
|
||||||
this._sendMessage();
|
this.sendMessage();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
break;
|
break;
|
||||||
case MessageComposerAction.SelectPrevSendHistory:
|
case MessageComposerAction.SelectPrevSendHistory:
|
||||||
|
@ -165,7 +179,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
case MessageComposerAction.EditPrevMessage:
|
case MessageComposerAction.EditPrevMessage:
|
||||||
// selection must be collapsed and caret at start
|
// selection must be collapsed and caret at start
|
||||||
if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) {
|
if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) {
|
||||||
const editEvent = findEditableEvent(this.props.room, false);
|
const editEvent = findEditableEvent(this.props.room, false);
|
||||||
if (editEvent) {
|
if (editEvent) {
|
||||||
// We're selecting history, so prevent the key event from doing anything else
|
// We're selecting history, so prevent the key event from doing anything else
|
||||||
|
@ -184,16 +198,16 @@ export default class SendMessageComposer extends React.Component {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (this._prepareToEncrypt) {
|
if (this.prepareToEncrypt) {
|
||||||
// This needs to be last!
|
// This needs to be last!
|
||||||
this._prepareToEncrypt();
|
this.prepareToEncrypt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// we keep sent messages/commands in a separate history (separate from undo history)
|
// we keep sent messages/commands in a separate history (separate from undo history)
|
||||||
// so you can alt+up/down in them
|
// so you can alt+up/down in them
|
||||||
selectSendHistory(up) {
|
private selectSendHistory(up: boolean): void {
|
||||||
const delta = up ? -1 : 1;
|
const delta = up ? -1 : 1;
|
||||||
// True if we are not currently selecting history, but composing a message
|
// True if we are not currently selecting history, but composing a message
|
||||||
if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) {
|
if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) {
|
||||||
|
@ -215,11 +229,11 @@ export default class SendMessageComposer extends React.Component {
|
||||||
});
|
});
|
||||||
if (parts) {
|
if (parts) {
|
||||||
this.model.reset(parts);
|
this.model.reset(parts);
|
||||||
this._editorRef.focus();
|
this.editorRef.current?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_isSlashCommand() {
|
private isSlashCommand(): boolean {
|
||||||
const parts = this.model.parts;
|
const parts = this.model.parts;
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
if (firstPart) {
|
if (firstPart) {
|
||||||
|
@ -237,7 +251,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_sendQuickReaction() {
|
private sendQuickReaction(): void {
|
||||||
const timeline = this.props.room.getLiveTimeline();
|
const timeline = this.props.room.getLiveTimeline();
|
||||||
const events = timeline.getEvents();
|
const events = timeline.getEvents();
|
||||||
const reaction = this.model.parts[1].text;
|
const reaction = this.model.parts[1].text;
|
||||||
|
@ -272,7 +286,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSlashCommand() {
|
private getSlashCommand(): [Command, string, string] {
|
||||||
const commandText = this.model.parts.reduce((text, part) => {
|
const commandText = this.model.parts.reduce((text, part) => {
|
||||||
// use mxid to textify user pills in a command
|
// use mxid to textify user pills in a command
|
||||||
if (part.type === "user-pill") {
|
if (part.type === "user-pill") {
|
||||||
|
@ -284,7 +298,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
return [cmd, args, commandText];
|
return [cmd, args, commandText];
|
||||||
}
|
}
|
||||||
|
|
||||||
async _runSlashCommand(cmd, args) {
|
private async runSlashCommand(cmd: Command, args: string): Promise<void> {
|
||||||
const result = cmd.run(this.props.room.roomId, args);
|
const result = cmd.run(this.props.room.roomId, args);
|
||||||
let messageContent;
|
let messageContent;
|
||||||
let error = result.error;
|
let error = result.error;
|
||||||
|
@ -302,7 +316,6 @@ export default class SendMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Command failure: %s", 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
|
// assume the error is a server error when the command is async
|
||||||
const isServerError = !!result.promise;
|
const isServerError = !!result.promise;
|
||||||
const title = isServerError ? _td("Server error") : _td("Command error");
|
const title = isServerError ? _td("Server error") : _td("Command error");
|
||||||
|
@ -326,7 +339,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _sendMessage() {
|
public async sendMessage(): Promise<void> {
|
||||||
if (this.model.isEmpty) {
|
if (this.model.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -335,21 +348,20 @@ export default class SendMessageComposer extends React.Component {
|
||||||
let shouldSend = true;
|
let shouldSend = true;
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
if (!containsEmote(this.model) && this._isSlashCommand()) {
|
if (!containsEmote(this.model) && this.isSlashCommand()) {
|
||||||
const [cmd, args, commandText] = this._getSlashCommand();
|
const [cmd, args, commandText] = this.getSlashCommand();
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
if (cmd.category === CommandCategories.messages) {
|
if (cmd.category === CommandCategories.messages) {
|
||||||
content = await this._runSlashCommand(cmd, args);
|
content = await this.runSlashCommand(cmd, args);
|
||||||
if (replyToEvent) {
|
if (replyToEvent) {
|
||||||
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
|
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._runSlashCommand(cmd, args);
|
this.runSlashCommand(cmd, args);
|
||||||
shouldSend = false;
|
shouldSend = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ask the user if their unknown command should be sent as a message
|
// ask the user if their unknown command should be sent as a message
|
||||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
|
||||||
const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
||||||
title: _t("Unknown Command"),
|
title: _t("Unknown Command"),
|
||||||
description: <div>
|
description: <div>
|
||||||
|
@ -378,7 +390,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
|
|
||||||
if (isQuickReaction(this.model)) {
|
if (isQuickReaction(this.model)) {
|
||||||
shouldSend = false;
|
shouldSend = false;
|
||||||
this._sendQuickReaction();
|
this.sendQuickReaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
|
@ -411,9 +423,9 @@ export default class SendMessageComposer extends React.Component {
|
||||||
this.sendHistoryManager.save(this.model, replyToEvent);
|
this.sendHistoryManager.save(this.model, replyToEvent);
|
||||||
// clear composer
|
// clear composer
|
||||||
this.model.reset([]);
|
this.model.reset([]);
|
||||||
this._editorRef.clearUndoHistory();
|
this.editorRef.current?.clearUndoHistory();
|
||||||
this._editorRef.focus();
|
this.editorRef.current?.focus();
|
||||||
this._clearStoredEditorState();
|
this.clearStoredEditorState();
|
||||||
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
||||||
dis.dispatch({ action: "scroll_to_bottom" });
|
dis.dispatch({ action: "scroll_to_bottom" });
|
||||||
}
|
}
|
||||||
|
@ -421,33 +433,33 @@ export default class SendMessageComposer extends React.Component {
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
dis.unregister(this.dispatcherRef);
|
dis.unregister(this.dispatcherRef);
|
||||||
window.removeEventListener("beforeunload", this._saveStoredEditorState);
|
window.removeEventListener("beforeunload", this.saveStoredEditorState);
|
||||||
this._saveStoredEditorState();
|
this.saveStoredEditorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: [REACT-WARNING] Move this to constructor
|
// TODO: [REACT-WARNING] Move this to constructor
|
||||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||||
const partCreator = new CommandPartCreator(this.props.room, this.context);
|
const partCreator = new CommandPartCreator(this.props.room, this.context);
|
||||||
const parts = this._restoreStoredEditorState(partCreator) || [];
|
const parts = this.restoreStoredEditorState(partCreator) || [];
|
||||||
this.model = new EditorModel(parts, partCreator);
|
this.model = new EditorModel(parts, partCreator);
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_');
|
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_');
|
||||||
}
|
}
|
||||||
|
|
||||||
get _editorStateKey() {
|
private get editorStateKey() {
|
||||||
return `mx_cider_state_${this.props.room.roomId}`;
|
return `mx_cider_state_${this.props.room.roomId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearStoredEditorState() {
|
private clearStoredEditorState(): void {
|
||||||
localStorage.removeItem(this._editorStateKey);
|
localStorage.removeItem(this.editorStateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
_restoreStoredEditorState(partCreator) {
|
private restoreStoredEditorState(partCreator: PartCreator): Part[] {
|
||||||
const json = localStorage.getItem(this._editorStateKey);
|
const json = localStorage.getItem(this.editorStateKey);
|
||||||
if (json) {
|
if (json) {
|
||||||
try {
|
try {
|
||||||
const { parts: serializedParts, replyEventId } = JSON.parse(json);
|
const { parts: serializedParts, replyEventId } = JSON.parse(json);
|
||||||
const parts = serializedParts.map(p => partCreator.deserializePart(p));
|
const parts: Part[] = serializedParts.map(p => partCreator.deserializePart(p));
|
||||||
if (replyEventId) {
|
if (replyEventId) {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: 'reply_to_event',
|
action: 'reply_to_event',
|
||||||
|
@ -462,20 +474,20 @@ export default class SendMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
// should save state when editor has contents or reply is open
|
// should save state when editor has contents or reply is open
|
||||||
_shouldSaveStoredEditorState = () => {
|
private shouldSaveStoredEditorState = (): boolean => {
|
||||||
return !this.model.isEmpty || this.props.replyToEvent;
|
return !this.model.isEmpty || !!this.props.replyToEvent;
|
||||||
}
|
};
|
||||||
|
|
||||||
_saveStoredEditorState = () => {
|
private saveStoredEditorState = (): void => {
|
||||||
if (this._shouldSaveStoredEditorState()) {
|
if (this.shouldSaveStoredEditorState()) {
|
||||||
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
|
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
|
||||||
localStorage.setItem(this._editorStateKey, JSON.stringify(item));
|
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
|
||||||
} else {
|
} else {
|
||||||
this._clearStoredEditorState();
|
this.clearStoredEditorState();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onAction = (payload) => {
|
private onAction = (payload: ActionPayload): void => {
|
||||||
// don't let the user into the composer if it is disabled - all of these branches lead
|
// don't let the user into the composer if it is disabled - all of these branches lead
|
||||||
// to the cursor being in the composer
|
// to the cursor being in the composer
|
||||||
if (this.props.disabled) return;
|
if (this.props.disabled) return;
|
||||||
|
@ -483,21 +495,21 @@ export default class SendMessageComposer extends React.Component {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'reply_to_event':
|
case 'reply_to_event':
|
||||||
case Action.FocusComposer:
|
case Action.FocusComposer:
|
||||||
this._editorRef && this._editorRef.focus();
|
this.editorRef.current?.focus();
|
||||||
break;
|
break;
|
||||||
case "send_composer_insert":
|
case "send_composer_insert":
|
||||||
if (payload.userId) {
|
if (payload.userId) {
|
||||||
this._editorRef && this._editorRef.insertMention(payload.userId);
|
this.editorRef.current?.insertMention(payload.userId);
|
||||||
} else if (payload.event) {
|
} else if (payload.event) {
|
||||||
this._editorRef && this._editorRef.insertQuotedMessage(payload.event);
|
this.editorRef.current?.insertQuotedMessage(payload.event);
|
||||||
} else if (payload.text) {
|
} else if (payload.text) {
|
||||||
this._editorRef && this._editorRef.insertPlaintext(payload.text);
|
this.editorRef.current?.insertPlaintext(payload.text);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_onPaste = (event) => {
|
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
|
||||||
const { clipboardData } = event;
|
const { clipboardData } = event;
|
||||||
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap
|
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap
|
||||||
// in the clipboard as well as the content being copied.
|
// in the clipboard as well as the content being copied.
|
||||||
|
@ -511,23 +523,27 @@ export default class SendMessageComposer extends React.Component {
|
||||||
);
|
);
|
||||||
return true; // to skip internal onPaste handler
|
return true; // to skip internal onPaste handler
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onChange = () => {
|
private onChange = (): void => {
|
||||||
if (this.props.onChange) this.props.onChange(this.model);
|
if (this.props.onChange) this.props.onChange(this.model);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
private focusComposer = (): void => {
|
||||||
|
this.editorRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
|
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this.onKeyDown}>
|
||||||
<BasicMessageComposer
|
<BasicMessageComposer
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
ref={this._setEditorRef}
|
ref={this.editorRef}
|
||||||
model={this.model}
|
model={this.model}
|
||||||
room={this.props.room}
|
room={this.props.room}
|
||||||
label={this.props.placeholder}
|
label={this.props.placeholder}
|
||||||
placeholder={this.props.placeholder}
|
placeholder={this.props.placeholder}
|
||||||
onPaste={this._onPaste}
|
onPaste={this.onPaste}
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
|
@ -70,7 +70,7 @@ export default class EditorModel {
|
||||||
* on the model that can span multiple parts. Also see `startRange()`.
|
* on the model that can span multiple parts. Also see `startRange()`.
|
||||||
* @param {TransformCallback} transformCallback
|
* @param {TransformCallback} transformCallback
|
||||||
*/
|
*/
|
||||||
setTransformCallback(transformCallback: TransformCallback) {
|
public setTransformCallback(transformCallback: TransformCallback): void {
|
||||||
this.transformCallback = transformCallback;
|
this.transformCallback = transformCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,23 +78,23 @@ export default class EditorModel {
|
||||||
* Set a callback for rerendering the model after it has been updated.
|
* Set a callback for rerendering the model after it has been updated.
|
||||||
* @param {ModelCallback} updateCallback
|
* @param {ModelCallback} updateCallback
|
||||||
*/
|
*/
|
||||||
setUpdateCallback(updateCallback: UpdateCallback) {
|
public setUpdateCallback(updateCallback: UpdateCallback): void {
|
||||||
this.updateCallback = updateCallback;
|
this.updateCallback = updateCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
get partCreator() {
|
public get partCreator(): PartCreator {
|
||||||
return this._partCreator;
|
return this._partCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isEmpty() {
|
public get isEmpty(): boolean {
|
||||||
return this._parts.reduce((len, part) => len + part.text.length, 0) === 0;
|
return this._parts.reduce((len, part) => len + part.text.length, 0) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
clone() {
|
public clone(): EditorModel {
|
||||||
return new EditorModel(this._parts, this._partCreator, this.updateCallback);
|
return new EditorModel(this._parts, this._partCreator, this.updateCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private insertPart(index: number, part: Part) {
|
private insertPart(index: number, part: Part): void {
|
||||||
this._parts.splice(index, 0, part);
|
this._parts.splice(index, 0, part);
|
||||||
if (this.activePartIdx >= index) {
|
if (this.activePartIdx >= index) {
|
||||||
++this.activePartIdx;
|
++this.activePartIdx;
|
||||||
|
@ -104,7 +104,7 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removePart(index: number) {
|
private removePart(index: number): void {
|
||||||
this._parts.splice(index, 1);
|
this._parts.splice(index, 1);
|
||||||
if (index === this.activePartIdx) {
|
if (index === this.activePartIdx) {
|
||||||
this.activePartIdx = null;
|
this.activePartIdx = null;
|
||||||
|
@ -118,22 +118,22 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private replacePart(index: number, part: Part) {
|
private replacePart(index: number, part: Part): void {
|
||||||
this._parts.splice(index, 1, part);
|
this._parts.splice(index, 1, part);
|
||||||
}
|
}
|
||||||
|
|
||||||
get parts() {
|
public get parts(): Part[] {
|
||||||
return this._parts;
|
return this._parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
get autoComplete() {
|
public get autoComplete(): AutocompleteWrapperModel {
|
||||||
if (this.activePartIdx === this.autoCompletePartIdx) {
|
if (this.activePartIdx === this.autoCompletePartIdx) {
|
||||||
return this._autoComplete;
|
return this._autoComplete;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPositionAtEnd() {
|
public getPositionAtEnd(): DocumentPosition {
|
||||||
if (this._parts.length) {
|
if (this._parts.length) {
|
||||||
const index = this._parts.length - 1;
|
const index = this._parts.length - 1;
|
||||||
const part = this._parts[index];
|
const part = this._parts[index];
|
||||||
|
@ -144,11 +144,11 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serializeParts() {
|
public serializeParts(): SerializedPart[] {
|
||||||
return this._parts.map(p => p.serialize());
|
return this._parts.map(p => p.serialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
private diff(newValue: string, inputType: string, caret: DocumentOffset) {
|
private diff(newValue: string, inputType: string, caret: DocumentOffset): IDiff {
|
||||||
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
|
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
|
||||||
// can't use caret position with drag and drop
|
// can't use caret position with drag and drop
|
||||||
if (inputType === "deleteByDrag") {
|
if (inputType === "deleteByDrag") {
|
||||||
|
@ -158,7 +158,7 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string) {
|
public reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string): void {
|
||||||
this._parts = serializedParts.map(p => this._partCreator.deserializePart(p));
|
this._parts = serializedParts.map(p => this._partCreator.deserializePart(p));
|
||||||
if (!caret) {
|
if (!caret) {
|
||||||
caret = this.getPositionAtEnd();
|
caret = this.getPositionAtEnd();
|
||||||
|
@ -180,7 +180,7 @@ export default class EditorModel {
|
||||||
* @param {DocumentPosition} position the position to start inserting at
|
* @param {DocumentPosition} position the position to start inserting at
|
||||||
* @return {Number} the amount of characters added
|
* @return {Number} the amount of characters added
|
||||||
*/
|
*/
|
||||||
insert(parts: Part[], position: IPosition) {
|
public insert(parts: Part[], position: IPosition): number {
|
||||||
const insertIndex = this.splitAt(position);
|
const insertIndex = this.splitAt(position);
|
||||||
let newTextLength = 0;
|
let newTextLength = 0;
|
||||||
for (let i = 0; i < parts.length; ++i) {
|
for (let i = 0; i < parts.length; ++i) {
|
||||||
|
@ -191,7 +191,7 @@ export default class EditorModel {
|
||||||
return newTextLength;
|
return newTextLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(newValue: string, inputType: string, caret: DocumentOffset) {
|
public update(newValue: string, inputType: string, caret: DocumentOffset): Promise<void> {
|
||||||
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);
|
||||||
let removedOffsetDecrease = 0;
|
let removedOffsetDecrease = 0;
|
||||||
|
@ -220,7 +220,7 @@ export default class EditorModel {
|
||||||
return Number.isFinite(result) ? result as number : 0;
|
return Number.isFinite(result) ? result as number : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean) {
|
private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean): Promise<void> {
|
||||||
const { index } = pos;
|
const { index } = pos;
|
||||||
const part = this._parts[index];
|
const part = this._parts[index];
|
||||||
if (part) {
|
if (part) {
|
||||||
|
@ -250,7 +250,7 @@ export default class EditorModel {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onAutoComplete = ({ replaceParts, close }: ICallback) => {
|
private onAutoComplete = ({ replaceParts, close }: ICallback): void => {
|
||||||
let pos;
|
let pos;
|
||||||
if (replaceParts) {
|
if (replaceParts) {
|
||||||
this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
|
this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
|
||||||
|
@ -270,7 +270,7 @@ export default class EditorModel {
|
||||||
this.updateCallback(pos);
|
this.updateCallback(pos);
|
||||||
};
|
};
|
||||||
|
|
||||||
private mergeAdjacentParts() {
|
private mergeAdjacentParts(): void {
|
||||||
let prevPart;
|
let prevPart;
|
||||||
for (let i = 0; i < this._parts.length; ++i) {
|
for (let i = 0; i < this._parts.length; ++i) {
|
||||||
let part = this._parts[i];
|
let part = this._parts[i];
|
||||||
|
@ -294,7 +294,7 @@ export default class EditorModel {
|
||||||
* @return {Number} how many characters before pos were also removed,
|
* @return {Number} how many characters before pos were also removed,
|
||||||
* usually because of non-editable parts that can only be removed in their entirety.
|
* usually because of non-editable parts that can only be removed in their entirety.
|
||||||
*/
|
*/
|
||||||
removeText(pos: IPosition, len: number) {
|
public removeText(pos: IPosition, len: number): number {
|
||||||
let { index, offset } = pos;
|
let { index, offset } = pos;
|
||||||
let removedOffsetDecrease = 0;
|
let removedOffsetDecrease = 0;
|
||||||
while (len > 0) {
|
while (len > 0) {
|
||||||
|
@ -329,7 +329,7 @@ export default class EditorModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
// return part index where insertion will insert between at offset
|
// return part index where insertion will insert between at offset
|
||||||
private splitAt(pos: IPosition) {
|
private splitAt(pos: IPosition): number {
|
||||||
if (pos.index === -1) {
|
if (pos.index === -1) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
@ -356,7 +356,7 @@ export default class EditorModel {
|
||||||
* @return {Number} how far from position (in characters) the insertion ended.
|
* @return {Number} how far from position (in characters) the insertion ended.
|
||||||
* This can be more than the length of `str` when crossing non-editable parts, which are skipped.
|
* This can be more than the length of `str` when crossing non-editable parts, which are skipped.
|
||||||
*/
|
*/
|
||||||
private addText(pos: IPosition, str: string, inputType: string) {
|
private addText(pos: IPosition, str: string, inputType: string): number {
|
||||||
let { index } = pos;
|
let { index } = pos;
|
||||||
const { offset } = pos;
|
const { offset } = pos;
|
||||||
let addLen = str.length;
|
let addLen = str.length;
|
||||||
|
@ -390,7 +390,7 @@ export default class EditorModel {
|
||||||
return addLen;
|
return addLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
positionForOffset(totalOffset: number, atPartEnd = false) {
|
public positionForOffset(totalOffset: number, atPartEnd = false): DocumentPosition {
|
||||||
let currentOffset = 0;
|
let currentOffset = 0;
|
||||||
const index = this._parts.findIndex(part => {
|
const index = this._parts.findIndex(part => {
|
||||||
const partLen = part.text.length;
|
const partLen = part.text.length;
|
||||||
|
@ -416,11 +416,11 @@ export default class EditorModel {
|
||||||
* @param {DocumentPosition?} positionB the other boundary of the range, optional
|
* @param {DocumentPosition?} positionB the other boundary of the range, optional
|
||||||
* @return {Range}
|
* @return {Range}
|
||||||
*/
|
*/
|
||||||
startRange(positionA: DocumentPosition, positionB = positionA) {
|
public startRange(positionA: DocumentPosition, positionB = positionA): Range {
|
||||||
return new Range(this, positionA, positionB);
|
return new Range(this, positionA, positionB);
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]) {
|
public replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]): void {
|
||||||
// convert end position to offset, so it is independent of how the document is split into parts
|
// convert end position to offset, so it is independent of how the document is split into parts
|
||||||
// which we'll change when splitting up at the start position
|
// which we'll change when splitting up at the start position
|
||||||
const endOffset = endPosition.asOffset(this);
|
const endOffset = endPosition.asOffset(this);
|
||||||
|
@ -445,9 +445,9 @@ export default class EditorModel {
|
||||||
* @param {ManualTransformCallback} callback to run the transformations in
|
* @param {ManualTransformCallback} callback to run the transformations in
|
||||||
* @return {Promise} a promise when auto-complete (if applicable) is done updating
|
* @return {Promise} a promise when auto-complete (if applicable) is done updating
|
||||||
*/
|
*/
|
||||||
transform(callback: ManualTransformCallback) {
|
public transform(callback: ManualTransformCallback): Promise<void> {
|
||||||
const pos = callback();
|
const pos = callback();
|
||||||
let acPromise = null;
|
let acPromise: Promise<void> = null;
|
||||||
if (!(pos instanceof Range)) {
|
if (!(pos instanceof Range)) {
|
||||||
acPromise = this.setActivePart(pos, true);
|
acPromise = this.setActivePart(pos, true);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -552,7 +552,7 @@ export class PartCreator {
|
||||||
// part creator that support auto complete for /commands,
|
// part creator that support auto complete for /commands,
|
||||||
// used in SendMessageComposer
|
// used in SendMessageComposer
|
||||||
export class CommandPartCreator extends PartCreator {
|
export class CommandPartCreator extends PartCreator {
|
||||||
createPartForInput(text: string, partIndex: number) {
|
public createPartForInput(text: string, partIndex: number): Part {
|
||||||
// at beginning and starts with /? create
|
// at beginning and starts with /? create
|
||||||
if (partIndex === 0 && text[0] === "/") {
|
if (partIndex === 0 && text[0] === "/") {
|
||||||
// text will be inserted by model, so pass empty string
|
// text will be inserted by model, so pass empty string
|
||||||
|
@ -562,11 +562,11 @@ export class CommandPartCreator extends PartCreator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
command(text: string) {
|
public command(text: string): CommandPart {
|
||||||
return new CommandPart(text, this.autoCompleteCreator);
|
return new CommandPart(text, this.autoCompleteCreator);
|
||||||
}
|
}
|
||||||
|
|
||||||
deserializePart(part: Part): Part {
|
public deserializePart(part: SerializedPart): Part {
|
||||||
if (part.type === "command") {
|
if (part.type === "command") {
|
||||||
return this.command(part.text);
|
return this.command(part.text);
|
||||||
} else {
|
} else {
|
||||||
|
@ -576,7 +576,7 @@ export class CommandPartCreator extends PartCreator {
|
||||||
}
|
}
|
||||||
|
|
||||||
class CommandPart extends PillCandidatePart {
|
class CommandPart extends PillCandidatePart {
|
||||||
get type(): IPillCandidatePart["type"] {
|
public get type(): IPillCandidatePart["type"] {
|
||||||
return Type.Command;
|
return Type.Command;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
|
||||||
import { SerializedPart } from "../editor/parts";
|
import { SerializedPart } from "../editor/parts";
|
||||||
import { Caret } from "../editor/caret";
|
import DocumentOffset from "../editor/offset";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used while editing, to pass the event, and to preserve editor state
|
* Used while editing, to pass the event, and to preserve editor state
|
||||||
|
@ -26,28 +26,28 @@ import { Caret } from "../editor/caret";
|
||||||
*/
|
*/
|
||||||
export default class EditorStateTransfer {
|
export default class EditorStateTransfer {
|
||||||
private serializedParts: SerializedPart[] = null;
|
private serializedParts: SerializedPart[] = null;
|
||||||
private caret: Caret = null;
|
private caret: DocumentOffset = null;
|
||||||
|
|
||||||
constructor(private readonly event: MatrixEvent) {}
|
constructor(private readonly event: MatrixEvent) {}
|
||||||
|
|
||||||
public setEditorState(caret: Caret, serializedParts: SerializedPart[]) {
|
public setEditorState(caret: DocumentOffset, serializedParts: SerializedPart[]) {
|
||||||
this.caret = caret;
|
this.caret = caret;
|
||||||
this.serializedParts = serializedParts;
|
this.serializedParts = serializedParts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasEditorState() {
|
public hasEditorState(): boolean {
|
||||||
return !!this.serializedParts;
|
return !!this.serializedParts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSerializedParts() {
|
public getSerializedParts(): SerializedPart[] {
|
||||||
return this.serializedParts;
|
return this.serializedParts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCaret() {
|
public getCaret(): DocumentOffset {
|
||||||
return this.caret;
|
return this.caret;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getEvent() {
|
public getEvent(): MatrixEvent {
|
||||||
return this.event;
|
return this.event;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,9 +16,11 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
||||||
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
|
||||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
|
||||||
import Pill from "../components/views/elements/Pill";
|
import Pill from "../components/views/elements/Pill";
|
||||||
import { parseAppLocalLink } from "./permalinks/Permalinks";
|
import { parseAppLocalLink } from "./permalinks/Permalinks";
|
||||||
|
|
||||||
|
@ -27,15 +29,15 @@ import { parseAppLocalLink } from "./permalinks/Permalinks";
|
||||||
* into pills based on the context of a given room. Returns a list of
|
* into pills based on the context of a given room. Returns a list of
|
||||||
* the resulting React nodes so they can be unmounted rather than leaking.
|
* the resulting React nodes so they can be unmounted rather than leaking.
|
||||||
*
|
*
|
||||||
* @param {Node[]} nodes - a list of sibling DOM nodes to traverse to try
|
* @param {Element[]} nodes - a list of sibling DOM nodes to traverse to try
|
||||||
* to turn into pills.
|
* to turn into pills.
|
||||||
* @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are
|
* @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are
|
||||||
* part of representing.
|
* part of representing.
|
||||||
* @param {Node[]} pills: an accumulator of the DOM nodes which contain
|
* @param {Element[]} pills: an accumulator of the DOM nodes which contain
|
||||||
* React components which have been mounted as part of this.
|
* React components which have been mounted as part of this.
|
||||||
* The initial caller should pass in an empty array to seed the accumulator.
|
* The initial caller should pass in an empty array to seed the accumulator.
|
||||||
*/
|
*/
|
||||||
export function pillifyLinks(nodes, mxEvent, pills) {
|
export function pillifyLinks(nodes: ArrayLike<Element>, mxEvent: MatrixEvent, pills: Element[]) {
|
||||||
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
|
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
|
||||||
const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
|
const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
|
||||||
let node = nodes[0];
|
let node = nodes[0];
|
||||||
|
@ -73,7 +75,7 @@ export function pillifyLinks(nodes, mxEvent, pills) {
|
||||||
// to clear the pills from the last run of pillifyLinks
|
// to clear the pills from the last run of pillifyLinks
|
||||||
!node.parentElement.classList.contains("mx_AtRoomPill")
|
!node.parentElement.classList.contains("mx_AtRoomPill")
|
||||||
) {
|
) {
|
||||||
let currentTextNode = node;
|
let currentTextNode = node as Node as Text;
|
||||||
const roomNotifTextNodes = [];
|
const roomNotifTextNodes = [];
|
||||||
|
|
||||||
// Take a textNode and break it up to make all the instances of @room their
|
// Take a textNode and break it up to make all the instances of @room their
|
||||||
|
@ -125,10 +127,10 @@ export function pillifyLinks(nodes, mxEvent, pills) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.childNodes && node.childNodes.length && !pillified) {
|
if (node.childNodes && node.childNodes.length && !pillified) {
|
||||||
pillifyLinks(node.childNodes, mxEvent, pills);
|
pillifyLinks(node.childNodes as NodeListOf<Element>, mxEvent, pills);
|
||||||
}
|
}
|
||||||
|
|
||||||
node = node.nextSibling;
|
node = node.nextSibling as Element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,10 +142,10 @@ export function pillifyLinks(nodes, mxEvent, pills) {
|
||||||
* emitter on BaseAvatar as per
|
* emitter on BaseAvatar as per
|
||||||
* https://github.com/vector-im/element-web/issues/12417
|
* https://github.com/vector-im/element-web/issues/12417
|
||||||
*
|
*
|
||||||
* @param {Node[]} pills - array of pill containers whose React
|
* @param {Element[]} pills - array of pill containers whose React
|
||||||
* components should be unmounted.
|
* components should be unmounted.
|
||||||
*/
|
*/
|
||||||
export function unmountPills(pills) {
|
export function unmountPills(pills: Element[]) {
|
||||||
for (const pillContainer of pills) {
|
for (const pillContainer of pills) {
|
||||||
ReactDOM.unmountComponentAtNode(pillContainer);
|
ReactDOM.unmountComponentAtNode(pillContainer);
|
||||||
}
|
}
|
|
@ -147,7 +147,7 @@ describe('<SendMessageComposer/>', () => {
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
});
|
});
|
||||||
|
|
||||||
const key = wrapper.find(SendMessageComposer).instance()._editorStateKey;
|
const key = wrapper.find(SendMessageComposer).instance().editorStateKey;
|
||||||
|
|
||||||
expect(wrapper.text()).toBe("Test Text");
|
expect(wrapper.text()).toBe("Test Text");
|
||||||
expect(localStorage.getItem(key)).toBeNull();
|
expect(localStorage.getItem(key)).toBeNull();
|
||||||
|
@ -188,7 +188,7 @@ describe('<SendMessageComposer/>', () => {
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
});
|
});
|
||||||
|
|
||||||
const key = wrapper.find(SendMessageComposer).instance()._editorStateKey;
|
const key = wrapper.find(SendMessageComposer).instance().editorStateKey;
|
||||||
|
|
||||||
expect(wrapper.text()).toBe("Hello World");
|
expect(wrapper.text()).toBe("Hello World");
|
||||||
expect(localStorage.getItem(key)).toBeNull();
|
expect(localStorage.getItem(key)).toBeNull();
|
||||||
|
|
Loading…
Reference in a new issue