Typescript conversion of Composer components and more

This commit is contained in:
Michael Telatynski 2021-06-30 13:01:26 +01:00
parent 29904a7ffc
commit e768ecb3d0
15 changed files with 492 additions and 444 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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