Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into spaces-jump-to-room

This commit is contained in:
Jaiwanth 2021-07-22 08:25:24 +05:30
commit 76040c652c
582 changed files with 15328 additions and 10678 deletions

View file

@ -55,7 +55,7 @@ interface IState {
export default class Autocomplete extends React.PureComponent<IProps, IState> {
autocompleter: Autocompleter;
queryRequested: string;
debounceCompletionsRequest: NodeJS.Timeout;
debounceCompletionsRequest: number;
private containerRef = createRef<HTMLDivElement>();
constructor(props) {

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,35 +28,36 @@ 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
room: Room,
userId: string,
showApps: boolean, // Render apps
room: Room;
userId: string;
showApps: boolean; // Render apps
// maxHeight attribute for the aux panel and the video
// therein
maxHeight: number,
maxHeight: number;
// a callback which is called when the content of the aux panel changes
// content in a way that is likely to make it change size.
onResize: () => void,
fullHeight: boolean,
onResize: () => void;
fullHeight: boolean;
resizeNotifier: ResizeNotifier,
resizeNotifier: ResizeNotifier;
}
interface Counter {
title: string,
value: number,
link: string,
severity: string,
stateKey: string,
title: string;
value: number;
link: string;
severity: string;
stateKey: string;
}
interface IState {
counters: Counter[],
counters: Counter[];
}
@replaceableComponent("views.rooms.AuxPanel")
@ -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 = [];
@ -179,9 +179,9 @@ export default class AuxPanel extends React.Component<IProps, IState> {
<span
className="m_RoomView_auxPanel_stateViews_span"
data-severity={severity}
key={ "x-" + stateKey }
key={"x-" + stateKey}
>
{span}
{ span }
</span>
);

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,12 @@ 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 {
this.modifiedFlag = true;
const { model } = this.props;
const { partCreator } = model;
const member = this.props.room.getMember(userId);
@ -736,7 +729,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.focus();
}
public insertQuotedMessage(event: MatrixEvent) {
public insertQuotedMessage(event: MatrixEvent): void {
this.modifiedFlag = true;
const { model } = this.props;
const { partCreator } = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
@ -751,7 +745,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.focus();
}
public insertPlaintext(text: string) {
public insertPlaintext(text: string): void {
this.modifiedFlag = true;
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,61 @@ 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
const isRestored = this.createEditorModel();
const ev = this.props.editState.getEvent();
this.state = {
saveDisabled: true,
saveDisabled: !isRestored || !this.isContentModified(createEditContent(this.model, ev)["m.new_content"]),
};
this._createEditorModel();
window.addEventListener("beforeunload", this._saveStoredEditorState);
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,47 +172,47 @@ 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);
dis.fire(Action.FocusSendMessageComposer);
}
event.preventDefault();
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);
dis.fire(Action.FocusSendMessageComposer);
};
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 +220,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,19 +254,18 @@ 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() ||
(oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] &&
if (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] &&
oldContent["format"] === newContent["format"] &&
oldContent["formatted_body"] === newContent["formatted_body"])) {
oldContent["formatted_body"] === newContent["formatted_body"]) {
return false;
}
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);
}
@ -368,10 +375,10 @@ export default class EditMessageComposer extends React.Component {
// close the event editing and focus composer
dis.dispatch({ action: "edit_event", event: null });
dis.fire(Action.FocusComposer);
dis.fire(Action.FocusSendMessageComposer);
};
_cancelPreviousPendingEdit() {
private cancelPreviousPendingEdit(): void {
const originalEvent = this.props.editState.getEvent();
const previousEdit = originalEvent.replacingEvent();
if (previousEdit && (
@ -389,54 +396,45 @@ 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(): boolean {
const { editState } = this.props;
const room = this._getRoom();
const room = this.getRoom();
const partCreator = new CommandPartCreator(room, this.context);
let parts;
let isRestored = false;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,
// restore serialized parts from the state
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);
// otherwise, either restore serialized parts from localStorage or parse the body of the event
const restoredParts = this.restoreStoredEditorState(partCreator);
parts = restoredParts || parseEvent(editState.getEvent(), partCreator);
isRestored = !!restoredParts;
}
this.model = new EditorModel(parts, partCreator);
this._saveStoredEditorState();
this.saveStoredEditorState();
return isRestored;
}
_getInitialCaretPosition() {
const { editState } = this.props;
let caretPosition;
if (editState.hasEditorState() && editState.getCaret()) {
// if restoring state from a previous editor,
// restore caret position from the state
const caret = editState.getCaret();
caretPosition = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
} else {
// otherwise, set it at the end
caretPosition = this.model.getPositionAtEnd();
}
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 +443,36 @@ 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);
}
} else if (payload.action === Action.FocusEditMessageComposer && this.editorRef.current) {
this.editorRef.current.focus();
}
};
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

@ -129,23 +129,23 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
presenceState={this.props.presenceState} />;
}
if (this.props.subtextLabel) {
presenceLabel = <span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>;
presenceLabel = <span className="mx_EntityTile_subtext">{ this.props.subtextLabel }</span>;
}
nameEl = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name" dir="auto">
{ name }
</div>
{presenceLabel}
{ presenceLabel }
</div>
);
} else if (this.props.subtextLabel) {
nameEl = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name" dir="auto">
{name}
{ name }
</div>
<span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>
<span className="mx_EntityTile_subtext">{ this.props.subtextLabel }</span>
</div>
);
} else {
@ -167,7 +167,7 @@ export default class EntityTile extends React.PureComponent<IProps, IState> {
const powerStatus = this.props.powerStatus;
if (powerStatus) {
const powerText = PowerLabel[powerStatus];
powerLabel = <div className="mx_EntityTile_power">{powerText}</div>;
powerLabel = <div className="mx_EntityTile_power">{ powerText }</div>;
}
let e2eIcon;

View file

@ -27,7 +27,6 @@ import { _t } from '../../../languageHandler';
import { hasText } from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -45,18 +44,24 @@ import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
import CallEventGrouper from "../../structures/CallEventGrouper";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from '../../../dispatcher/actions';
import MemberAvatar from '../avatars/MemberAvatar';
import SenderProfile from '../messages/SenderProfile';
import MessageTimestamp from '../messages/MessageTimestamp';
import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
[EventType.Sticker]: 'messages.MessageEvent',
[EventType.KeyVerificationCancel]: 'messages.MKeyVerificationConclusion',
[EventType.KeyVerificationDone]: 'messages.MKeyVerificationConclusion',
[EventType.CallInvite]: 'messages.TextualEvent',
[EventType.CallAnswer]: 'messages.TextualEvent',
[EventType.CallHangup]: 'messages.TextualEvent',
[EventType.CallReject]: 'messages.TextualEvent',
[EventType.CallInvite]: 'messages.CallEvent',
};
const stateEventTileTypes = {
@ -163,8 +168,6 @@ export function getHandlerTile(ev) {
return eventTileTypes[type];
}
const MAX_READ_AVATARS = 5;
// Our component structure for EventTiles on the timeline is:
//
// .-EventTile------------------------------------------------.
@ -185,8 +188,7 @@ export interface IReadReceiptProps {
export enum TileShape {
Notif = "notif",
FileGrid = "file_grid",
Reply = "reply",
ReplyPreview = "reply_preview",
Pinned = "pinned",
}
interface IProps {
@ -267,7 +269,7 @@ interface IProps {
showReactions?: boolean;
// which layout to use
layout: Layout;
layout?: Layout;
// whether or not to show flair at all
enableFlair?: boolean;
@ -286,11 +288,17 @@ interface IProps {
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
// CallEventGrouper for this event
callEventGrouper?: CallEventGrouper;
// Symbol of the root node
as?: string
as?: string;
// whether or not to always show timestamps
alwaysShowTimestamps?: boolean
alwaysShowTimestamps?: boolean;
// whether or not to display the sender
hideSender?: boolean;
}
interface IState {
@ -314,13 +322,14 @@ export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef();
private replyThread = React.createRef();
private replyThread = React.createRef<ReplyThread>();
public readonly ref = createRef<HTMLElement>();
static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
onHeightChanged: function() {},
layout: Layout.Group,
};
static contextType = MatrixClientContext;
@ -423,7 +432,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillMount() {
this.verifyEvent(this.props.mxEvent);
}
@ -445,7 +454,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps) {
// re-check the sender verification as outgoing events progress through
// the send process.
@ -649,6 +658,10 @@ export default class EventTile extends React.Component<IProps, IState> {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
}
const MAX_READ_AVATARS = this.props.layout == Layout.Bubble
? 2
: 5;
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
// We currently must include `mx_EventTile_readAvatars` in the DOM
@ -665,7 +678,6 @@ export default class EventTile extends React.Component<IProps, IState> {
);
}
const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
const avatars = [];
const receiptOffset = 15;
let left = 0;
@ -732,7 +744,7 @@ export default class EventTile extends React.Component<IProps, IState> {
);
}
onSenderProfileClick = event => {
onSenderProfileClick = () => {
const mxEvent = this.props.mxEvent;
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
@ -840,39 +852,9 @@ export default class EventTile extends React.Component<IProps, IState> {
};
render() {
const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
const SenderProfile = sdk.getComponent('messages.SenderProfile');
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const msgtype = this.props.mxEvent.getContent().msgtype;
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
//console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
const content = this.props.mxEvent.getContent();
const msgtype = content.msgtype;
const eventType = this.props.mxEvent.getType();
let tileHandler = getHandlerTile(this.props.mxEvent);
// Info messages are basically information about commands processed on a room
let isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === EventType.RoomCreate) ||
(eventType === EventType.RoomEncryption) ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = (
!isBubbleMessage && eventType !== EventType.RoomMessage &&
eventType !== EventType.Sticker && eventType !== EventType.RoomCreate
);
// If we're showing hidden events in the timeline, we should use the
// source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing).
if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) {
tileHandler = "messages.ViewSourceEvent";
isBubbleMessage = false;
// Reuse info message avatar and sender profile styling
isInfoMessage = true;
}
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
@ -899,7 +881,7 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_12hr: this.props.isTwelveHour,
// Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_highlight: this.props.tileShape === TileShape.Notif ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_last: this.props.last,
@ -932,7 +914,7 @@ export default class EventTile extends React.Component<IProps, IState> {
let avatarSize;
let needsSenderProfile;
if (this.props.tileShape === "notif") {
if (this.props.tileShape === TileShape.Notif) {
avatarSize = 24;
needsSenderProfile = true;
} else if (tileHandler === 'messages.RoomCreate' || isBubbleMessage) {
@ -946,7 +928,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} else if (this.props.layout == Layout.IRC) {
avatarSize = 14;
needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== "file_grid") {
} else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) {
// no avatar or sender profile for continuation messages
avatarSize = 0;
needsSenderProfile = false;
@ -975,8 +957,8 @@ export default class EventTile extends React.Component<IProps, IState> {
);
}
if (needsSenderProfile) {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
if (needsSenderProfile && this.props.hideSender !== true) {
if (!this.props.tileShape) {
sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair}
@ -986,7 +968,6 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = !isEditing ? <MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
@ -996,8 +977,12 @@ export default class EventTile extends React.Component<IProps, IState> {
onFocusChange={this.onActionBarFocusChange}
/> : undefined;
const showTimestamp = this.props.mxEvent.getTs() &&
(this.props.alwaysShowTimestamps || this.props.last || this.state.hover || this.state.actionBarFocused);
const showTimestamp = this.props.mxEvent.getTs()
&& (this.props.alwaysShowTimestamps
|| this.props.last
|| this.state.hover
|| this.state.actionBarFocused);
const timestamp = showTimestamp ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
@ -1026,7 +1011,6 @@ export default class EventTile extends React.Component<IProps, IState> {
{ 'requestLink': (sub) => <a onClick={this.onRequestKeysClick}>{ sub }</a> },
);
const TooltipButton = sdk.getComponent('elements.TooltipButton');
const keyRequestInfo = isEncryptionFailure && !isRedacted ?
<div className="mx_EventTile_keyRequestInfo">
<span className="mx_EventTile_keyRequestInfo_text">
@ -1037,7 +1021,6 @@ export default class EventTile extends React.Component<IProps, IState> {
let reactionsRow;
if (!isRedacted) {
const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
reactionsRow = <ReactionsRow
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
@ -1065,7 +1048,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
switch (this.props.tileShape) {
case 'notif': {
case TileShape.Notif: {
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
return React.createElement(this.props.as || "li", {
"className": classes,
@ -1093,11 +1076,12 @@ export default class EventTile extends React.Component<IProps, IState> {
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape}
/>
</div>,
]);
}
case 'file_grid': {
case TileShape.FileGrid: {
return React.createElement(this.props.as || "li", {
"className": classes,
"aria-live": ariaLive,
@ -1128,44 +1112,6 @@ export default class EventTile extends React.Component<IProps, IState> {
]);
}
case 'reply':
case 'reply_preview': {
let thread;
if (this.props.tileShape === 'reply_preview') {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
null,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
return React.createElement(this.props.as || "li", {
"className": classes,
"aria-live": ariaLive,
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
}, [
ircTimestamp,
avatar,
sender,
ircPadlock,
<div className="mx_EventTile_reply" key="mx_EventTile_reply">
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false}
/>
</div>,
]);
}
default: {
const thread = ReplyThread.makeThread(
this.props.mxEvent,
@ -1176,6 +1122,8 @@ export default class EventTile extends React.Component<IProps, IState> {
this.props.alwaysShowTimestamps || this.state.hover,
);
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
React.createElement(this.props.as || "li", {
@ -1185,12 +1133,15 @@ export default class EventTile extends React.Component<IProps, IState> {
"aria-live": ariaLive,
"aria-atomic": "true",
"data-scroll-tokens": scrollToken,
"data-layout": this.props.layout,
"data-self": isOwnEvent,
"data-has-reply": !!thread,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
}, [
ircTimestamp,
sender,
ircPadlock,
}, <>
{ ircTimestamp }
{ sender }
{ ircPadlock }
<div className="mx_EventTile_line" key="mx_EventTile_line">
{ groupTimestamp }
{ groupPadlock }
@ -1204,15 +1155,15 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged}
callEventGrouper={this.props.callEventGrouper}
/>
{ keyRequestInfo }
{ reactionsRow }
{ actionBar }
</div>,
msgOption,
avatar,
])
</div>
{ reactionsRow }
{ msgOption }
{ avatar }
</>)
);
}
}
@ -1225,7 +1176,7 @@ function isMessageEvent(ev) {
return (messageTypes.includes(ev.getType()));
}
export function haveTileForEvent(e) {
export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
// Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && !isMessageEvent(e)) return false;
@ -1235,7 +1186,7 @@ export function haveTileForEvent(e) {
const handler = getHandlerTile(e);
if (handler === undefined) return false;
if (handler === 'messages.TextualEvent') {
return hasText(e);
return hasText(e, showHiddenEvents);
} else if (handler === 'messages.RoomCreate') {
return Boolean(e.getContent()['predecessor']);
} else {
@ -1315,7 +1266,7 @@ class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
className={classes}
onMouseEnter={this.onHoverStart}
onMouseLeave={this.onHoverEnd}
>{tooltip}</div>
>{ tooltip }</div>
);
}
}
@ -1379,8 +1330,8 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
<div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip}
{ nonCssBadge }
{ tooltip }
</span>
</span>
</div>

View file

@ -84,7 +84,7 @@ export default class ExtraTile extends React.Component<IProps, IState> {
let nameContainer = (
<div className="mx_RoomTile_nameContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
{ name }
</div>
</div>
);
@ -106,11 +106,11 @@ export default class ExtraTile extends React.Component<IProps, IState> {
title={this.props.isMinimized ? name : undefined}
>
<div className="mx_RoomTile_avatarContainer">
{this.props.avatar}
{ this.props.avatar }
</div>
{nameContainer}
{ nameContainer }
<div className="mx_RoomTile_badgeContainer">
{badge}
{ badge }
</div>
</Button>
</React.Fragment>

View file

@ -25,7 +25,7 @@ export default (props) => {
});
let badge;
if (props.numUnreadMessages) {
badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>);
badge = (<div className="mx_JumpToBottomButton_badge">{ props.numUnreadMessages }</div>);
}
return (<div className={className}>
<AccessibleButton className="mx_JumpToBottomButton_scrollDown"

View file

@ -0,0 +1,92 @@
/*
Copyright 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.
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, { useContext, useEffect } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IPreviewUrlResponse } from "matrix-js-sdk/src/client";
import { useStateToggle } from "../../../hooks/useStateToggle";
import LinkPreviewWidget from "./LinkPreviewWidget";
import AccessibleButton from "../elements/AccessibleButton";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
const INITIAL_NUM_PREVIEWS = 2;
interface IProps {
links: string[]; // the URLs to be previewed
mxEvent: MatrixEvent; // the Event associated with the preview
onCancelClick(): void; // called when the preview's cancel ('hide') button is clicked
onHeightChanged(): void; // called when the preview's contents has loaded
}
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onHeightChanged }) => {
const cli = useContext(MatrixClientContext);
const [expanded, toggleExpanded] = useStateToggle();
const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => {
try {
return [link, await cli.getUrlPreview(link, ts)];
} catch (error) {
console.error("Failed to get URL preview: " + error);
}
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
}, [links, ts], []);
useEffect(() => {
onHeightChanged();
}, [onHeightChanged, expanded, previews]);
const showPreviews = expanded ? previews : previews.slice(0, INITIAL_NUM_PREVIEWS);
let toggleButton: JSX.Element;
if (previews.length > INITIAL_NUM_PREVIEWS) {
toggleButton = <AccessibleButton onClick={toggleExpanded}>
{ expanded
? _t("Collapse")
: _t("Show %(count)s other previews", { count: previews.length - showPreviews.length }) }
</AccessibleButton>;
}
return <div className="mx_LinkPreviewGroup">
{ showPreviews.map(([link, preview], i) => (
<LinkPreviewWidget key={link} link={link} preview={preview} mxEvent={mxEvent}>
{ i === 0 ? (
<AccessibleButton
className="mx_LinkPreviewGroup_hide"
onClick={onCancelClick}
aria-label={_t("Close preview")}
>
<img
className="mx_filterFlipColor"
alt=""
role="presentation"
src={require("../../../../res/img/cancel.svg")}
width="18"
height="18"
/>
</AccessibleButton>
): undefined }
</LinkPreviewWidget>
)) }
{ toggleButton }
</div>;
};
export default LinkPreviewGroup;

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016 - 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,71 +15,44 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { AllHtmlEntities } from 'html-entities';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client';
import { linkifyElement } from '../../../HtmlUtils';
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import ImageView from '../elements/ImageView';
interface IProps {
link: string;
preview: IPreviewUrlResponse;
mxEvent: MatrixEvent; // the Event associated with the preview
}
@replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component {
static propTypes = {
link: PropTypes.string.isRequired, // the URL being previewed
mxEvent: PropTypes.object.isRequired, // the Event associated with the preview
onCancelClick: PropTypes.func, // called when the preview's cancel ('hide') button is clicked
onHeightChanged: PropTypes.func, // called when the preview's contents has loaded
};
constructor(props) {
super(props);
this.state = {
preview: null,
};
this.unmounted = false;
MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((res)=>{
if (this.unmounted) {
return;
}
this.setState(
{ preview: res },
this.props.onHeightChanged,
);
}, (error)=>{
console.error("Failed to get URL preview: " + error);
});
this._description = createRef();
}
export default class LinkPreviewWidget extends React.Component<IProps> {
private readonly description = createRef<HTMLDivElement>();
componentDidMount() {
if (this._description.current) {
linkifyElement(this._description.current);
if (this.description.current) {
linkifyElement(this.description.current);
}
}
componentDidUpdate() {
if (this._description.current) {
linkifyElement(this._description.current);
if (this.description.current) {
linkifyElement(this.description.current);
}
}
componentWillUnmount() {
this.unmounted = true;
}
onImageClick = ev => {
const p = this.state.preview;
private onImageClick = ev => {
const p = this.props.preview;
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
const ImageView = sdk.getComponent("elements.ImageView");
let src = p["og:image"];
if (src && src.startsWith("mxc://")) {
@ -100,7 +72,7 @@ export default class LinkPreviewWidget extends React.Component {
};
render() {
const p = this.state.preview;
const p = this.props.preview;
if (!p || Object.keys(p).length === 0) {
return <div />;
}
@ -136,21 +108,21 @@ export default class LinkPreviewWidget extends React.Component {
// opaque string. This does not allow any HTML to be injected into the DOM.
const description = AllHtmlEntities.decode(p["og:description"] || "");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_LinkPreviewWidget">
{ img }
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title"><a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a></div>
<div className="mx_LinkPreviewWidget_siteName">{ p["og:site_name"] ? (" - " + p["og:site_name"]) : null }</div>
<div className="mx_LinkPreviewWidget_description" ref={this._description}>
<div className="mx_LinkPreviewWidget_title">
<a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a>
{ p["og:site_name"] && <span className="mx_LinkPreviewWidget_siteName">
{ (" - " + p["og:site_name"]) }
</span> }
</div>
<div className="mx_LinkPreviewWidget_description" ref={this.description}>
{ description }
</div>
</div>
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}>
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
{ this.props.children }
</div>
);
}

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,8 @@ import AccessibleButton from '../elements/AccessibleButton';
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash';
import SpaceStore from "../../../stores/SpaceStore";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -92,7 +93,7 @@ export default class MemberList extends React.Component<IProps, IState> {
this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true;
}
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillMount() {
const cli = MatrixClientPeg.get();
this.mounted = true;
@ -133,7 +134,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 +238,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();
@ -509,7 +510,7 @@ export default class MemberList extends React.Component<IProps, IState> {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
} else if (SpaceStore.spacesEnabled && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
@ -542,14 +543,14 @@ export default class MemberList extends React.Component<IProps, IState> {
const footer = (
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={ _t('Filter room members') }
onSearch={ this.onSearchQueryChanged } />
placeholder={_t('Filter room members')}
onSearch={this.onSearchQueryChanged} />
);
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />

View file

@ -17,7 +17,6 @@ import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@ -43,13 +42,15 @@ 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";
import EmojiPicker from '../emojipicker/EmojiPicker';
import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar";
interface IComposerAvatarProps {
me: object;
}
function ComposerAvatar(props: IComposerAvatarProps) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
return <div className="mx_MessageComposer_avatar">
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
</div>;
@ -75,7 +76,6 @@ const EmojiButton = ({ addEmoji }) => {
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker');
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
@ -318,14 +318,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 +333,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,
});
@ -366,15 +365,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
];
if (!this.state.tombstone && this.state.canSendMessages) {
const SendMessageComposer = sdk.getComponent("rooms.SendMessageComposer");
controls.push(
<SendMessageComposer
ref={(c) => this.messageComposerInput = c}
key="controls_input"
room={this.props.room}
placeholder={this.renderPlaceholderText()}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
@ -415,7 +411,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
className="mx_MessageComposer_roomReplaced_link"
onClick={this.onTombstoneClick}
>
{_t("The conversation continues here.")}
{ _t("The conversation continues here.") }
</a>
) : '';
@ -425,7 +421,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
src={require("../../../../res/img/room_replaced.svg")}
/>
<span className="mx_MessageComposer_roomReplaced_header">
{_t("This room has been replaced and is no longer active.")}
{ _t("This room has been replaced and is no longer active.") }
</span><br />
{ continuesLink }
</div>
@ -449,7 +445,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
return (
<div className="mx_MessageComposer mx_GroupLayout">
{recordingTooltip}
{ recordingTooltip }
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row">

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

@ -68,9 +68,9 @@ const NewRoomIntro = () => {
<h2>{ room.name }</h2>
<p>{_t("This is the beginning of your direct message history with <displayName/>.", {}, {
<p>{ _t("This is the beginning of your direct message history with <displayName/>.", {}, {
displayName: () => <b>{ displayName }</b>,
})}</p>
}) }</p>
{ caption && <p>{ caption }</p> }
</React.Fragment>;
} else {
@ -132,7 +132,7 @@ const NewRoomIntro = () => {
showSpaceInvite(parentSpace);
}}
>
{_t("Invite to %(spaceName)s", { spaceName: parentSpace.name })}
{ _t("Invite to %(spaceName)s", { spaceName: parentSpace.name }) }
</AccessibleButton>
{ room.canInvite(cli.getUserId()) && <AccessibleButton
className="mx_NewRoomIntro_inviteButton"
@ -141,7 +141,7 @@ const NewRoomIntro = () => {
dis.dispatch({ action: "view_invite", roomId });
}}
>
{_t("Invite to just this room")}
{ _t("Invite to just this room") }
</AccessibleButton> }
</div>;
} else if (room.canInvite(cli.getUserId())) {
@ -153,7 +153,7 @@ const NewRoomIntro = () => {
dis.dispatch({ action: "view_invite", roomId });
}}
>
{_t("Invite to this room")}
{ _t("Invite to this room") }
</AccessibleButton>
</div>;
}
@ -170,10 +170,10 @@ const NewRoomIntro = () => {
<h2>{ room.name }</h2>
<p>{createdText} {_t("This is the start of <roomName/>.", {}, {
<p>{ createdText } { _t("This is the start of <roomName/>.", {}, {
roomName: () => <b>{ room.name }</b>,
})}</p>
<p>{topicText}</p>
}) }</p>
<p>{ topicText }</p>
{ buttons }
</React.Fragment>;
}
@ -190,7 +190,7 @@ const NewRoomIntro = () => {
"Your private messages are normally encrypted, but this room isn't. "+
"Usually this is due to an unsupported device or method being used, " +
"like email invites. <a>Enable encryption in settings.</a>", {},
{ a: sub => <a onClick={openRoomSettings} href="#">{sub}</a> },
{ a: sub => <a onClick={openRoomSettings} href="#">{ sub }</a> },
);
return <div className="mx_NewRoomIntro">
@ -201,7 +201,7 @@ const NewRoomIntro = () => {
title={_t("End-to-end encryption isn't enabled")}
subtitle={sub2}
/>
)}
) }
{ body }
</div>;

View file

@ -126,14 +126,14 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
if (onClick) {
return (
<AccessibleButton {...props} className={classes} onClick={onClick}>
<span className="mx_NotificationBadge_count">{symbol}</span>
<span className="mx_NotificationBadge_count">{ symbol }</span>
</AccessibleButton>
);
}
return (
<div className={classes}>
<span className="mx_NotificationBadge_count">{symbol}</span>
<span className="mx_NotificationBadge_count">{ symbol }</span>
</div>
);
}

View file

@ -29,6 +29,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { TileShape } from "./EventTile";
interface IProps {
room: Room;
@ -84,9 +85,11 @@ export default class PinnedEventTile extends React.Component<IProps> {
<div className="mx_PinnedEventTile_message">
<MessageEvent
mxEvent={this.props.event}
// @ts-ignore - complaining that className is invalid when it's not
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
tileShape={TileShape.Pinned}
/>
</div>

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2017 - 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,14 +16,13 @@ limitations under the License.
import React from 'react';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import PropTypes from "prop-types";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import ReplyTile from './ReplyTile';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventSubscription } from 'fbemitter';
function cancelQuoting() {
dis.dispatch({
@ -32,47 +31,50 @@ function cancelQuoting() {
});
}
interface IProps {
permalinkCreator: RoomPermalinkCreator;
}
interface IState {
event: MatrixEvent;
}
@replaceableComponent("views.rooms.ReplyPreview")
export default class ReplyPreview extends React.Component {
static propTypes = {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
export default class ReplyPreview extends React.Component<IProps, IState> {
private unmounted = false;
private readonly roomStoreToken: EventSubscription;
constructor(props) {
super(props);
this.unmounted = false;
this.state = {
event: RoomViewStore.getQuotingEvent(),
};
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
}
componentWillUnmount() {
this.unmounted = true;
// Remove RoomStore listener
if (this._roomStoreToken) {
this._roomStoreToken.remove();
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
}
_onRoomViewStoreUpdate() {
private onRoomViewStoreUpdate = (): void => {
if (this.unmounted) return;
const event = RoomViewStore.getQuotingEvent();
if (this.state.event !== event) {
this.setState({ event });
}
}
};
render() {
if (!this.state.event) return null;
const EventTile = sdk.getComponent('rooms.EventTile');
return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section">
<div className="mx_ReplyPreview_header mx_ReplyPreview_title">
@ -88,15 +90,12 @@ export default class ReplyPreview extends React.Component {
/>
</div>
<div className="mx_ReplyPreview_clear" />
<EventTile
alwaysShowTimestamps={true}
tileShape="reply_preview"
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
as="div"
/>
<div className="mx_ReplyPreview_tile">
<ReplyTile
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator}
/>
</div>
</div>
</div>;
}

View file

@ -0,0 +1,155 @@
/*
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
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 classNames from 'classnames';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import SenderProfile from "../messages/SenderProfile";
import MImageReplyBody from "../messages/MImageReplyBody";
import * as sdk from '../../../index';
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
import { replaceableComponent } from '../../../utils/replaceableComponent';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import MFileBody from "../messages/MFileBody";
interface IProps {
mxEvent: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
highlights?: string[];
highlightLink?: string;
onHeightChanged?(): void;
}
@replaceableComponent("views.rooms.ReplyTile")
export default class ReplyTile extends React.PureComponent<IProps> {
static defaultProps = {
onHeightChanged: () => {},
};
componentDidMount() {
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
this.props.mxEvent.on("Event.beforeRedaction", this.onEventRequiresUpdate);
this.props.mxEvent.on("Event.replaced", this.onEventRequiresUpdate);
}
componentWillUnmount() {
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
this.props.mxEvent.removeListener("Event.beforeRedaction", this.onEventRequiresUpdate);
this.props.mxEvent.removeListener("Event.replaced", this.onEventRequiresUpdate);
}
private onDecrypted = (): void => {
this.forceUpdate();
if (this.props.onHeightChanged) {
this.props.onHeightChanged();
}
};
private onEventRequiresUpdate = (): void => {
// Force update when necessary - redactions and edits
this.forceUpdate();
};
private onClick = (e: React.MouseEvent): void => {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
};
render() {
const mxEvent = this.props.mxEvent;
const msgType = mxEvent.getContent().msgtype;
const evType = mxEvent.getType() as EventType;
const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
const { mxEvent } = this.props;
console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
return <div className="mx_ReplyTile mx_ReplyTile_info mx_MNoticeBody">
{ _t('This event could not be displayed') }
</div>;
}
const EventTileType = sdk.getComponent(tileHandler);
const classes = classNames("mx_ReplyTile", {
mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(),
mx_ReplyTile_audio: msgType === MsgType.Audio,
mx_ReplyTile_video: msgType === MsgType.Video,
});
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
let sender;
const needsSenderProfile = (
!isInfoMessage &&
msgType !== MsgType.Image &&
tileHandler !== EventType.RoomCreate &&
evType !== EventType.Sticker
);
if (needsSenderProfile) {
sender = <SenderProfile
mxEvent={this.props.mxEvent}
enableFlair={false}
/>;
}
const msgtypeOverrides = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides = {
// Use MImageReplyBody so that the sticker isn't taking up a lot of space
[EventType.Sticker]: MImageReplyBody,
};
return (
<div className={classes}>
<a href={permalink} onClick={this.onClick}>
{ sender }
<EventTileType
ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
showUrlPreview={false}
overrideBodyTypes={msgtypeOverrides}
overrideEventTypes={evOverrides}
replacingEventId={this.props.mxEvent.replacingEventId()}
maxImageHeight={96} />
</a>
</div>
);
}
}

View file

@ -109,7 +109,7 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
classNames='mx_RoomBreadcrumbs'
>
<Toolbar className='mx_RoomBreadcrumbs' aria-label={_t("Recently visited rooms")}>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
{ tiles.slice(this.state.skipFirst ? 1 : 0) }
</Toolbar>
</CSSTransition>
);
@ -117,7 +117,7 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
return (
<div className='mx_RoomBreadcrumbs'>
<div className="mx_RoomBreadcrumbs_placeholder">
{_t("No recently visited rooms")}
{ _t("No recently visited rooms") }
</div>
</div>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2017-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.
@ -21,9 +21,10 @@ import { linkifyElement } from '../../../HtmlUtils';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import { getDisplayAliasForAliasSet } from '../../../Rooms';
export function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
return getDisplayAliasForAliasSet(room.canonicalAlias, room.aliases);
}
export const roomShape = PropTypes.shape({

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket 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.
@ -16,11 +16,9 @@ limitations under the License.
*/
import React from 'react';
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,54 +29,65 @@ import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import { PlaceCallType } from "../../../CallHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { throttle } from 'lodash';
import { MatrixEvent, Room, RoomState } from 'matrix-js-sdk/src';
import { E2EStatus } from '../../../utils/ShieldUtils';
import { IOOBData } from '../../../stores/ThreepidInviteStore';
import { SearchScope } from './SearchBar';
export interface ISearchInfo {
searchTerm: string;
searchScope: SearchScope;
searchCount: number;
}
interface IProps {
room: Room;
oobData?: IOOBData;
inRoom: boolean;
onSettingsClick: () => void;
onSearchClick: () => void;
onForgetClick: () => void;
onCallPlaced: (type: PlaceCallType) => void;
onAppsClick: () => void;
e2eStatus: E2EStatus;
appsShown: boolean;
searchInfo: ISearchInfo;
}
@replaceableComponent("views.rooms.RoomHeader")
export default class RoomHeader extends React.Component {
static propTypes = {
room: PropTypes.object,
oobData: PropTypes.object,
inRoom: PropTypes.bool,
onSettingsClick: PropTypes.func,
onSearchClick: PropTypes.func,
onLeaveClick: PropTypes.func,
e2eStatus: PropTypes.string,
onAppsClick: PropTypes.func,
appsShown: PropTypes.bool,
onCallPlaced: PropTypes.func, // (PlaceCallType) => void;
};
export default class RoomHeader extends React.Component<IProps> {
static defaultProps = {
editing: false,
inRoom: false,
};
componentDidMount() {
public componentDidMount() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents);
cli.on("RoomState.events", this.onRoomStateEvents);
}
componentWillUnmount() {
public componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents);
cli.removeListener("RoomState.events", this.onRoomStateEvents);
}
}
_onRoomStateEvents = (event, state) => {
private onRoomStateEvents = (event: MatrixEvent, state: RoomState) => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return;
}
// redisplay the room name, topic, etc.
this._rateLimitedUpdate();
this.rateLimitedUpdate();
};
_rateLimitedUpdate = new RateLimitedFunc(function() {
/* eslint-disable @babel/no-invalid-this */
private rateLimitedUpdate = throttle(() => {
this.forceUpdate();
}, 500);
}, 500, { leading: true, trailing: true });
render() {
public render() {
let searchStatus = null;
// don't display the search count until the search completes and
@ -112,18 +121,18 @@ export default class RoomHeader extends React.Component {
const name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}>
<RoomName room={this.props.room}>
{(name) => {
{ (name) => {
const roomName = name || oobName;
return <div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>;
}}
} }
</RoomName>
{ searchStatus }
</div>;
const topicElement = <RoomTopic room={this.props.room}>
{(topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto">
{ (topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto">
{ topic }
</div>}
</div> }
</RoomTopic>;
let roomAvatar;

View file

@ -417,7 +417,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
}
private renderCommunityInvites(): ReactComponentElement<typeof ExtraTile>[] {
if (SettingsStore.getValue("feature_spaces")) return [];
if (SpaceStore.spacesEnabled) return [];
// TODO: Put community invites in a more sensible place (not in the room list)
// See https://github.com/vector-im/element-web/issues/14456
return MatrixClientPeg.get().getGroups().filter(g => {
@ -507,13 +507,13 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
if (!this.props.isMinimized) {
if (this.state.isNameFiltering) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Can't see what youre looking for?")}</div>
<div>{ _t("Can't see what youre looking for?") }</div>
<AccessibleButton
className="mx_RoomList_explorePrompt_startChat"
kind="link"
onClick={this.onStartChat}
>
{_t("Start a new chat")}
{ _t("Start a new chat") }
</AccessibleButton>
<AccessibleButton
className="mx_RoomList_explorePrompt_explore"
@ -532,13 +532,13 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
className="mx_RoomList_explorePrompt_spaceInvite"
onClick={this.onSpaceInviteClick}
>
{_t("Invite people")}
{ _t("Invite people") }
</AccessibleButton> }
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleButton
className="mx_RoomList_explorePrompt_spaceExplore"
onClick={this.onExplore}
>
{_t("Explore rooms")}
{ _t("Explore rooms") }
</AccessibleButton> }
</div>;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
@ -549,20 +549,20 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
// show a prompt to join/create rooms if the user is in 0 rooms and no historical
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1 && unfilteredFavourite < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Use the + to make a new room or explore existing ones below")}</div>
<div>{ _t("Use the + to make a new room or explore existing ones below") }</div>
<AccessibleButton
className="mx_RoomList_explorePrompt_startChat"
kind="link"
onClick={this.onStartChat}
>
{_t("Start a new chat")}
{ _t("Start a new chat") }
</AccessibleButton>
<AccessibleButton
className="mx_RoomList_explorePrompt_explore"
kind="link"
onClick={this.onExplore}
>
{_t("Explore all public rooms")}
{ _t("Explore all public rooms") }
</AccessibleButton>
</div>;
}
@ -572,7 +572,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const sublists = this.renderSublists();
return (
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}>
{({ onKeyDownHandler }) => (
{ ({ onKeyDownHandler }) => (
<div
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
@ -581,10 +581,10 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
role="tree"
aria-label={_t("Rooms")}
>
{sublists}
{explorePrompt}
{ sublists }
{ explorePrompt }
</div>
)}
) }
</RovingTabIndexProvider>
);
}

View file

@ -22,7 +22,7 @@ import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/SpaceStore";
interface IProps {
onVisibilityChange?: () => void
onVisibilityChange?: () => void;
}
const RoomListNumResults: React.FC<IProps> = ({ onVisibilityChange }) => {

View file

@ -340,7 +340,7 @@ export default class RoomPreviewBar extends React.Component {
footer = (
<div>
<Spinner w={20} h={20} />
{_t("Loading room preview")}
{ _t("Loading room preview") }
</div>
);
}
@ -465,11 +465,11 @@ export default class RoomPreviewBar extends React.Component {
if (inviteMember) {
inviterElement = <span>
<span className="mx_RoomPreviewBar_inviter">
{inviteMember.rawDisplayName}
</span> ({inviteMember.userId})
{ inviteMember.rawDisplayName }
</span> ({ inviteMember.userId })
</span>;
} else {
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{this.props.inviterName}</span>);
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{ this.props.inviterName }</span>);
}
const isDM = this._isDMInvite();
@ -549,7 +549,7 @@ export default class RoomPreviewBar extends React.Component {
if (!Array.isArray(subTitle)) {
subTitle = [subTitle];
}
subTitleElements = subTitle.map((t, i) => <p key={`subTitle${i}`}>{t}</p>);
subTitleElements = subTitle.map((t, i) => <p key={`subTitle${i}`}>{ t }</p>);
}
let titleElement;

View file

@ -408,10 +408,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
this.setState({ addRoomContextMenuPosition: null });
};
private onUnreadFirstChanged = async () => {
private onUnreadFirstChanged = () => {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
};
@ -574,20 +574,20 @@ export default class RoomSublist extends React.Component<IProps, IState> {
<React.Fragment>
<hr />
<div>
<div className='mx_RoomSublist_contextMenu_title'>{_t("Appearance")}</div>
<div className='mx_RoomSublist_contextMenu_title'>{ _t("Appearance") }</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Show rooms with unread messages first")}
{ _t("Show rooms with unread messages first") }
</StyledMenuItemCheckbox>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.layout.showPreviews}
>
{_t("Show previews of messages")}
{ _t("Show previews of messages") }
</StyledMenuItemCheckbox>
</div>
</React.Fragment>
@ -603,14 +603,14 @@ export default class RoomSublist extends React.Component<IProps, IState> {
>
<div className="mx_RoomSublist_contextMenu">
<div>
<div className='mx_RoomSublist_contextMenu_title'>{_t("Sort by")}</div>
<div className='mx_RoomSublist_contextMenu_title'>{ _t("Sort by") }</div>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("Activity")}
{ _t("Activity") }
</StyledMenuItemRadio>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
@ -618,10 +618,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
checked={isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("A-Z")}
{ _t("A-Z") }
</StyledMenuItemRadio>
</div>
{otherSections}
{ otherSections }
</div>
</ContextMenu>
);
@ -634,7 +634,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
onFinished={this.onCloseAddRoomMenu}
compact
>
{this.props.addRoomContextMenu(this.onCloseAddRoomMenu)}
{ this.props.addRoomContextMenu(this.onCloseAddRoomMenu) }
</IconizedContextMenu>
);
}
@ -647,7 +647,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
title={_t("List options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{contextMenu}
{ contextMenu }
</React.Fragment>
);
}
@ -655,7 +655,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private renderHeader(): React.ReactElement {
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
{({ onFocus, isActive, ref }) => {
{ ({ onFocus, isActive, ref }) => {
const tabIndex = isActive ? 0 : -1;
let ariaLabel = _t("Jump to first unread room.");
@ -711,7 +711,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const badgeContainer = (
<div className="mx_RoomSublist_badgeContainer">
{badge}
{ badge }
</div>
);
@ -746,17 +746,17 @@ export default class RoomSublist extends React.Component<IProps, IState> {
title={this.props.isMinimized ? this.props.label : undefined}
>
<span className={collapseClasses} />
<span>{this.props.label}</span>
<span>{ this.props.label }</span>
</Button>
{this.renderMenu()}
{this.props.isMinimized ? null : badgeContainer}
{this.props.isMinimized ? null : addRoomButton}
{ this.renderMenu() }
{ this.props.isMinimized ? null : badgeContainer }
{ this.props.isMinimized ? null : addRoomButton }
</div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
{ this.props.isMinimized ? badgeContainer : null }
{ this.props.isMinimized ? addRoomButton : null }
</div>
);
}}
} }
</RovingTabIndexWrapper>
);
}
@ -804,7 +804,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const label = _t("Show %(count)s more", { count: numMissing });
let showMoreText = (
<span className='mx_RoomSublist_showNButtonText'>
{label}
{ label }
</span>
);
if (this.props.isMinimized) showMoreText = null;
@ -816,9 +816,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
aria-label={label}
>
<span className='mx_RoomSublist_showMoreButtonChevron mx_RoomSublist_showNButtonChevron'>
{/* set by CSS masking */}
{ /* set by CSS masking */ }
</span>
{showMoreText}
{ showMoreText }
</RovingAccessibleButton>
);
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
@ -826,7 +826,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const label = _t("Show less");
let showLessText = (
<span className='mx_RoomSublist_showNButtonText'>
{label}
{ label }
</span>
);
if (this.props.isMinimized) showLessText = null;
@ -838,9 +838,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
aria-label={label}
>
<span className='mx_RoomSublist_showLessButtonChevron mx_RoomSublist_showNButtonChevron'>
{/* set by CSS masking */}
{ /* set by CSS masking */ }
</span>
{showLessText}
{ showLessText }
</RovingAccessibleButton>
);
}
@ -891,9 +891,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
enable={handles}
>
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
{ visibleTiles }
</div>
{showNButton}
{ showNButton }
</Resizable>
</React.Fragment>
);
@ -909,8 +909,8 @@ export default class RoomSublist extends React.Component<IProps, IState> {
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
>
{this.renderHeader()}
{content}
{ this.renderHeader() }
{ content }
</div>
);
}

View file

@ -358,6 +358,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.setState({ generalMenuPosition: null }); // hide the menu
};
private onCopyRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'copy_room',
room_id: this.props.room.roomId,
});
this.setState({ generalMenuPosition: null }); // hide the menu
};
private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -456,7 +467,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1}
/>
{contextMenu}
{ contextMenu }
</React.Fragment>
);
}
@ -510,13 +521,18 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
label={lowPriorityLabel}
iconClassName="mx_RoomTile_iconArrowDown"
/>
{canInvite ? (
{ canInvite ? (
<IconizedContextMenuOption
onClick={this.onInviteClick}
label={_t("Invite People")}
iconClassName="mx_RoomTile_iconInvite"
/>
) : null}
) : null }
<IconizedContextMenuOption
onClick={this.onCopyRoomClick}
label={_t("Copy Room Link")}
iconClassName="mx_RoomTile_iconCopyLink"
/>
<IconizedContextMenuOption
onClick={this.onOpenRoomSettings}
label={_t("Settings")}
@ -541,7 +557,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
title={_t("Room options")}
isExpanded={!!this.state.generalMenuPosition}
/>
{contextMenu}
{ contextMenu }
</React.Fragment>
);
}
@ -605,7 +621,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
id={messagePreviewId(this.props.room.roomId)}
title={this.state.messagePreview}
>
{this.state.messagePreview}
{ this.state.messagePreview }
</div>
);
}
@ -619,9 +635,9 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
let nameContainer = (
<div className="mx_RoomTile_nameContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
{ name }
</div>
{messagePreview}
{ messagePreview }
</div>
);
if (this.props.isMinimized) nameContainer = null;
@ -659,7 +675,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({ onFocus, isActive, ref }) =>
{ ({ onFocus, isActive, ref }) =>
<Button
{...props}
onFocus={onFocus}
@ -673,11 +689,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy}
>
{roomAvatar}
{nameContainer}
{badge}
{this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
{ roomAvatar }
{ nameContainer }
{ badge }
{ this.renderGeneralMenu() }
{ this.renderNotificationsMenu(isActive) }
</Button>
}
</RovingTabIndexWrapper>

View file

@ -72,26 +72,26 @@ export default class RoomUpgradeWarningBar extends React.PureComponent {
<div>
<div className="mx_RoomUpgradeWarningBar_body">
<p>
{_t(
{ _t(
"Upgrading this room will shut down the current instance of the room and create " +
"an upgraded room with the same name.",
)}
) }
</p>
<p>
{_t(
{ _t(
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members " +
"to the new version of the room.</i> We'll post a link to the new room in the old " +
"version of the room - room members will have to click this link to join the new room.",
{}, {
"b": (sub) => <b>{sub}</b>,
"i": (sub) => <i>{sub}</i>,
"b": (sub) => <b>{ sub }</b>,
"i": (sub) => <i>{ sub }</i>,
},
)}
) }
</p>
</div>
<p className="mx_RoomUpgradeWarningBar_upgradelink">
<AccessibleButton onClick={this.onUpgradeClick}>
{_t("Upgrade this room to the recommended room version")}
{ _t("Upgrade this room to the recommended room version") }
</AccessibleButton>
</p>
</div>
@ -101,7 +101,7 @@ export default class RoomUpgradeWarningBar extends React.PureComponent {
doUpgradeWarnings = (
<div className="mx_RoomUpgradeWarningBar_body">
<p>
{_t("This room has already been upgraded.")}
{ _t("This room has already been upgraded.") }
</p>
</div>
);
@ -111,19 +111,19 @@ export default class RoomUpgradeWarningBar extends React.PureComponent {
<div className="mx_RoomUpgradeWarningBar">
<div className="mx_RoomUpgradeWarningBar_wrapped">
<div className="mx_RoomUpgradeWarningBar_header">
{_t(
{ _t(
"This room is running room version <roomVersion />, which this homeserver has " +
"marked as <i>unstable</i>.",
{},
{
"roomVersion": () => <code>{this.props.room.getVersion()}</code>,
"i": (sub) => <i>{sub}</i>,
"roomVersion": () => <code>{ this.props.room.getVersion() }</code>,
"i": (sub) => <i>{ sub }</i>,
},
)}
) }
</div>
{doUpgradeWarnings}
{ doUpgradeWarnings }
<div className="mx_RoomUpgradeWarningBar_small">
{_t("Only room administrators will see this warning")}
{ _t("Only room administrators will see this warning") }
</div>
</div>
</div>

View file

@ -100,7 +100,7 @@ export default class SearchBar extends React.Component<IProps, IState> {
aria-checked={this.state.scope === SearchScope.Room}
role="radio"
>
{_t("This Room")}
{ _t("This Room") }
</AccessibleButton>
<AccessibleButton
className={allRoomsClasses}
@ -108,7 +108,7 @@ export default class SearchBar extends React.Component<IProps, IState> {
aria-checked={this.state.scope === SearchScope.All}
role="radio"
>
{_t("All Rooms")}
{ _t("All Rooms") }
</AccessibleButton>
</div>
<div className="mx_SearchBar_input mx_textinput">
@ -119,7 +119,7 @@ export default class SearchBar extends React.Component<IProps, IState> {
placeholder={_t("Search…")}
onKeyDown={this.onSearchChange}
/>
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch} />
<AccessibleButton className={searchButtonClasses} onClick={this.onSearch} />
</div>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick} />
</div>

View file

@ -15,39 +15,42 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { haveTileForEvent } from "./EventTile";
import React from "react";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import RoomContext from "../../../contexts/RoomContext";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import DateSeparator from "../messages/DateSeparator";
import EventTile, { haveTileForEvent } from "./EventTile";
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,
export default class SearchResultTile extends React.Component<IProps> {
static contextType = RoomContext;
// 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');
public render() {
const result = this.props.searchResult;
const mxEv = result.context.getEvent();
const eventId = mxEv.getId();
const ts1 = mxEv.getTs();
const ret = [<DateSeparator key={ts1 + "-search"} ts={ts1} />];
const layout = SettingsStore.getValue("layout");
const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
const enableFlair = SettingsStore.getValue(UIFeature.Flair);
const timeline = result.context.getTimeline();
for (let j = 0; j < timeline.length; j++) {
@ -57,26 +60,25 @@ export default class SearchResultTile extends React.Component {
if (!contextual) {
highlights = this.props.searchHighlights;
}
if (haveTileForEvent(ev)) {
ret.push((
if (haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline)) {
ret.push(
<EventTile
key={`${eventId}+${j}`}
mxEvent={ev}
layout={layout}
contextual={contextual}
highlights={highlights}
permalinkCreator={this.props.permalinkCreator}
highlightLink={this.props.resultLink}
onHeightChanged={this.props.onHeightChanged}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
isTwelveHour={isTwelveHour}
alwaysShowTimestamps={alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
/>
));
enableFlair={enableFlair}
/>,
);
}
}
return (
<li data-scroll-tokens={eventId}>
{ ret }
</li>);
return <li data-scroll-tokens={eventId}>{ ret }</li>;
}
}

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,42 +13,53 @@ 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 { DebouncedFunc, throttle } from 'lodash';
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model';
import {
htmlSerializeIfNeeded,
textSerialize,
containsEmote,
stripEmoteCommand,
unescapeMessage,
htmlSerializeIfNeeded,
startsWith,
stripEmoteCommand,
stripPrefix,
textSerialize,
unescapeMessage,
} 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";
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 +75,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 +90,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 +108,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 +125,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 +181,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 +200,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 +231,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,17 +254,17 @@ 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;
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].getType() === "m.room.message") {
if (events[i].getType() === EventType.RoomMessage) {
let shouldReact = true;
const lastMessage = events[i];
const userId = MatrixClientPeg.get().getUserId();
const messageReactions = this.props.room.getUnfilteredTimelineSet()
.getRelationsForEvent(lastMessage.getId(), "m.annotation", "m.reaction");
.getRelationsForEvent(lastMessage.getId(), RelationType.Annotation, EventType.Reaction);
// if we have already sent this reaction, don't redact but don't re-send
if (messageReactions) {
@ -258,9 +275,9 @@ export default class SendMessageComposer extends React.Component {
shouldReact = !myReactionKeys.includes(reaction);
}
if (shouldReact) {
MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), "m.reaction", {
MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), EventType.Reaction, {
"m.relates_to": {
"rel_type": "m.annotation",
"rel_type": RelationType.Annotation,
"event_id": lastMessage.getId(),
"key": reaction,
},
@ -272,7 +289,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 +301,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 +319,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 +342,7 @@ export default class SendMessageComposer extends React.Component {
}
}
async _sendMessage() {
public async sendMessage(): Promise<void> {
if (this.model.isEmpty) {
return;
}
@ -335,21 +351,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 +393,7 @@ export default class SendMessageComposer extends React.Component {
if (isQuickReaction(this.model)) {
shouldSend = false;
this._sendQuickReaction();
this.sendQuickReaction();
}
if (shouldSend) {
@ -411,9 +426,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 +436,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
UNSAFE_componentWillMount() { // eslint-disable-line
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,42 +477,42 @@ 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;
switch (payload.action) {
case 'reply_to_event':
case Action.FocusComposer:
this._editorRef && this._editorRef.focus();
case Action.FocusSendMessageComposer:
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 +526,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

@ -27,7 +27,7 @@ export default class SimpleRoomHeader extends React.Component {
static propTypes = {
title: PropTypes.string,
// `src` to a TintableSvg. Optional.
// `src` to an image. Optional.
icon: PropTypes.string,
};

View file

@ -224,7 +224,7 @@ export default class Stickerpicker extends React.PureComponent {
}
_getStickerpickerContent() {
// Handle Integration Manager errors
// Handle integration manager errors
if (this.state._imError) {
return this._errorStickerpickerContent();
}

View file

@ -20,13 +20,14 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import { isValid3pidInvite } from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import ErrorDialog from '../dialogs/ErrorDialog';
import AccessibleButton from '../elements/AccessibleButton';
import SpaceStore from "../../../stores/SpaceStore";
interface IProps {
event: MatrixEvent;
@ -104,7 +105,6 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
// Revert echo because of error
this.setState({ invited: true });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Revoke 3pid invite failed', '', ErrorDialog, {
title: _t("Failed to revoke invite"),
description: _t(
@ -119,16 +119,14 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
};
render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
let adminTools = null;
if (this.state.canKick && this.state.invited) {
adminTools = (
<div className="mx_MemberInfo_container">
<h3>{_t("Admin Tools")}</h3>
<h3>{ _t("Admin Tools") }</h3>
<div className="mx_MemberInfo_buttons">
<AccessibleButton className="mx_MemberInfo_field" onClick={this.onKickClick}>
{_t("Revoke invite")}
{ _t("Revoke invite") }
</AccessibleButton>
</div>
</div>
@ -136,7 +134,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
}
let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && this.room.isSpaceRoom()) {
if (SpaceStore.spacesEnabled && this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} />
@ -152,16 +150,16 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
onClick={this.onCancel}
title={_t('Close')}
/>
<h2>{this.state.displayName}</h2>
<h2>{ this.state.displayName }</h2>
</div>
<div className="mx_MemberInfo_container">
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">
{_t("Invited by %(sender)s", { sender: this.state.senderName })}
{ _t("Invited by %(sender)s", { sender: this.state.senderName }) }
</div>
</div>
</div>
{adminTools}
{ adminTools }
</div>
);
}

View file

@ -18,26 +18,22 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler";
import React, { ReactNode } from "react";
import {
IRecordingUpdate,
RECORDING_PLAYBACK_SAMPLES,
RecordingState,
VoiceRecording,
} from "../../../voice/VoiceRecording";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import classNames from "classnames";
import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers";
import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import MediaDeviceHandler from "../../../MediaDeviceHandler";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
interface IProps {
room: Room;
@ -46,8 +42,6 @@ interface IProps {
interface IState {
recorder?: VoiceRecording;
recordingPhase?: RecordingState;
relHeights: number[];
seconds: number;
}
/**
@ -55,58 +49,18 @@ interface IState {
*/
@replaceableComponent("views.rooms.VoiceRecordComposerTile")
export default class VoiceRecordComposerTile extends React.PureComponent<IProps, IState> {
private waveform: number[] = [];
private seconds = 0;
private scheduledAnimationFrame = false;
public constructor(props) {
super(props);
this.state = {
recorder: null, // no recording started by default
seconds: 0,
relHeights: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
};
}
public componentDidUpdate(prevProps, prevState) {
if (!prevState.recorder && this.state.recorder) {
this.state.recorder.liveData.onUpdate(this.onRecordingUpdate);
}
}
public async componentWillUnmount() {
await VoiceRecordingStore.instance.disposeRecording();
}
private onRecordingUpdate = (update: IRecordingUpdate): void => {
this.waveform = update.waveform;
this.seconds = update.timeSeconds;
if (this.scheduledAnimationFrame) {
return;
}
this.scheduledAnimationFrame = true;
// The audio recorder flushes data faster than the screen refresh rate
// Using requestAnimationFrame makes sure that we only flush the data
// to react once per tick to avoid unneeded work.
requestAnimationFrame(() => {
// The waveform and the downsample target are pretty close, so we should be fine to
// do this, despite the docs on arrayFastResample.
const bars = arrayFastResample(Array.from(this.waveform), RECORDING_PLAYBACK_SAMPLES);
this.setState({
// The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the
// waveform. This results in a slightly more cinematic/animated waveform for the
// user.
relHeights: bars.map(b => percentageOf(b, 0, 0.50)),
seconds: this.seconds,
});
this.scheduledAnimationFrame = false;
});
};
// called by composer
public async send() {
if (!this.state.recorder) {
@ -141,7 +95,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// https://github.com/matrix-org/matrix-doc/pull/3246
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)),
},
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
});
@ -170,9 +124,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
Modal.createTrackedDialog('Microphone Access Error', '', ErrorDialog, {
title: _t("Unable to access your microphone"),
description: <>
<p>{_t(
<p>{ _t(
"We were unable to access your microphone. Please check your browser settings and try again.",
)}</p>
) }</p>
</>,
});
};
@ -181,13 +135,13 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
// change between this and recording, but at least we will have tried.
try {
const devices = await MediaDeviceHandler.getDevices();
if (!devices?.['audioInput']?.length) {
if (!devices?.[MediaDeviceKindEnum.AudioInput]?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"),
description: <>
<p>{_t(
<p>{ _t(
"We didn't find a microphone on your device. Please check your settings and try again.",
)}</p>
) }</p>
</>,
});
return;
@ -228,7 +182,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
}
// only other UI is the recording-in-progress UI
return <div className="mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
return <div className="mx_MediaBody mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
<LiveRecordingClock recorder={this.state.recorder} />
<LiveRecordingWaveform recorder={this.state.recorder} />
</div>;
@ -270,9 +224,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
}
return (<>
{deleteButton}
{this.renderWaveformArea()}
{recordingInfo}
{ deleteButton }
{ this.renderWaveformArea() }
{ recordingInfo }
</>);
}
}

View file

@ -64,8 +64,8 @@ export default class WhoIsTypingTile extends React.Component<IProps, IState> {
}
componentDidUpdate(_, prevState) {
const wasVisible = this._isVisible(prevState);
const isVisible = this._isVisible(this.state);
const wasVisible = WhoIsTypingTile.isVisible(prevState);
const isVisible = WhoIsTypingTile.isVisible(this.state);
if (this.props.onShown && !wasVisible && isVisible) {
this.props.onShown();
} else if (this.props.onHidden && wasVisible && !isVisible) {
@ -83,12 +83,12 @@ export default class WhoIsTypingTile extends React.Component<IProps, IState> {
Object.values(this.state.delayedStopTypingTimers).forEach((t) => (t as Timer).abort());
}
private _isVisible(state: IState): boolean {
private static isVisible(state: IState): boolean {
return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0;
}
public isVisible = (): boolean => {
return this._isVisible(this.state);
return WhoIsTypingTile.isVisible(this.state);
};
private onRoomTimeline = (event: MatrixEvent, room: Room): void => {