Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into spaces-jump-to-room
This commit is contained in:
commit
76040c652c
582 changed files with 15328 additions and 10678 deletions
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>);
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
92
src/components/views/rooms/LinkPreviewGroup.tsx
Normal file
92
src/components/views/rooms/LinkPreviewGroup.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>;
|
||||
|
|
@ -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>;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
155
src/components/views/rooms/ReplyTile.tsx
Normal file
155
src/components/views/rooms/ReplyTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
|
@ -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 you’re looking for?")}</div>
|
||||
<div>{ _t("Can't see what you’re 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
</>);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue