Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/ts/10

This commit is contained in:
Michael Telatynski 2021-07-01 21:01:06 +01:00
commit c51e62a62b
33 changed files with 627 additions and 738 deletions

View file

@ -4,7 +4,6 @@ src/Markdown.js
src/NodeAnimator.js
src/components/structures/RoomDirectory.js
src/components/views/rooms/MemberList.js
src/ratelimitedfunc.js
src/utils/DMRoomMap.js
src/utils/MultiInviter.js
test/components/structures/MessagePanel-test.js

View file

@ -48,6 +48,7 @@ limitations under the License.
.mx_cryptoEvent_buttons {
align-items: center;
display: flex;
gap: 5px;
}
.mx_cryptoEvent_state {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import { randomString } from "matrix-js-sdk/src/randomstring";
import { IContent } from "matrix-js-sdk/src/models/event";
import { getCurrentLanguage } from './languageHandler';
import PlatformPeg from './PlatformPeg';
@ -868,7 +869,7 @@ export default class CountlyAnalytics {
roomId: string,
isEdit: boolean,
isReply: boolean,
content: {format?: string, msgtype: string},
content: IContent,
) {
if (this.disabled) return;
const cli = MatrixClientPeg.get();

View file

@ -358,11 +358,11 @@ interface IOpts {
stripReplyFallback?: boolean;
returnString?: boolean;
forComposerQuote?: boolean;
ref?: React.Ref<any>;
ref?: React.Ref<HTMLSpanElement>;
}
export interface IOptsReturnNode extends IOpts {
returnString: false;
returnString: false | undefined;
}
export interface IOptsReturnString extends IOpts {
@ -403,9 +403,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
try {
if (highlights && highlights.length > 0) {
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
const safeHighlights = highlights.map(function(highlight) {
return sanitizeHtml(highlight, sanitizeParams);
});
const safeHighlights = highlights
// sanitizeHtml can hang if an unclosed HTML tag is thrown at it
// A search for `<foo` will make the browser crash
// an alternative would be to escape HTML special characters
// but that would bring no additional benefit as the highlighter
// does not work with those special chars
.filter((highlight: string): boolean => !highlight.includes("<"))
.map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams));
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
sanitizeParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join('');

View file

@ -1181,7 +1181,7 @@ export const Commands = [
];
// 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 => {
CommandMap.set(cmd.command, cmd);
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
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/') return {}; // not a command
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
let cmd;
let args;
let cmd: string;
let args: string;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[2];
@ -1208,6 +1208,11 @@ export function parseCommandString(input: string) {
return { cmd, args };
}
interface ICmd {
cmd?: Command;
args?: string;
}
/**
* 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.
@ -1216,7 +1221,7 @@ export function parseCommandString(input: string) {
* processing the command, or 'promise' if a request was sent out.
* 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);
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {

View file

@ -23,7 +23,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import dis from '../../dispatcher/dispatcher';
import RateLimitedFunc from '../../ratelimitedfunc';
import GroupStore from '../../stores/GroupStore';
import {
RIGHT_PANEL_PHASES_NO_ARGS,
@ -48,6 +47,7 @@ import FilePanel from "./FilePanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { throttle } from 'lodash';
interface IProps {
room?: Room; // if showing panels for a given room, this is set
@ -73,7 +73,6 @@ interface IState {
export default class RightPanel extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private readonly delayedUpdate: RateLimitedFunc;
private dispatcherRef: string;
constructor(props, context) {
@ -84,12 +83,12 @@ export default class RightPanel extends React.Component<IProps, IState> {
isUserPrivilegedInGroup: null,
member: this.getUserForPanel(),
};
this.delayedUpdate = new RateLimitedFunc(() => {
this.forceUpdate();
}, 500);
}
private readonly delayedUpdate = throttle((): void => {
this.forceUpdate();
}, 500, { leading: true, trailing: true });
// Helper function to split out the logic for getPhaseFromProps() and the constructor
// as both are called at the same time in the constructor.
private getUserForPanel() {

View file

@ -37,7 +37,6 @@ import Modal from '../../Modal';
import * as sdk from '../../index';
import CallHandler, { PlaceCallType } from '../../CallHandler';
import dis from '../../dispatcher/dispatcher';
import rateLimitedFunc from '../../ratelimitedfunc';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
import MainSplit from './MainSplit';
@ -82,6 +81,7 @@ import { IOpts } from "../../createRoom";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
import EditorStateTransfer from "../../utils/EditorStateTransfer";
import { throttle } from "lodash";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -675,8 +675,8 @@ export default class RoomView extends React.Component<IProps, IState> {
);
}
// cancel any pending calls to the rate_limited_funcs
this.updateRoomMembers.cancelPendingCall();
// cancel any pending calls to the throttled updated
this.updateRoomMembers.cancel();
for (const watcher of this.settingWatchers) {
SettingsStore.unwatchSetting(watcher);
@ -1092,7 +1092,7 @@ export default class RoomView extends React.Component<IProps, IState> {
return;
}
this.updateRoomMembers(member);
this.updateRoomMembers();
};
private onMyMembership = (room: Room, membership: string, oldMembership: string) => {
@ -1114,10 +1114,10 @@ export default class RoomView extends React.Component<IProps, IState> {
}
// rate limited because a power level change will emit an event for every member in the room.
private updateRoomMembers = rateLimitedFunc(() => {
private updateRoomMembers = throttle(() => {
this.updateDMState();
this.updateE2EStatus(this.state.room);
}, 500);
}, 500, { leading: true, trailing: true });
private checkDesktopNotifications() {
const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount();

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,33 +15,43 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
import * as sdk from '../../../index';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ISecretStorageKeyInfo } from 'matrix-js-sdk';
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton from '../../views/elements/AccessibleButton';
import Spinner from '../../views/elements/Spinner';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
function keyHasPassphrase(keyInfo) {
return (
function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
return Boolean(
keyInfo.passphrase &&
keyInfo.passphrase.salt &&
keyInfo.passphrase.iterations
keyInfo.passphrase.iterations,
);
}
@replaceableComponent("structures.auth.SetupEncryptionBody")
export default class SetupEncryptionBody extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
interface IProps {
onFinished: (boolean) => void;
}
constructor() {
super();
interface IState {
phase: Phase;
verificationRequest: VerificationRequest;
backupInfo: IKeyBackupInfo;
}
@replaceableComponent("structures.auth.SetupEncryptionBody")
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
constructor(props) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate);
store.on("update", this.onStoreUpdate);
store.start();
this.state = {
phase: store.phase,
@ -53,10 +63,10 @@ export default class SetupEncryptionBody extends React.Component {
};
}
_onStoreUpdate = () => {
private onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === Phase.Finished) {
this.props.onFinished();
this.props.onFinished(true);
return;
}
this.setState({
@ -66,18 +76,18 @@ export default class SetupEncryptionBody extends React.Component {
});
};
componentWillUnmount() {
public componentWillUnmount() {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate);
store.off("update", this.onStoreUpdate);
store.stop();
}
_onUsePassphraseClick = async () => {
private onUsePassphraseClick = async () => {
const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase();
}
};
_onVerifyClick = () => {
private onVerifyClick = () => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const requestPromise = cli.requestVerification(userId);
@ -91,42 +101,44 @@ export default class SetupEncryptionBody extends React.Component {
request.cancel();
},
});
}
};
onSkipClick = () => {
private onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
}
};
onSkipConfirmClick = () => {
private onSkipConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm();
}
};
onSkipBackClick = () => {
private onSkipBackClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterSkip();
}
};
onDoneClick = () => {
private onDoneClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
}
};
render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
private onEncryptionPanelClose = () => {
this.props.onFinished(false);
};
public render() {
const {
phase,
} = this.state;
if (this.state.verificationRequest) {
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
return <EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.props.onFinished}
onClose={this.onEncryptionPanelClose}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
isRoomEncrypted={false}
/>;
} else if (phase === Phase.Intro) {
const store = SetupEncryptionStore.sharedInstance();
@ -139,14 +151,14 @@ export default class SetupEncryptionBody extends React.Component {
let useRecoveryKeyButton;
if (recoveryKeyPrompt) {
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this._onUsePassphraseClick}>
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}>
{recoveryKeyPrompt}
</AccessibleButton>;
}
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = <AccessibleButton kind="primary" onClick={this._onVerifyClick}>
verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{ _t("Use another login") }
</AccessibleButton>;
}
@ -217,7 +229,6 @@ export default class SetupEncryptionBody extends React.Component {
</div>
);
} else if (phase === Phase.Busy || phase === Phase.Loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <Spinner />;
} else {
console.log(`SetupEncryptionBody: Unknown phase ${phase}`);

View file

@ -1,121 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import { replaceableComponent } from '../../../utils/replaceableComponent';
import VerificationRequestDialog from './VerificationRequestDialog';
import BaseDialog from './BaseDialog';
import DialogButtons from '../elements/DialogButtons';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
@replaceableComponent("views.dialogs.NewSessionReviewDialog")
export default class NewSessionReviewDialog extends React.PureComponent {
static propTypes = {
userId: PropTypes.string.isRequired,
device: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
}
onCancelClick = () => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, {
headerImage: require("../../../../res/img/e2e/warning.svg"),
title: _t("Your account is not secure"),
description: <div>
{_t("One of the following may be compromised:")}
<ul>
<li>{_t("Your password")}</li>
<li>{_t("Your homeserver")}</li>
<li>{_t("This session, or the other session")}</li>
<li>{_t("The internet connection either session is using")}</li>
</ul>
<div>
{_t("We recommend you change your password and Security Key in Settings immediately")}
</div>
</div>,
onFinished: () => this.props.onFinished(false),
});
}
onContinueClick = () => {
const { userId, device } = this.props;
const cli = MatrixClientPeg.get();
const requestPromise = cli.requestVerification(
userId,
[device.deviceId],
);
this.props.onFinished(true);
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
onFinished: async () => {
const request = await requestPromise;
request.cancel();
},
});
}
render() {
const { device } = this.props;
const icon = <span className="mx_NewSessionReviewDialog_headerIcon mx_E2EIcon_warning"></span>;
const titleText = _t("New session");
const title = <h2 className="mx_NewSessionReviewDialog_header">
{icon}
{titleText}
</h2>;
return (
<BaseDialog
title={title}
onFinished={this.props.onFinished}
>
<div className="mx_NewSessionReviewDialog_body">
<p>{_t(
"Use this session to verify your new one, " +
"granting it access to encrypted messages:",
)}</p>
<div className="mx_NewSessionReviewDialog_deviceInfo">
<div>
<span className="mx_NewSessionReviewDialog_deviceName">
{device.getDisplayName()}
</span> <span className="mx_NewSessionReviewDialog_deviceID">
({device.deviceId})
</span>
</div>
</div>
<p>{_t(
"If you didnt sign in to this session, " +
"your account may be compromised.",
)}</p>
<DialogButtons
cancelButton={_t("This wasn't me")}
cancelButtonClass="danger"
primaryButton={_t("Continue")}
onCancel={this.onCancelClick}
onPrimaryButtonClick={this.onContinueClick}
/>
</div>
</BaseDialog>
);
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,27 +15,33 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import BaseDialog from "./BaseDialog";
import EncryptionPanel from "../right_panel/EncryptionPanel";
import { User } from 'matrix-js-sdk';
interface IProps {
verificationRequest: VerificationRequest;
verificationRequestPromise: Promise<VerificationRequest>;
onFinished: () => void;
member: User;
}
interface IState {
verificationRequest: VerificationRequest;
}
@replaceableComponent("views.dialogs.VerificationRequestDialog")
export default class VerificationRequestDialog extends React.Component {
static propTypes = {
verificationRequest: PropTypes.object,
verificationRequestPromise: PropTypes.object,
onFinished: PropTypes.func.isRequired,
member: PropTypes.string,
};
constructor(...args) {
super(...args);
this.state = {};
if (this.props.verificationRequest) {
this.state.verificationRequest = this.props.verificationRequest;
} else if (this.props.verificationRequestPromise) {
export default class VerificationRequestDialog extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
verificationRequest: this.props.verificationRequest,
};
if (this.props.verificationRequestPromise) {
this.props.verificationRequestPromise.then(r => {
this.setState({ verificationRequest: r });
});
@ -43,8 +49,6 @@ export default class VerificationRequestDialog extends React.Component {
}
render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
const request = this.state.verificationRequest;
const otherUserId = request && request.otherUserId;
const member = this.props.member ||
@ -65,6 +69,7 @@ export default class VerificationRequestDialog extends React.Component {
verificationRequestPromise={this.props.verificationRequestPromise}
onClose={this.props.onFinished}
member={member}
isRoomEncrypted={false}
/>
</BaseDialog>;
}

View file

@ -154,7 +154,7 @@ export default class MKeyVerificationRequest extends React.Component<IProps> {
<AccessibleButton kind="danger" onClick={this.onRejectClicked}>
{_t("Decline")}
</AccessibleButton>
<AccessibleButton onClick={this.onAcceptClicked}>
<AccessibleButton kind="primary" onClick={this.onAcceptClicked}>
{_t("Accept")}
</AccessibleButton>
</div>);

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,134 +14,151 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import React, { createRef, SyntheticEvent } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import highlight from 'highlight.js';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MsgType } from "matrix-js-sdk/src/@types/event";
import * as HtmlUtils from '../../../HtmlUtils';
import { formatDate } from '../../../DateUtils';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import * as ContextMenu from '../../structures/ContextMenu';
import { toRightOf } from '../../structures/ContextMenu';
import SettingsStore from "../../../settings/SettingsStore";
import ReplyThread from "../elements/ReplyThread";
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import { isPermalinkHost } from "../../../utils/permalinks/Permalinks";
import { toRightOf } from "../../structures/ContextMenu";
import { copyPlaintext } from "../../../utils/strings";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
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")
export default class TextualBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
export default class TextualBody extends React.Component<IProps, IState> {
private readonly contentRef = createRef<HTMLSpanElement>();
/* a list of words to highlight */
highlights: PropTypes.array,
/* 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,
};
private unmounted = false;
private pills: Element[] = [];
constructor(props) {
super(props);
this._content = createRef();
this.state = {
// the URLs (if any) to be previewed with a LinkPreviewWidget
// inside this TextualBody.
links: [],
// track whether the preview widget is hidden
widgetHidden: false,
};
}
componentDidMount() {
this._unmounted = false;
this._pills = [];
if (!this.props.editState) {
this._applyFormatting();
this.applyFormatting();
}
}
_applyFormatting() {
private applyFormatting(): void {
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
// 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.
pillifyLinks([this._content.current], this.props.mxEvent, this._pills);
HtmlUtils.linkifyElement(this._content.current);
pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills);
HtmlUtils.linkifyElement(this.contentRef.current);
this.calculateUrlPreview();
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
// 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) {
for (let i = 0; i < pres.length; i++) {
// If there already is a div wrapping the codeblock we want to skip this.
// 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
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
// when the <pre> overflows and is scrolled horizontally.
const div = this._wrapInDiv(pres[i]);
this._handleCodeBlockExpansion(pres[i]);
this._addCodeExpansionButton(div, pres[i]);
this._addCodeCopyButton(div);
const div = this.wrapInDiv(pres[i]);
this.handleCodeBlockExpansion(pres[i]);
this.addCodeExpansionButton(div, pres[i]);
this.addCodeCopyButton(div);
if (showLineNumbers) {
this._addLineNumbers(pres[i]);
this.addLineNumbers(pres[i]);
}
}
}
// Highlight code
const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
const codes = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("code");
if (codes.length > 0) {
// Do this asynchronously: parsing code takes time and we don't
// need to block the DOM update on it.
setTimeout(() => {
if (this._unmounted) return;
if (this.unmounted) return;
for (let i = 0; i < codes.length; i++) {
// If the code already has the hljs class we want to skip this.
// This happens after the codeblock was edited.
if (codes[i].className.includes("hljs")) continue;
this._highlightCode(codes[i]);
this.highlightCode(codes[i]);
}
}, 10);
}
}
}
_addCodeElement(pre) {
private addCodeElement(pre: HTMLPreElement): void {
const code = document.createElement("code");
code.append(...pre.childNodes);
pre.appendChild(code);
}
_addCodeExpansionButton(div, pre) {
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
// Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
@ -175,7 +190,7 @@ export default class TextualBody extends React.Component {
div.appendChild(button);
}
_addCodeCopyButton(div) {
private addCodeCopyButton(div: HTMLDivElement): void {
const button = document.createElement("span");
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";
button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("code")[0];
const copyCode = button.parentElement.getElementsByTagName("code")[0];
const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
@ -200,7 +214,7 @@ export default class TextualBody extends React.Component {
div.appendChild(button);
}
_wrapInDiv(pre) {
private wrapInDiv(pre: HTMLPreElement): HTMLDivElement {
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
@ -212,13 +226,13 @@ export default class TextualBody extends React.Component {
return div;
}
_handleCodeBlockExpansion(pre) {
private handleCodeBlockExpansion(pre: HTMLPreElement): void {
if (!SettingsStore.getValue("expandCodeByDefault")) {
pre.className = "mx_EventTile_collapsedCodeBlock";
}
}
_addLineNumbers(pre) {
private addLineNumbers(pre: HTMLPreElement): void {
// Calculate number of lines in pre
const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
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")) {
highlight.highlightBlock(code);
} else {
@ -249,14 +263,14 @@ export default class TextualBody extends React.Component {
const stoppedEditing = prevProps.editState && !this.props.editState;
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
if (messageWasEdited || stoppedEditing) {
this._applyFormatting();
this.applyFormatting();
}
}
}
componentWillUnmount() {
this._unmounted = true;
unmountPills(this._pills);
this.unmounted = true;
unmountPills(this.pills);
}
shouldComponentUpdate(nextProps, nextState) {
@ -273,12 +287,12 @@ export default class TextualBody extends React.Component {
nextState.widgetHidden !== this.state.widgetHidden);
}
calculateUrlPreview() {
private calculateUrlPreview(): void {
//console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
if (this.props.showUrlPreview) {
// 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) {
// de-duplicate the links after stripping hashes as they don't affect the preview
// using a set here maintains the order
@ -291,8 +305,8 @@ export default class TextualBody extends React.Component {
this.setState({ links });
// lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) {
const hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
if (window.localStorage) {
const hidden = !!window.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
this.setState({ widgetHidden: hidden });
}
} 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];
while (node) {
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
const spoilerContainer = document.createElement('span');
const reason = node.getAttribute("data-mx-spoiler");
const Spoiler = sdk.getComponent('elements.Spoiler');
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
const spoiler = <Spoiler
reason={reason}
contentHtml={node.outerHTML}
/>;
const spoiler = <Spoiler reason={reason} contentHtml={node.outerHTML} />;
ReactDOM.render(spoiler, spoilerContainer);
node.parentNode.replaceChild(spoilerContainer, node);
@ -322,15 +332,15 @@ export default class TextualBody extends React.Component {
}
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) {
let links = [];
private findLinks(nodes: ArrayLike<Element>): string[] {
let links: string[] = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
@ -348,7 +358,7 @@ export default class TextualBody extends React.Component {
return links;
}
isLinkPreviewable(node) {
private isLinkPreviewable(node: Element): boolean {
// don't try to preview relative links
if (!node.getAttribute("href").startsWith("http://") &&
!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 });
// FIXME: persist this somewhere smarter than local storage
if (global.localStorage) {
@ -390,7 +400,7 @@ export default class TextualBody extends React.Component {
this.forceUpdate();
};
onEmoteSenderClick = event => {
private onEmoteSenderClick = (): void => {
const mxEvent = this.props.mxEvent;
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
@ -398,7 +408,7 @@ export default class TextualBody extends React.Component {
});
};
getEventTileOps = () => ({
public getEventTileOps = () => ({
isWidgetHidden: () => {
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();
// 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
@ -431,7 +441,6 @@ export default class TextualBody extends React.Component {
const scalarClient = integrationManager.getScalarClient();
scalarClient.connect().then(() => {
const completeUrl = scalarClient.getStarterLink(starterLink);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const integrationsUrl = integrationManager.uiUrl;
Modal.createTrackedDialog('Add an integration', '', QuestionDialog, {
title: _t("Add an Integration"),
@ -458,12 +467,11 @@ export default class TextualBody extends React.Component {
});
};
_openHistoryDialog = async () => {
const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
private openHistoryDialog = async (): Promise<void> => {
Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
};
_renderEditedMarker() {
private renderEditedMarker() {
const date = this.props.mxEvent.replacingEventDate();
const dateString = date && formatDate(date);
@ -479,7 +487,7 @@ export default class TextualBody extends React.Component {
return (
<AccessibleTooltipButton
className="mx_EventTile_edited"
onClick={this._openHistoryDialog}
onClick={this.openHistoryDialog}
title={_t("Edited at %(date)s. Click to view edits.", { date: dateString })}
tooltip={tooltip}
>
@ -490,24 +498,25 @@ export default class TextualBody extends React.Component {
render() {
if (this.props.editState) {
const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer');
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
}
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
// 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, {
disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
disableBigEmoji: content.msgtype === MsgType.Emote
|| !SettingsStore.getValue<boolean>('TextualBody.enableBigEmoji'),
// Part of Replies fallback support
stripReplyFallback: stripReply,
ref: this._content,
ref: this.contentRef,
returnString: false,
});
if (this.props.replacingEventId) {
body = <>
{body}
{this._renderEditedMarker()}
{this.renderEditedMarker()}
</>;
}
@ -521,7 +530,6 @@ export default class TextualBody extends React.Component {
let widgets;
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget
key={link}
@ -534,7 +542,7 @@ export default class TextualBody extends React.Component {
}
switch (content.msgtype) {
case "m.emote":
case MsgType.Emote:
return (
<span className="mx_MEmoteBody mx_EventTile_content">
*&nbsp;
@ -549,7 +557,7 @@ export default class TextualBody extends React.Component {
{ widgets }
</span>
);
case "m.notice":
case MsgType.Notice:
return (
<span className="mx_MNoticeBody mx_EventTile_content">
{ body }

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,20 +15,20 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import * as TextForEvent from "../../../TextForEvent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.TextualEvent")
export default class TextualEvent extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};
interface IProps {
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.TextualEvent")
export default class TextualEvent extends React.Component<IProps> {
render() {
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 (
<div className="mx_TextualEvent">{ text }</div>
);

View file

@ -39,9 +39,8 @@ interface IProps {
member: RoomMember | User;
onClose: () => void;
verificationRequest: VerificationRequest;
verificationRequestPromise: Promise<VerificationRequest>;
verificationRequestPromise?: Promise<VerificationRequest>;
layout: string;
inDialog: boolean;
isRoomEncrypted: boolean;
}

View file

@ -21,7 +21,6 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AppsDrawer from './AppsDrawer';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { UIFeature } from "../../../settings/UIFeature";
@ -29,6 +28,7 @@ import ResizeNotifier from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom';
import { objectHasDiff } from "../../../utils/objects";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { throttle } from 'lodash';
interface IProps {
// js-sdk room object
@ -99,9 +99,9 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
}
private rateLimitedUpdate = new RateLimitedFunc(() => {
private rateLimitedUpdate = throttle(() => {
this.setState({ counters: this.computeCounters() });
}, 500);
}, 500, { leading: true, trailing: true });
private computeCounters() {
const counters = [];

View file

@ -41,7 +41,7 @@ import { Key } from "../../../Keyboard";
import { EMOTICON_TO_EMOJI } from "../../../emoji";
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
import Range from "../../../editor/range";
import MessageComposerFormatBar from "./MessageComposerFormatBar";
import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar";
import DocumentOffset from "../../../editor/offset";
import { IDiff } from "../../../editor/diff";
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;
function ctrlShortcutLabel(key) {
function ctrlShortcutLabel(key: string): string {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
}
@ -81,14 +81,6 @@ function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
a.type === b.type;
}
enum Formatting {
Bold = "bold",
Italics = "italics",
Strikethrough = "strikethrough",
Code = "code",
Quote = "quote",
}
interface IProps {
model: EditorModel;
room: Room;
@ -111,9 +103,9 @@ interface IState {
@replaceableComponent("views.rooms.BasicMessageEditor")
export default class BasicMessageEditor extends React.Component<IProps, IState> {
private editorRef = createRef<HTMLDivElement>();
public readonly editorRef = createRef<HTMLDivElement>();
private autocompleteRef = createRef<Autocomplete>();
private formatBarRef = createRef<typeof MessageComposerFormatBar>();
private formatBarRef = createRef<MessageComposerFormatBar>();
private modifiedFlag = 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 range = model.startRange(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);
if (selection) { // set the caret/selection
try {
@ -230,25 +222,25 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
};
private showPlaceholder() {
private showPlaceholder(): void {
// escape single quotes
const placeholder = this.props.placeholder.replace(/'/g, '\\\'');
this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`);
this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty");
}
private hidePlaceholder() {
private hidePlaceholder(): void {
this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty");
this.editorRef.current.style.removeProperty("--placeholder");
}
private onCompositionStart = () => {
private onCompositionStart = (): void => {
this.isIMEComposing = true;
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
this.hidePlaceholder();
};
private onCompositionEnd = () => {
private onCompositionEnd = (): void => {
this.isIMEComposing = false;
// 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.
@ -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
// emits events related to the composition after compositionend
// has been fired
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 text = selection.toString();
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");
};
private onCut = (event: ClipboardEvent) => {
private onCut = (event: ClipboardEvent): void => {
this.onCutCopy(event, "cut");
};
private onPaste = (event: ClipboardEvent<HTMLDivElement>) => {
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
event.preventDefault(); // we always handle the paste ourselves
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
// 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);
};
private onInput = (event: Partial<InputEvent>) => {
private onInput = (event: Partial<InputEvent>): void => {
// ignore any input while doing IME compositions
if (this.isIMEComposing) {
return;
@ -339,7 +331,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
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 { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel);
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 need to recalculate it, to be able to know where to insert content after
// losing focus
private setLastCaretFromPosition(position: DocumentPosition) {
private setLastCaretFromPosition(position: DocumentPosition): void {
const { model } = this.props;
this._isCaretAtEnd = position.isAtEnd(model);
this.lastCaret = position.asOffset(model);
this.lastSelection = cloneSelection(document.getSelection());
}
private refreshLastCaretIfNeeded() {
private refreshLastCaretIfNeeded(): DocumentOffset {
// XXX: needed when going up and down in editing messages ... not sure why yet
// because the editors should stop doing this when when blurred ...
// maybe it's on focus and the _editorRef isn't available yet or something.
@ -377,38 +369,38 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
return this.lastCaret;
}
clearUndoHistory() {
public clearUndoHistory(): void {
this.historyManager.clear();
}
getCaret() {
public getCaret(): DocumentOffset {
return this.lastCaret;
}
isSelectionCollapsed() {
public isSelectionCollapsed(): boolean {
return !this.lastSelection || this.lastSelection.isCollapsed;
}
isCaretAtStart() {
public isCaretAtStart(): boolean {
return this.getCaret().offset === 0;
}
isCaretAtEnd() {
public isCaretAtEnd(): boolean {
return this._isCaretAtEnd;
}
private onBlur = () => {
private onBlur = (): void => {
document.removeEventListener("selectionchange", this.onSelectionChange);
};
private onFocus = () => {
private onFocus = (): void => {
document.addEventListener("selectionchange", this.onSelectionChange);
// force to recalculate
this.lastSelection = null;
this.refreshLastCaretIfNeeded();
};
private onSelectionChange = () => {
private onSelectionChange = (): void => {
const { isEmpty } = this.props.model;
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;
let handled = false;
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 {
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
const { model } = this.props;
@ -557,27 +549,27 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
}
isModified() {
public isModified(): boolean {
return this.modifiedFlag;
}
private onAutoCompleteConfirm = (completion: ICompletion) => {
private onAutoCompleteConfirm = (completion: ICompletion): void => {
this.modifiedFlag = true;
this.props.model.autoComplete.onComponentConfirm(completion);
};
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => {
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
this.modifiedFlag = true;
this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({ completionIndex });
};
private configureEmoticonAutoReplace = () => {
private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
};
private configureShouldShowPillAvatar = () => {
private configureShouldShowPillAvatar = (): void => {
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
this.setState({ showPillAvatar });
};
@ -611,8 +603,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.editorRef.current.focus();
}
private getInitialCaretPosition() {
let caretPosition;
private getInitialCaretPosition(): DocumentPosition {
let caretPosition: DocumentPosition;
if (this.props.initialCaret) {
// if restoring state from a previous editor,
// restore caret position from the state
@ -625,7 +617,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
return caretPosition;
}
private onFormatAction = (action: Formatting) => {
private onFormatAction = (action: Formatting): void => {
const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
// trim the range as we want it to exclude leading/trailing spaces
range.trim();
@ -680,9 +672,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
});
const shortcuts = {
bold: ctrlShortcutLabel("B"),
italics: ctrlShortcutLabel("I"),
quote: ctrlShortcutLabel(">"),
[Formatting.Bold]: ctrlShortcutLabel("B"),
[Formatting.Italics]: ctrlShortcutLabel("I"),
[Formatting.Quote]: ctrlShortcutLabel(">"),
};
const { completionIndex } = this.state;
@ -714,11 +706,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
</div>);
}
focus() {
public focus(): void {
this.editorRef.current.focus();
}
public insertMention(userId: string) {
public insertMention(userId: string): void {
const { model } = this.props;
const { partCreator } = model;
const member = this.props.room.getMember(userId);
@ -736,7 +728,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.focus();
}
public insertQuotedMessage(event: MatrixEvent) {
public insertQuotedMessage(event: MatrixEvent): void {
const { model } = this.props;
const { partCreator } = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
@ -751,7 +743,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.focus();
}
public insertPlaintext(text: string) {
public insertPlaintext(text: string): void {
const { model } = this.props;
const { partCreator } = model;
const caret = this.getCaret();

View file

@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
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");
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
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 PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model';
import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import classNames from 'classnames';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { CommandCategories, getCommand } from '../../../SlashCommands';
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics";
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SendHistoryManager from '../../../SendHistoryManager';
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 isReply = !!(relatesTo && relatesTo["m.in_reply_to"]);
return isReply;
return !!(relatesTo && relatesTo["m.in_reply_to"]);
}
function getHtmlReplyFallback(mxEvent) {
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
if (!html) {
return "";
@ -54,7 +58,7 @@ function getHtmlReplyFallback(mxEvent) {
return (mxReply && mxReply.outerHTML) || "";
}
function getTextReplyFallback(mxEvent) {
function getTextReplyFallback(mxEvent: MatrixEvent): string {
const body = mxEvent.getContent().body;
const lines = body.split("\n").map(l => l.trim());
if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
@ -63,12 +67,12 @@ function getTextReplyFallback(mxEvent) {
return "";
}
function createEditContent(model, editedEvent) {
function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IContent {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
}
const isReply = _isReply(editedEvent);
const isReply = eventIsReply(editedEvent);
let plainPrefix = "";
let htmlPrefix = "";
@ -79,11 +83,11 @@ function createEditContent(model, editedEvent) {
const body = textSerialize(model);
const newContent = {
"msgtype": isEmote ? "m.emote" : "m.text",
const newContent: IContent = {
"msgtype": isEmote ? MsgType.Emote : MsgType.Text,
"body": body,
};
const contentBody = {
const contentBody: IContent = {
msgtype: newContent.msgtype,
body: `${plainPrefix} * ${body}`,
};
@ -105,55 +109,60 @@ function createEditContent(model, editedEvent) {
}, contentBody);
}
interface IProps {
editState: EditorStateTransfer;
className?: string;
}
interface IState {
saveDisabled: boolean;
}
@replaceableComponent("views.rooms.EditMessageComposer")
export default class EditMessageComposer extends React.Component {
static propTypes = {
// the message event being edited
editState: PropTypes.instanceOf(EditorStateTransfer).isRequired,
};
export default class EditMessageComposer extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
constructor(props, context) {
super(props, context);
this.model = null;
this._editorRef = null;
private readonly editorRef = createRef<BasicMessageComposer>();
private readonly dispatcherRef: string;
private model: EditorModel = null;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above
this.state = {
saveDisabled: true,
};
this._createEditorModel();
window.addEventListener("beforeunload", this._saveStoredEditorState);
this.createEditorModel();
window.addEventListener("beforeunload", this.saveStoredEditorState);
this.dispatcherRef = dis.register(this.onAction);
}
_setEditorRef = ref => {
this._editorRef = ref;
};
_getRoom() {
private getRoom(): Room {
return this.context.getRoom(this.props.editState.getEvent().getRoomId());
}
_onKeyDown = (event) => {
private onKeyDown = (event: KeyboardEvent): void => {
// ignore any keypress while doing IME compositions
if (this._editorRef.isComposing(event)) {
if (this.editorRef.current?.isComposing(event)) {
return;
}
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case MessageComposerAction.Send:
this._sendEdit();
this.sendEdit();
event.preventDefault();
break;
case MessageComposerAction.CancelEditing:
this._cancelEdit();
this.cancelEdit();
break;
case MessageComposerAction.EditPrevMessage: {
if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) {
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) {
return;
}
const previousEvent = findEditableEvent(this._getRoom(), false,
const previousEvent = findEditableEvent(this.getRoom(), false,
this.props.editState.getEvent().getId());
if (previousEvent) {
dis.dispatch({ action: 'edit_event', event: previousEvent });
@ -162,14 +171,14 @@ export default class EditMessageComposer extends React.Component {
break;
}
case MessageComposerAction.EditNextMessage: {
if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) {
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) {
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) {
dis.dispatch({ action: 'edit_event', event: nextEvent });
} else {
this._clearStoredEditorState();
this.clearStoredEditorState();
dis.dispatch({ action: 'edit_event', event: null });
dis.fire(Action.FocusComposer);
}
@ -177,32 +186,32 @@ export default class EditMessageComposer extends React.Component {
break;
}
}
};
private get editorRoomKey(): string {
return `mx_edit_room_${this.getRoom().roomId}`;
}
get _editorRoomKey() {
return `mx_edit_room_${this._getRoom().roomId}`;
}
get _editorStateKey() {
private get editorStateKey(): string {
return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
}
_cancelEdit = () => {
this._clearStoredEditorState();
private cancelEdit = (): void => {
this.clearStoredEditorState();
dis.dispatch({ action: "edit_event", event: null });
dis.fire(Action.FocusComposer);
};
private get shouldSaveStoredEditorState(): boolean {
return localStorage.getItem(this.editorRoomKey) !== null;
}
get _shouldSaveStoredEditorState() {
return localStorage.getItem(this._editorRoomKey) !== null;
}
_restoreStoredEditorState(partCreator) {
const json = localStorage.getItem(this._editorStateKey);
private restoreStoredEditorState(partCreator: PartCreator): Part[] {
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
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;
} catch (e) {
console.error("Error parsing editing state: ", e);
@ -210,25 +219,25 @@ export default class EditMessageComposer extends React.Component {
}
}
_clearStoredEditorState() {
localStorage.removeItem(this._editorRoomKey);
localStorage.removeItem(this._editorStateKey);
private clearStoredEditorState(): void {
localStorage.removeItem(this.editorRoomKey);
localStorage.removeItem(this.editorStateKey);
}
_clearPreviousEdit() {
if (localStorage.getItem(this._editorRoomKey)) {
localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`);
private clearPreviousEdit(): void {
if (localStorage.getItem(this.editorRoomKey)) {
localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this.editorRoomKey)}`);
}
}
_saveStoredEditorState() {
private saveStoredEditorState(): void {
const item = SendHistoryManager.createItem(this.model);
this._clearPreviousEdit();
localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId());
localStorage.setItem(this._editorStateKey, JSON.stringify(item));
this.clearPreviousEdit();
localStorage.setItem(this.editorRoomKey, this.props.editState.getEvent().getId());
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
}
_isSlashCommand() {
private isSlashCommand(): boolean {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
@ -244,10 +253,10 @@ export default class EditMessageComposer extends React.Component {
return false;
}
_isContentModified(newContent) {
private isContentModified(newContent: IContent): boolean {
// if nothing has changed then bail
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["format"] === newContent["format"] &&
oldContent["formatted_body"] === newContent["formatted_body"])) {
@ -256,7 +265,7 @@ export default class EditMessageComposer extends React.Component {
return true;
}
_getSlashCommand() {
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
@ -268,7 +277,7 @@ export default class EditMessageComposer extends React.Component {
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);
let messageContent;
let error = result.error;
@ -285,7 +294,6 @@ export default class EditMessageComposer extends React.Component {
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!result.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
@ -309,7 +317,7 @@ export default class EditMessageComposer extends React.Component {
}
}
_sendEdit = async () => {
private sendEdit = async (): Promise<void> => {
const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent();
const editContent = createEditContent(this.model, editedEvent);
@ -318,20 +326,19 @@ export default class EditMessageComposer extends React.Component {
let shouldSend = true;
// If content is modified then send an updated event into the room
if (this._isContentModified(newContent)) {
if (this.isContentModified(newContent)) {
const roomId = editedEvent.getRoomId();
if (!containsEmote(this.model) && this._isSlashCommand()) {
const [cmd, args, commandText] = this._getSlashCommand();
if (!containsEmote(this.model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) {
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 {
this._runSlashCommand(cmd, args, roomId);
this.runSlashCommand(cmd, args, roomId);
shouldSend = false;
}
} else {
// 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, {
title: _t("Unknown Command"),
description: <div>
@ -358,9 +365,9 @@ export default class EditMessageComposer extends React.Component {
}
}
if (shouldSend) {
this._cancelPreviousPendingEdit();
this.cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent);
this._clearStoredEditorState();
this.clearStoredEditorState();
dis.dispatch({ action: "message_sent" });
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
}
@ -371,7 +378,7 @@ export default class EditMessageComposer extends React.Component {
dis.fire(Action.FocusComposer);
};
_cancelPreviousPendingEdit() {
private cancelPreviousPendingEdit(): void {
const originalEvent = this.props.editState.getEvent();
const previousEdit = originalEvent.replacingEvent();
if (previousEdit && (
@ -389,23 +396,23 @@ export default class EditMessageComposer extends React.Component {
const sel = document.getSelection();
let caret;
if (sel.focusNode) {
caret = getCaretOffsetAndText(this._editorRef, sel).caret;
caret = getCaretOffsetAndText(this.editorRef.current?.editorRef.current, sel).caret;
}
const parts = this.model.serializeParts();
// 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,
// it will set the cursor at the end.
this.props.editState.setEditorState(caret, parts);
window.removeEventListener("beforeunload", this._saveStoredEditorState);
if (this._shouldSaveStoredEditorState) {
this._saveStoredEditorState();
window.removeEventListener("beforeunload", this.saveStoredEditorState);
if (this.shouldSaveStoredEditorState) {
this.saveStoredEditorState();
}
dis.unregister(this.dispatcherRef);
}
_createEditorModel() {
private createEditorModel(): void {
const { editState } = this.props;
const room = this._getRoom();
const room = this.getRoom();
const partCreator = new CommandPartCreator(room, this.context);
let parts;
if (editState.hasEditorState()) {
@ -414,13 +421,13 @@ export default class EditMessageComposer extends React.Component {
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
} else {
//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._saveStoredEditorState();
this.saveStoredEditorState();
}
_getInitialCaretPosition() {
private getInitialCaretPosition(): CaretPosition {
const { editState } = this.props;
let caretPosition;
if (editState.hasEditorState() && editState.getCaret()) {
@ -435,8 +442,8 @@ export default class EditMessageComposer extends React.Component {
return caretPosition;
}
_onChange = () => {
if (!this.state.saveDisabled || !this._editorRef || !this._editorRef.isModified()) {
private onChange = (): void => {
if (!this.state.saveDisabled || !this.editorRef.current?.isModified()) {
return;
}
@ -445,33 +452,34 @@ export default class EditMessageComposer extends React.Component {
});
};
onAction = payload => {
if (payload.action === "edit_composer_insert" && this._editorRef) {
private onAction = (payload: ActionPayload) => {
if (payload.action === "edit_composer_insert" && this.editorRef.current) {
if (payload.userId) {
this._editorRef.insertMention(payload.userId);
this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) {
this._editorRef.insertQuotedMessage(payload.event);
this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) {
this._editorRef.insertPlaintext(payload.text);
this.editorRef.current?.insertPlaintext(payload.text);
}
}
};
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
ref={this._setEditorRef}
ref={this.editorRef}
model={this.model}
room={this._getRoom()}
room={this.getRoom()}
initialCaret={this.props.editState.getCaret()}
label={_t("Edit message")}
onChange={this._onChange}
onChange={this.onChange}
/>
<div className="mx_EditMessageComposer_buttons">
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={this._sendEdit} disabled={this.state.saveDisabled}>
{_t("Save")}
<AccessibleButton kind="secondary" onClick={this.cancelEdit}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.sendEdit} disabled={this.state.saveDisabled}>
{ _t("Save") }
</AccessibleButton>
</div>
</div>);

View file

@ -267,7 +267,7 @@ interface IProps {
showReactions?: boolean;
// which layout to use
layout: Layout;
layout?: Layout;
// whether or not to show flair at all
enableFlair?: boolean;
@ -321,6 +321,7 @@ export default class EventTile extends React.Component<IProps, IState> {
static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
onHeightChanged: function() {},
layout: Layout.Group,
};
static contextType = MatrixClientContext;

View file

@ -22,7 +22,6 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher/dispatcher';
import { isValid3pidInvite } from "../../../RoomInvite";
import rateLimitedFunction from "../../../ratelimitedfunc";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard";
@ -43,6 +42,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash';
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -133,7 +133,7 @@ export default class MemberList extends React.Component<IProps, IState> {
}
// cancel any pending calls to the rate_limited_funcs
this.updateList.cancelPendingCall();
this.updateList.cancel();
}
/**
@ -237,9 +237,9 @@ export default class MemberList extends React.Component<IProps, IState> {
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
};
private updateList = rateLimitedFunction(() => {
private updateList = throttle(() => {
this.updateListNow();
}, 500);
}, 500, { leading: true, trailing: true });
private updateListNow(): void {
const members = this.roomMembers();

View file

@ -43,6 +43,7 @@ import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
interface IComposerAvatarProps {
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>({
action: Action.ComposerInsert,
text: emoji,
});
}
sendMessage = async () => {
private sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
@ -333,11 +334,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
return;
}
// XXX: Private function access
this.messageComposerInput._sendMessage();
this.messageComposerInput.sendMessage();
};
onChange = (model) => {
private onChange = (model: EditorModel) => {
this.setState({
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");
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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import React, { createRef } from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.MessageComposerFormatBar")
export default class MessageComposerFormatBar extends React.PureComponent {
static propTypes = {
onAction: PropTypes.func.isRequired,
shortcuts: PropTypes.object.isRequired,
};
export enum Formatting {
Bold = "bold",
Italics = "italics",
Strikethrough = "strikethrough",
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);
this.state = { visible: false };
}
@ -37,49 +51,53 @@ export default class MessageComposerFormatBar extends React.PureComponent {
const classes = classNames("mx_MessageComposerFormatBar", {
"mx_MessageComposerFormatBar_shown": this.state.visible,
});
return (<div className={classes} ref={ref => this._formatBarRef = ref}>
<FormatButton label={_t("Bold")} onClick={() => this.props.onAction("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("Strikethrough")} onClick={() => this.props.onAction("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("Quote")} onClick={() => this.props.onAction("quote")} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
return (<div className={classes} ref={this.formatBarRef}>
<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(Formatting.Italics)} icon="Italic" shortcut={this.props.shortcuts.italics} 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(Formatting.Code)} icon="Code" 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>);
}
showAt(selectionRect) {
public showAt(selectionRect: DOMRect): void {
if (!this.formatBarRef.current) return;
this.setState({ visible: true });
const parentRect = this._formatBarRef.parentElement.getBoundingClientRect();
this._formatBarRef.style.left = `${selectionRect.left - parentRect.left}px`;
const parentRect = this.formatBarRef.current.parentElement.getBoundingClientRect();
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.
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 });
}
}
class FormatButton extends React.PureComponent {
static propTypes = {
label: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
icon: PropTypes.string.isRequired,
shortcut: PropTypes.string,
visible: PropTypes.bool,
};
interface IFormatButtonProps {
label: string;
icon: string;
shortcut?: string;
visible?: boolean;
onClick(): void;
}
class FormatButton extends React.PureComponent<IFormatButtonProps> {
render() {
const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`;
let 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>
<div className="mx_Tooltip_title">
{this.props.label}
{ this.props.label }
</div>
<div className="mx_Tooltip_sub">
{shortcut}
{ shortcut }
</div>
</div>;

View file

@ -20,7 +20,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
@ -31,6 +30,7 @@ import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import { PlaceCallType } from "../../../CallHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { throttle } from 'lodash';
@replaceableComponent("views.rooms.RoomHeader")
export default class RoomHeader extends React.Component {
@ -73,10 +73,9 @@ export default class RoomHeader extends React.Component {
this._rateLimitedUpdate();
};
_rateLimitedUpdate = new RateLimitedFunc(function() {
/* eslint-disable @babel/no-invalid-this */
_rateLimitedUpdate = throttle(() => {
this.forceUpdate();
}, 500);
}, 500, { leading: true, trailing: true });
render() {
let searchStatus = null;

View file

@ -16,31 +16,28 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { haveTileForEvent } from "./EventTile";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import EventTile, { haveTileForEvent } from "./EventTile";
import DateSeparator from '../messages/DateSeparator';
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
// a matrix-js-sdk SearchResult containing the details of this result
searchResult: SearchResult;
// a list of strings to be highlighted in the results
searchHighlights?: string[];
// href for the highlights in this result
resultLink?: string;
onHeightChanged?: () => void;
permalinkCreator?: RoomPermalinkCreator;
}
@replaceableComponent("views.rooms.SearchResultTile")
export default class SearchResultTile extends React.Component {
static propTypes = {
// a matrix-js-sdk SearchResult containing the details of this result
searchResult: PropTypes.object.isRequired,
// a list of strings to be highlighted in the results
searchHighlights: PropTypes.array,
// href for the highlights in this result
resultLink: PropTypes.string,
onHeightChanged: PropTypes.func,
};
render() {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventTile = sdk.getComponent('rooms.EventTile');
export default class SearchResultTile extends React.Component<IProps> {
public render() {
const result = this.props.searchResult;
const mxEv = result.context.getEvent();
const eventId = mxEv.getId();

View file

@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
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");
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
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 EditorModel from '../../../editor/model';
import {
@ -27,29 +29,36 @@ import {
startsWith,
stripPrefix,
} from '../../../editor/serialize';
import { CommandPartCreator } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories, getCommand } from '../../../SlashCommands';
import * as sdk from '../../../index';
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import Modal from '../../../Modal';
import { _t, _td } from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc';
import { Action } from "../../../dispatcher/actions";
import { containsEmoji } from "../../../effects/utils";
import { CHAT_EFFECTS } from '../../../effects';
import CountlyAnalytics from "../../../CountlyAnalytics";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import EMOJI_REGEX from 'emojibase-regex';
import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
import { replaceableComponent } from "../../../utils/replaceableComponent";
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";
import { DebouncedFunc, throttle } from 'lodash';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
function addReplyToMessageContent(
content: IContent,
repliedToEvent: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): void {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
Object.assign(content, replyContent);
@ -65,7 +74,11 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
}
// exported for tests
export function createMessageContent(model, permalinkCreator, replyToEvent) {
export function createMessageContent(
model: EditorModel,
permalinkCreator: RoomPermalinkCreator,
replyToEvent: MatrixEvent,
): IContent {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
@ -76,7 +89,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
model = unescapeMessage(model);
const body = textSerialize(model);
const content = {
const content: IContent = {
msgtype: isEmote ? "m.emote" : "m.text",
body: body,
};
@ -94,7 +107,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
}
// exported for tests
export function isQuickReaction(model) {
export function isQuickReaction(model: EditorModel): boolean {
const parts = model.parts;
if (parts.length == 0) return false;
const text = textSerialize(model);
@ -111,46 +124,48 @@ export function isQuickReaction(model) {
return false;
}
interface IProps {
room: Room;
placeholder?: string;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
disabled?: boolean;
onChange?(model: EditorModel): void;
}
@replaceableComponent("views.rooms.SendMessageComposer")
export default class SendMessageComposer extends React.Component {
static propTypes = {
room: PropTypes.object.isRequired,
placeholder: PropTypes.string,
permalinkCreator: PropTypes.object.isRequired,
replyToEvent: PropTypes.object,
onChange: PropTypes.func,
disabled: PropTypes.bool,
};
export default class SendMessageComposer extends React.Component<IProps> {
static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
constructor(props, context) {
super(props, context);
this.model = null;
this._editorRef = null;
this.currentlyComposedEditorState = null;
private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
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);
this.context = context; // otherwise React will only set it prior to render due to type def above
if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) {
this._prepareToEncrypt = new RateLimitedFunc(() => {
this.prepareToEncrypt = throttle(() => {
this.context.prepareToEncrypt(this.props.room);
}, 60000);
}, 60000, { leading: true, trailing: false });
}
window.addEventListener("beforeunload", this._saveStoredEditorState);
window.addEventListener("beforeunload", this.saveStoredEditorState);
}
_setEditorRef = ref => {
this._editorRef = ref;
};
_onKeyDown = (event) => {
private onKeyDown = (event: KeyboardEvent): void => {
// ignore any keypress while doing IME compositions
if (this._editorRef.isComposing(event)) {
if (this.editorRef.current?.isComposing(event)) {
return;
}
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case MessageComposerAction.Send:
this._sendMessage();
this.sendMessage();
event.preventDefault();
break;
case MessageComposerAction.SelectPrevSendHistory:
@ -165,7 +180,7 @@ export default class SendMessageComposer extends React.Component {
}
case MessageComposerAction.EditPrevMessage:
// 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);
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
@ -184,29 +199,29 @@ export default class SendMessageComposer extends React.Component {
});
break;
default:
if (this._prepareToEncrypt) {
if (this.prepareToEncrypt) {
// This needs to be last!
this._prepareToEncrypt();
this.prepareToEncrypt();
}
}
};
// we keep sent messages/commands in a separate history (separate from undo history)
// so you can alt+up/down in them
selectSendHistory(up) {
private selectSendHistory(up: boolean): boolean {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return;
return false;
}
this.currentlyComposedEditorState = this.model.serializeParts();
} else if (this.sendHistoryManager.currentIndex + delta === this.sendHistoryManager.history.length) {
// True when we return to the message being composed currently
this.model.reset(this.currentlyComposedEditorState);
this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length;
return;
return true;
}
const { parts, replyEventId } = this.sendHistoryManager.getItem(delta);
dis.dispatch({
@ -215,11 +230,12 @@ export default class SendMessageComposer extends React.Component {
});
if (parts) {
this.model.reset(parts);
this._editorRef.focus();
this.editorRef.current?.focus();
}
return true;
}
_isSlashCommand() {
private isSlashCommand(): boolean {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
@ -237,7 +253,7 @@ export default class SendMessageComposer extends React.Component {
return false;
}
_sendQuickReaction() {
private sendQuickReaction(): void {
const timeline = this.props.room.getLiveTimeline();
const events = timeline.getEvents();
const reaction = this.model.parts[1].text;
@ -272,7 +288,7 @@ export default class SendMessageComposer extends React.Component {
}
}
_getSlashCommand() {
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
@ -284,7 +300,7 @@ export default class SendMessageComposer extends React.Component {
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);
let messageContent;
let error = result.error;
@ -302,7 +318,6 @@ export default class SendMessageComposer extends React.Component {
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!result.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
@ -326,7 +341,7 @@ export default class SendMessageComposer extends React.Component {
}
}
async _sendMessage() {
public async sendMessage(): Promise<void> {
if (this.model.isEmpty) {
return;
}
@ -335,21 +350,20 @@ export default class SendMessageComposer extends React.Component {
let shouldSend = true;
let content;
if (!containsEmote(this.model) && this._isSlashCommand()) {
const [cmd, args, commandText] = this._getSlashCommand();
if (!containsEmote(this.model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) {
if (cmd.category === CommandCategories.messages) {
content = await this._runSlashCommand(cmd, args);
content = await this.runSlashCommand(cmd, args);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
}
} else {
this._runSlashCommand(cmd, args);
this.runSlashCommand(cmd, args);
shouldSend = false;
}
} else {
// 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, {
title: _t("Unknown Command"),
description: <div>
@ -378,7 +392,7 @@ export default class SendMessageComposer extends React.Component {
if (isQuickReaction(this.model)) {
shouldSend = false;
this._sendQuickReaction();
this.sendQuickReaction();
}
if (shouldSend) {
@ -411,9 +425,9 @@ export default class SendMessageComposer extends React.Component {
this.sendHistoryManager.save(this.model, replyToEvent);
// clear composer
this.model.reset([]);
this._editorRef.clearUndoHistory();
this._editorRef.focus();
this._clearStoredEditorState();
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({ action: "scroll_to_bottom" });
}
@ -421,33 +435,33 @@ export default class SendMessageComposer extends React.Component {
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
window.removeEventListener("beforeunload", this._saveStoredEditorState);
this._saveStoredEditorState();
window.removeEventListener("beforeunload", this.saveStoredEditorState);
this.saveStoredEditorState();
}
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
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.dispatcherRef = dis.register(this.onAction);
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_');
}
get _editorStateKey() {
private get editorStateKey() {
return `mx_cider_state_${this.props.room.roomId}`;
}
_clearStoredEditorState() {
localStorage.removeItem(this._editorStateKey);
private clearStoredEditorState(): void {
localStorage.removeItem(this.editorStateKey);
}
_restoreStoredEditorState(partCreator) {
const json = localStorage.getItem(this._editorStateKey);
private restoreStoredEditorState(partCreator: PartCreator): Part[] {
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
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) {
dis.dispatch({
action: 'reply_to_event',
@ -462,20 +476,20 @@ export default class SendMessageComposer extends React.Component {
}
// should save state when editor has contents or reply is open
_shouldSaveStoredEditorState = () => {
return !this.model.isEmpty || this.props.replyToEvent;
}
private shouldSaveStoredEditorState = (): boolean => {
return !this.model.isEmpty || !!this.props.replyToEvent;
};
_saveStoredEditorState = () => {
if (this._shouldSaveStoredEditorState()) {
private saveStoredEditorState = (): void => {
if (this.shouldSaveStoredEditorState()) {
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
localStorage.setItem(this._editorStateKey, JSON.stringify(item));
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} 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
// to the cursor being in the composer
if (this.props.disabled) return;
@ -483,21 +497,21 @@ export default class SendMessageComposer extends React.Component {
switch (payload.action) {
case 'reply_to_event':
case Action.FocusComposer:
this._editorRef && this._editorRef.focus();
this.editorRef.current?.focus();
break;
case "send_composer_insert":
if (payload.userId) {
this._editorRef && this._editorRef.insertMention(payload.userId);
this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) {
this._editorRef && this._editorRef.insertQuotedMessage(payload.event);
this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) {
this._editorRef && this._editorRef.insertPlaintext(payload.text);
this.editorRef.current?.insertPlaintext(payload.text);
}
break;
}
};
_onPaste = (event) => {
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
const { clipboardData } = event;
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap
// in the clipboard as well as the content being copied.
@ -511,23 +525,27 @@ export default class SendMessageComposer extends React.Component {
);
return true; // to skip internal onPaste handler
}
}
};
onChange = () => {
private onChange = (): void => {
if (this.props.onChange) this.props.onChange(this.model);
}
};
private focusComposer = (): void => {
this.editorRef.current?.focus();
};
render() {
return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this.onKeyDown}>
<BasicMessageComposer
onChange={this.onChange}
ref={this._setEditorRef}
ref={this.editorRef}
model={this.model}
room={this.props.room}
label={this.props.placeholder}
placeholder={this.props.placeholder}
onPaste={this._onPaste}
onPaste={this.onPaste}
disabled={this.props.disabled}
/>
</div>

View file

@ -16,7 +16,6 @@ limitations under the License.
import React from "react";
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
@ -30,6 +29,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/reque
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import { Action } from "../../../dispatcher/actions";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import VerificationRequestDialog from "../dialogs/VerificationRequestDialog";
interface IProps {
toastKey: string;
@ -123,7 +123,6 @@ export default class VerificationRequestToast extends React.PureComponent<IProps
},
});
} else {
const VerificationRequestDialog = sdk.getComponent("views.dialogs.VerificationRequestDialog");
Modal.createTrackedDialog('Incoming Verification', '', VerificationRequestDialog, {
verificationRequest: request,
onFinished: () => {

View file

@ -70,7 +70,7 @@ export default class EditorModel {
* on the model that can span multiple parts. Also see `startRange()`.
* @param {TransformCallback} transformCallback
*/
setTransformCallback(transformCallback: TransformCallback) {
public setTransformCallback(transformCallback: TransformCallback): void {
this.transformCallback = transformCallback;
}
@ -78,23 +78,23 @@ export default class EditorModel {
* Set a callback for rerendering the model after it has been updated.
* @param {ModelCallback} updateCallback
*/
setUpdateCallback(updateCallback: UpdateCallback) {
public setUpdateCallback(updateCallback: UpdateCallback): void {
this.updateCallback = updateCallback;
}
get partCreator() {
public get partCreator(): PartCreator {
return this._partCreator;
}
get isEmpty() {
public get isEmpty(): boolean {
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);
}
private insertPart(index: number, part: Part) {
private insertPart(index: number, part: Part): void {
this._parts.splice(index, 0, part);
if (this.activePartIdx >= index) {
++this.activePartIdx;
@ -104,7 +104,7 @@ export default class EditorModel {
}
}
private removePart(index: number) {
private removePart(index: number): void {
this._parts.splice(index, 1);
if (index === this.activePartIdx) {
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);
}
get parts() {
public get parts(): Part[] {
return this._parts;
}
get autoComplete() {
public get autoComplete(): AutocompleteWrapperModel {
if (this.activePartIdx === this.autoCompletePartIdx) {
return this._autoComplete;
}
return null;
}
getPositionAtEnd() {
public getPositionAtEnd(): DocumentPosition {
if (this._parts.length) {
const index = this._parts.length - 1;
const part = this._parts[index];
@ -144,11 +144,11 @@ export default class EditorModel {
}
}
serializeParts() {
public serializeParts(): SerializedPart[] {
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, "");
// can't use caret position with drag and drop
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));
if (!caret) {
caret = this.getPositionAtEnd();
@ -180,7 +180,7 @@ export default class EditorModel {
* @param {DocumentPosition} position the position to start inserting at
* @return {Number} the amount of characters added
*/
insert(parts: Part[], position: IPosition) {
public insert(parts: Part[], position: IPosition): number {
const insertIndex = this.splitAt(position);
let newTextLength = 0;
for (let i = 0; i < parts.length; ++i) {
@ -191,7 +191,7 @@ export default class EditorModel {
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 position = this.positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0;
@ -220,7 +220,7 @@ export default class EditorModel {
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 part = this._parts[index];
if (part) {
@ -250,7 +250,7 @@ export default class EditorModel {
return Promise.resolve();
}
private onAutoComplete = ({ replaceParts, close }: ICallback) => {
private onAutoComplete = ({ replaceParts, close }: ICallback): void => {
let pos;
if (replaceParts) {
this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts);
@ -270,7 +270,7 @@ export default class EditorModel {
this.updateCallback(pos);
};
private mergeAdjacentParts() {
private mergeAdjacentParts(): void {
let prevPart;
for (let i = 0; i < this._parts.length; ++i) {
let part = this._parts[i];
@ -294,7 +294,7 @@ export default class EditorModel {
* @return {Number} how many characters before pos were also removed,
* usually because of non-editable parts that can only be removed in their entirety.
*/
removeText(pos: IPosition, len: number) {
public removeText(pos: IPosition, len: number): number {
let { index, offset } = pos;
let removedOffsetDecrease = 0;
while (len > 0) {
@ -329,7 +329,7 @@ export default class EditorModel {
}
// return part index where insertion will insert between at offset
private splitAt(pos: IPosition) {
private splitAt(pos: IPosition): number {
if (pos.index === -1) {
return 0;
}
@ -356,7 +356,7 @@ export default class EditorModel {
* @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.
*/
private addText(pos: IPosition, str: string, inputType: string) {
private addText(pos: IPosition, str: string, inputType: string): number {
let { index } = pos;
const { offset } = pos;
let addLen = str.length;
@ -390,7 +390,7 @@ export default class EditorModel {
return addLen;
}
positionForOffset(totalOffset: number, atPartEnd = false) {
public positionForOffset(totalOffset: number, atPartEnd = false): DocumentPosition {
let currentOffset = 0;
const index = this._parts.findIndex(part => {
const partLen = part.text.length;
@ -416,11 +416,11 @@ export default class EditorModel {
* @param {DocumentPosition?} positionB the other boundary of the range, optional
* @return {Range}
*/
startRange(positionA: DocumentPosition, positionB = positionA) {
public startRange(positionA: DocumentPosition, positionB = positionA): Range {
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
// which we'll change when splitting up at the start position
const endOffset = endPosition.asOffset(this);
@ -445,9 +445,9 @@ export default class EditorModel {
* @param {ManualTransformCallback} callback to run the transformations in
* @return {Promise} a promise when auto-complete (if applicable) is done updating
*/
transform(callback: ManualTransformCallback) {
public transform(callback: ManualTransformCallback): Promise<void> {
const pos = callback();
let acPromise = null;
let acPromise: Promise<void> = null;
if (!(pos instanceof Range)) {
acPromise = this.setActivePart(pos, true);
} else {

View file

@ -552,7 +552,7 @@ export class PartCreator {
// part creator that support auto complete for /commands,
// used in SendMessageComposer
export class CommandPartCreator extends PartCreator {
createPartForInput(text: string, partIndex: number) {
public createPartForInput(text: string, partIndex: number): Part {
// at beginning and starts with /? create
if (partIndex === 0 && text[0] === "/") {
// 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);
}
deserializePart(part: Part): Part {
public deserializePart(part: SerializedPart): Part {
if (part.type === "command") {
return this.command(part.text);
} else {
@ -576,7 +576,7 @@ export class CommandPartCreator extends PartCreator {
}
class CommandPart extends PillCandidatePart {
get type(): IPillCandidatePart["type"] {
public get type(): IPillCandidatePart["type"] {
return Type.Command;
}
}

View file

@ -2342,15 +2342,6 @@
"Message edits": "Message edits",
"Modal Widget": "Modal Widget",
"Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s",
"Your account is not secure": "Your account is not secure",
"Your password": "Your password",
"This session, or the other session": "This session, or the other session",
"The internet connection either session is using": "The internet connection either session is using",
"We recommend you change your password and Security Key in Settings immediately": "We recommend you change your password and Security Key in Settings immediately",
"New session": "New session",
"Use this session to verify your new one, granting it access to encrypted messages:": "Use this session to verify your new one, granting it access to encrypted messages:",
"If you didnt sign in to this session, your account may be compromised.": "If you didnt sign in to this session, your account may be compromised.",
"This wasn't me": "This wasn't me",
"Doesn't look like a valid email address": "Doesn't look like a valid email address",
"Continuing without email": "Continuing without email",
"Just a heads up, if you don't add an email and forget your password, you could <b>permanently lose access to your account</b>.": "Just a heads up, if you don't add an email and forget your password, you could <b>permanently lose access to your account</b>.",

View file

@ -1,47 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* 'debounces' a function to only execute every n milliseconds.
* Useful when react-sdk gets many, many events but only wants
* to update the interface once for all of them.
*
* Note that the function must not take arguments, since the args
* could be different for each invocation of the function.
*
* The returned function has a 'cancelPendingCall' property which can be called
* on unmount or similar to cancel any pending update.
*/
import {throttle} from "lodash";
export default function ratelimitedfunc(fn, time) {
const throttledFn = throttle(fn, time, {
leading: true,
trailing: true,
});
const _bind = throttledFn.bind;
throttledFn.bind = function() {
const boundFn = _bind.apply(throttledFn, arguments);
boundFn.cancelPendingCall = throttledFn.cancelPendingCall;
return boundFn;
};
throttledFn.cancelPendingCall = function() {
throttledFn.cancel();
};
return throttledFn;
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
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
@ -26,28 +26,28 @@ import { Caret } from "../editor/caret";
*/
export default class EditorStateTransfer {
private serializedParts: SerializedPart[] = null;
private caret: Caret = null;
private caret: DocumentOffset = null;
constructor(private readonly event: MatrixEvent) {}
public setEditorState(caret: Caret, serializedParts: SerializedPart[]) {
public setEditorState(caret: DocumentOffset, serializedParts: SerializedPart[]) {
this.caret = caret;
this.serializedParts = serializedParts;
}
public hasEditorState() {
public hasEditorState(): boolean {
return !!this.serializedParts;
}
public getSerializedParts() {
public getSerializedParts(): SerializedPart[] {
return this.serializedParts;
}
public getCaret() {
public getCaret(): DocumentOffset {
return this.caret;
}
public getEvent() {
public getEvent(): MatrixEvent {
return this.event;
}
}

View file

@ -16,9 +16,11 @@ limitations under the License.
import React from "react";
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 SettingsStore from "../settings/SettingsStore";
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import Pill from "../components/views/elements/Pill";
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
* 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.
* @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are
* 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.
* 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 shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
let node = nodes[0];
@ -73,7 +75,7 @@ export function pillifyLinks(nodes, mxEvent, pills) {
// to clear the pills from the last run of pillifyLinks
!node.parentElement.classList.contains("mx_AtRoomPill")
) {
let currentTextNode = node;
let currentTextNode = node as Node as Text;
const roomNotifTextNodes = [];
// 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) {
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
* 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.
*/
export function unmountPills(pills) {
export function unmountPills(pills: Element[]) {
for (const pillContainer of pills) {
ReactDOM.unmountComponentAtNode(pillContainer);
}

View file

@ -147,7 +147,7 @@ describe('<SendMessageComposer/>', () => {
wrapper.update();
});
const key = wrapper.find(SendMessageComposer).instance()._editorStateKey;
const key = wrapper.find(SendMessageComposer).instance().editorStateKey;
expect(wrapper.text()).toBe("Test Text");
expect(localStorage.getItem(key)).toBeNull();
@ -188,7 +188,7 @@ describe('<SendMessageComposer/>', () => {
wrapper.update();
});
const key = wrapper.find(SendMessageComposer).instance()._editorStateKey;
const key = wrapper.find(SendMessageComposer).instance().editorStateKey;
expect(wrapper.text()).toBe("Hello World");
expect(localStorage.getItem(key)).toBeNull();

View file

@ -2345,9 +2345,9 @@ camelcase@^6.0.0:
integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001173:
version "1.0.30001178"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001178.tgz#3ad813b2b2c7d585b0be0a2440e1e233c6eabdbc"
integrity sha512-VtdZLC0vsXykKni8Uztx45xynytOi71Ufx9T8kHptSw9AL4dpqailUJJHavttuzUe1KYuBYtChiWv+BAb7mPmQ==
version "1.0.30001241"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001241.tgz"
integrity sha512-1uoSZ1Pq1VpH0WerIMqwptXHNNGfdl7d1cJUFs80CwQ/lVzdhTvsFZCeNFslze7AjsQnb4C85tzclPa1VShbeQ==
capture-exit@^2.0.0:
version "2.0.0"