Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View file

@ -0,0 +1,362 @@
/*
Copyright 2018-2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { AriaRole } from "react";
import classNames from "classnames";
import { Resizable, Size } from "re-resizable";
import { Room } from "matrix-js-sdk/src/matrix";
import { IWidget } from "matrix-widget-api";
import AppTile from "../elements/AppTile";
import dis from "../../../dispatcher/dispatcher";
import * as ScalarMessaging from "../../../ScalarMessaging";
import WidgetUtils from "../../../utils/WidgetUtils";
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer, { IConfig } from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers";
import UIStore from "../../../stores/UIStore";
import { ActionPayload } from "../../../dispatcher/payloads";
import Spinner from "../elements/Spinner";
import SdkConfig from "../../../SdkConfig";
interface IProps {
userId: string;
room: Room;
resizeNotifier: ResizeNotifier;
showApps?: boolean; // Should apps be rendered
maxHeight: number;
role?: AriaRole;
}
interface IState {
apps: {
[Container.Top]: IWidget[];
[Container.Center]: IWidget[];
[Container.Right]?: IWidget[];
};
resizingVertical: boolean; // true when changing the height of the apps drawer
resizingHorizontal: boolean; // true when changing the distribution of the width between widgets
resizing: boolean;
}
export default class AppsDrawer extends React.Component<IProps, IState> {
private unmounted = false;
private resizeContainer?: HTMLDivElement;
private resizer: Resizer<IConfig>;
private dispatcherRef?: string;
public static defaultProps: Partial<IProps> = {
showApps: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
apps: this.getApps(),
resizingVertical: false,
resizingHorizontal: false,
resizing: false,
};
this.resizer = this.createResizer();
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
}
public componentDidMount(): void {
ScalarMessaging.startListening();
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this.updateApps);
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount(): void {
this.unmounted = true;
ScalarMessaging.stopListening();
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this.updateApps);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
if (this.resizeContainer) {
this.resizer.detach();
}
this.props.resizeNotifier.off("isResizing", this.onIsResizing);
}
private onIsResizing = (resizing: boolean): void => {
// This one is the vertical, ie. change height of apps drawer
this.setState({ resizingVertical: resizing });
if (!resizing) {
this.relaxResizer();
}
};
private createResizer(): Resizer<IConfig> {
// This is the horizontal one, changing the distribution of the width between the app tiles
// (ie. a vertical resize handle because, the handle itself is vertical...)
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle--vertical",
reverse: "mx_ResizeHandle_reverse",
};
const collapseConfig = {
onResizeStart: () => {
this.resizeContainer?.classList.add("mx_AppsDrawer--resizing");
this.setState({ resizingHorizontal: true });
},
onResizeStop: () => {
this.resizeContainer?.classList.remove("mx_AppsDrawer--resizing");
WidgetLayoutStore.instance.setResizerDistributions(
this.props.room,
Container.Top,
this.topApps()
.slice(1)
.map((_, i) => this.resizer.forHandleAt(i)!.size),
);
this.setState({ resizingHorizontal: false });
},
};
// pass a truthy container for now, we won't call attach until we update it
const resizer = new Resizer(null, PercentageDistributor, collapseConfig);
resizer.setClassNames(classNames);
return resizer;
}
private collectResizer = (ref: HTMLDivElement): void => {
if (this.resizeContainer) {
this.resizer.detach();
}
if (ref) {
this.resizer.container = ref;
this.resizer.attach();
}
this.resizeContainer = ref;
this.loadResizerPreferences();
};
private getAppsHash = (apps: IWidget[]): string => apps.map((app) => app.id).join("~");
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) {
// Room has changed, update apps
this.updateApps();
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps[Container.Top])) {
this.loadResizerPreferences();
}
}
private relaxResizer = (): void => {
const distributors = this.resizer.getDistributors();
// relax all items if they had any overconstrained flexboxes
distributors.forEach((d) => d.start());
distributors.forEach((d) => d.finish());
};
private loadResizerPreferences = (): void => {
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
if (this.state.apps && this.topApps().length - 1 === distributions.length) {
distributions.forEach((size, i) => {
const distributor = this.resizer.forHandleAt(i);
if (distributor) {
distributor.size = size;
distributor.finish();
}
});
} else if (this.state.apps) {
const distributors = this.resizer.getDistributors();
distributors.forEach((d) => d.item.clearSize());
distributors.forEach((d) => d.start());
distributors.forEach((d) => d.finish());
}
};
private isResizing(): boolean {
return this.state.resizingVertical || this.state.resizingHorizontal;
}
private onAction = (action: ActionPayload): void => {
const hideWidgetKey = this.props.room.roomId + "_hide_widget_drawer";
switch (action.action) {
case "appsDrawer":
// Note: these booleans are awkward because localstorage is fundamentally
// string-based. We also do exact equality on the strings later on.
if (action.show) {
localStorage.setItem(hideWidgetKey, "false");
} else {
// Store hidden state of widget
// Don't show if previously hidden
localStorage.setItem(hideWidgetKey, "true");
}
break;
}
};
private getApps = (): IState["apps"] => ({
[Container.Top]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top),
[Container.Center]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Center),
});
private topApps = (): IWidget[] => this.state.apps[Container.Top];
private centerApps = (): IWidget[] => this.state.apps[Container.Center];
private updateApps = (): void => {
if (this.unmounted) return;
this.setState({
apps: this.getApps(),
});
};
public render(): React.ReactNode {
if (!this.props.showApps) return <div />;
const widgetIsMaxmised: boolean = this.centerApps().length > 0;
const appsToDisplay = widgetIsMaxmised ? this.centerApps() : this.topApps();
const apps = appsToDisplay.map((app, index, arr) => {
return (
<AppTile
key={app.id}
app={app}
fullWidth={arr.length < 2}
room={this.props.room}
userId={this.props.userId}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
pointerEvents={this.isResizing() ? "none" : undefined}
/>
);
});
if (apps.length === 0) {
return <div />;
}
let spinner;
if (
apps.length === 0 &&
WidgetEchoStore.roomHasPendingWidgets(this.props.room.roomId, WidgetUtils.getRoomWidgets(this.props.room))
) {
spinner = <Spinner />;
}
const classes = classNames({
"mx_AppsDrawer": true,
"mx_AppsDrawer--maximised": widgetIsMaxmised,
"mx_AppsDrawer--resizing": this.state.resizing,
"mx_AppsDrawer--2apps": apps.length === 2,
"mx_AppsDrawer--3apps": apps.length === 3,
});
const appContainers = (
<div className="mx_AppsContainer" ref={this.collectResizer}>
{apps.map((app, i) => {
if (i < 1) return app;
return (
<React.Fragment key={app.key}>
<ResizeHandle reverse={i > apps.length / 2} />
{app}
</React.Fragment>
);
})}
</div>
);
let drawer;
if (widgetIsMaxmised) {
drawer = appContainers;
} else {
drawer = (
<PersistentVResizer
room={this.props.room}
minHeight={100}
maxHeight={this.props.maxHeight - 50}
className="mx_AppsDrawer_resizer"
handleWrapperClass="mx_AppsDrawer_resizer_container"
handleClass="mx_AppsDrawer_resizer_container_handle"
resizeNotifier={this.props.resizeNotifier}
>
{appContainers}
</PersistentVResizer>
);
}
return (
<div role={this.props.role} className={classes}>
{drawer}
{spinner}
</div>
);
}
}
interface IPersistentResizerProps {
room: Room;
minHeight: number;
maxHeight: number;
className: string;
handleWrapperClass: string;
handleClass: string;
resizeNotifier: ResizeNotifier;
children: React.ReactNode;
}
const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
room,
minHeight,
maxHeight,
className,
handleWrapperClass,
handleClass,
resizeNotifier,
children,
}) => {
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, Container.Top);
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
if (!minHeight) minHeight = 100;
if (!maxHeight) maxHeight = (UIStore.instance.windowHeight / 4) * 3;
// Convert from percentage to height. Note that the default height is 280px.
if (defaultHeight) {
defaultHeight = clamp(defaultHeight, 0, 100);
defaultHeight = percentageWithin(defaultHeight / 100, minHeight, maxHeight);
} else {
defaultHeight = SdkConfig.get().default_widget_container_height ?? 280;
}
return (
<Resizable
// types do not support undefined height/width
// but resizable code checks specifically for undefined on Size prop
size={{ height: Math.min(defaultHeight, maxHeight), width: undefined } as unknown as Size}
minHeight={minHeight}
maxHeight={maxHeight}
onResizeStart={() => {
resizeNotifier.startResizing();
}}
onResize={() => {
resizeNotifier.notifyTimelineHeightChanged();
}}
onResizeStop={(e, dir, ref, d) => {
let newHeight = defaultHeight! + d.height;
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
resizeNotifier.stopResizing();
}}
className={className}
handleWrapperClass={handleWrapperClass}
handleClasses={{ bottom: handleClass }}
enable={{ bottom: true }}
>
{children}
</Resizable>
);
};

View file

@ -0,0 +1,314 @@
/*
Copyright 2017-2024 New Vector Ltd.
Copyright 2016 Aviral Dasgupta
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, KeyboardEvent } from "react";
import classNames from "classnames";
import { flatMap } from "lodash";
import { Room } from "matrix-js-sdk/src/matrix";
import Autocompleter, { ICompletion, ISelectionRange, IProviderCompletions } from "../../../autocomplete/Autocompleter";
import SettingsStore from "../../../settings/SettingsStore";
import RoomContext from "../../../contexts/RoomContext";
const MAX_PROVIDER_MATCHES = 20;
export const generateCompletionDomId = (n: number): string => `mx_Autocomplete_Completion_${n}`;
interface IProps {
// the query string for which to show autocomplete suggestions
query: string;
// method invoked with range and text content when completion is confirmed
onConfirm: (completion: ICompletion) => void;
// method invoked when selected (if any) completion changes
onSelectionChange?: (partIndex: number) => void;
selection: ISelectionRange;
// The room in which we're autocompleting
room: Room;
}
interface IState {
completions: IProviderCompletions[];
completionList: ICompletion[];
selectionOffset: number;
shouldShowCompletions: boolean;
hide: boolean;
forceComplete: boolean;
}
export default class Autocomplete extends React.PureComponent<IProps, IState> {
public autocompleter?: Autocompleter;
public queryRequested?: string;
public debounceCompletionsRequest?: number;
private containerRef = createRef<HTMLDivElement>();
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
// list of completionResults, each containing completions
completions: [],
// array of completions, so we can look up current selection by offset quickly
completionList: [],
// how far down the completion list we are (THIS IS 1-INDEXED!)
selectionOffset: 1,
// whether we should show completions if they're available
shouldShowCompletions: true,
hide: false,
forceComplete: false,
};
}
public componentDidMount(): void {
this.autocompleter = new Autocompleter(this.props.room, this.context.timelineRenderingType);
this.applyNewProps();
}
private applyNewProps(oldQuery?: string, oldRoom?: Room): void {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter?.destroy();
this.autocompleter = new Autocompleter(this.props.room);
}
// Query hasn't changed so don't try to complete it
if (oldQuery === this.props.query) {
return;
}
this.complete(this.props.query, this.props.selection);
}
public componentWillUnmount(): void {
this.autocompleter?.destroy();
}
private complete(query: string, selection: ISelectionRange): Promise<void> {
this.queryRequested = query;
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
}
if (query === "") {
this.setState({
// Clear displayed completions
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: 1,
// Hide the autocomplete box
hide: true,
});
return Promise.resolve();
}
let autocompleteDelay = SettingsStore.getValue("autocompleteDelay");
// Don't debounce if we are already showing completions
if (this.state.completions.length > 0 || this.state.forceComplete) {
autocompleteDelay = 0;
}
return new Promise((resolve) => {
this.debounceCompletionsRequest = window.setTimeout(() => {
resolve(this.processQuery(query, selection));
}, autocompleteDelay);
});
}
private async processQuery(query: string, selection: ISelectionRange): Promise<void> {
return this.autocompleter
?.getCompletions(query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES)
.then((completions) => {
// Only ever process the completions for the most recent query being processed
if (query !== this.queryRequested) {
return;
}
this.processCompletions(completions);
});
}
private processCompletions(completions: IProviderCompletions[]): void {
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
let selectionOffset = 1;
if (completionList.length > 0) {
/* If the currently selected completion is still in the completion list,
try to find it and jump to it. If not, select composer.
*/
const currentSelection =
this.state.selectionOffset <= 1
? null
: this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex((completion) => completion.completion === currentSelection);
if (selectionOffset === -1) {
selectionOffset = 1;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
}
let hide = true;
// If `completion.command.command` is truthy, then a provider has matched with the query
const anyMatches = completions.some((completion) => !!completion.command.command);
if (anyMatches) {
hide = false;
if (this.props.onSelectionChange) {
this.props.onSelectionChange(selectionOffset - 1);
}
}
this.setState({
completions,
completionList,
selectionOffset,
hide,
// Force complete is turned off each time since we can't edit the query in that case
forceComplete: false,
});
}
public hasSelection(): boolean {
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
}
public countCompletions(): number {
return this.state.completionList.length;
}
// called from MessageComposerInput
public moveSelection(delta: number): void {
const completionCount = this.countCompletions();
if (completionCount === 0) return; // there are no items to move the selection through
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
this.setSelection(1 + index);
}
public onEscape(e: KeyboardEvent): boolean | undefined {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
return;
}
e.preventDefault();
// selectionOffset = 0, so we don't end up completing when autocomplete is hidden
this.hide();
}
private hide = (): void => {
this.setState({
hide: true,
selectionOffset: 1,
completions: [],
completionList: [],
});
};
public forceComplete(): Promise<number> {
return new Promise((resolve) => {
this.setState(
{
forceComplete: true,
hide: false,
},
() => {
this.complete(this.props.query, this.props.selection).then(() => {
resolve(this.countCompletions());
});
},
);
});
}
public onConfirmCompletion = (): void => {
this.onCompletionClicked(this.state.selectionOffset);
};
private onCompletionClicked = (selectionOffset: number): boolean => {
const count = this.countCompletions();
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
return false;
}
this.props.onConfirm(this.state.completionList[selectionOffset - 1]);
this.hide();
return true;
};
private setSelection(selectionOffset: number): void {
this.setState({ selectionOffset, hide: false });
if (this.props.onSelectionChange) {
this.props.onSelectionChange(selectionOffset - 1);
}
}
public componentDidUpdate(prevProps: IProps): void {
this.applyNewProps(prevProps.query, prevProps.room);
// this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`] as HTMLElement;
if (selectedCompletion) {
selectedCompletion.scrollIntoView({
behavior: "auto",
block: "nearest",
});
} else if (this.containerRef.current) {
this.containerRef.current.scrollTo({ top: 0 });
}
}
public render(): React.ReactNode {
let position = 1;
const renderedCompletions = this.state.completions
.map((completionResult, i) => {
const completions = completionResult.completions.map((completion, j) => {
const selected = position === this.state.selectionOffset;
const className = classNames("mx_Autocomplete_Completion", { selected });
const componentPosition = position;
position++;
const onClick = (): void => {
this.onCompletionClicked(componentPosition);
};
return React.cloneElement(completion.component, {
"key": j,
"ref": `completion${componentPosition}`,
"id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs
className,
onClick,
"aria-selected": selected,
});
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
<div className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</div>
{completionResult.provider.renderCompletions(completions)}
</div>
) : null;
})
.filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
{renderedCompletions}
</div>
) : null;
}
}

View file

@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import AppsDrawer from "./AppsDrawer";
import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { UIFeature } from "../../../settings/UIFeature";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import LegacyCallViewForRoom from "../voip/LegacyCallViewForRoom";
import { objectHasDiff } from "../../../utils/objects";
interface IProps {
// js-sdk room object
room: Room;
userId: string;
showApps: boolean; // Render apps
resizeNotifier: ResizeNotifier;
children?: ReactNode;
}
export default class AuxPanel extends React.Component<IProps> {
public static defaultProps = {
showApps: true,
};
public shouldComponentUpdate(nextProps: IProps): boolean {
return objectHasDiff(this.props, nextProps);
}
public render(): React.ReactNode {
const callView = (
<LegacyCallViewForRoom
roomId={this.props.room.roomId}
resizeNotifier={this.props.resizeNotifier}
showApps={this.props.showApps}
/>
);
let appsDrawer;
if (SettingsStore.getValue(UIFeature.Widgets)) {
appsDrawer = (
<AppsDrawer
room={this.props.room}
userId={this.props.userId}
showApps={this.props.showApps}
resizeNotifier={this.props.resizeNotifier}
/>
);
}
return (
<AutoHideScrollbar role="region" className="mx_AuxPanel">
{this.props.children}
{appsDrawer}
{callView}
</AutoHideScrollbar>
);
}
}

View file

@ -0,0 +1,915 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { createRef, ClipboardEvent, SyntheticEvent } from "react";
import { Room, MatrixEvent } from "matrix-js-sdk/src/matrix";
import EMOTICON_REGEX from "emojibase-regex/emoticon";
import { logger } from "matrix-js-sdk/src/logger";
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
import EditorModel from "../../../editor/model";
import HistoryManager from "../../../editor/history";
import { Caret, setSelection } from "../../../editor/caret";
import {
formatRange,
formatRangeAsLink,
replaceRangeAndMoveCaret,
toggleInlineFormat,
} from "../../../editor/operations";
import { getCaretOffsetAndText, getRangeForSelection } from "../../../editor/dom";
import Autocomplete, { generateCompletionDomId } from "../rooms/Autocomplete";
import { getAutoCompleteCreator, Part, SerializedPart, Type } from "../../../editor/parts";
import { parseEvent, parsePlainTextMessage } from "../../../editor/deserialize";
import { renderModel } from "../../../editor/render";
import SettingsStore from "../../../settings/SettingsStore";
import { IS_MAC, Key } from "../../../Keyboard";
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
import Range from "../../../editor/range";
import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar";
import DocumentOffset from "../../../editor/offset";
import { IDiff } from "../../../editor/diff";
import AutocompleteWrapperModel from "../../../editor/autocomplete";
import DocumentPosition from "../../../editor/position";
import { ICompletion } from "../../../autocomplete/Autocompleter";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { ALTERNATE_KEY_NAME, KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { _t } from "../../../languageHandler";
import { linkify } from "../../../linkify-matrix";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
// matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp("(?:^|\\s)(" + EMOTICON_REGEX.source + ")\\s|:^$");
export const REGEX_EMOTICON = new RegExp("(?:^|\\s)(" + EMOTICON_REGEX.source + ")$");
const SURROUND_WITH_CHARACTERS = ['"', "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
["(", ")"],
["[", "]"],
["{", "}"],
["<", ">"],
]);
function ctrlShortcutLabel(key: string, needsShift = false, needsAlt = false): string {
return (
(IS_MAC ? "⌘" : _t(ALTERNATE_KEY_NAME[Key.CONTROL])) +
(needsShift ? "+" + _t(ALTERNATE_KEY_NAME[Key.SHIFT]) : "") +
(needsAlt ? "+" + _t(ALTERNATE_KEY_NAME[Key.ALT]) : "") +
"+" +
key
);
}
function cloneSelection(selection: Selection): Partial<Selection> {
return {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode,
focusOffset: selection.focusOffset,
isCollapsed: selection.isCollapsed,
rangeCount: selection.rangeCount,
type: selection.type,
};
}
function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
return (
a.anchorNode === b.anchorNode &&
a.anchorOffset === b.anchorOffset &&
a.focusNode === b.focusNode &&
a.focusOffset === b.focusOffset &&
a.isCollapsed === b.isCollapsed &&
a.rangeCount === b.rangeCount &&
a.type === b.type
);
}
interface IProps {
model: EditorModel;
room: Room;
threadId?: string;
placeholder?: string;
label?: string;
initialCaret?: DocumentOffset;
disabled?: boolean;
onChange?(selection?: Caret, inputType?: string, diff?: IDiff): void;
onPaste?(event: Event | SyntheticEvent, data: DataTransfer, model: EditorModel): boolean;
}
interface IState {
useMarkdown: boolean;
showPillAvatar: boolean;
query?: string;
showVisualBell?: boolean;
autoComplete?: AutocompleteWrapperModel;
completionIndex?: number;
surroundWith: boolean;
}
export default class BasicMessageEditor extends React.Component<IProps, IState> {
public readonly editorRef = createRef<HTMLDivElement>();
private autocompleteRef = createRef<Autocomplete>();
private formatBarRef = createRef<MessageComposerFormatBar>();
private modifiedFlag = false;
private isIMEComposing = false;
private hasTextSelected = false;
private readonly isSafari: boolean;
private _isCaretAtEnd = false;
private lastCaret!: DocumentOffset;
private lastSelection: ReturnType<typeof cloneSelection> | null = null;
private readonly useMarkdownHandle: string;
private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string;
private readonly historyManager = new HistoryManager();
public constructor(props: IProps) {
super(props);
this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
showVisualBell: false,
};
const ua = navigator.userAgent.toLowerCase();
this.isSafari = ua.includes("safari/") && !ua.includes("chrome/");
this.useMarkdownHandle = SettingsStore.watchSetting(
"MessageComposerInput.useMarkdown",
null,
this.configureUseMarkdown,
);
this.emoticonSettingHandle = SettingsStore.watchSetting(
"MessageComposerInput.autoReplaceEmoji",
null,
this.configureEmoticonAutoReplace,
);
this.configureEmoticonAutoReplace();
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting(
"Pill.shouldShowPillAvatar",
null,
this.configureShouldShowPillAvatar,
);
this.surroundWithHandle = SettingsStore.watchSetting(
"MessageComposerInput.surroundWith",
null,
this.surroundWithSettingChanged,
);
}
public componentDidUpdate(prevProps: IProps): void {
// We need to re-check the placeholder when the enabled state changes because it causes the
// placeholder element to remount, which gets rid of the `::before` class. Re-evaluating the
// placeholder means we get a proper `::before` with the placeholder.
const enabledChange = this.props.disabled !== prevProps.disabled;
const placeholderChanged = this.props.placeholder !== prevProps.placeholder;
if (this.props.placeholder && (placeholderChanged || enabledChange)) {
const { isEmpty } = this.props.model;
if (isEmpty) {
this.showPlaceholder();
} else {
this.hidePlaceholder();
}
}
}
public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number | undefined {
const { model } = this.props;
const range = model.startRange(caretPosition);
// expand range max 9 characters backwards from caretPosition,
// as a space to look for an emoticon
let n = 9;
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type);
});
const emoticonMatch = regex.exec(range.text);
// ignore matches at start of proper substrings
// so xd will not match if the string was "mixd 123456"
// and we are lookinh at xd 123456 part of the string
if (emoticonMatch && (n >= 0 || emoticonMatch.index !== 0)) {
const query = emoticonMatch[1];
// variations of plaintext emoitcons(E.g. :P vs :p vs :-P) are handled upstream by the emojibase-bindings library
const data = EMOTICON_TO_EMOJI.get(query);
if (data) {
const { partCreator } = model;
const firstMatch = emoticonMatch[0];
const moveStart = firstMatch[0] === " " ? 1 : 0;
// we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji,
// so move the start forward to the start of the emoticon.
// Take + 1 because index is reported without the possible preceding space.
range.moveStartForwards(emoticonMatch.index + moveStart);
// If the end is a trailing space/newline move end backwards, so that we don't replace it
if (["\n", " "].includes(firstMatch[firstMatch.length - 1])) {
range.moveEndBackwards(1);
}
// this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted.
return range.replace([partCreator.emoji(data.unicode)]);
}
}
}
private updateEditorState = (selection?: Caret, inputType?: string, diff?: IDiff): void => {
if (!this.editorRef.current) return;
renderModel(this.editorRef.current, this.props.model);
if (selection) {
// set the caret/selection
try {
setSelection(this.editorRef.current, this.props.model, selection);
} catch (err) {
logger.error(err);
}
// if caret selection is a range, take the end position
const position = selection instanceof Range ? selection.end : selection;
this.setLastCaretFromPosition(position);
}
const { isEmpty } = this.props.model;
if (this.props.placeholder) {
if (isEmpty) {
this.showPlaceholder();
} else {
this.hidePlaceholder();
}
}
if (isEmpty) {
this.formatBarRef.current?.hide();
}
this.setState({
autoComplete: this.props.model.autoComplete ?? undefined,
// if a change is happening then clear the showVisualBell
showVisualBell: diff ? false : this.state.showVisualBell,
});
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
// inputType is falsy during initial mount, don't consider re-loading the draft as typing
let isTyping = !this.props.model.isEmpty && !!inputType;
// If the user is entering a command, only consider them typing if it is one which sends a message into the room
if (isTyping && this.props.model.parts[0].type === "command") {
const { cmd } = parseCommandString(this.props.model.parts[0].text);
const command = CommandMap.get(cmd!);
if (!command?.isEnabled(MatrixClientPeg.get()) || command.category !== CommandCategories.messages) {
isTyping = false;
}
}
SdkContextClass.instance.typingStore.setSelfTyping(
this.props.room.roomId,
this.props.threadId ?? null,
isTyping,
);
this.props.onChange?.(selection, inputType, diff);
};
private showPlaceholder(): void {
this.editorRef.current?.style.setProperty("--placeholder", `'${CSS.escape(this.props.placeholder ?? "")}'`);
this.editorRef.current?.classList.add("mx_BasicMessageComposer_inputEmpty");
}
private hidePlaceholder(): void {
this.editorRef.current?.classList.remove("mx_BasicMessageComposer_inputEmpty");
this.editorRef.current?.style.removeProperty("--placeholder");
}
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 = (): 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.
// however, modifying the DOM (caused by the editor model update) from the compositionend handler seems
// to confuse the IME in Chrome, likely causing https://github.com/vector-im/element-web/issues/10913 ,
// so we do it async
// however, doing this async seems to break things in Safari for some reason, so browser sniff.
if (this.isSafari) {
this.onInput({ inputType: "insertCompositionText" });
} else {
Promise.resolve().then(() => {
this.onInput({ inputType: "insertCompositionText" });
});
}
};
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
// From https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
// Safari emits an additional keyDown after compositionend
return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
}
private onCutCopy = (event: ClipboardEvent, type: string): void => {
const selection = document.getSelection()!;
const text = selection.toString();
if (text && this.editorRef.current) {
const { model } = this.props;
const range = getRangeForSelection(this.editorRef.current, model, selection);
const selectedParts = range.parts.map((p) => p.serialize());
event.clipboardData.setData("application/x-element-composer", JSON.stringify(selectedParts));
event.clipboardData.setData("text/plain", text); // so plain copy/paste works
if (type === "cut") {
// Remove the text, updating the model as appropriate
this.modifiedFlag = true;
replaceRangeAndMoveCaret(range, []);
}
event.preventDefault();
}
};
private onCopy = (event: ClipboardEvent): void => {
this.onCutCopy(event, "copy");
};
private onCut = (event: ClipboardEvent): void => {
this.onCutCopy(event, "cut");
};
private onPasteHandler = (event: Event | SyntheticEvent, data: DataTransfer): boolean | undefined => {
event.preventDefault(); // we always handle the paste ourselves
if (!this.editorRef.current) return;
if (this.props.onPaste?.(event, data, this.props.model)) {
// to prevent double handling, allow props.onPaste to skip internal onPaste
return true;
}
const { model } = this.props;
const { partCreator } = model;
const plainText = data.getData("text/plain");
const partsText = data.getData("application/x-element-composer");
let parts: Part[];
if (partsText) {
const serializedTextParts = JSON.parse(partsText);
parts = serializedTextParts.map((p: SerializedPart) => partCreator.deserializePart(p));
} else {
parts = parsePlainTextMessage(plainText, partCreator, { shouldEscape: false });
}
this.modifiedFlag = true;
const range = getRangeForSelection(this.editorRef.current, model, document.getSelection()!);
// If the user is pasting a link, and has a range selected which is not a link, wrap the range with the link
if (plainText && range.length > 0 && linkify.test(plainText) && !linkify.test(range.text)) {
formatRangeAsLink(range, plainText);
} else {
replaceRangeAndMoveCaret(range, parts);
}
};
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean | undefined => {
return this.onPasteHandler(event, event.clipboardData);
};
private onBeforeInput = (event: InputEvent): void => {
// ignore any input while doing IME compositions
if (this.isIMEComposing) {
return;
}
if (event.inputType === "insertFromPaste" && event.dataTransfer) {
this.onPasteHandler(event, event.dataTransfer);
}
};
private onInput = (event: Partial<InputEvent>): void => {
if (!this.editorRef.current) return;
// ignore any input while doing IME compositions
if (this.isIMEComposing) {
return;
}
this.modifiedFlag = true;
const sel = document.getSelection()!;
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel);
this.props.model.update(text, event.inputType, caret);
};
private insertText(textToInsert: string, inputType = "insertText"): void {
if (!this.editorRef.current) return;
const sel = document.getSelection()!;
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel);
const newText = text.slice(0, caret.offset) + textToInsert + text.slice(caret.offset);
caret.offset += textToInsert.length;
this.modifiedFlag = true;
this.props.model.update(newText, inputType, caret);
}
// this is used later to see if we need to recalculate the caret
// on selectionchange. If it is just a consequence of typing
// 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): void {
const { model } = this.props;
this._isCaretAtEnd = position.isAtEnd(model);
this.lastCaret = position.asOffset(model);
this.lastSelection = cloneSelection(document.getSelection()!);
}
private refreshLastCaretIfNeeded(): DocumentOffset | undefined {
// 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.
if (!this.editorRef.current) {
return;
}
const selection = document.getSelection()!;
if (!this.lastSelection || !selectionEquals(this.lastSelection, selection)) {
this.lastSelection = cloneSelection(selection);
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, selection);
this.lastCaret = caret;
this._isCaretAtEnd = caret.offset === text.length;
}
return this.lastCaret;
}
public clearUndoHistory(): void {
this.historyManager.clear();
}
public getCaret(): DocumentOffset {
return this.lastCaret;
}
public isSelectionCollapsed(): boolean {
return !this.lastSelection || !!this.lastSelection.isCollapsed;
}
public isCaretAtStart(): boolean {
return this.getCaret().offset === 0;
}
public isCaretAtEnd(): boolean {
return this._isCaretAtEnd;
}
private onBlur = (): void => {
document.removeEventListener("selectionchange", this.onSelectionChange);
};
private onFocus = (): void => {
document.addEventListener("selectionchange", this.onSelectionChange);
// force to recalculate
this.lastSelection = null;
this.refreshLastCaretIfNeeded();
};
private onSelectionChange = (): void => {
if (!this.editorRef.current) return;
const { isEmpty } = this.props.model;
this.refreshLastCaretIfNeeded();
const selection = document.getSelection()!;
if (this.hasTextSelected && selection.isCollapsed) {
this.hasTextSelected = false;
this.formatBarRef.current?.hide();
} else if (!selection.isCollapsed && !isEmpty) {
this.hasTextSelected = true;
const range = getRangeForSelection(this.editorRef.current, this.props.model, selection);
if (this.formatBarRef.current && this.state.useMarkdown && !!range.text.trim()) {
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
this.formatBarRef.current.showAt(selectionRect);
}
}
};
private onKeyDown = (event: React.KeyboardEvent): void => {
if (!this.editorRef.current) return;
if (this.isSafari && event.which == 229) {
// Swallow the extra keyDown by Safari
event.stopPropagation();
return;
}
const model = this.props.model;
let handled = false;
if (this.state.surroundWith && document.getSelection()!.type !== "Caret") {
// This surrounds the selected text with a character. This is
// intentionally left out of the keybinding manager as the keybinds
// here shouldn't be changeable
const selectionRange = getRangeForSelection(
this.editorRef.current,
this.props.model,
document.getSelection()!,
);
// trim the range as we want it to exclude leading/trailing spaces
selectionRange.trim();
if ([...SURROUND_WITH_DOUBLE_CHARACTERS.keys(), ...SURROUND_WITH_CHARACTERS].includes(event.key)) {
this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true;
toggleInlineFormat(selectionRange, event.key, SURROUND_WITH_DOUBLE_CHARACTERS.get(event.key));
handled = true;
}
}
const navAction = getKeyBindingsManager().getNavigationAction(event);
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.MESSAGE_COMPOSER_OR_HOME,
navAction === KeyBindingAction.PreviousLandmark,
);
handled = true;
}
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(event);
if (model.autoComplete?.hasCompletions()) {
const autoComplete = model.autoComplete;
switch (autocompleteAction) {
case KeyBindingAction.ForceCompleteAutocomplete:
case KeyBindingAction.CompleteAutocomplete:
this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true;
autoComplete.confirmCompletion();
handled = true;
break;
case KeyBindingAction.PrevSelectionInAutocomplete:
autoComplete.selectPreviousSelection();
handled = true;
break;
case KeyBindingAction.NextSelectionInAutocomplete:
autoComplete.selectNextSelection();
handled = true;
break;
case KeyBindingAction.CancelAutocomplete:
autoComplete.onEscape(event);
handled = true;
break;
}
} else if (autocompleteAction === KeyBindingAction.ForceCompleteAutocomplete && !this.state.showVisualBell) {
// there is no current autocomplete window, try to open it
this.tabCompleteName();
handled = true;
} else if ([KeyBindingAction.Delete, KeyBindingAction.Backspace].includes(accessibilityAction!)) {
this.formatBarRef.current?.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
return;
}
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case KeyBindingAction.FormatBold:
this.onFormatAction(Formatting.Bold);
handled = true;
break;
case KeyBindingAction.FormatItalics:
this.onFormatAction(Formatting.Italics);
handled = true;
break;
case KeyBindingAction.FormatCode:
this.onFormatAction(Formatting.Code);
handled = true;
break;
case KeyBindingAction.FormatQuote:
this.onFormatAction(Formatting.Quote);
handled = true;
break;
case KeyBindingAction.FormatLink:
this.onFormatAction(Formatting.InsertLink);
handled = true;
break;
case KeyBindingAction.EditRedo: {
const history = this.historyManager.redo();
if (history) {
const { parts, caret } = history;
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyRedo");
}
handled = true;
break;
}
case KeyBindingAction.EditUndo: {
const history = this.historyManager.undo(this.props.model);
if (history) {
const { parts, caret } = history;
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyUndo");
}
handled = true;
break;
}
case KeyBindingAction.NewLine:
this.insertText("\n");
handled = true;
break;
case KeyBindingAction.MoveCursorToStart:
setSelection(this.editorRef.current, model, {
index: 0,
offset: 0,
});
handled = true;
break;
case KeyBindingAction.MoveCursorToEnd:
setSelection(this.editorRef.current, model, {
index: model.parts.length - 1,
offset: model.parts[model.parts.length - 1].text.length,
});
handled = true;
break;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
};
private async tabCompleteName(): Promise<void> {
try {
await new Promise<void>((resolve) => this.setState({ showVisualBell: false }, resolve));
const { model } = this.props;
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return (
part.text[offset] !== " " &&
part.text[offset] !== "+" &&
(part.type === Type.Plain || part.type === Type.PillCandidate || part.type === Type.Command)
);
});
const { partCreator } = model;
// await for auto-complete to be open
await model.transform(() => {
const addedLen = range.replace([partCreator.pillCandidate(range.text)]);
return model.positionForOffset(caret.offset + addedLen, true);
});
// Don't try to do things with the autocomplete if there is none shown
if (model.autoComplete) {
await model.autoComplete.startSelection();
if (!model.autoComplete.hasSelection()) {
this.setState({ showVisualBell: true });
model.autoComplete.close();
}
} else {
this.setState({ showVisualBell: true });
}
} catch (err) {
logger.error(err);
}
}
public isModified(): boolean {
return this.modifiedFlag;
}
private onAutoCompleteConfirm = (completion: ICompletion): void => {
this.modifiedFlag = true;
this.props.model.autoComplete?.onComponentConfirm(completion);
};
private onAutoCompleteSelectionChange = (completionIndex: number): void => {
this.modifiedFlag = true;
this.setState({ completionIndex });
};
private configureUseMarkdown = (): void => {
const useMarkdown = SettingsStore.getValue("MessageComposerInput.useMarkdown");
this.setState({ useMarkdown });
if (!useMarkdown && this.formatBarRef.current) {
this.formatBarRef.current.hide();
}
};
private configureEmoticonAutoReplace = (): void => {
this.props.model.setTransformCallback(this.transform);
};
private configureShouldShowPillAvatar = (): void => {
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
this.setState({ showPillAvatar });
};
private surroundWithSettingChanged = (): void => {
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
this.setState({ surroundWith });
};
private transform = (documentPosition: DocumentPosition): void => {
const shouldReplace = SettingsStore.getValue("MessageComposerInput.autoReplaceEmoji");
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
};
public componentWillUnmount(): void {
document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current?.removeEventListener("beforeinput", this.onBeforeInput, true);
this.editorRef.current?.removeEventListener("input", this.onInput, true);
this.editorRef.current?.removeEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current?.removeEventListener("compositionend", this.onCompositionEnd, true);
SettingsStore.unwatchSetting(this.useMarkdownHandle);
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
SettingsStore.unwatchSetting(this.surroundWithHandle);
}
public componentDidMount(): void {
const model = this.props.model;
model.setUpdateCallback(this.updateEditorState);
const partCreator = model.partCreator;
// TODO: does this allow us to get rid of EditorStateTransfer?
// not really, but we could not serialize the parts, and just change the autoCompleter
partCreator.setAutoCompleteCreator(
getAutoCompleteCreator(
() => this.autocompleteRef.current,
(query) => new Promise((resolve) => this.setState({ query }, resolve)),
),
);
// initial render of model
this.updateEditorState(this.getInitialCaretPosition());
// attach input listener by hand so React doesn't proxy the events,
// as the proxied event doesn't support inputType, which we need.
this.editorRef.current?.addEventListener("beforeinput", this.onBeforeInput, true);
this.editorRef.current?.addEventListener("input", this.onInput, true);
this.editorRef.current?.addEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current?.addEventListener("compositionend", this.onCompositionEnd, true);
this.editorRef.current?.focus();
}
private getInitialCaretPosition(): DocumentPosition {
let caretPosition: DocumentPosition;
if (this.props.initialCaret) {
// if restoring state from a previous editor,
// restore caret position from the state
const caret = this.props.initialCaret;
caretPosition = this.props.model.positionForOffset(caret.offset, caret.atNodeEnd);
} else {
// otherwise, set it at the end
caretPosition = this.props.model.getPositionAtEnd();
}
return caretPosition;
}
public onFormatAction = (action: Formatting): void => {
if (!this.state.useMarkdown || !this.editorRef.current) {
return;
}
const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()!);
this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true;
formatRange(range, action);
};
public render(): React.ReactNode {
let autoComplete: JSX.Element | undefined;
if (this.state.autoComplete && this.state.query) {
const query = this.state.query;
const queryLen = query.length;
autoComplete = (
<div className="mx_BasicMessageComposer_AutoCompleteWrapper">
<Autocomplete
ref={this.autocompleteRef}
query={query}
onConfirm={this.onAutoCompleteConfirm}
onSelectionChange={this.onAutoCompleteSelectionChange}
selection={{ beginning: true, end: queryLen, start: queryLen }}
room={this.props.room}
/>
</div>
);
}
const wrapperClasses = classNames("mx_BasicMessageComposer", {
mx_BasicMessageComposer_input_error: this.state.showVisualBell,
});
const classes = classNames("mx_BasicMessageComposer_input", {
mx_BasicMessageComposer_input_shouldShowPillAvatar: this.state.showPillAvatar,
mx_BasicMessageComposer_input_disabled: this.props.disabled,
});
const shortcuts = {
[Formatting.Bold]: ctrlShortcutLabel("B"),
[Formatting.Italics]: ctrlShortcutLabel("I"),
[Formatting.Code]: ctrlShortcutLabel("E"),
[Formatting.Quote]: ctrlShortcutLabel(">", true),
[Formatting.InsertLink]: ctrlShortcutLabel("L", true),
};
const { completionIndex } = this.state;
const hasAutocomplete = !!this.state.autoComplete;
let activeDescendant: string | undefined;
if (hasAutocomplete && completionIndex! >= 0) {
activeDescendant = generateCompletionDomId(completionIndex!);
}
return (
<div className={wrapperClasses}>
{autoComplete}
<MessageComposerFormatBar
ref={this.formatBarRef}
onAction={this.onFormatAction}
shortcuts={shortcuts}
/>
<div
className={classes}
contentEditable={this.props.disabled ? undefined : true}
tabIndex={0}
onBlur={this.onBlur}
onFocus={this.onFocus}
onCopy={this.onCopy}
onCut={this.onCut}
onPaste={this.onPaste}
onKeyDown={this.onKeyDown}
ref={this.editorRef}
aria-label={this.props.label}
role="textbox"
aria-multiline="true"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={hasAutocomplete ? !this.autocompleteRef.current?.state.hide : undefined}
aria-owns={hasAutocomplete ? "mx_Autocomplete" : undefined}
aria-activedescendant={activeDescendant}
dir="auto"
aria-disabled={this.props.disabled}
data-testid="basicmessagecomposer"
translate="no"
/>
</div>
);
}
public focus(): void {
this.editorRef.current?.focus();
}
public insertMention(userId: string): void {
this.modifiedFlag = true;
const { model } = this.props;
const { partCreator } = model;
const member = this.props.room.getMember(userId);
const displayName = member ? member.rawDisplayName : userId;
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
// Insert suffix only if the caret is at the start of the composer
const parts = partCreator.createMentionParts(caret.offset === 0, displayName, userId);
model.transform(() => {
const addedLen = model.insert(parts, position);
return model.positionForOffset(caret.offset + addedLen, true);
});
// refocus on composer, as we just clicked "Mention"
this.focus();
}
public insertQuotedMessage(event: MatrixEvent): void {
this.modifiedFlag = true;
const { model } = this.props;
const { partCreator } = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
// add two newlines
quoteParts.push(partCreator.newline());
quoteParts.push(partCreator.newline());
model.transform(() => {
const addedLen = model.insert(quoteParts, model.positionForOffset(0));
return model.positionForOffset(addedLen, true);
});
// refocus on composer, as we just clicked "Quote"
this.focus();
}
public insertPlaintext(text: string): void {
this.modifiedFlag = true;
const { model } = this.props;
const { partCreator } = model;
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
model.transform(() => {
const addedLen = model.insert(partCreator.plainWithEmoji(text), position);
return model.positionForOffset(caret.offset + addedLen, true);
});
}
}

View file

@ -0,0 +1,41 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext } from "react";
import classNames from "classnames";
import AccessibleButton, { ButtonProps } from "../elements/AccessibleButton";
import { OverflowMenuContext } from "./MessageComposerButtons";
import { IconizedContextMenuOption } from "../context_menus/IconizedContextMenu";
import { Ref } from "../../../accessibility/roving/types";
interface Props extends Omit<ButtonProps<"div">, "element"> {
inputRef?: Ref;
title: string;
iconClassName: string;
}
export const CollapsibleButton: React.FC<Props> = ({
title,
children,
className,
iconClassName,
inputRef,
...props
}) => {
const inOverflowMenu = !!useContext(OverflowMenuContext);
if (inOverflowMenu) {
return <IconizedContextMenuOption {...props} iconClassName={iconClassName} label={title} inputRef={inputRef} />;
}
return (
<AccessibleButton {...props} title={title} className={classNames(className, iconClassName)} ref={inputRef}>
{children}
</AccessibleButton>
);
};

View file

@ -0,0 +1,110 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps, CSSProperties } from "react";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import { E2EStatus } from "../../../utils/ShieldUtils";
import { XOR } from "../../../@types/common";
export enum E2EState {
Verified = "verified",
Warning = "warning",
Unknown = "unknown",
Normal = "normal",
Unauthenticated = "unauthenticated",
}
const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
[E2EState.Warning]: _td("encryption|cross_signing_user_warning"),
[E2EState.Normal]: _td("encryption|cross_signing_user_normal"),
[E2EState.Verified]: _td("encryption|cross_signing_user_verified"),
};
const crossSigningRoomTitles: { [key in E2EState]?: TranslationKey } = {
[E2EState.Warning]: _td("encryption|cross_signing_room_warning"),
[E2EState.Normal]: _td("encryption|cross_signing_room_normal"),
[E2EState.Verified]: _td("encryption|cross_signing_room_verified"),
};
interface Props {
className?: string;
size?: number;
onClick?: () => void;
hideTooltip?: boolean;
tooltipPlacement?: ComponentProps<typeof Tooltip>["placement"];
bordered?: boolean;
}
interface UserProps extends Props {
isUser: true;
status: E2EState | E2EStatus;
}
interface RoomProps extends Props {
isUser?: false;
status: E2EStatus;
}
const E2EIcon: React.FC<XOR<UserProps, RoomProps>> = ({
isUser,
status,
className,
size,
onClick,
hideTooltip,
tooltipPlacement,
bordered,
}) => {
const classes = classNames(
{
mx_E2EIcon: true,
mx_E2EIcon_bordered: bordered,
mx_E2EIcon_warning: status === E2EState.Warning,
mx_E2EIcon_normal: status === E2EState.Normal,
mx_E2EIcon_verified: status === E2EState.Verified,
},
className,
);
let e2eTitle: TranslationKey | undefined;
if (isUser) {
e2eTitle = crossSigningUserTitles[status];
} else {
e2eTitle = crossSigningRoomTitles[status];
}
let style: CSSProperties | undefined;
if (size) {
style = { width: `${size}px`, height: `${size}px` };
}
const label = e2eTitle ? _t(e2eTitle) : "";
let content: JSX.Element;
if (onClick) {
content = <AccessibleButton onClick={onClick} className={classes} style={style} />;
} else {
content = <div className={classes} style={style} />;
}
if (!e2eTitle || hideTooltip) {
return content;
}
return (
<Tooltip label={label} placement={tooltipPlacement} isTriggerInteractive={!!onClick}>
{content}
</Tooltip>
);
};
export default E2EIcon;

View file

@ -0,0 +1,500 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, KeyboardEvent } from "react";
import classNames from "classnames";
import { EventStatus, MatrixEvent, Room, MsgType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
import { ReplacementEvent, RoomMessageEventContent, RoomMessageTextEventContent } from "matrix-js-sdk/src/types";
import { _t } from "../../../languageHandler";
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, Part, PartCreator, SerializedPart } from "../../../editor/parts";
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandCategories } from "../../../SlashCommands";
import { Action } from "../../../dispatcher/actions";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import SendHistoryManager from "../../../SendHistoryManager";
import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from "../elements/AccessibleButton";
import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog";
import SettingsStore from "../../../settings/SettingsStore";
import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
import RoomContext from "../../../contexts/RoomContext";
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { editorRoomKey, editorStateKey } from "../../../Editing";
import DocumentOffset from "../../../editor/offset";
import { attachMentions, attachRelation } from "./SendMessageComposer";
import { filterBoolean } from "../../../utils/arrays";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
if (!html) {
return "";
}
const rootNode = new DOMParser().parseFromString(html, "text/html").body;
const mxReply = rootNode.querySelector("mx-reply");
return (mxReply && mxReply.outerHTML) || "";
}
function getTextReplyFallback(mxEvent: MatrixEvent): string {
const body: string = mxEvent.getContent().body;
const lines = body.split("\n").map((l) => l.trim());
if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
return `${lines[0]}\n\n`;
}
return "";
}
// exported for tests
export function createEditContent(
model: EditorModel,
editedEvent: MatrixEvent,
replyToEvent?: MatrixEvent,
): RoomMessageEventContent {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
}
const isReply = !!editedEvent.replyEventId;
let plainPrefix = "";
let htmlPrefix = "";
if (isReply) {
plainPrefix = getTextReplyFallback(editedEvent);
htmlPrefix = getHtmlReplyFallback(editedEvent);
}
const body = textSerialize(model);
const newContent: RoomMessageEventContent = {
msgtype: isEmote ? MsgType.Emote : MsgType.Text,
body: body,
};
const contentBody: RoomMessageTextEventContent & Omit<ReplacementEvent<RoomMessageEventContent>, "m.relates_to"> = {
"msgtype": newContent.msgtype,
"body": `${plainPrefix} * ${body}`,
"m.new_content": newContent,
};
const formattedBody = htmlSerializeIfNeeded(model, {
forceHTML: isReply,
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
if (formattedBody) {
newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody;
contentBody.format = newContent.format;
contentBody.formatted_body = `${htmlPrefix} * ${formattedBody}`;
}
// Build the mentions properties for both the content and new_content.
attachMentions(editedEvent.sender!.userId, contentBody, model, replyToEvent, editedEvent.getContent());
attachRelation(contentBody, { rel_type: "m.replace", event_id: editedEvent.getId() });
return contentBody as RoomMessageEventContent;
}
interface IEditMessageComposerProps extends MatrixClientProps {
editState: EditorStateTransfer;
className?: string;
}
interface IState {
saveDisabled: boolean;
}
class EditMessageComposer extends React.Component<IEditMessageComposerProps, IState> {
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
private readonly editorRef = createRef<BasicMessageComposer>();
private readonly dispatcherRef: string;
private readonly replyToEvent?: MatrixEvent;
private model!: EditorModel;
public constructor(props: IEditMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
const isRestored = this.createEditorModel();
const ev = this.props.editState.getEvent();
this.replyToEvent = ev.replyEventId ? this.context.room?.findEventById(ev.replyEventId) : undefined;
const editContent = createEditContent(this.model, ev, this.replyToEvent);
this.state = {
saveDisabled: !isRestored || !this.isContentModified(editContent["m.new_content"]!),
};
window.addEventListener("beforeunload", this.saveStoredEditorState);
this.dispatcherRef = dis.register(this.onAction);
}
private getRoom(): Room {
if (!this.context.room) {
throw new Error(`Cannot render without room`);
}
return this.context.room;
}
private onKeyDown = (event: KeyboardEvent): void => {
// ignore any keypress while doing IME compositions
if (this.editorRef.current?.isComposing(event)) {
return;
}
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case KeyBindingAction.SendMessage:
this.sendEdit();
event.stopPropagation();
event.preventDefault();
break;
case KeyBindingAction.CancelReplyOrEdit:
event.stopPropagation();
this.cancelEdit();
break;
case KeyBindingAction.EditPrevMessage: {
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) {
return;
}
const previousEvent = findEditableEvent({
events: this.events,
isForward: false,
fromEventId: this.props.editState.getEvent().getId(),
matrixClient: MatrixClientPeg.safeGet(),
});
if (previousEvent) {
dis.dispatch({
action: Action.EditEvent,
event: previousEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
event.preventDefault();
}
break;
}
case KeyBindingAction.EditNextMessage: {
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) {
return;
}
const nextEvent = findEditableEvent({
events: this.events,
isForward: true,
fromEventId: this.props.editState.getEvent().getId(),
matrixClient: MatrixClientPeg.safeGet(),
});
if (nextEvent) {
dis.dispatch({
action: Action.EditEvent,
event: nextEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
} else {
this.cancelEdit();
}
event.preventDefault();
break;
}
}
};
private endEdit(): void {
localStorage.removeItem(this.editorRoomKey);
localStorage.removeItem(this.editorStateKey);
// close the event editing and focus composer
dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.dispatch({
action: Action.FocusSendMessageComposer,
context: this.context.timelineRenderingType,
});
}
private get editorRoomKey(): string {
return editorRoomKey(this.props.editState.getEvent().getRoomId()!, this.context.timelineRenderingType);
}
private get editorStateKey(): string {
return editorStateKey(this.props.editState.getEvent().getId()!);
}
private get events(): MatrixEvent[] {
const liveTimelineEvents = this.context.liveTimeline?.getEvents();
const room = this.getRoom();
if (!liveTimelineEvents || !room) return [];
const pendingEvents = room.getPendingEvents();
const isInThread = Boolean(this.props.editState.getEvent().getThread());
return liveTimelineEvents.concat(isInThread ? [] : pendingEvents);
}
private cancelEdit = (): void => {
this.endEdit();
};
private get shouldSaveStoredEditorState(): boolean {
return localStorage.getItem(this.editorRoomKey) !== null;
}
private restoreStoredEditorState(partCreator: PartCreator): Part[] | undefined {
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
const { parts: serializedParts } = JSON.parse(json);
const parts: Part[] = serializedParts.map((p: SerializedPart) => partCreator.deserializePart(p));
return parts;
} catch (e) {
logger.error("Error parsing editing state: ", e);
}
}
}
private clearPreviousEdit(): void {
if (localStorage.getItem(this.editorRoomKey)) {
localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this.editorRoomKey)}`);
}
}
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));
};
private isContentModified(newContent: RoomMessageEventContent): boolean {
// if nothing has changed then bail
const oldContent = this.props.editState.getEvent().getContent<RoomMessageEventContent>();
if (
oldContent["msgtype"] === newContent["msgtype"] &&
oldContent["body"] === newContent["body"] &&
(oldContent as RoomMessageTextEventContent)["format"] ===
(newContent as RoomMessageTextEventContent)["format"] &&
(oldContent as RoomMessageTextEventContent)["formatted_body"] ===
(newContent as RoomMessageTextEventContent)["formatted_body"]
) {
return false;
}
return true;
}
private sendEdit = async (): Promise<void> => {
if (this.state.saveDisabled) return;
const editedEvent = this.props.editState.getEvent();
PosthogAnalytics.instance.trackEvent<ComposerEvent>({
eventName: "Composer",
isEditing: true,
messageType: "Text",
inThread: !!editedEvent?.getThread(),
isReply: !!editedEvent.replyEventId,
});
// Replace emoticon at the end of the message
if (SettingsStore.getValue("MessageComposerInput.autoReplaceEmoji") && this.editorRef.current) {
const caret = this.editorRef.current.getCaret();
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current.replaceEmoticon(position, REGEX_EMOTICON);
}
const editContent = createEditContent(this.model, editedEvent, this.replyToEvent);
const newContent = editContent["m.new_content"]!;
let shouldSend = true;
if (newContent?.body === "") {
this.cancelPreviousPendingEdit();
createRedactEventDialog({
mxEvent: editedEvent,
onCloseDialog: () => {
this.cancelEdit();
},
});
return;
}
// If content is modified then send an updated event into the room
if (this.isContentModified(newContent)) {
const roomId = editedEvent.getRoomId()!;
if (!containsEmote(this.model) && isSlashCommand(this.model)) {
const [cmd, args, commandText] = getSlashCommand(this.model);
if (cmd) {
const threadId = editedEvent?.getThread()?.id || null;
const [content, commandSuccessful] = await runSlashCommand(
MatrixClientPeg.safeGet(),
cmd,
args,
roomId,
threadId,
);
if (!commandSuccessful) {
return; // errored
}
if (cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects) {
editContent["m.new_content"] = content!;
} else {
shouldSend = false;
}
} else {
const sendAnyway = await shouldSendAnyway(commandText);
// re-focus the composer after QuestionDialog is closed
dis.dispatch({
action: Action.FocusAComposer,
context: this.context.timelineRenderingType,
});
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (shouldSend) {
this.cancelPreviousPendingEdit();
const event = this.props.editState.getEvent();
const threadId = event.threadRootId || null;
this.props.mxClient.sendMessage(roomId, threadId, editContent);
dis.dispatch({ action: "message_sent" });
}
}
this.endEdit();
};
private cancelPreviousPendingEdit(): void {
const originalEvent = this.props.editState.getEvent();
const previousEdit = originalEvent.replacingEvent();
if (
previousEdit &&
(previousEdit.status === EventStatus.QUEUED || previousEdit.status === EventStatus.NOT_SENT)
) {
this.props.mxClient.cancelPendingEvent(previousEdit);
}
}
public componentWillUnmount(): void {
// store caret and serialized parts in the
// editorstate so it can be restored when the remote echo event tile gets rendered
// in case we're currently editing a pending event
const sel = document.getSelection()!;
let caret: DocumentOffset | undefined;
if (sel.focusNode && this.editorRef.current?.editorRef.current) {
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 ?? null, parts);
window.removeEventListener("beforeunload", this.saveStoredEditorState);
if (this.shouldSaveStoredEditorState) {
this.saveStoredEditorState();
}
dis.unregister(this.dispatcherRef);
}
private createEditorModel(): boolean {
const { editState } = this.props;
const room = this.getRoom();
const partCreator = new CommandPartCreator(room, this.props.mxClient);
let parts: Part[];
let isRestored = false;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,
// restore serialized parts from the state
// (editState.hasEditorState() checks getSerializedParts is not null)
parts = filterBoolean<Part>(editState.getSerializedParts()!.map((p) => partCreator.deserializePart(p)));
} else {
// 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, {
shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
isRestored = !!restoredParts;
}
this.model = new EditorModel(parts, partCreator);
this.saveStoredEditorState();
return isRestored;
}
private onChange = (): void => {
if (!this.state.saveDisabled || !this.editorRef.current?.isModified()) {
return;
}
this.setState({
saveDisabled: false,
});
};
private onAction = (payload: ActionPayload): void => {
if (!this.editorRef.current) return;
if (payload.action === Action.ComposerInsert) {
if (payload.timelineRenderingType !== this.context.timelineRenderingType) return;
if (payload.composerType !== ComposerType.Edit) return;
if (payload.userId) {
this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) {
this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) {
this.editorRef.current?.insertPlaintext(payload.text);
}
} else if (payload.action === Action.FocusEditMessageComposer) {
this.editorRef.current.focus();
}
};
public render(): React.ReactNode {
const room = this.getRoom();
if (!room) return null;
return (
<div className={classNames("mx_EditMessageComposer", this.props.className)} onKeyDown={this.onKeyDown}>
<BasicMessageComposer
ref={this.editorRef}
model={this.model}
room={room}
threadId={this.props.editState?.getEvent()?.getThread()?.id}
initialCaret={this.props.editState.getCaret() ?? undefined}
label={_t("composer|edit_composer_label")}
onChange={this.onChange}
/>
<div className="mx_EditMessageComposer_buttons">
<AccessibleButton kind="secondary" onClick={this.cancelEdit}>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.sendEdit} disabled={this.state.saveDisabled}>
{_t("action|save")}
</AccessibleButton>
</div>
</div>
);
}
}
const EditMessageComposerWithMatrixClient = withMatrixClientHOC(EditMessageComposer);
export default EditMessageComposerWithMatrixClient;

View file

@ -0,0 +1,62 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { useContext } from "react";
import { _t } from "../../../languageHandler";
import ContextMenu, { aboveLeftOf, MenuProps, useContextMenu } from "../../structures/ContextMenu";
import EmojiPicker from "../emojipicker/EmojiPicker";
import { CollapsibleButton } from "./CollapsibleButton";
import { OverflowMenuContext } from "./MessageComposerButtons";
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
menuPosition?: MenuProps;
className?: string;
}
export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonProps): JSX.Element {
const overflowMenuCloser = useContext(OverflowMenuContext);
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: React.ReactElement | null = null;
if (menuDisplayed && button.current) {
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
const onFinished = (): void => {
closeMenu();
overflowMenuCloser?.();
};
contextMenu = (
<ContextMenu {...position} onFinished={onFinished} managed={false}>
<EmojiPicker onChoose={addEmoji} onFinished={onFinished} />
</ContextMenu>
);
}
const computedClassName = classNames("mx_EmojiButton", className, {
mx_EmojiButton_highlight: menuDisplayed,
});
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return (
<>
<CollapsibleButton
className={computedClassName}
iconClassName="mx_EmojiButton_icon"
onClick={openMenu}
title={_t("common|emoji")}
inputRef={button}
/>
{contextMenu}
</>
);
}

View file

@ -0,0 +1,170 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import AccessibleButton from "../elements/AccessibleButton";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import E2EIcon, { E2EState } from "./E2EIcon";
import BaseAvatar from "../avatars/BaseAvatar";
import PresenceLabel from "./PresenceLabel";
export enum PowerStatus {
Admin = "admin",
Moderator = "moderator",
}
const PowerLabel: Record<PowerStatus, TranslationKey> = {
[PowerStatus.Admin]: _td("power_level|admin"),
[PowerStatus.Moderator]: _td("power_level|mod"),
};
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";
const PRESENCE_CLASS: Record<PresenceState, string> = {
"offline": "mx_EntityTile_offline",
"online": "mx_EntityTile_online",
"unavailable": "mx_EntityTile_unavailable",
"io.element.unreachable": "mx_EntityTile_unreachable",
};
function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string {
if (showPresence === false) {
return "mx_EntityTile_online_beenactive";
}
// offline is split into two categories depending on whether we have
// a last_active_ago for them.
if (presenceState === "offline") {
if (lastActiveAgo) {
return PRESENCE_CLASS["offline"] + "_beenactive";
} else {
return PRESENCE_CLASS["offline"] + "_neveractive";
}
} else if (presenceState) {
return PRESENCE_CLASS[presenceState];
} else {
return PRESENCE_CLASS["offline"] + "_neveractive";
}
}
interface IProps {
name?: string;
nameJSX?: JSX.Element;
title?: string;
avatarJsx?: JSX.Element; // <BaseAvatar />
className?: string;
presenceState: PresenceState;
presenceLastActiveAgo: number;
presenceLastTs: number;
presenceCurrentlyActive?: boolean;
onClick(): void;
showPresence: boolean;
subtextLabel?: string;
e2eStatus?: E2EState;
powerStatus?: PowerStatus;
}
interface IState {
hover: boolean;
}
export default class EntityTile extends React.PureComponent<IProps, IState> {
public static defaultProps = {
onClick: () => {},
presenceState: "offline",
presenceLastActiveAgo: 0,
presenceLastTs: 0,
showInviteButton: false,
showPresence: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
hover: false,
};
}
/**
* Creates the PresenceLabel component if needed
* @returns The PresenceLabel component if we need to render it, undefined otherwise
*/
private getPresenceLabel(): JSX.Element | undefined {
if (!this.props.showPresence) return;
const activeAgo = this.props.presenceLastActiveAgo
? Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)
: -1;
return (
<PresenceLabel
activeAgo={activeAgo}
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState}
/>
);
}
public render(): React.ReactNode {
const mainClassNames: Record<string, boolean> = {
mx_EntityTile: true,
};
if (this.props.className) mainClassNames[this.props.className] = true;
const presenceClass = presenceClassForMember(
this.props.presenceState,
this.props.presenceLastActiveAgo,
this.props.showPresence,
);
mainClassNames[presenceClass] = true;
const name = this.props.nameJSX || this.props.name;
const nameAndPresence = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name">{name}</div>
{this.getPresenceLabel()}
</div>
);
let powerLabel;
const powerStatus = this.props.powerStatus;
if (powerStatus) {
const powerText = _t(PowerLabel[powerStatus]);
powerLabel = <div className="mx_EntityTile_power">{powerText}</div>;
}
let e2eIcon;
const { e2eStatus } = this.props;
if (e2eStatus) {
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} bordered={true} />;
}
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} size="36px" aria-hidden="true" />;
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (
<div>
<AccessibleButton
className={classNames(mainClassNames)}
title={this.props.title}
onClick={this.props.onClick}
>
<div className="mx_EntityTile_avatar">
{av}
{e2eIcon}
</div>
{nameAndPresence}
{powerLabel}
</AccessibleButton>
</div>
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex";
import Toolbar from "../../../../accessibility/Toolbar";
import { _t } from "../../../../languageHandler";
import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg";
import { Icon as ViewInRoomIcon } from "../../../../../res/img/element-icons/view-in-room.svg";
import { ButtonEvent } from "../../elements/AccessibleButton";
export function EventTileThreadToolbar({
viewInRoom,
copyLinkToThread,
}: {
viewInRoom: (evt: ButtonEvent) => void;
copyLinkToThread: (evt: ButtonEvent) => void;
}): JSX.Element {
return (
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
onClick={viewInRoom}
title={_t("timeline|mab|view_in_room")}
key="view_in_room"
>
<ViewInRoomIcon />
</RovingAccessibleButton>
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
onClick={copyLinkToThread}
title={_t("timeline|mab|copy_link_thread")}
key="copy_link_to_thread"
>
<LinkIcon />
</RovingAccessibleButton>
</Toolbar>
);
}

View file

@ -0,0 +1,87 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import NotificationBadge from "./NotificationBadge";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import { ButtonEvent } from "../elements/AccessibleButton";
import useHover from "../../../hooks/useHover";
interface ExtraTileProps {
isMinimized: boolean;
isSelected: boolean;
displayName: string;
avatar: React.ReactElement;
notificationState?: NotificationState;
onClick: (ev: ButtonEvent) => void;
}
export default function ExtraTile({
isSelected,
isMinimized,
notificationState,
displayName,
onClick,
avatar,
}: ExtraTileProps): JSX.Element {
const [, { onMouseOver, onMouseLeave }] = useHover(() => false);
// XXX: We copy classes because it's easier
const classes = classNames({
mx_ExtraTile: true,
mx_RoomTile: true,
mx_RoomTile_selected: isSelected,
mx_RoomTile_minimized: isMinimized,
});
let badge: JSX.Element | null = null;
if (notificationState) {
badge = <NotificationBadge notification={notificationState} />;
}
let name = displayName;
if (typeof name !== "string") name = "";
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const nameClasses = classNames({
mx_RoomTile_title: true,
mx_RoomTile_titleHasUnreadEvents: notificationState?.isUnread,
});
let nameContainer: JSX.Element | null = (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
</div>
);
if (isMinimized) nameContainer = null;
return (
<RovingAccessibleButton
className={classes}
onMouseEnter={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={onClick}
role="treeitem"
title={name}
disableTooltip={!isMinimized}
>
<div className="mx_RoomTile_avatarContainer">{avatar}</div>
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
{nameContainer}
<div className="mx_RoomTile_badgeContainer">{badge}</div>
</div>
</div>
</RovingAccessibleButton>
);
}

View file

@ -0,0 +1,38 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Robin Townsend <robin@robin.town>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext } from "react";
import { EventTimeline } from "matrix-js-sdk/src/matrix";
import EventTileBubble from "../messages/EventTileBubble";
import RoomContext from "../../../contexts/RoomContext";
import { _t } from "../../../languageHandler";
const HistoryTile: React.FC = () => {
const { room } = useContext(RoomContext);
const oldState = room?.getLiveTimeline().getState(EventTimeline.BACKWARDS);
const historyState = oldState?.getStateEvents("m.room.history_visibility")[0]?.getContent().history_visibility;
let subtitle: string | undefined;
if (historyState == "invited") {
subtitle = _t("timeline|no_permission_messages_before_invite");
} else if (historyState == "joined") {
subtitle = _t("timeline|no_permission_messages_before_join");
}
return (
<EventTileBubble
className="mx_HistoryTile"
title={_t("timeline|historical_messages_unavailable")}
subtitle={subtitle}
/>
);
};
export default HistoryTile;

View file

@ -0,0 +1,41 @@
/*
Copyright 2019-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
interface IProps {
numUnreadMessages?: number;
highlight: boolean;
onScrollToBottomClick: (e: ButtonEvent) => void;
}
const JumpToBottomButton: React.FC<IProps> = (props) => {
const className = classNames({
mx_JumpToBottomButton: true,
mx_JumpToBottomButton_highlight: props.highlight,
});
let badge;
if (props.numUnreadMessages) {
badge = <div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>;
}
return (
<div className={className}>
<AccessibleButton
className="mx_JumpToBottomButton_scrollDown"
title={_t("room|jump_to_bottom_button")}
onClick={props.onScrollToBottomClick}
/>
{badge}
</div>
);
};
export default JumpToBottomButton;

View file

@ -0,0 +1,112 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useEffect } from "react";
import { MatrixEvent, MatrixError, IPreviewUrlResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
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 fetchPreviews(cli, links, ts);
},
[links, ts],
[],
);
useEffect(() => {
onHeightChanged?.();
}, [onHeightChanged, expanded, previews]);
const showPreviews = expanded ? previews : previews.slice(0, INITIAL_NUM_PREVIEWS);
let toggleButton: JSX.Element | undefined;
if (previews.length > INITIAL_NUM_PREVIEWS) {
toggleButton = (
<AccessibleButton onClick={toggleExpanded}>
{expanded
? _t("action|collapse")
: _t("timeline|url_preview|show_n_more", { 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("timeline|url_preview|close")}
>
<img
className="mx_filterFlipColor"
alt=""
role="presentation"
src={require("../../../../res/img/cancel.svg").default}
width="18"
height="18"
draggable="false"
/>
</AccessibleButton>
) : undefined}
</LinkPreviewWidget>
))}
{toggleButton}
</div>
);
};
const fetchPreviews = (cli: MatrixClient, links: string[], ts: number): Promise<[string, IPreviewUrlResponse][]> => {
return Promise.all<[string, IPreviewUrlResponse] | void>(
links.map(async (link): Promise<[string, IPreviewUrlResponse] | undefined> => {
try {
const preview = await cli.getUrlPreview(link, ts);
// Ensure at least one of the rendered fields is truthy
if (
preview?.["og:image"]?.startsWith("mxc://") ||
!!preview?.["og:description"] ||
!!preview?.["og:title"]
) {
return [link, preview];
}
} catch (error) {
if (error instanceof MatrixError && error.httpStatus === 404) {
// Quieten 404 Not found errors, not all URLs can have a preview generated
logger.debug("Failed to get URL preview: ", error);
} else {
logger.error("Failed to get URL preview: ", error);
}
}
}),
).then((a) => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
};
export default LinkPreviewGroup;

View file

@ -0,0 +1,139 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps, createRef, ReactNode } from "react";
import { decode } from "html-entities";
import { MatrixEvent, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
import { Linkify } from "../../../HtmlUtils";
import SettingsStore from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils";
import { mediaFromMxc } from "../../../customisations/Media";
import ImageView from "../elements/ImageView";
import LinkWithTooltip from "../elements/LinkWithTooltip";
import PlatformPeg from "../../../PlatformPeg";
interface IProps {
link: string;
preview: IPreviewUrlResponse;
mxEvent: MatrixEvent; // the Event associated with the preview
children?: ReactNode;
}
export default class LinkPreviewWidget extends React.Component<IProps> {
private image = createRef<HTMLImageElement>();
private onImageClick = (ev: React.MouseEvent): void => {
const p = this.props.preview;
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
let src: string | null | undefined = p["og:image"];
if (src?.startsWith("mxc://")) {
src = mediaFromMxc(src).srcHttp;
}
if (!src) return;
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
src: src,
width: p["og:image:width"],
height: p["og:image:height"],
name: p["og:title"] || p["og:description"] || this.props.link,
fileSize: p["matrix:image:size"],
link: this.props.link,
};
if (this.image.current) {
const clientRect = this.image.current.getBoundingClientRect();
params.thumbnailInfo = {
width: clientRect.width,
height: clientRect.height,
positionX: clientRect.x,
positionY: clientRect.y,
};
}
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
};
public render(): React.ReactNode {
const p = this.props.preview;
// FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing?
let image: string | null = p["og:image"] ?? null;
if (!SettingsStore.getValue("showImages")) {
image = null; // Don't render a button to show the image, just hide it outright
}
const imageMaxWidth = 100;
const imageMaxHeight = 100;
if (image && image.startsWith("mxc://")) {
// We deliberately don't want a square here, so use the source HTTP thumbnail function
image = mediaFromMxc(image).getThumbnailOfSourceHttp(imageMaxWidth, imageMaxHeight, "scale");
}
const thumbHeight =
ImageUtils.thumbHeight(p["og:image:width"], p["og:image:height"], imageMaxWidth, imageMaxHeight) ??
imageMaxHeight;
let img: JSX.Element | undefined;
if (image) {
img = (
<div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
<img
ref={this.image}
style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }}
src={image}
onClick={this.onImageClick}
alt=""
/>
</div>
);
}
// The description includes &-encoded HTML entities, we decode those as React treats the thing as an
// opaque string. This does not allow any HTML to be injected into the DOM.
const description = decode(p["og:description"] || "");
const title = p["og:title"]?.trim() ?? "";
const anchor = (
<a href={this.props.link} target="_blank" rel="noreferrer noopener">
{title}
</a>
);
const needsTooltip = PlatformPeg.get()?.needsUrlTooltips() && this.props.link !== title;
return (
<div className="mx_LinkPreviewWidget">
<div className="mx_LinkPreviewWidget_wrapImageCaption">
{img}
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title">
{needsTooltip ? (
<LinkWithTooltip tooltip={new URL(this.props.link, window.location.href).toString()}>
{anchor}
</LinkWithTooltip>
) : (
anchor
)}
{p["og:site_name"] && (
<span className="mx_LinkPreviewWidget_siteName">{" - " + p["og:site_name"]}</span>
)}
</div>
<div className="mx_LinkPreviewWidget_description">
<Linkify>{description}</Linkify>
</div>
</div>
</div>
{this.props.children}
</div>
);
}
}

View file

@ -0,0 +1,66 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { FC } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { Call } from "../../../models/Call";
import { useParticipantCount } from "../../../hooks/useCall";
export enum LiveContentType {
Video,
// More coming soon
}
interface Props {
type: LiveContentType;
text: string;
active: boolean;
participantCount: number;
}
/**
* Summary line used to call out live, interactive content such as calls.
*/
export const LiveContentSummary: FC<Props> = ({ type, text, active, participantCount }) => (
<span className="mx_LiveContentSummary">
<span
className={classNames("mx_LiveContentSummary_text", {
mx_LiveContentSummary_text_video: type === LiveContentType.Video,
mx_LiveContentSummary_text_active: active,
})}
>
{text}
</span>
{participantCount > 0 && (
<>
{" • "}
<span
className="mx_LiveContentSummary_participants"
aria-label={_t("voip|n_people_joined", { count: participantCount })}
>
{participantCount}
</span>
</>
)}
</span>
);
interface LiveContentSummaryWithCallProps {
call: Call;
}
export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) => (
<LiveContentSummary
type={LiveContentType.Video}
text={_t("common|video")}
active={false}
participantCount={useParticipantCount(call)}
/>
);

View file

@ -0,0 +1,450 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2017, 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import {
MatrixEvent,
Room,
RoomEvent,
RoomMember,
RoomMemberEvent,
RoomState,
RoomStateEvent,
User,
UserEvent,
EventType,
ClientEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { throttle } from "lodash";
import { Button, Tooltip } from "@vector-im/compound-web";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { isValid3pidInvite } from "../../../RoomInvite";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import BaseCard from "../right_panel/BaseCard";
import TruncatedList from "../elements/TruncatedList";
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import { ButtonEvent } from "../elements/AccessibleButton";
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from "../avatars/BaseAvatar";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import PosthogTrackers from "../../../PosthogTrackers";
import { SDKContext } from "../../../contexts/SDKContext";
import { canInviteTo } from "../../../utils/room/canInviteTo";
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
import { Action } from "../../../dispatcher/actions";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
interface IProps {
roomId: string;
searchQuery: string;
onClose(): void;
onSearchQueryChanged: (query: string) => void;
}
interface IState {
loading: boolean;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
truncateAtJoined: number;
truncateAtInvited: number;
}
export default class MemberList extends React.Component<IProps, IState> {
private readonly showPresence: boolean;
private mounted = false;
public static contextType = SDKContext;
public declare context: React.ContextType<typeof SDKContext>;
private tiles: Map<string, MemberTile> = new Map();
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
this.state = this.getMembersState([], []);
this.showPresence = context?.memberListStore.isPresenceEnabled() ?? true;
this.mounted = true;
this.listenForMembersChanges();
}
private listenForMembersChanges(): void {
const cli = MatrixClientPeg.safeGet();
cli.on(RoomStateEvent.Update, this.onRoomStateUpdate);
cli.on(RoomMemberEvent.Name, this.onRoomMemberName);
cli.on(RoomStateEvent.Events, this.onRoomStateEvent);
// We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on(UserEvent.LastPresenceTs, this.onUserPresenceChange);
cli.on(UserEvent.Presence, this.onUserPresenceChange);
cli.on(UserEvent.CurrentlyActive, this.onUserPresenceChange);
cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek
cli.on(RoomEvent.MyMembership, this.onMyMembership);
}
public componentDidMount(): void {
this.updateListNow(true);
}
public componentWillUnmount(): void {
this.mounted = false;
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
cli.removeListener(RoomMemberEvent.Name, this.onRoomMemberName);
cli.removeListener(RoomEvent.MyMembership, this.onMyMembership);
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvent);
cli.removeListener(ClientEvent.Room, this.onRoom);
cli.removeListener(UserEvent.LastPresenceTs, this.onUserPresenceChange);
cli.removeListener(UserEvent.Presence, this.onUserPresenceChange);
cli.removeListener(UserEvent.CurrentlyActive, this.onUserPresenceChange);
}
// cancel any pending calls to the rate_limited_funcs
this.updateList.cancel();
}
private get canInvite(): boolean {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId);
return !!room && canInviteTo(room);
}
private getMembersState(invitedMembers: Array<RoomMember>, joinedMembers: Array<RoomMember>): IState {
return {
loading: false,
filteredJoinedMembers: joinedMembers,
filteredInvitedMembers: invitedMembers,
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
};
}
private onUserPresenceChange = (event: MatrixEvent | undefined, user: User): void => {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener.
const tile = this.tiles.get(user.userId);
if (tile) {
this.updateList(); // reorder the membership list
}
};
private onRoom = (room: Room): void => {
if (room.roomId !== this.props.roomId) {
return;
}
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this.updateListNow(true);
};
private onMyMembership = (room: Room, membership: string, oldMembership?: string): void => {
if (
room.roomId === this.props.roomId &&
membership === KnownMembership.Join &&
oldMembership !== KnownMembership.Join
) {
// we just joined the room, load the member list
this.updateListNow(true);
}
};
private onRoomStateUpdate = (state: RoomState): void => {
if (state.roomId !== this.props.roomId) return;
this.updateList();
};
private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this.updateList();
};
private onRoomStateEvent = (event: MatrixEvent): void => {
if (event.getRoomId() === this.props.roomId && event.getType() === EventType.RoomThirdPartyInvite) {
this.updateList();
}
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
};
private updateList = throttle(
() => {
this.updateListNow(false);
},
500,
{ leading: true, trailing: true },
);
// XXX: exported for tests
public async updateListNow(showLoadingSpinner?: boolean): Promise<void> {
if (!this.mounted) {
return;
}
if (showLoadingSpinner) {
this.setState({ loading: true });
}
const { joined, invited } = await this.context.memberListStore.loadMemberList(
this.props.roomId,
this.props.searchQuery,
);
if (!this.mounted) {
return;
}
this.setState({
loading: false,
filteredJoinedMembers: joined,
filteredInvitedMembers: invited,
});
}
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
};
private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
};
private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element => {
// For now we'll pretend this is any entity. It should probably be a separate tile.
const text = _t("common|and_n_others", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg").default} name="..." size="36px" />
}
name={text}
showPresence={false}
onClick={onClick}
/>
);
};
private showMoreJoinedMemberList = (): void => {
this.setState({
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
});
};
private showMoreInvitedMemberList = (): void => {
this.setState({
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
});
};
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any): void {
if (prevProps.searchQuery !== this.props.searchQuery) {
this.updateListNow(false);
}
}
private onSearchQueryChanged = (searchQuery: string): void => {
this.props.onSearchQueryChanged(searchQuery);
};
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
dis.dispatch({
action: Action.View3pidInvite,
event: inviteEvent,
});
};
private getPending3PidInvites(): MatrixEvent[] {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const room = MatrixClientPeg.safeGet().getRoom(this.props.roomId);
if (room) {
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
if (!isValid3pidInvite(e)) return false;
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
if (memberEvent) return false;
return true;
});
}
return [];
}
private makeMemberTiles(members: Array<RoomMember | MatrixEvent>): JSX.Element[] {
return members.map((m) => {
if (m instanceof RoomMember) {
// Is a Matrix invite
return (
<MemberTile
key={m.userId}
member={m}
ref={(tile) => {
if (tile) this.tiles.set(m.userId, tile);
else this.tiles.delete(m.userId);
}}
showPresence={this.showPresence}
/>
);
} else {
// Is a 3pid invite
return (
<EntityTile
key={m.getStateKey()}
name={m.getContent().display_name}
showPresence={false}
onClick={() => this.onPending3pidInviteClick(m)}
/>
);
}
});
}
private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
};
private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this.getPending3PidInvites());
}
return this.makeMemberTiles(targets.slice(start, end));
};
private getChildCountInvited = (): number => {
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
};
public render(): React.ReactNode {
if (this.state.loading) {
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberList"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
onClose={this.props.onClose}
>
<Spinner />
</BaseCard>
);
}
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId);
let inviteButton: JSX.Element | undefined;
if (room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers)) {
const inviteButtonText = room.isSpaceRoom() ? _t("space|invite_this_space") : _t("room|invite_this_room");
const button = (
<Button
size="sm"
kind="secondary"
className="mx_MemberList_invite"
onClick={this.onInviteButtonClick}
disabled={!this.state.canInvite}
>
<UserAddIcon width="1em" height="1em" />
{inviteButtonText}
</Button>
);
if (this.state.canInvite) {
inviteButton = button;
} else {
inviteButton = <Tooltip label={_t("member_list|invite_button_no_perms_tooltip")}>{button}</Tooltip>;
}
}
let invitedHeader;
let invitedSection;
if (this.getChildCountInvited() > 0) {
invitedHeader = <h2>{_t("member_list|invited_list_heading")}</h2>;
invitedSection = (
<TruncatedList
className="mx_MemberList_section mx_MemberList_invited"
truncateAt={this.state.truncateAtInvited}
createOverflowElement={this.createOverflowTileInvited}
getChildren={this.getChildrenInvited}
getChildCount={this.getChildCountInvited}
/>
);
}
const footer = (
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={_t("member_list|filter_placeholder")}
onSearch={this.onSearchQueryChanged}
initialValue={this.props.searchQuery}
/>
);
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberList"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
footer={footer}
onClose={this.props.onClose}
>
{inviteButton}
<div className="mx_MemberList_wrapper">
<TruncatedList
className="mx_MemberList_section mx_MemberList_joined"
truncateAt={this.state.truncateAtJoined}
createOverflowElement={this.createOverflowTileJoined}
getChildren={this.getChildrenJoined}
getChildCount={this.getChildCountJoined}
/>
{invitedHeader}
{invitedSection}
</div>
</BaseCard>
);
}
private onInviteButtonClick = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId)!;
inviteToRoom(room);
};
}

View file

@ -0,0 +1,221 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RoomMember, RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Action } from "../../../dispatcher/actions";
import EntityTile, { PowerStatus, PresenceState } from "./EntityTile";
import MemberAvatar from "./../avatars/MemberAvatar";
import DisambiguatedProfile from "../messages/DisambiguatedProfile";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { E2EState } from "./E2EIcon";
import { asyncSome } from "../../../utils/arrays";
import { getUserDeviceIds } from "../../../utils/crypto/deviceInfo";
interface IProps {
member: RoomMember;
showPresence?: boolean;
}
interface IState {
isRoomEncrypted: boolean;
e2eStatus?: E2EState;
}
export default class MemberTile extends React.Component<IProps, IState> {
private userLastModifiedTime?: number;
private memberLastModifiedTime?: number;
public static defaultProps = {
showPresence: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
isRoomEncrypted: false,
};
}
public componentDidMount(): void {
const cli = MatrixClientPeg.safeGet();
const { roomId } = this.props.member;
if (roomId) {
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
this.setState({
isRoomEncrypted,
});
if (isRoomEncrypted) {
cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.updateE2EStatus();
} else {
// Listen for room to become encrypted
cli.on(RoomStateEvent.Events, this.onRoomStateEvents);
}
}
}
public componentWillUnmount(): void {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
}
}
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
const { roomId } = this.props.member;
if (ev.getRoomId() !== roomId) return;
// The room is encrypted now.
const cli = MatrixClientPeg.safeGet();
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
this.setState({
isRoomEncrypted: true,
});
this.updateE2EStatus();
};
private onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => {
if (userId !== this.props.member.userId) return;
this.updateE2EStatus();
};
private async updateE2EStatus(): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const { userId } = this.props.member;
const isMe = userId === cli.getUserId();
const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId);
if (!userTrust?.isCrossSigningVerified()) {
this.setState({
e2eStatus: userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal,
});
return;
}
const deviceIDs = await getUserDeviceIds(cli, userId);
const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => {
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified());
});
this.setState({
e2eStatus: anyDeviceUnverified ? E2EState.Warning : E2EState.Verified,
});
}
public shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean {
if (
this.memberLastModifiedTime === undefined ||
this.memberLastModifiedTime < nextProps.member.getLastModifiedTime()
) {
return true;
}
if (
nextProps.member.user &&
(this.userLastModifiedTime === undefined ||
this.userLastModifiedTime < nextProps.member.user.getLastModifiedTime())
) {
return true;
}
if (nextState.isRoomEncrypted !== this.state.isRoomEncrypted || nextState.e2eStatus !== this.state.e2eStatus) {
return true;
}
return false;
}
private onClick = (): void => {
dis.dispatch({
action: Action.ViewUser,
member: this.props.member,
push: true,
});
};
private getDisplayName(): string {
return this.props.member.name;
}
private getPowerLabel(): string {
return _t("member_list|power_label", {
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
roomId: this.props.member.roomId,
}),
powerLevelNumber: this.props.member.powerLevel,
}).trim();
}
public render(): React.ReactNode {
const member = this.props.member;
const name = this.getDisplayName();
const presenceState = member.user?.presence as PresenceState | undefined;
const av = <MemberAvatar member={member} size="36px" aria-hidden="true" />;
if (member.user) {
this.userLastModifiedTime = member.user.getLastModifiedTime();
}
this.memberLastModifiedTime = member.getLastModifiedTime();
const powerStatusMap = new Map([
[100, PowerStatus.Admin],
[50, PowerStatus.Moderator],
]);
// Find the nearest power level with a badge
let powerLevel = this.props.member.powerLevel;
for (const [pl] of powerStatusMap) {
if (this.props.member.powerLevel >= pl) {
powerLevel = pl;
break;
}
}
const powerStatus = powerStatusMap.get(powerLevel);
let e2eStatus: E2EState | undefined;
if (this.state.isRoomEncrypted) {
e2eStatus = this.state.e2eStatus;
}
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
return (
<EntityTile
{...this.props}
presenceState={presenceState}
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av}
title={this.getPowerLabel()}
name={name}
nameJSX={nameJSX}
powerStatus={powerStatus}
showPresence={this.props.showPresence}
e2eStatus={e2eStatus}
onClick={this.onClick}
/>
);
}
}

View file

@ -0,0 +1,734 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, ReactNode } from "react";
import classNames from "classnames";
import {
IEventRelation,
MatrixEvent,
Room,
RoomMember,
EventType,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import { Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads";
import Stickerpicker from "./Stickerpicker";
import { makeRoomPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import E2EIcon from "./E2EIcon";
import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, MenuProps } from "../../structures/ContextMenu";
import ReplyPreview from "./ReplyPreview";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { RecordingState } from "../../../audio/VoiceRecording";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from "../../../utils/ShieldUtils";
import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
import UIStore, { UI_EVENTS } from "../../../stores/UIStore";
import RoomContext from "../../../contexts/RoomContext";
import { SettingUpdatedPayload } from "../../../dispatcher/payloads/SettingUpdatedPayload";
import MessageComposerButtons from "./MessageComposerButtons";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { Features } from "../../../settings/Settings";
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/";
import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { VoiceBroadcastInfoState } from "../../../voice-broadcast";
import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStartVoiceMessageBroadcastDialog";
import { UIFeature } from "../../../settings/UIFeature";
import { formatTimeLeft } from "../../../DateUtils";
// The prefix used when persisting editor drafts to localstorage.
export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_";
let instanceCount = 0;
interface ISendButtonProps {
onClick: (ev: ButtonEvent) => void;
title?: string; // defaults to something generic
}
function SendButton(props: ISendButtonProps): JSX.Element {
return (
<AccessibleButton
className="mx_MessageComposer_sendMessage"
onClick={props.onClick}
title={props.title ?? _t("composer|send_button_title")}
data-testid="sendmessagebtn"
/>
);
}
interface IProps extends MatrixClientProps {
room: Room;
resizeNotifier: ResizeNotifier;
permalinkCreator?: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
relation?: IEventRelation;
e2eStatus?: E2EStatus;
compact?: boolean;
}
interface IState {
composerContent: string;
isComposerEmpty: boolean;
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
me?: RoomMember;
isMenuOpen: boolean;
isStickerPickerOpen: boolean;
showStickersButton: boolean;
showPollsButton: boolean;
showVoiceBroadcastButton: boolean;
isWysiwygLabEnabled: boolean;
isRichTextEnabled: boolean;
initialComposerContent: string;
}
type WysiwygComposerState = {
content: string;
isRichText: boolean;
replyEventId?: string;
};
export class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef?: string;
private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number;
private _voiceRecording: Optional<VoiceMessageRecording>;
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
public static defaultProps = {
compact: false,
showVoiceBroadcastButton: false,
isRichTextEnabled: true,
};
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.context = context; // otherwise React will only set it prior to render due to type def above
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
window.addEventListener("beforeunload", this.saveWysiwygEditorState);
const isWysiwygLabEnabled = SettingsStore.getValue<boolean>("feature_wysiwyg_composer");
let isRichTextEnabled = true;
let initialComposerContent = "";
if (isWysiwygLabEnabled) {
const wysiwygState = this.restoreWysiwygEditorState();
if (wysiwygState) {
isRichTextEnabled = wysiwygState.isRichText;
initialComposerContent = wysiwygState.content;
if (wysiwygState.replyEventId) {
dis.dispatch({
action: "reply_to_event",
event: this.props.room.findEventById(wysiwygState.replyEventId),
context: this.context.timelineRenderingType,
});
}
}
}
this.state = {
isComposerEmpty: initialComposerContent?.length === 0,
composerContent: initialComposerContent,
haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
isStickerPickerOpen: false,
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
isWysiwygLabEnabled: isWysiwygLabEnabled,
isRichTextEnabled: isRichTextEnabled,
initialComposerContent: initialComposerContent,
};
this.instanceId = instanceCount++;
SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null);
SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null);
SettingsStore.monitorSetting(Features.VoiceBroadcast, null);
SettingsStore.monitorSetting("feature_wysiwyg_composer", null);
}
private get editorStateKey(): string {
let key = WYSIWYG_EDITOR_STATE_STORAGE_PREFIX + this.props.room.roomId;
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
key += `_${this.props.relation.event_id}`;
}
return key;
}
private restoreWysiwygEditorState(): WysiwygComposerState | undefined {
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
const state: WysiwygComposerState = JSON.parse(json);
return state;
} catch (e) {
logger.error(e);
}
}
return undefined;
}
private saveWysiwygEditorState = (): void => {
if (this.shouldSaveWysiwygEditorState()) {
const { isRichTextEnabled, composerContent } = this.state;
const replyEventId = this.props.replyToEvent ? this.props.replyToEvent.getId() : undefined;
const item: WysiwygComposerState = {
content: composerContent,
isRichText: isRichTextEnabled,
replyEventId: replyEventId,
};
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} else {
this.clearStoredEditorState();
}
};
// should save state when wysiwyg is enabled and has contents or reply is open
private shouldSaveWysiwygEditorState = (): boolean => {
const { isWysiwygLabEnabled, isComposerEmpty } = this.state;
return isWysiwygLabEnabled && (!isComposerEmpty || !!this.props.replyToEvent);
};
private clearStoredEditorState(): void {
localStorage.removeItem(this.editorStateKey);
}
private get voiceRecording(): Optional<VoiceMessageRecording> {
return this._voiceRecording;
}
private set voiceRecording(rec: Optional<VoiceMessageRecording>) {
if (this._voiceRecording) {
this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted);
this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon);
}
this._voiceRecording = rec;
if (rec) {
// Delay saying we have a recording until it is started, as we might not yet
// have A/V permissions
rec.on(RecordingState.Started, this.onRecordingStarted);
// We show a little heads up that the recording is about to automatically end soon. The 3s
// display time is completely arbitrary.
rec.on(RecordingState.EndingSoon, this.onRecordingEndingSoon);
}
}
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
this.waitForOwnMember();
UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current!);
UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize);
this.updateRecordingState(); // grab any cached recordings
}
private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry): void => {
if (type === UI_EVENTS.Resize) {
const { narrow } = this.context;
this.setState({
isMenuOpen: !narrow ? false : this.state.isMenuOpen,
isStickerPickerOpen: false,
});
}
};
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case "reply_to_event":
if (payload.context === this.context.timelineRenderingType) {
// add a timeout for the reply preview to be rendered, so
// that the ScrollPanel listening to the resizeNotifier can
// correctly measure it's new height and scroll down to keep
// at the bottom if it already is
window.setTimeout(() => {
this.props.resizeNotifier.notifyTimelineHeightChanged();
}, 100);
}
break;
case Action.SettingUpdated: {
const settingUpdatedPayload = payload as SettingUpdatedPayload;
switch (settingUpdatedPayload.settingName) {
case "MessageComposerInput.showStickersButton": {
const showStickersButton = SettingsStore.getValue("MessageComposerInput.showStickersButton");
if (this.state.showStickersButton !== showStickersButton) {
this.setState({ showStickersButton });
}
break;
}
case "MessageComposerInput.showPollsButton": {
const showPollsButton = SettingsStore.getValue("MessageComposerInput.showPollsButton");
if (this.state.showPollsButton !== showPollsButton) {
this.setState({ showPollsButton });
}
break;
}
case Features.VoiceBroadcast: {
if (this.state.showVoiceBroadcastButton !== settingUpdatedPayload.newValue) {
this.setState({ showVoiceBroadcastButton: !!settingUpdatedPayload.newValue });
}
break;
}
case "feature_wysiwyg_composer": {
if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) {
this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) });
}
break;
}
}
}
}
};
private waitForOwnMember(): void {
// If we have the member already, do that
const me = this.props.room.getMember(MatrixClientPeg.safeGet().getUserId()!);
if (me) {
this.setState({ me });
return;
}
// Otherwise, wait for member loading to finish and then update the member for the avatar.
// The members should already be loading, and loadMembersIfNeeded
// will return the promise for the existing operation
this.props.room.loadMembersIfNeeded().then(() => {
const me = this.props.room.getMember(MatrixClientPeg.safeGet().getSafeUserId()) ?? undefined;
this.setState({ me });
});
}
public componentWillUnmount(): void {
VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
window.removeEventListener("beforeunload", this.saveWysiwygEditorState);
this.saveWysiwygEditorState();
// clean up our listeners by setting our cached recording to falsy (see internal setter)
this.voiceRecording = null;
}
private onTombstoneClick = (ev: ButtonEvent): void => {
ev.preventDefault();
const replacementRoomId = this.context.tombstone?.getContent()["replacement_room"];
const replacementRoom = MatrixClientPeg.safeGet().getRoom(replacementRoomId);
let createEventId: string | undefined;
if (replacementRoom) {
const createEvent = replacementRoom.currentState.getStateEvents(EventType.RoomCreate, "");
if (createEvent?.getId()) createEventId = createEvent.getId();
}
const sender = this.context.tombstone?.getSender();
const viaServers = sender ? [sender.split(":").slice(1).join(":")] : undefined;
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
highlighted: true,
event_id: createEventId,
room_id: replacementRoomId,
auto_join: true,
// Try to join via the server that sent the event. This converts @something:example.org
// into a server domain by splitting on colons and ignoring the first entry ("@something").
via_servers: viaServers,
metricsTrigger: "Tombstone",
metricsViaKeyboard: ev.type !== "click",
});
};
private renderPlaceholderText = (): string => {
if (this.props.replyToEvent) {
const replyingToThread = this.props.relation?.rel_type === THREAD_RELATION_TYPE.name;
if (replyingToThread && this.props.e2eStatus) {
return _t("composer|placeholder_thread_encrypted");
} else if (replyingToThread) {
return _t("composer|placeholder_thread");
} else if (this.props.e2eStatus) {
return _t("composer|placeholder_reply_encrypted");
} else {
return _t("composer|placeholder_reply");
}
} else {
if (this.props.e2eStatus) {
return _t("composer|placeholder_encrypted");
} else {
return _t("composer|placeholder");
}
}
};
private addEmoji = (emoji: string): boolean => {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
text: emoji,
timelineRenderingType: this.context.timelineRenderingType,
});
return true;
};
private sendMessage = async (): Promise<void> => {
if (this.state.haveRecording && this.voiceRecordingButton.current) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
await this.voiceRecordingButton.current?.send();
return;
}
this.messageComposerInput.current?.sendMessage();
if (this.state.isWysiwygLabEnabled) {
const { permalinkCreator, relation, replyToEvent } = this.props;
const composerContent = this.state.composerContent;
this.setState({ composerContent: "", initialComposerContent: "" });
dis.dispatch({
action: Action.ClearAndFocusSendMessageComposer,
timelineRenderingType: this.context.timelineRenderingType,
});
await sendMessage(composerContent, this.state.isRichTextEnabled, {
mxClient: this.props.mxClient,
roomContext: this.context,
permalinkCreator,
relation,
replyToEvent,
});
}
};
private onChange = (model: EditorModel): void => {
this.setState({
isComposerEmpty: model.isEmpty,
});
};
private onWysiwygChange = (content: string): void => {
this.setState({
composerContent: content,
isComposerEmpty: content?.length === 0,
});
};
private onRichTextToggle = async (): Promise<void> => {
const { richToPlain, plainToRich } = await getConversionFunctions();
const { isRichTextEnabled, composerContent } = this.state;
const convertedContent = isRichTextEnabled
? await richToPlain(composerContent, false)
: await plainToRich(composerContent, false);
this.setState({
isRichTextEnabled: !isRichTextEnabled,
composerContent: convertedContent,
initialComposerContent: convertedContent,
});
};
private onVoiceStoreUpdate = (): void => {
this.updateRecordingState();
};
private updateRecordingState(): void {
const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId);
if (this.voiceRecording) {
// If the recording has already started, it's probably a cached one.
if (this.voiceRecording.hasRecording && !this.voiceRecording.isRecording) {
this.setState({ haveRecording: true });
}
// Note: Listeners for recording states are set by the `this.voiceRecording` setter.
} else {
this.setState({ haveRecording: false });
}
}
private onRecordingStarted = (): void => {
// update the recording instance, just in case
const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId);
this.setState({
haveRecording: !!this.voiceRecording,
});
};
private onRecordingEndingSoon = ({ secondsLeft }: { secondsLeft: number }): void => {
this.setState({ recordingTimeLeftSeconds: secondsLeft });
window.setTimeout(() => this.setState({ recordingTimeLeftSeconds: undefined }), 3000);
};
private setStickerPickerOpen = (isStickerPickerOpen: boolean): void => {
this.setState({
isStickerPickerOpen,
isMenuOpen: false,
});
};
private toggleStickerPickerOpen = (): void => {
this.setStickerPickerOpen(!this.state.isStickerPickerOpen);
};
private toggleButtonMenu = (): void => {
this.setState({
isMenuOpen: !this.state.isMenuOpen,
});
};
private get showStickersButton(): boolean {
return this.state.showStickersButton && !isLocalRoom(this.props.room);
}
private getMenuPosition(): MenuProps | undefined {
if (this.ref.current) {
const hasFormattingButtons = this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled;
const contentRect = this.ref.current.getBoundingClientRect();
// Here we need to remove the all the extra space above the editor
// Instead of doing a querySelector or pass a ref to find the compute the height formatting buttons
// We are using an arbitrary value, the formatting buttons height doesn't change during the lifecycle of the component
// It's easier to just use a constant here instead of an over-engineering way to find the height
const heightToRemove = hasFormattingButtons ? 36 : 0;
const fixedRect = new DOMRect(
contentRect.x,
contentRect.y + heightToRemove,
contentRect.width,
contentRect.height - heightToRemove,
);
return aboveLeftOf(fixedRect);
}
}
private onRecordStartEndClick = (): void => {
const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent();
if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) {
createCantStartVoiceMessageBroadcastDialog();
} else {
this.voiceRecordingButton.current?.onRecordStartEndClick();
}
if (this.context.narrow) {
this.toggleButtonMenu();
}
};
public render(): React.ReactNode {
const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus);
const e2eIcon = hasE2EIcon && (
<div className="mx_MessageComposer_e2eIconWrapper">
<E2EIcon key="e2eIcon" status={this.props.e2eStatus!} className="mx_MessageComposer_e2eIcon" />
</div>
);
const controls: ReactNode[] = [];
const menuPosition = this.getMenuPosition();
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
let composer: ReactNode;
if (canSendMessages) {
if (this.state.isWysiwygLabEnabled && menuPosition) {
composer = (
<SendWysiwygComposer
key="controls_input"
disabled={this.state.haveRecording}
onChange={this.onWysiwygChange}
onSend={this.sendMessage}
isRichTextEnabled={this.state.isRichTextEnabled}
initialContent={this.state.initialComposerContent}
e2eStatus={this.props.e2eStatus}
menuPosition={menuPosition}
placeholder={this.renderPlaceholderText()}
eventRelation={this.props.relation}
/>
);
} else {
composer = (
<SendMessageComposer
ref={this.messageComposerInput}
key="controls_input"
room={this.props.room}
placeholder={this.renderPlaceholderText()}
permalinkCreator={this.props.permalinkCreator}
relation={this.props.relation}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
disabled={this.state.haveRecording}
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
/>
);
}
controls.push(
<VoiceRecordComposerTile
key="controls_voice_record"
ref={this.voiceRecordingButton}
room={this.props.room}
permalinkCreator={this.props.permalinkCreator}
relation={this.props.relation}
replyToEvent={this.props.replyToEvent}
/>,
);
} else if (this.context.tombstone) {
const replacementRoomId = this.context.tombstone.getContent()["replacement_room"];
const continuesLink = replacementRoomId ? (
<a
href={makeRoomPermalink(MatrixClientPeg.safeGet(), replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link"
onClick={this.onTombstoneClick}
>
{_t("composer|room_upgraded_link")}
</a>
) : (
""
);
controls.push(
<div className="mx_MessageComposer_replaced_wrapper" key="room_replaced">
<div className="mx_MessageComposer_replaced_valign">
<img
aria-hidden
alt=""
className="mx_MessageComposer_roomReplaced_icon"
src={require("../../../../res/img/room_replaced.svg").default}
/>
<span className="mx_MessageComposer_roomReplaced_header">
{_t("composer|room_upgraded_notice")}
</span>
<br />
{continuesLink}
</div>
</div>,
);
} else {
controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error">
{_t("composer|no_perms_notice")}
</div>,
);
}
let recordingTooltip: JSX.Element | undefined;
const isTooltipOpen = Boolean(this.state.recordingTimeLeftSeconds);
const secondsLeft = this.state.recordingTimeLeftSeconds ? Math.round(this.state.recordingTimeLeftSeconds) : 0;
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null;
controls.push(
<Stickerpicker
room={this.props.room}
threadId={threadId}
isStickerPickerOpen={this.state.isStickerPickerOpen}
setStickerPickerOpen={this.setStickerPickerOpen}
menuPosition={menuPosition}
key="stickers"
/>,
);
const showSendButton = canSendMessages && (!this.state.isComposerEmpty || this.state.haveRecording);
const classes = classNames({
"mx_MessageComposer": true,
"mx_MessageComposer--compact": this.props.compact,
"mx_MessageComposer_e2eStatus": hasE2EIcon,
"mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled,
});
return (
<Tooltip open={isTooltipOpen} description={formatTimeLeft(secondsLeft)} placement="bottom">
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
{recordingTooltip}
<div className="mx_MessageComposer_wrapper">
<ReplyPreview
replyToEvent={this.props.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
/>
<div className="mx_MessageComposer_row">
{e2eIcon}
{composer}
<div className="mx_MessageComposer_actions">
{controls}
{canSendMessages && (
<MessageComposerButtons
addEmoji={this.addEmoji}
haveRecording={this.state.haveRecording}
isMenuOpen={this.state.isMenuOpen}
isStickerPickerOpen={this.state.isStickerPickerOpen}
menuPosition={menuPosition}
relation={this.props.relation}
onRecordStartEndClick={this.onRecordStartEndClick}
setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={
!window.electron && SettingsStore.getValue(UIFeature.LocationSharing)
}
showPollsButton={this.state.showPollsButton}
showStickersButton={this.showStickersButton}
isRichTextEnabled={this.state.isRichTextEnabled}
onComposerModeClick={this.onRichTextToggle}
toggleButtonMenu={this.toggleButtonMenu}
showVoiceBroadcastButton={this.state.showVoiceBroadcastButton}
onStartVoiceBroadcastClick={() => {
setUpVoiceBroadcastPreRecording(
this.props.room,
MatrixClientPeg.safeGet(),
SdkContextClass.instance.voiceBroadcastPlaybacksStore,
SdkContextClass.instance.voiceBroadcastRecordingsStore,
SdkContextClass.instance.voiceBroadcastPreRecordingStore,
);
this.toggleButtonMenu();
}}
/>
)}
{showSendButton && (
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={
this.state.haveRecording
? _t("composer|send_button_voice_message")
: undefined
}
/>
)}
</div>
</div>
</div>
</div>
</Tooltip>
);
}
}
const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
export default MessageComposerWithMatrixClient;

View file

@ -0,0 +1,373 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import { IEventRelation, Room, MatrixClient, THREAD_RELATION_TYPE, M_POLL_START } from "matrix-js-sdk/src/matrix";
import React, { createContext, ReactElement, ReactNode, useContext, useRef } from "react";
import { _t } from "../../../languageHandler";
import { CollapsibleButton } from "./CollapsibleButton";
import { MenuProps } from "../../structures/ContextMenu";
import dis from "../../../dispatcher/dispatcher";
import ErrorDialog from "../dialogs/ErrorDialog";
import { LocationButton } from "../location";
import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import ContentMessages from "../../../ContentMessages";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RoomContext from "../../../contexts/RoomContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import { EmojiButton } from "./EmojiButton";
import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
interface IProps {
addEmoji: (emoji: string) => boolean;
haveRecording: boolean;
isMenuOpen: boolean;
isStickerPickerOpen: boolean;
menuPosition?: MenuProps;
onRecordStartEndClick: () => void;
relation?: IEventRelation;
setStickerPickerOpen: (isStickerPickerOpen: boolean) => void;
showLocationButton: boolean;
showPollsButton: boolean;
showStickersButton: boolean;
toggleButtonMenu: () => void;
showVoiceBroadcastButton: boolean;
onStartVoiceBroadcastClick: () => void;
isRichTextEnabled: boolean;
onComposerModeClick: () => void;
}
type OverflowMenuCloser = () => void;
export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null);
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
const matrixClient = useContext(MatrixClientContext);
const { room, narrow } = useContext(RoomContext);
const isWysiwygLabEnabled = useSettingValue<boolean>("feature_wysiwyg_composer");
if (!matrixClient || !room || props.haveRecording) {
return null;
}
let mainButtons: ReactNode[];
let moreButtons: ReactNode[];
if (narrow) {
mainButtons = [
isWysiwygLabEnabled ? (
<ComposerModeButton
key="composerModeButton"
isRichTextEnabled={props.isRichTextEnabled}
onClick={props.onComposerModeClick}
/>
) : (
emojiButton(props)
),
];
moreButtons = [
uploadButton(), // props passed via UploadButtonContext
showStickersButton(props),
voiceRecordingButton(props, narrow),
startVoiceBroadcastButton(props),
props.showPollsButton ? pollButton(room, props.relation) : null,
showLocationButton(props, room, matrixClient),
];
} else {
mainButtons = [
isWysiwygLabEnabled ? (
<ComposerModeButton
key="composerModeButton"
isRichTextEnabled={props.isRichTextEnabled}
onClick={props.onComposerModeClick}
/>
) : (
emojiButton(props)
),
uploadButton(), // props passed via UploadButtonContext
];
moreButtons = [
showStickersButton(props),
voiceRecordingButton(props, narrow),
startVoiceBroadcastButton(props),
props.showPollsButton ? pollButton(room, props.relation) : null,
showLocationButton(props, room, matrixClient),
];
}
mainButtons = filterBoolean(mainButtons);
moreButtons = filterBoolean(moreButtons);
const moreOptionsClasses = classNames({
mx_MessageComposer_button: true,
mx_MessageComposer_buttonMenu: true,
mx_MessageComposer_closeButtonMenu: props.isMenuOpen,
});
return (
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
{mainButtons}
{moreButtons.length > 0 && (
<AccessibleButton
className={moreOptionsClasses}
onClick={props.toggleButtonMenu}
title={_t("quick_settings|sidebar_settings")}
/>
)}
{props.isMenuOpen && (
<IconizedContextMenu
onFinished={props.toggleButtonMenu}
{...props.menuPosition}
wrapperClassName="mx_MessageComposer_Menu"
compact={true}
>
<OverflowMenuContext.Provider value={props.toggleButtonMenu}>
<IconizedContextMenuOptionList>{moreButtons}</IconizedContextMenuOptionList>
</OverflowMenuContext.Provider>
</IconizedContextMenu>
)}
</UploadButtonContextProvider>
);
};
function emojiButton(props: IProps): ReactElement {
return (
<EmojiButton
key="emoji_button"
addEmoji={props.addEmoji}
menuPosition={props.menuPosition}
className="mx_MessageComposer_button"
/>
);
}
function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
}
type UploadButtonFn = () => void;
export const UploadButtonContext = createContext<UploadButtonFn | null>(null);
interface IUploadButtonProps {
roomId: string;
relation?: IEventRelation;
children: ReactNode;
}
// We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes.
const UploadButtonContextProvider: React.FC<IUploadButtonProps> = ({ roomId, relation, children }) => {
const cli = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const uploadInput = useRef<HTMLInputElement>(null);
const onUploadClick = (): void => {
if (cli?.isGuest()) {
dis.dispatch({ action: "require_registration" });
return;
}
uploadInput.current?.click();
};
useDispatcher(dis, (payload) => {
if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") {
onUploadClick();
}
});
const onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
if (ev.target.files?.length === 0) return;
// Take a copy, so we can safely reset the value of the form control
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(ev.target.files!),
roomId,
relation,
cli,
roomContext.timelineRenderingType,
);
// This is the onChange handler for a file form control, but we're
// not keeping any state, so reset the value of the form control
// to empty.
// NB. we need to set 'value': the 'files' property is immutable.
ev.target.value = "";
};
const uploadInputStyle = { display: "none" };
return (
<UploadButtonContext.Provider value={onUploadClick}>
{children}
<input
ref={uploadInput}
type="file"
style={uploadInputStyle}
multiple
onClick={chromeFileInputFix}
onChange={onUploadFileInputChange}
/>
</UploadButtonContext.Provider>
);
};
// Must be rendered within an UploadButtonContextProvider
const UploadButton: React.FC = () => {
const overflowMenuCloser = useContext(OverflowMenuContext);
const uploadButtonFn = useContext(UploadButtonContext);
const onClick = (): void => {
uploadButtonFn?.();
overflowMenuCloser?.(); // close overflow menu
};
return (
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_upload"
onClick={onClick}
title={_t("common|attachment")}
/>
);
};
function showStickersButton(props: IProps): ReactElement | null {
return props.showStickersButton ? (
<CollapsibleButton
id="stickersButton"
key="controls_stickers"
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_stickers"
onClick={() => props.setStickerPickerOpen(!props.isStickerPickerOpen)}
title={props.isStickerPickerOpen ? _t("composer|close_sticker_picker") : _t("common|sticker")}
/>
) : null;
}
const startVoiceBroadcastButton: React.FC<IProps> = (props: IProps): ReactElement | null => {
return props.showVoiceBroadcastButton ? (
<CollapsibleButton
key="start_voice_broadcast"
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_voiceBroadcast"
onClick={props.onStartVoiceBroadcastClick}
title={_t("voice_broadcast|action")}
/>
) : null;
};
function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement | null {
// XXX: recording UI does not work well in narrow mode, so hide for now
return narrow ? null : (
<CollapsibleButton
key="voice_message_send"
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_voiceMessage"
onClick={props.onRecordStartEndClick}
title={_t("composer|voice_message_button")}
/>
);
}
function pollButton(room: Room, relation?: IEventRelation): ReactElement {
return <PollButton key="polls" room={room} relation={relation} />;
}
interface IPollButtonProps {
room: Room;
relation?: IEventRelation;
}
class PollButton extends React.PureComponent<IPollButtonProps> {
public static contextType = OverflowMenuContext;
public declare context: React.ContextType<typeof OverflowMenuContext>;
private onCreateClick = (): void => {
this.context?.(); // close overflow menu
const canSend = this.props.room.currentState.maySendEvent(
M_POLL_START.name,
MatrixClientPeg.safeGet().getSafeUserId(),
);
if (!canSend) {
Modal.createDialog(ErrorDialog, {
title: _t("composer|poll_button_no_perms_title"),
description: _t("composer|poll_button_no_perms_description"),
});
} else {
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : undefined;
Modal.createDialog(
PollCreateDialog,
{
room: this.props.room,
threadId,
},
"mx_CompoundDialog",
false, // isPriorityModal
true, // isStaticModal
);
}
};
public render(): React.ReactNode {
// do not allow sending polls within threads at this time
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) return null;
return (
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_poll"
onClick={this.onCreateClick}
title={_t("composer|poll_button")}
/>
);
}
}
function showLocationButton(props: IProps, room: Room, matrixClient: MatrixClient): ReactElement | null {
const sender = room.getMember(matrixClient.getSafeUserId());
return props.showLocationButton && sender ? (
<LocationButton
key="location"
roomId={room.roomId}
relation={props.relation}
sender={sender}
menuPosition={props.menuPosition}
/>
) : null;
}
interface WysiwygToggleButtonProps {
isRichTextEnabled: boolean;
onClick: (ev: ButtonEvent) => void;
}
function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps): JSX.Element {
const title = isRichTextEnabled ? _t("composer|mode_plain") : _t("composer|mode_rich_text");
return (
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName={classNames({
mx_MessageComposer_plain_text: !isRichTextEnabled,
mx_MessageComposer_rich_text: isRichTextEnabled,
})}
onClick={onClick}
title={title}
/>
);
}
export default MessageComposerButtons;

View file

@ -0,0 +1,137 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
export enum Formatting {
Bold = "bold",
Italics = "italics",
Strikethrough = "strikethrough",
Code = "code",
Quote = "quote",
InsertLink = "insert_link",
}
interface IProps {
shortcuts: Partial<Record<Formatting, string>>;
onAction(action: Formatting): void;
}
interface IState {
visible: boolean;
}
export default class MessageComposerFormatBar extends React.PureComponent<IProps, IState> {
private readonly formatBarRef = createRef<HTMLDivElement>();
public constructor(props: IProps) {
super(props);
this.state = { visible: false };
}
public render(): React.ReactNode {
const classes = classNames("mx_MessageComposerFormatBar", {
mx_MessageComposerFormatBar_shown: this.state.visible,
});
return (
<Toolbar className={classes} ref={this.formatBarRef} aria-label={_t("composer|formatting_toolbar_label")}>
<FormatButton
label={_t("composer|format_bold")}
onClick={() => this.props.onAction(Formatting.Bold)}
icon="Bold"
shortcut={this.props.shortcuts.bold}
visible={this.state.visible}
/>
<FormatButton
label={_t("composer|format_italics")}
onClick={() => this.props.onAction(Formatting.Italics)}
icon="Italic"
shortcut={this.props.shortcuts.italics}
visible={this.state.visible}
/>
<FormatButton
label={_t("composer|format_strikethrough")}
onClick={() => this.props.onAction(Formatting.Strikethrough)}
icon="Strikethrough"
visible={this.state.visible}
/>
<FormatButton
label={_t("composer|format_code_block")}
onClick={() => this.props.onAction(Formatting.Code)}
icon="Code"
shortcut={this.props.shortcuts.code}
visible={this.state.visible}
/>
<FormatButton
label={_t("action|quote")}
onClick={() => this.props.onAction(Formatting.Quote)}
icon="Quote"
shortcut={this.props.shortcuts.quote}
visible={this.state.visible}
/>
<FormatButton
label={_t("composer|format_insert_link")}
onClick={() => this.props.onAction(Formatting.InsertLink)}
icon="InsertLink"
shortcut={this.props.shortcuts.insert_link}
visible={this.state.visible}
/>
</Toolbar>
);
}
public showAt(selectionRect: DOMRect): void {
if (!this.formatBarRef.current?.parentElement) return;
this.setState({ visible: true });
const parentRect = this.formatBarRef.current.parentElement.getBoundingClientRect();
this.formatBarRef.current.style.left = `${selectionRect.left - parentRect.left}px`;
const halfBarHeight = this.formatBarRef.current.clientHeight / 2; // used to center the bar
const offset = halfBarHeight + 2; // makes sure the bar won't cover selected text
const offsetLimit = halfBarHeight + offset;
const position = Math.max(selectionRect.top - parentRect.top - offsetLimit, -offsetLimit);
this.formatBarRef.current.style.top = `${position}px`;
}
public hide(): void {
this.setState({ visible: false });
}
}
interface IFormatButtonProps {
label: string;
icon: string;
shortcut?: string;
visible?: boolean;
onClick(): void;
}
class FormatButton extends React.PureComponent<IFormatButtonProps> {
public render(): React.ReactNode {
const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`;
// element="button" and type="button" are necessary for the buttons to work on WebKit,
// otherwise the text is deselected before onClick can ever be called
return (
<RovingAccessibleButton
element="button"
type="button"
onClick={this.props.onClick}
aria-label={this.props.label}
title={this.props.label}
caption={this.props.shortcut}
className={className}
/>
);
}
}

View file

@ -0,0 +1,298 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext } from "react";
import { EventType, Room, User, MatrixClient } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RoomContext from "../../../contexts/RoomContext";
import DMRoomMap from "../../../utils/DMRoomMap";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import MiniAvatarUploader, { AVATAR_SIZE } from "../elements/MiniAvatarUploader";
import RoomAvatar from "../avatars/RoomAvatar";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
import { Action } from "../../../dispatcher/actions";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { showSpaceInvite } from "../../../utils/space";
import EventTileBubble from "../messages/EventTileBubble";
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { privateShouldBeEncrypted } from "../../../utils/rooms";
import { LocalRoom } from "../../../models/LocalRoom";
import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
const isPublic: boolean = room.getJoinRule() === "public";
return isPublic || !privateShouldBeEncrypted(matrixClient) || isEncrypted;
}
const determineIntroMessage = (room: Room, encryptedSingle3rdPartyInvite: boolean): TranslationKey => {
if (room instanceof LocalRoom) {
return _td("room|intro|send_message_start_dm");
}
if (encryptedSingle3rdPartyInvite) {
return _td("room|intro|encrypted_3pid_dm_pending_join");
}
return _td("room|intro|start_of_dm_history");
};
const NewRoomIntro: React.FC = () => {
const cli = useContext(MatrixClientContext);
const { room, roomId } = useContext(RoomContext);
if (!room || !roomId) {
throw new Error("Unable to create a NewRoomIntro without room and roomId");
}
const isLocalRoom = room instanceof LocalRoom;
const dmPartner = isLocalRoom ? room.targets[0]?.userId : DMRoomMap.shared().getUserIdForRoomId(roomId);
let body: JSX.Element;
if (dmPartner) {
const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room);
const introMessage = determineIntroMessage(room, encryptedSingle3rdPartyInvite);
let caption: string | undefined;
if (
!(room instanceof LocalRoom) &&
!encryptedSingle3rdPartyInvite &&
room.getJoinedMemberCount() + room.getInvitedMemberCount() === 2
) {
caption = _t("room|intro|dm_caption");
}
const member = room?.getMember(dmPartner);
const displayName = room?.name || member?.rawDisplayName || dmPartner;
body = (
<React.Fragment>
<RoomAvatar
room={room}
size={AVATAR_SIZE}
onClick={() => {
defaultDispatcher.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || ({ userId: dmPartner } as User),
});
}}
/>
<h2>{room.name}</h2>
<p>
{_t(
introMessage,
{},
{
displayName: () => <strong>{displayName}</strong>,
},
)}
</p>
{caption && <p>{caption}</p>}
</React.Fragment>
);
} else {
const inRoom = room && room.getMyMembership() === KnownMembership.Join;
const topic = room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
const canAddTopic = inRoom && room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getSafeUserId());
const onTopicClick = (): void => {
defaultDispatcher.dispatch(
{
action: "open_room_settings",
room_id: roomId,
},
true,
);
// focus the topic field to help the user find it as it'll gain an outline
setTimeout(() => {
window.document.getElementById("profileTopic")?.focus();
});
};
let topicText;
if (canAddTopic && topic) {
topicText = _t(
"room|intro|topic_edit",
{ topic },
{
a: (sub) => (
<AccessibleButton element="a" kind="link_inline" onClick={onTopicClick}>
{sub}
</AccessibleButton>
),
},
);
} else if (topic) {
topicText = _t("room|intro|topic", { topic });
} else if (canAddTopic) {
topicText = _t(
"room|intro|no_topic",
{},
{
a: (sub) => (
<AccessibleButton element="a" kind="link_inline" onClick={onTopicClick}>
{sub}
</AccessibleButton>
),
},
);
}
const creator = room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
const creatorName = (creator && room?.getMember(creator)?.rawDisplayName) || creator;
let createdText: string;
if (creator === cli.getUserId()) {
createdText = _t("room|intro|you_created");
} else {
createdText = _t("room|intro|user_created", {
displayName: creatorName,
});
}
let parentSpace: Room | undefined;
if (
SpaceStore.instance.activeSpaceRoom?.canInvite(cli.getSafeUserId()) &&
SpaceStore.instance.isRoomInSpace(SpaceStore.instance.activeSpace!, room.roomId)
) {
parentSpace = SpaceStore.instance.activeSpaceRoom;
}
let buttons: JSX.Element | undefined;
if (parentSpace && shouldShowComponent(UIComponent.InviteUsers)) {
buttons = (
<div className="mx_NewRoomIntro_buttons">
<AccessibleButton
className="mx_NewRoomIntro_inviteButton"
kind="primary"
onClick={() => {
showSpaceInvite(parentSpace!);
}}
>
{_t("invite|to_space", { spaceName: parentSpace.name })}
</AccessibleButton>
{room.canInvite(cli.getSafeUserId()) && (
<AccessibleButton
className="mx_NewRoomIntro_inviteButton"
kind="primary_outline"
onClick={() => {
defaultDispatcher.dispatch({ action: "view_invite", roomId });
}}
>
{_t("room|intro|room_invite")}
</AccessibleButton>
)}
</div>
);
} else if (room.canInvite(cli.getSafeUserId()) && shouldShowComponent(UIComponent.InviteUsers)) {
buttons = (
<div className="mx_NewRoomIntro_buttons">
<AccessibleButton
className="mx_NewRoomIntro_inviteButton"
kind="primary"
onClick={() => {
defaultDispatcher.dispatch({ action: "view_invite", roomId });
}}
>
{_t("room|invite_this_room")}
</AccessibleButton>
</div>
);
}
const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url;
let avatar = <RoomAvatar room={room} size={AVATAR_SIZE} viewAvatarOnClick={!!avatarUrl} />;
if (!avatarUrl) {
avatar = (
<MiniAvatarUploader
hasAvatar={false}
noAvatarLabel={_t("room|intro|no_avatar_label")}
setAvatarUrl={(url) => cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, "")}
>
{avatar}
</MiniAvatarUploader>
);
}
body = (
<React.Fragment>
{avatar}
<h2>{room.name}</h2>
<p>
{createdText}{" "}
{_t(
"room|intro|start_of_room",
{},
{
roomName: () => <strong>{room.name}</strong>,
},
)}
</p>
<p>{topicText}</p>
{buttons}
</React.Fragment>
);
}
function openRoomSettings(event: ButtonEvent): void {
event.preventDefault();
defaultDispatcher.dispatch({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.Security,
});
}
const subText = _t("room|intro|private_unencrypted_warning");
let subButton: JSX.Element | undefined;
if (
room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.safeGet()) &&
!isLocalRoom
) {
subButton = (
<AccessibleButton kind="link_inline" onClick={openRoomSettings}>
{_t("room|intro|enable_encryption_prompt")}
</AccessibleButton>
);
}
const subtitle = (
<span>
{" "}
{subText} {subButton}{" "}
</span>
);
return (
<li className="mx_NewRoomIntro">
{!hasExpectedEncryptionSettings(cli, room) && (
<EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon_warning"
title={_t("room|intro|unencrypted_warning")}
subtitle={subtitle}
/>
)}
{body}
</li>
);
};
export default NewRoomIntro;

View file

@ -0,0 +1,124 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import { Tooltip } from "@vector-im/compound-web";
import SettingsStore from "../../../settings/SettingsStore";
import { XOR } from "../../../@types/common";
import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import { _t } from "../../../languageHandler";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { StatelessNotificationBadge } from "./NotificationBadge/StatelessNotificationBadge";
interface IProps {
notification: NotificationState;
/**
* If true, show nothing if the notification would only cause a dot to be shown rather than
* a badge. That is: only display badges and not dots. Default: false.
*/
hideIfDot?: boolean;
/**
* The room ID, if any, the badge represents.
*/
roomId?: string;
}
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
showUnsentTooltip?: boolean;
/**
* If specified will return an AccessibleButton instead of a div.
*/
onClick(ev: React.MouseEvent): void;
}
interface IState {
showCounts: boolean; // whether to show counts.
}
export default class NotificationBadge extends React.PureComponent<XOR<IProps, IClickableProps>, IState> {
private countWatcherRef: string;
public constructor(props: IProps) {
super(props);
this.props.notification.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.state = {
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
};
this.countWatcherRef = SettingsStore.watchSetting(
"Notifications.alwaysShowBadgeCounts",
this.roomId,
this.countPreferenceChanged,
);
}
private get roomId(): string | null {
// We should convert this to null for safety with the SettingsStore
return this.props.roomId || null;
}
public componentWillUnmount(): void {
SettingsStore.unwatchSetting(this.countWatcherRef);
this.props.notification.off(NotificationStateEvents.Update, this.onNotificationUpdate);
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (prevProps.notification) {
prevProps.notification.off(NotificationStateEvents.Update, this.onNotificationUpdate);
}
this.props.notification.on(NotificationStateEvents.Update, this.onNotificationUpdate);
}
private countPreferenceChanged = (): void => {
this.setState({ showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId) });
};
private onNotificationUpdate = (): void => {
this.forceUpdate(); // notification state changed - update
};
public render(): ReactNode {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { notification, showUnsentTooltip, hideIfDot, onClick, tabIndex } = this.props;
if (notification.isIdle && !notification.knocked) return null;
if (hideIfDot && notification.level < NotificationLevel.Notification) {
// This would just be a dot and we've been told not to show dots, so don't show it
return null;
}
const commonProps: React.ComponentProps<typeof StatelessNotificationBadge> = {
symbol: notification.symbol,
count: notification.count,
level: notification.level,
knocked: notification.knocked,
};
let badge: JSX.Element;
if (onClick) {
badge = <StatelessNotificationBadge {...commonProps} onClick={onClick} tabIndex={tabIndex} />;
} else {
badge = <StatelessNotificationBadge {...commonProps} />;
}
if (showUnsentTooltip && notification.level === NotificationLevel.Unsent) {
return (
<Tooltip label={_t("notifications|message_didnt_send")} placement="right">
{badge}
</Tooltip>
);
}
return badge;
}
}

View file

@ -0,0 +1,105 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { forwardRef } from "react";
import classNames from "classnames";
import { formatCount } from "../../../../utils/FormattingUtils";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel";
import { useSettingValue } from "../../../../hooks/useSettings";
import { XOR } from "../../../../@types/common";
interface Props {
symbol: string | null;
count: number;
level: NotificationLevel;
knocked?: boolean;
/**
* If true, where we would normally show a badge, we instead show a dot. No numeric count will
* be displayed (but may affect whether the the dot is displayed). See class doc
* for the difference between the two.
*/
forceDot?: boolean;
}
interface ClickableProps extends Props {
/**
* If specified will return an AccessibleButton instead of a div.
*/
onClick(ev: ButtonEvent): void;
tabIndex?: number;
}
/**
* A notification indicator that conveys what activity / notifications the user has in whatever
* context it is being used.
*
* Can either be a 'badge': a small circle with a number in it (the 'count'), or a 'dot': a smaller, empty circle.
* The two can be used to convey the same meaning but in different contexts, for example: for unread
* notifications in the room list, it may have a green badge with the number of unread notifications,
* but somewhere else it may just have a green dot as a more compact representation of the same information.
*/
export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props, ClickableProps>>(
({ symbol, count, level, knocked, forceDot = false, ...props }, ref) => {
const hideBold = useSettingValue("feature_hidebold");
// Don't show a badge if we don't need to
if ((level === NotificationLevel.None || (hideBold && level == NotificationLevel.Activity)) && !knocked) {
return <></>;
}
const hasUnreadCount = level >= NotificationLevel.Notification && (!!count || !!symbol);
const isEmptyBadge = symbol === null && count === 0;
if (symbol === null && count > 0) {
symbol = formatCount(count);
}
// We show a dot if either:
// * The props force us to, or
// * It's just an activity-level notification or (in theory) lower and the room isn't knocked
const badgeType =
forceDot || (level <= NotificationLevel.Activity && !knocked)
? "dot"
: !symbol || symbol.length < 3
? "badge_2char"
: "badge_3char";
const classes = classNames({
"mx_NotificationBadge": true,
"mx_NotificationBadge_visible": isEmptyBadge || knocked ? true : hasUnreadCount,
"mx_NotificationBadge_level_notification": level == NotificationLevel.Notification,
"mx_NotificationBadge_level_highlight": level >= NotificationLevel.Highlight,
"mx_NotificationBadge_knocked": knocked,
// Exactly one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char
"mx_NotificationBadge_dot": badgeType === "dot",
"mx_NotificationBadge_2char": badgeType === "badge_2char",
"mx_NotificationBadge_3char": badgeType === "badge_3char",
// Badges with text should always use light colors
"cpd-theme-light": badgeType !== "dot",
});
if (props.onClick) {
return (
<AccessibleButton {...props} className={classes} onClick={props.onClick} ref={ref}>
<span className="mx_NotificationBadge_count">{symbol}</span>
{props.children}
</AccessibleButton>
);
}
return (
<div className={classes} ref={ref}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</div>
);
},
);

View file

@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import React from "react";
import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications";
import { StatelessNotificationBadge } from "./StatelessNotificationBadge";
interface Props {
room?: Room;
threadId?: string;
/**
* If true, where we would normally show a badge, we instead show a dot. No numeric count will
* be displayed.
*/
forceDot?: boolean;
}
export function UnreadNotificationBadge({ room, threadId, forceDot }: Props): JSX.Element {
const { symbol, count, level } = useUnreadNotifications(room, threadId);
return <StatelessNotificationBadge symbol={symbol} count={count} level={level} forceDot={forceDot} />;
}

View file

@ -0,0 +1,237 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2017 Travis Ralston
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, useCallback, useState } from "react";
import { EventTimeline, EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { IconButton, Menu, MenuItem, Separator, Tooltip } from "@vector-im/compound-web";
import ViewIcon from "@vector-im/compound-design-tokens/assets/web/icons/visibility-on";
import UnpinIcon from "@vector-im/compound-design-tokens/assets/web/icons/unpin";
import ForwardIcon from "@vector-im/compound-design-tokens/assets/web/icons/forward";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import ThreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads";
import classNames from "classnames";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from "../../../languageHandler";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useRoomState } from "../../../hooks/useRoomState";
import { isContentActionable } from "../../../utils/EventUtils";
import { getForwardableEvent } from "../../../events";
import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload";
import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import PinningUtils from "../../../utils/PinningUtils.ts";
import PosthogTrackers from "../../../PosthogTrackers.ts";
const AVATAR_SIZE = "32px";
/**
* Properties for {@link PinnedEventTile}.
*/
interface PinnedEventTileProps {
/**
* The event to display.
*/
event: MatrixEvent;
/**
* The permalink creator to use.
*/
permalinkCreator: RoomPermalinkCreator;
/**
* The room the event is in.
*/
room: Room;
}
/**
* A pinned event tile.
*/
export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTileProps): JSX.Element {
const sender = event.getSender();
if (!sender) {
throw new Error("Pinned event unexpectedly has no sender");
}
const isInThread = Boolean(event.threadRootId);
const displayThreadInfo = !event.isThreadRoot && isInThread;
return (
<div className="mx_PinnedEventTile" role="listitem">
<div>
<MemberAvatar
className="mx_PinnedEventTile_senderAvatar"
member={event.sender}
size={AVATAR_SIZE}
fallbackUserId={sender}
/>
</div>
<div className="mx_PinnedEventTile_wrapper">
<div className="mx_PinnedEventTile_top">
<Tooltip label={event.sender?.name || sender}>
<span className={classNames("mx_PinnedEventTile_sender", getUserNameColorClass(sender))}>
{event.sender?.name || sender}
</span>
</Tooltip>
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} />
</div>
<MessageEvent
mxEvent={event}
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
permalinkCreator={permalinkCreator}
replacingEventId={event.replacingEventId()}
/>
{displayThreadInfo && (
<div className="mx_PinnedEventTile_thread">
<ThreadIcon />
{_t(
"right_panel|pinned_messages|reply_thread",
{},
{
link: (sub) => (
<button
type="button"
onClick={() => {
if (!event.threadRootId) return;
const rootEvent = room.findEventById(event.threadRootId);
if (!rootEvent) return;
dis.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: rootEvent,
push: true,
});
}}
>
{sub}
</button>
),
},
)}
</div>
)}
</div>
</div>
);
}
/**
* Properties for {@link PinMenu}.
*/
interface PinMenuProps extends PinnedEventTileProps {}
/**
* A popover menu with actions on the pinned event
*/
function PinMenu({ event, room, permalinkCreator }: PinMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const matrixClient = useMatrixClientContext();
/**
* View the event in the timeline.
*/
const onViewInTimeline = useCallback(() => {
PosthogTrackers.trackInteraction("PinnedMessageListViewTimeline");
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: event.getId(),
highlighted: true,
room_id: event.getRoomId(),
metricsTrigger: undefined, // room doesn't change
});
}, [event]);
/**
* Whether the client can unpin the event.
* If the room state change, we want to check again the permission
*/
const canUnpin = useRoomState(room, () => PinningUtils.canUnpin(matrixClient, event));
/**
* Unpin the event.
* @param event
*/
const onUnpin = useCallback(async (): Promise<void> => {
await PinningUtils.pinOrUnpinEvent(matrixClient, event);
PosthogTrackers.trackPinUnpinMessage("Unpin", "MessagePinningList");
}, [event, matrixClient]);
const contentActionable = isContentActionable(event);
// Get the forwardable event for the given event
const forwardableEvent = contentActionable && getForwardableEvent(event, matrixClient);
/**
* Open the forward dialog.
*/
const onForward = useCallback(() => {
if (forwardableEvent) {
dis.dispatch<OpenForwardDialogPayload>({
action: Action.OpenForwardDialog,
event: forwardableEvent,
permalinkCreator: permalinkCreator,
});
}
}, [forwardableEvent, permalinkCreator]);
/**
* Whether the client can redact the event.
*/
const canRedact =
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.maySendRedactionForEvent(event, matrixClient.getSafeUserId()) &&
event.getType() !== EventType.RoomServerAcl &&
event.getType() !== EventType.RoomEncryption;
/**
* Redact the event.
*/
const onRedact = useCallback(
(): void =>
createRedactEventDialog({
mxEvent: event,
}),
[event],
);
return (
<Menu
open={open}
onOpenChange={setOpen}
showTitle={false}
title={_t("right_panel|pinned_messages|menu")}
side="right"
align="start"
trigger={
<IconButton size="24px" aria-label={_t("right_panel|pinned_messages|menu")}>
<TriggerIcon />
</IconButton>
}
>
<MenuItem Icon={ViewIcon} label={_t("right_panel|pinned_messages|view")} onSelect={onViewInTimeline} />
{canUnpin && <MenuItem Icon={UnpinIcon} label={_t("action|unpin")} onSelect={onUnpin} />}
{forwardableEvent && <MenuItem Icon={ForwardIcon} label={_t("action|forward")} onSelect={onForward} />}
{canRedact && (
<>
<Separator />
<MenuItem kind="critical" Icon={DeleteIcon} label={_t("action|delete")} onSelect={onRedact} />
</>
)}
</Menu>
);
}

View file

@ -0,0 +1,318 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, useEffect, useMemo, useState } from "react";
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin-solid";
import { Button } from "@vector-im/compound-web";
import { M_POLL_START, MatrixEvent, MsgType, Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents";
import { _t } from "../../../languageHandler";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import dis from "../../../dispatcher/dispatcher";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import MessageEvent from "../messages/MessageEvent";
import PosthogTrackers from "../../../PosthogTrackers.ts";
/**
* The props for the {@link PinnedMessageBanner} component.
*/
interface PinnedMessageBannerProps {
/**
* The permalink creator to use.
*/
permalinkCreator: RoomPermalinkCreator;
/**
* The room where the banner is displayed
*/
room: Room;
}
/**
* A banner that displays the pinned messages in a room.
*/
export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBannerProps): JSX.Element | null {
const pinnedEventIds = usePinnedEvents(room);
const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds);
const eventCount = pinnedEvents.length;
const isSinglePinnedEvent = eventCount === 1;
const [currentEventIndex, setCurrentEventIndex] = useState(eventCount - 1);
// When the number of pinned messages changes, we want to display the last message
useEffect(() => {
setCurrentEventIndex(() => eventCount - 1);
}, [eventCount]);
const pinnedEvent = pinnedEvents[currentEventIndex];
if (!pinnedEvent) return null;
const shouldUseMessageEvent = pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure();
const onBannerClick = (): void => {
PosthogTrackers.trackInteraction("PinnedMessageBannerClick");
// Scroll to the pinned message
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: pinnedEvent.getId(),
highlighted: true,
room_id: room.roomId,
metricsTrigger: undefined, // room doesn't change
});
// Cycle through the pinned messages
// When we reach the first message, we go back to the last message
setCurrentEventIndex((currentEventIndex) => (--currentEventIndex === -1 ? eventCount - 1 : currentEventIndex));
};
return (
<div
className="mx_PinnedMessageBanner"
data-single-message={isSinglePinnedEvent}
aria-label={_t("room|pinned_message_banner|description")}
data-testid="pinned-message-banner"
>
<button
aria-label={_t("room|pinned_message_banner|go_to_message")}
type="button"
className="mx_PinnedMessageBanner_main"
onClick={onBannerClick}
>
<div className="mx_PinnedMessageBanner_content">
<Indicators count={eventCount} currentIndex={currentEventIndex} />
<PinIcon width="20px" height="20px" className="mx_PinnedMessageBanner_PinIcon" />
{!isSinglePinnedEvent && (
<div className="mx_PinnedMessageBanner_title" data-testid="banner-counter">
{_t(
"room|pinned_message_banner|title",
{
index: currentEventIndex + 1,
length: eventCount,
},
{ bold: (sub) => <span className="mx_PinnedMessageBanner_title_counter">{sub}</span> },
)}
</div>
)}
<EventPreview pinnedEvent={pinnedEvent} />
{/* In case of redacted event, we want to display the nice sentence of the message event like in the timeline or in the pinned message list */}
{shouldUseMessageEvent && (
<div className="mx_PinnedMessageBanner_redactedMessage">
<MessageEvent
mxEvent={pinnedEvent}
maxImageHeight={20}
permalinkCreator={permalinkCreator}
replacingEventId={pinnedEvent.replacingEventId()}
/>
</div>
)}
</div>
</button>
{!isSinglePinnedEvent && <BannerButton room={room} />}
</div>
);
}
/**
* The props for the {@link EventPreview} component.
*/
interface EventPreviewProps {
/**
* The pinned event to display the preview for
*/
pinnedEvent: MatrixEvent;
}
/**
* A component that displays a preview for the pinned event.
*/
function EventPreview({ pinnedEvent }: EventPreviewProps): JSX.Element | null {
const preview = useEventPreview(pinnedEvent);
if (!preview) return null;
const prefix = getPreviewPrefix(pinnedEvent.getType(), pinnedEvent.getContent().msgtype as MsgType);
if (!prefix)
return (
<span className="mx_PinnedMessageBanner_message" data-testid="banner-message">
{preview}
</span>
);
return (
<span className="mx_PinnedMessageBanner_message" data-testid="banner-message">
{_t(
"room|pinned_message_banner|preview",
{
prefix,
preview,
},
{
bold: (sub) => <span className="mx_PinnedMessageBanner_prefix">{sub}</span>,
},
)}
</span>
);
}
/**
* Hooks to generate a preview for the pinned event.
* @param pinnedEvent
*/
function useEventPreview(pinnedEvent: MatrixEvent | null): string | null {
return useMemo(() => {
if (!pinnedEvent || pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure()) return null;
return MessagePreviewStore.instance.generatePreviewForEvent(pinnedEvent);
}, [pinnedEvent]);
}
/**
* Get the prefix for the preview based on the type and the message type.
* @param type
* @param msgType
*/
function getPreviewPrefix(type: string, msgType: MsgType): string | null {
switch (type) {
case M_POLL_START.name:
return _t("room|pinned_message_banner|prefix|poll");
default:
}
switch (msgType) {
case MsgType.Audio:
return _t("room|pinned_message_banner|prefix|audio");
case MsgType.Image:
return _t("room|pinned_message_banner|prefix|image");
case MsgType.Video:
return _t("room|pinned_message_banner|prefix|video");
case MsgType.File:
return _t("room|pinned_message_banner|prefix|file");
default:
return null;
}
}
const MAX_INDICATORS = 3;
/**
* The props for the {@link IndicatorsProps} component.
*/
interface IndicatorsProps {
/**
* The number of messages pinned
*/
count: number;
/**
* The current index of the pinned message
*/
currentIndex: number;
}
/**
* A component that displays vertical indicators for the pinned messages.
*/
function Indicators({ count, currentIndex }: IndicatorsProps): JSX.Element {
// We only display a maximum of 3 indicators at one time.
// When there is more than 3 messages pinned, we will cycle through the indicators
// If there is only 2 messages pinned, we will display 2 indicators
// In case of 1 message pinned, the indicators are not displayed, see {@link PinnedMessageBanner} logic.
const numberOfIndicators = Math.min(count, MAX_INDICATORS);
// The index of the active indicator
const index = currentIndex % numberOfIndicators;
// We hide the indicators when we are on the last cycle and there are less than 3 remaining messages pinned
const numberOfCycles = Math.ceil(count / numberOfIndicators);
// If the current index is greater than the last cycle index, we are on the last cycle
const isLastCycle = currentIndex >= (numberOfCycles - 1) * MAX_INDICATORS;
// The index of the last message in the last cycle
const lastCycleIndex = numberOfIndicators - (numberOfCycles * numberOfIndicators - count);
return (
<div className="mx_PinnedMessageBanner_Indicators">
{Array.from({ length: numberOfIndicators }).map((_, i) => (
<Indicator key={i} active={i === index} hidden={isLastCycle && lastCycleIndex <= i} />
))}
</div>
);
}
/**
* The props for the {@link Indicator} component.
*/
interface IndicatorProps {
/**
* Whether the indicator is active
*/
active: boolean;
/**
* Whether the indicator is hidden
*/
hidden: boolean;
}
/**
* A component that displays a vertical indicator for a pinned message.
*/
function Indicator({ active, hidden }: IndicatorProps): JSX.Element {
return (
<div
data-testid="banner-indicator"
className={classNames("mx_PinnedMessageBanner_Indicator", {
"mx_PinnedMessageBanner_Indicator--active": active,
"mx_PinnedMessageBanner_Indicator--hidden": hidden,
})}
/>
);
}
function getRightPanelPhase(roomId: string): RightPanelPhases | null {
if (!RightPanelStore.instance.isOpenForRoom(roomId)) return null;
return RightPanelStore.instance.currentCard.phase;
}
/**
* The props for the {@link BannerButton} component.
*/
interface BannerButtonProps {
/**
* The room where the banner is displayed
*/
room: Room;
}
/**
* A button that allows the user to view or close the list of pinned messages.
*/
function BannerButton({ room }: BannerButtonProps): JSX.Element {
const [currentPhase, setCurrentPhase] = useState<RightPanelPhases | null>(getRightPanelPhase(room.roomId));
useEventEmitter(RightPanelStore.instance, UPDATE_EVENT, () => setCurrentPhase(getRightPanelPhase(room.roomId)));
const isPinnedMessagesPhase = currentPhase === RightPanelPhases.PinnedMessages;
return (
<Button
className="mx_PinnedMessageBanner_actions"
kind="tertiary"
onClick={() => {
if (isPinnedMessagesPhase) PosthogTrackers.trackInteraction("PinnedMessageBannerCloseListButton");
else PosthogTrackers.trackInteraction("PinnedMessageBannerViewAllButton");
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
}}
>
{isPinnedMessagesPhase
? _t("room|pinned_message_banner|button_close_list")
: _t("room|pinned_message_banner|button_view_all")}
</Button>
);
}

View file

@ -0,0 +1,70 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { formatDuration } from "../../../DateUtils";
export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy");
interface IProps {
// number of milliseconds ago this user was last active.
// zero = unknown
activeAgo?: number;
// if true, activeAgo is an approximation and "Now" should
// be shown instead
currentlyActive?: boolean;
// offline, online, etc
presenceState?: string;
// whether to apply colouring to the label
coloured?: boolean;
className?: string;
}
export default class PresenceLabel extends React.Component<IProps> {
public static defaultProps = {
activeAgo: -1,
};
private getPrettyPresence(presence?: string, activeAgo?: number, currentlyActive?: boolean): string {
// for busy presence, we ignore the 'currentlyActive' flag: they're busy whether
// they're active or not. It can be set while the user is active in which case
// the 'active ago' ends up being 0.
if (presence && BUSY_PRESENCE_NAME.matches(presence)) return _t("presence|busy");
if (presence === "io.element.unreachable") return _t("presence|unreachable");
if (!currentlyActive && activeAgo !== undefined && activeAgo > 0) {
const duration = formatDuration(activeAgo);
if (presence === "online") return _t("presence|online_for", { duration: duration });
if (presence === "unavailable") return _t("presence|idle_for", { duration: duration }); // XXX: is this actually right?
if (presence === "offline") return _t("presence|offline_for", { duration: duration });
return _t("presence|unknown_for", { duration: duration });
} else {
if (presence === "online") return _t("presence|online");
if (presence === "unavailable") return _t("presence|idle"); // XXX: is this actually right?
if (presence === "offline") return _t("presence|offline");
return _t("presence|unknown");
}
}
public render(): React.ReactNode {
return (
<div
className={classNames("mx_PresenceLabel", this.props.className, {
mx_PresenceLabel_online: this.props.coloured && this.props.presenceState === "online",
})}
>
{this.getPrettyPresence(this.props.presenceState, this.props.activeAgo, this.props.currentlyActive)}
</div>
);
}
}

View file

@ -0,0 +1,262 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { PropsWithChildren } from "react";
import { User } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web";
import ReadReceiptMarker, { IReadReceiptPosition } from "./ReadReceiptMarker";
import { IReadReceiptProps } from "./EventTile";
import AccessibleButton from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { formatDate } from "../../../DateUtils";
import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
import ContextMenu, { aboveLeftOf, MenuItem, useContextMenu } from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import { formatList } from "../../../utils/FormattingUtils";
// #20547 Design specified that we should show the three latest read receipts
const MAX_READ_AVATARS_PLUS_N = 3;
// #21935 If weve got just 4, dont show +1, just show all of them
const MAX_READ_AVATARS = MAX_READ_AVATARS_PLUS_N + 1;
const READ_AVATAR_OFFSET = 10;
export const READ_AVATAR_SIZE = 16;
interface Props {
readReceipts: IReadReceiptProps[];
readReceiptMap: { [userId: string]: IReadReceiptPosition };
checkUnmounting?: () => boolean;
suppressAnimation: boolean;
isTwelveHour?: boolean;
}
interface IAvatarPosition {
hidden: boolean;
position: number;
}
export function determineAvatarPosition(index: number, max: number): IAvatarPosition {
if (index < max) {
return {
hidden: false,
position: index,
};
} else {
return {
hidden: true,
position: 0,
};
}
}
export function readReceiptTooltip(members: string[], maxAvatars: number): string | undefined {
return formatList(members, maxAvatars);
}
export function ReadReceiptGroup({
readReceipts,
readReceiptMap,
checkUnmounting,
suppressAnimation,
isTwelveHour,
}: Props): JSX.Element {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
// If we are above MAX_READ_AVATARS, well have to remove a few to have space for the +n count.
const hasMore = readReceipts.length > MAX_READ_AVATARS;
const maxAvatars = hasMore ? MAX_READ_AVATARS_PLUS_N : MAX_READ_AVATARS;
const tooltipMembers: string[] = readReceipts.map((it) => it.roomMember?.name ?? it.userId);
const tooltipText = readReceiptTooltip(tooltipMembers, maxAvatars);
// return early if there are no read receipts
if (readReceipts.length === 0) {
// We currently must include `mx_ReadReceiptGroup_container` in
// the DOM of all events, as it is the positioned parent of the
// animated read receipts. We can't let it unmount when a receipt
// moves events, so for now we mount it for all events. Without
// it, the animation will start from the top of the timeline
// (because it lost its container).
// See also https://github.com/vector-im/element-web/issues/17561
return (
<div className="mx_EventTile_msgOption">
<div className="mx_ReadReceiptGroup">
<div className="mx_ReadReceiptGroup_button">
<span className="mx_ReadReceiptGroup_container" />
</div>
</div>
</div>
);
}
const avatars = readReceipts
.map((receipt, index) => {
const { hidden, position } = determineAvatarPosition(index, maxAvatars);
const userId = receipt.userId;
let readReceiptPosition: IReadReceiptPosition | undefined;
if (readReceiptMap) {
readReceiptPosition = readReceiptMap[userId];
if (!readReceiptPosition) {
readReceiptPosition = {};
readReceiptMap[userId] = readReceiptPosition;
}
}
return (
<ReadReceiptMarker
key={userId}
member={receipt.roomMember}
fallbackUserId={userId}
offset={position * READ_AVATAR_OFFSET}
hidden={hidden}
readReceiptPosition={readReceiptPosition}
checkUnmounting={checkUnmounting}
suppressAnimation={suppressAnimation}
timestamp={receipt.ts}
showTwelveHour={isTwelveHour}
/>
);
})
.reverse();
let remText: JSX.Element | undefined;
const remainder = readReceipts.length - maxAvatars;
if (remainder > 0) {
remText = (
<span className="mx_ReadReceiptGroup_remainder" aria-live="off">
+{remainder}
</span>
);
}
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && button.current) {
const buttonRect = button.current.getBoundingClientRect();
contextMenu = (
<ContextMenu menuClassName="mx_ReadReceiptGroup_popup" onFinished={closeMenu} {...aboveLeftOf(buttonRect)}>
<AutoHideScrollbar>
<SectionHeader className="mx_ReadReceiptGroup_title">
{_t("timeline|read_receipt_title", { count: readReceipts.length })}
</SectionHeader>
{readReceipts.map((receipt) => (
<ReadReceiptPerson
key={receipt.userId}
{...receipt}
isTwelveHour={isTwelveHour}
onAfterClick={closeMenu}
/>
))}
</AutoHideScrollbar>
</ContextMenu>
);
}
return (
<div className="mx_EventTile_msgOption">
<Tooltip
label={_t("timeline|read_receipt_title", { count: readReceipts.length })}
caption={tooltipText}
placement="top-end"
>
<div className="mx_ReadReceiptGroup" role="group" aria-label={_t("timeline|read_receipts_label")}>
<AccessibleButton
className="mx_ReadReceiptGroup_button"
ref={button}
aria-label={tooltipText}
aria-haspopup="true"
onClick={openMenu}
>
{remText}
<span
className="mx_ReadReceiptGroup_container"
style={{
width:
Math.min(maxAvatars, readReceipts.length) * READ_AVATAR_OFFSET +
READ_AVATAR_SIZE -
READ_AVATAR_OFFSET,
}}
>
{avatars}
</span>
</AccessibleButton>
{contextMenu}
</div>
</Tooltip>
</div>
);
}
interface ReadReceiptPersonProps extends IReadReceiptProps {
isTwelveHour?: boolean;
onAfterClick?: () => void;
}
// Export for testing
export function ReadReceiptPerson({
userId,
roomMember,
ts,
isTwelveHour,
onAfterClick,
}: ReadReceiptPersonProps): JSX.Element {
return (
<Tooltip description={roomMember?.rawDisplayName ?? userId} caption={userId} placement="top">
<div>
<MenuItem
className="mx_ReadReceiptGroup_person"
onClick={() => {
dis.dispatch({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
// The ViewUser action leads to the RightPanelStore, and RightPanelStoreIPanelState defines the
// member property of IRightPanelCardState as `RoomMember | User`, so were fine for now, but we
// should definitely clean this up later
member: roomMember ?? ({ userId } as User),
push: false,
});
onAfterClick?.();
}}
>
<MemberAvatar
member={roomMember}
fallbackUserId={userId}
size="24px"
aria-hidden="true"
aria-live="off"
resizeMethod="crop"
hideTitle
/>
<div className="mx_ReadReceiptGroup_name">
<p>{roomMember?.name ?? userId}</p>
<p className="mx_ReadReceiptGroup_secondary">{formatDate(new Date(ts), isTwelveHour)}</p>
</div>
</MenuItem>
</div>
</Tooltip>
);
}
interface ISectionHeaderProps {
className?: string;
}
function SectionHeader({ className, children }: PropsWithChildren<ISectionHeaderProps>): JSX.Element {
const [onFocus, , ref] = useRovingTabIndex<HTMLHeadingElement>();
return (
<h3 className={className} role="menuitem" onFocus={onFocus} tabIndex={-1} ref={ref}>
{children}
</h3>
);
}

View file

@ -0,0 +1,193 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import NodeAnimator from "../../../NodeAnimator";
import { toPx } from "../../../utils/units";
import MemberAvatar from "../avatars/MemberAvatar";
import { READ_AVATAR_SIZE } from "./ReadReceiptGroup";
// The top & right from the bounding client rect of each read receipt
export interface IReadReceiptPosition {
top?: number;
right?: number;
}
interface IProps {
// the RoomMember to show the RR for
member?: RoomMember | null;
// userId to fallback the avatar to
// if the member hasn't been loaded yet
fallbackUserId: string;
// number of pixels to offset the avatar from the right of its parent;
// typically a negative value.
offset: number;
// true to hide the avatar (it will still be animated)
hidden?: boolean;
// don't animate this RR into position
suppressAnimation?: boolean;
// an opaque object for storing information about this user's RR in this room
readReceiptPosition?: IReadReceiptPosition;
// A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted.
checkUnmounting?: () => boolean;
// Timestamp when the receipt was read
timestamp?: number;
// True to show twelve hour format, false otherwise
showTwelveHour?: boolean;
}
interface IState {
suppressDisplay: boolean;
startStyles?: IReadReceiptMarkerStyle[];
}
interface IReadReceiptMarkerStyle {
top: number;
right: number;
}
export default class ReadReceiptMarker extends React.PureComponent<IProps, IState> {
private avatar = createRef<HTMLDivElement>();
public constructor(props: IProps) {
super(props);
this.state = {
// if we are going to animate the RR, we don't show it on first render,
// and instead just add a placeholder to the DOM; once we've been
// mounted, we start an animation which moves the RR from its old
// position.
suppressDisplay: !this.props.suppressAnimation,
};
}
public componentWillUnmount(): void {
// before we remove the rr, store its location in the map, so that if
// it reappears, it can be animated from the right place.
const rrInfo = this.props.readReceiptPosition;
if (!rrInfo) {
return;
}
// checking the DOM properties can force a re-layout, which can be
// quite expensive; so if the parent messagepanel is being unmounted,
// then don't bother with this.
if (this.props.checkUnmounting && this.props.checkUnmounting()) {
return;
}
this.buildReadReceiptInfo(rrInfo);
}
public componentDidMount(): void {
if (!this.state.suppressDisplay) {
// we've already done our display - nothing more to do.
return;
}
this.animateMarker();
}
public componentDidUpdate(prevProps: IProps): void {
const differentOffset = prevProps.offset !== this.props.offset;
const visibilityChanged = prevProps.hidden !== this.props.hidden;
if (differentOffset || visibilityChanged) {
this.animateMarker();
}
}
private buildReadReceiptInfo(target: IReadReceiptPosition = {}): IReadReceiptPosition {
const element = this.avatar.current;
// this is the mx_ReadReceiptsGroup_container
const horizontalContainer = element?.offsetParent;
if (!horizontalContainer || !horizontalContainer.getBoundingClientRect) {
// this seems to happen sometimes for reasons I don't understand
// the docs for `offsetParent` say it may be null if `display` is
// `none`, but I can't see why that would happen.
logger.warn(`ReadReceiptMarker for ${this.props.fallbackUserId} has no valid horizontalContainer`);
target.top = 0;
target.right = 0;
return target;
}
const elementRect = element.getBoundingClientRect();
target.top = elementRect.top;
target.right = elementRect.right - horizontalContainer.getBoundingClientRect().right;
return target;
}
private animateMarker(): void {
const oldInfo = this.props.readReceiptPosition;
const newInfo = this.buildReadReceiptInfo();
const newPosition = newInfo.top ?? 0;
const oldPosition =
oldInfo && oldInfo.top !== undefined
? // start at the old height and in the old h pos
oldInfo.top
: // treat new RRs as though they were off the top of the screen
-READ_AVATAR_SIZE;
const startStyles: IReadReceiptMarkerStyle[] = [];
if (oldInfo?.right) {
startStyles.push({
top: oldPosition - newPosition,
right: oldInfo.right,
});
}
startStyles.push({
top: oldPosition - newPosition,
right: 0,
});
this.setState({
suppressDisplay: false,
startStyles,
});
}
public render(): React.ReactNode {
if (this.state.suppressDisplay) {
return <div ref={this.avatar} />;
}
const style = {
right: toPx(this.props.offset),
top: "0px",
};
return (
<NodeAnimator startStyles={this.state.startStyles} innerRef={this.avatar}>
<MemberAvatar
member={this.props.member ?? null}
fallbackUserId={this.props.fallbackUserId}
aria-hidden="true"
aria-live="off"
size="14px"
style={style}
hideTitle
tabIndex={-1}
/>
</NodeAnimator>
);
}
}

View file

@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import ReplyTile from "./ReplyTile";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import AccessibleButton from "../elements/AccessibleButton";
function cancelQuoting(context: TimelineRenderingType): void {
dis.dispatch({
action: "reply_to_event",
event: null,
context,
});
}
interface IProps {
permalinkCreator?: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
}
export default class ReplyPreview extends React.Component<IProps> {
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
public render(): JSX.Element | null {
if (!this.props.replyToEvent) return null;
return (
<div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section">
<div className="mx_ReplyPreview_header">
<span>{_t("composer|replying_title")}</span>
<AccessibleButton
className="mx_ReplyPreview_header_cancel"
onClick={() => cancelQuoting(this.context.timelineRenderingType)}
/>
</div>
<ReplyTile mxEvent={this.props.replyToEvent} permalinkCreator={this.props.permalinkCreator} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,181 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import classNames from "classnames";
import { MatrixEvent, MatrixEventEvent, EventType, MsgType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SenderProfile from "../messages/SenderProfile";
import MImageReplyBody from "../messages/MImageReplyBody";
import { isVoiceMessage } from "../../../utils/EventUtils";
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import MFileBody from "../messages/MFileBody";
import MemberAvatar from "../avatars/MemberAvatar";
import MVoiceMessageBody from "../messages/MVoiceMessageBody";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { renderReplyTile } from "../../../events/EventTileFactory";
import { GetRelationsForEvent } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
mxEvent: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
highlights?: string[];
highlightLink?: string;
onHeightChanged?(): void;
toggleExpandedQuote?: () => void;
getRelationsForEvent?: GetRelationsForEvent;
}
export default class ReplyTile extends React.PureComponent<IProps> {
private anchorElement = createRef<HTMLAnchorElement>();
public static defaultProps = {
onHeightChanged: () => {},
};
public componentDidMount(): void {
this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted);
this.props.mxEvent.on(MatrixEventEvent.BeforeRedaction, this.onEventRequiresUpdate);
this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onEventRequiresUpdate);
}
public componentWillUnmount(): void {
this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted);
this.props.mxEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onEventRequiresUpdate);
this.props.mxEvent.removeListener(MatrixEventEvent.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 => {
const clickTarget = e.target as HTMLElement;
// Following a link within a reply should not dispatch the `view_room` action
// so that the browser can direct the user to the correct location
// The exception being the link wrapping the reply
if (
clickTarget.tagName.toLowerCase() !== "a" ||
clickTarget.closest("a") === null ||
clickTarget === this.anchorElement.current
) {
// 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();
// Expand thread on shift key
if (this.props.toggleExpandedQuote && e.shiftKey) {
this.props.toggleExpandedQuote();
} else {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
metricsTrigger: undefined, // room doesn't change
});
}
}
};
public render(): React.ReactNode {
const mxEvent = this.props.mxEvent;
const msgType = mxEvent.getContent().msgtype;
const evType = mxEvent.getType();
const { hasRenderer, isInfoMessage, isSeeingThroughMessageHiddenForModeration } = getEventDisplayInfo(
MatrixClientPeg.safeGet(),
mxEvent,
false /* Replies are never hidden, so this should be fine */,
);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!hasRenderer) {
const { mxEvent } = this.props;
logger.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
return (
<div className="mx_ReplyTile mx_ReplyTile_info mx_MNoticeBody">{_t("timeline|error_no_renderer")}</div>
);
}
const classes = classNames("mx_ReplyTile", {
mx_ReplyTile_inline: msgType === MsgType.Emote,
mx_ReplyTile_info: isInfoMessage && !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(mxEvent.getId()!);
}
let sender;
const hasOwnSender = isInfoMessage || evType === EventType.RoomCreate;
if (!hasOwnSender) {
sender = (
<div className="mx_ReplyTile_sender">
<MemberAvatar member={mxEvent.sender} fallbackUserId={mxEvent.getSender()} size="16px" />
<SenderProfile mxEvent={mxEvent} />
</div>
);
}
const msgtypeOverrides: Record<string, typeof React.Component> = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides: Record<string, typeof React.Component> = {
// 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} ref={this.anchorElement}>
{sender}
{renderReplyTile(
{
...this.props,
// overrides
ref: undefined,
showUrlPreview: false,
overrideBodyTypes: msgtypeOverrides,
overrideEventTypes: evOverrides,
maxImageHeight: 96,
isSeeingThroughMessageHiddenForModeration,
// appease TS
highlights: this.props.highlights,
highlightLink: this.props.highlightLink,
onHeightChanged: this.props.onHeightChanged,
permalinkCreator: this.props.permalinkCreator,
},
false /* showHiddenEvents shouldn't be relevant */,
)}
</a>
</div>
);
}
}

View file

@ -0,0 +1,130 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { CSSTransition } from "react-transition-group";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
interface IProps {}
interface IState {
// Both of these control the animation for the breadcrumbs. For details on the
// actual animation, see the CSS.
//
// doAnimation is to lie to the CSSTransition component (see onBreadcrumbsUpdate
// for info). skipFirst is used to try and reduce jerky animation - also see the
// breadcrumb update function for info on that.
doAnimation: boolean;
skipFirst: boolean;
}
const RoomBreadcrumbTile: React.FC<{ room: Room; onClick: (ev: ButtonEvent) => void }> = ({ room, onClick }) => {
const [onFocus, isActive, ref] = useRovingTabIndex();
return (
<AccessibleButton
className="mx_RoomBreadcrumbs_crumb"
onClick={onClick}
aria-label={_t("a11y|room_name", { name: room.name })}
title={room.name}
onFocus={onFocus}
ref={ref}
tabIndex={isActive ? 0 : -1}
placement="right"
>
<DecoratedRoomAvatar
room={room}
size="32px"
displayBadge={true}
hideIfDot={true}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
</AccessibleButton>
);
};
export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState> {
private isMounted = true;
public constructor(props: IProps) {
super(props);
this.state = {
doAnimation: true, // technically we want animation on mount, but it won't be perfect
skipFirst: false, // render the thing, as boring as it is
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
public componentWillUnmount(): void {
this.isMounted = false;
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
private onBreadcrumbsUpdate = (): void => {
if (!this.isMounted) return;
// We need to trick the CSSTransition component into updating, which means we need to
// tell it to not animate, then to animate a moment later. This causes two updates
// which means two renders. The skipFirst change is so that our don't-animate state
// doesn't show the breadcrumb we're about to reveal as it causes a visual jump/jerk.
// The second update, on the next available tick, causes the "enter" animation to start
// again and this time we want to show the newest breadcrumb because it'll be hidden
// off screen for the animation.
this.setState({ doAnimation: false, skipFirst: true });
window.setTimeout(() => this.setState({ doAnimation: true, skipFirst: false }), 0);
};
private viewRoom = (room: Room, index: number, viaKeyboard = false): void => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebHorizontalBreadcrumbs",
metricsViaKeyboard: viaKeyboard,
});
};
public render(): React.ReactElement {
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => (
<RoomBreadcrumbTile
key={r.roomId}
room={r}
onClick={(ev: ButtonEvent) => this.viewRoom(r, i, ev.type !== "click")}
/>
));
if (tiles.length > 0) {
// NOTE: The CSSTransition timeout MUST match the timeout in our CSS!
return (
<CSSTransition appear={true} in={this.state.doAnimation} timeout={640} classNames="mx_RoomBreadcrumbs">
<Toolbar className="mx_RoomBreadcrumbs" aria-label={_t("room_list|breadcrumbs_label")}>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
</Toolbar>
</CSSTransition>
);
} else {
return (
<div className="mx_RoomBreadcrumbs">
<div className="mx_RoomBreadcrumbs_placeholder">{_t("room_list|breadcrumbs_empty")}</div>
</div>
);
}
}
}

View file

@ -0,0 +1,33 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import React, { HTMLAttributes, ReactHTML } from "react";
import { roomContextDetails } from "../../../utils/i18n-helpers";
type Props<T extends keyof ReactHTML> = HTMLAttributes<T> & {
component?: T;
room: Room;
};
export function RoomContextDetails<T extends keyof ReactHTML>({ room, component, ...other }: Props<T>): JSX.Element {
const contextDetails = roomContextDetails(room);
if (contextDetails) {
return React.createElement(
component ?? "div",
{
...other,
"aria-label": contextDetails.ariaLabel,
},
[contextDetails.details],
);
}
return <></>;
}

View file

@ -0,0 +1,403 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useContext, useMemo, useState } from "react";
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call";
import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid";
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { useRoomName } from "../../../hooks/useRoomName";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers";
import { _t } from "../../../languageHandler";
import { Flex } from "../../utils/Flex";
import { Box } from "../../utils/Box";
import { getPlatformCallTypeLabel, useRoomCall } from "../../../hooks/room/useRoomCall";
import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
import SdkConfig from "../../../SdkConfig";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus";
import { E2EStatus } from "../../../utils/ShieldUtils";
import FacePile from "../elements/FacePile";
import { useRoomState } from "../../../hooks/useRoomState";
import RoomAvatar from "../avatars/RoomAvatar";
import { formatCount } from "../../../utils/FormattingUtils";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import PosthogTrackers from "../../../PosthogTrackers";
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
import { RoomKnocksBar } from "./RoomKnocksBar";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import { notificationLevelToIndicator } from "../../../utils/notifications";
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
import { ButtonEvent } from "../elements/AccessibleButton";
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import RoomContext from "../../../contexts/RoomContext";
import { MainSplitContentType } from "../../structures/RoomView";
import defaultDispatcher from "../../../dispatcher/dispatcher.ts";
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx";
export default function RoomHeader({
room,
additionalButtons,
oobData,
}: {
room: Room;
additionalButtons?: ViewRoomOpts["buttons"];
oobData?: IOOBData;
}): JSX.Element {
const client = useMatrixClientContext();
const roomName = useRoomName(room);
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
const {
voiceCallDisabledReason,
voiceCallClick,
videoCallDisabledReason,
videoCallClick,
toggleCallMaximized: toggleCall,
isViewingCall,
isConnectedToCall,
hasActiveCallSession,
callOptions,
showVoiceCallButton,
showVideoCallButton,
} = useRoomCall(room);
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
/**
* A special mode where only Element Call is used. In this case we want to
* hide the voice call button
*/
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
}, [groupCallsEnabled]);
const threadNotifications = useRoomThreadNotifications(room);
const globalNotificationState = useGlobalNotificationState();
const dmMember = useDmMember(room);
const isDirectMessage = !!dmMember;
const e2eStatus = useEncryptionStatus(client, room);
const notificationsEnabled = useFeatureEnabled("feature_notifications");
const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join");
const videoClick = useCallback(
(ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]),
[callOptions, videoCallClick],
);
const toggleCallButton = (
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
<IconButton onClick={toggleCall}>
<VideoCallIcon />
</IconButton>
</Tooltip>
);
const joinCallButton = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<Button
size="sm"
onClick={videoClick}
Icon={VideoCallIcon}
className="mx_RoomHeader_join_button"
disabled={!!videoCallDisabledReason}
color="primary"
aria-label={videoCallDisabledReason ?? _t("action|join")}
>
{_t("action|join")}
</Button>
</Tooltip>
);
const callIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<VideoCallIcon />
</Tooltip>
);
const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback(
(newOpen: boolean) => {
if (!videoCallDisabledReason) setMenuOpen(newOpen);
},
[videoCallDisabledReason],
);
const startVideoCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={menuOpen}
onOpenChange={onOpenChange}
title={_t("voip|video_call_using")}
trigger={
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
>
{callIconWithTooltip}
</IconButton>
}
side="left"
align="start"
>
{callOptions.map((option) => (
<MenuItem
key={option}
label={getPlatformCallTypeLabel(option)}
aria-label={getPlatformCallTypeLabel(option)}
onClick={(ev) => videoCallClick(ev, option)}
Icon={VideoCallIcon}
onSelect={() => {} /* Dummy handler since we want the click event.*/}
/>
))}
</Menu>
) : (
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
onClick={videoClick}
>
{callIconWithTooltip}
</IconButton>
)}
</>
);
let voiceCallButton: JSX.Element | undefined = (
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
<IconButton
// We need both: isViewingCall and isConnectedToCall
// - in the Lobby we are viewing a call but are not connected to it.
// - in pip view we are connected to the call but not viewing it.
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
>
<VoiceCallIcon />
</IconButton>
</Tooltip>
);
const closeLobbyButton = (
<Tooltip label={_t("voip|close_lobby")}>
<IconButton onClick={toggleCall}>
<CloseCallIcon />
</IconButton>
</Tooltip>
);
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
if (isConnectedToCall) {
videoCallButton = toggleCallButton;
} else if (isViewingCall) {
videoCallButton = closeLobbyButton;
}
if (!showVideoCallButton) {
videoCallButton = undefined;
}
if (!showVoiceCallButton) {
voiceCallButton = undefined;
}
const roomContext = useContext(RoomContext);
const isVideoRoom = calcIsVideoRoom(room);
const showChatButton =
isVideoRoom ||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
roomContext.mainSplitContentType === MainSplitContentType.Call;
const onAvatarClick = (): void => {
defaultDispatcher.dispatch({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.General,
});
};
return (
<>
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
<WithPresenceIndicator room={room} size="8px">
{/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */}
<RoomAvatar
room={room}
size="40px"
oobData={oobData}
onClick={onAvatarClick}
tabIndex={-1}
aria-label={_t("room|header_avatar_open_settings_label")}
/>
</WithPresenceIndicator>
<button
aria-label={_t("right_panel|room_summary_card|title")}
tabIndex={0}
onClick={() => RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary)}
className="mx_RoomHeader_infoWrapper"
>
<Box flex="1" className="mx_RoomHeader_info">
<BodyText
as="div"
size="lg"
weight="semibold"
dir="auto"
role="heading"
aria-level={1}
className="mx_RoomHeader_heading"
>
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
{!isDirectMessage && joinRule === JoinRule.Public && (
<Tooltip label={_t("common|public_room")} placement="right">
<PublicIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon text-secondary"
aria-label={_t("common|public_room")}
/>
</Tooltip>
)}
{isDirectMessage && e2eStatus === E2EStatus.Verified && (
<Tooltip label={_t("common|verified")} placement="right">
<VerifiedIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon mx_Verified"
aria-label={_t("common|verified")}
/>
</Tooltip>
)}
{isDirectMessage && e2eStatus === E2EStatus.Warning && (
<Tooltip label={_t("room|header_untrusted_label")} placement="right">
<ErrorIcon
width="16px"
height="16px"
className="mx_RoomHeader_icon mx_Untrusted"
aria-label={_t("room|header_untrusted_label")}
/>
</Tooltip>
)}
</BodyText>
</Box>
</button>
<Flex align="center" gap="var(--cpd-space-2x)">
{additionalButtons?.map((props) => {
const label = props.label();
return (
<Tooltip label={label} key={props.id}>
<IconButton
aria-label={label}
onClick={(event) => {
event.stopPropagation();
props.onClick();
}}
>
{typeof props.icon === "function" ? props.icon() : props.icon}
</IconButton>
</Tooltip>
);
})}
{isViewingCall && <CallGuestLinkButton room={room} />}
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
joinCallButton
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
</>
)}
<Tooltip label={_t("right_panel|room_summary_card|title")}>
<IconButton
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
}}
aria-label={_t("right_panel|room_summary_card|title")}
>
<RoomInfoIcon />
</IconButton>
</Tooltip>
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
}}
aria-label={_t("common|threads")}
>
<ThreadsIcon />
</IconButton>
</Tooltip>
{notificationsEnabled && (
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
<IconButton
indicator={notificationLevelToIndicator(globalNotificationState.level)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
}}
aria-label={_t("notifications|enable_prompt_toast_title")}
>
<NotificationsIcon />
</IconButton>
</Tooltip>
)}
</Flex>
{!isDirectMessage && (
<BodyText as="div" size="sm" weight="medium">
<FacePile
className="mx_RoomHeader_members"
members={members.slice(0, 3)}
size="20px"
overflow={false}
viewUserOnClick={false}
tooltipLabel={_t("room|header_face_pile_tooltip")}
onClick={(e: ButtonEvent) => {
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomMemberList);
e.stopPropagation();
}}
aria-label={_t("common|n_members", { count: memberCount })}
>
{formatCount(memberCount)}
</FacePile>
</BodyText>
)}
</Flex>
{askToJoinEnabled && <RoomKnocksBar room={room} />}
</>
);
}

View file

@ -0,0 +1,158 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import ExternalLinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import { Button, IconButton, Tooltip } from "@vector-im/compound-web";
import React, { useCallback } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, JoinRule, Room } from "matrix-js-sdk/src/matrix";
import Modal from "../../../../Modal";
import ShareDialog from "../../dialogs/ShareDialog";
import { _t } from "../../../../languageHandler";
import SettingsStore from "../../../../settings/SettingsStore";
import { calculateRoomVia } from "../../../../utils/permalinks/Permalinks";
import BaseDialog from "../../dialogs/BaseDialog";
import { useGuestAccessInformation } from "../../../../hooks/room/useGuestAccessInformation";
/**
* Display a button to open a dialog to share a link to the call using a element call guest spa url (`element_call:guest_spa_url` in the EW config).
* @param room
* @returns Nothing if there is not the option to share a link (No guest_spa_url is set) or a button to open a dialog to share the link.
*/
export const CallGuestLinkButton: React.FC<{ room: Room }> = ({ room }) => {
const { canInviteGuests, guestSpaUrl, isRoomJoinable, canInvite } = useGuestAccessInformation(room);
const generateCallLink = useCallback(() => {
if (!isRoomJoinable()) throw new Error("Cannot create link for room that users can not join without invite.");
if (!guestSpaUrl) throw new Error("No guest SPA url for external links provided.");
const url = new URL(guestSpaUrl);
url.pathname = "/room/";
// Set params for the sharable url
url.searchParams.set("roomId", room.roomId);
if (room.hasEncryptionStateEvent()) url.searchParams.set("perParticipantE2EE", "true");
for (const server of calculateRoomVia(room)) {
url.searchParams.set("viaServers", server);
}
// Move params into hash
url.hash = "/" + room.name + url.search;
url.search = "";
logger.info("Generated element call external url:", url);
return url;
}, [guestSpaUrl, isRoomJoinable, room]);
const showLinkModal = useCallback(() => {
try {
// generateCallLink throws if the invite rules are not met
const target = generateCallLink();
Modal.createDialog(ShareDialog, {
target,
customTitle: _t("share|share_call"),
subtitle: _t("share|share_call_subtitle"),
});
} catch (e) {
logger.error("Could not generate call link.", e);
}
}, [generateCallLink]);
const shareClick = useCallback(() => {
if (isRoomJoinable()) {
showLinkModal();
} else {
// the room needs to be set to public or knock to generate a link
Modal.createDialog(JoinRuleDialog, {
room,
// If the user cannot invite the Knocking is not given as an option.
canInvite,
}).finished.then(() => {
if (isRoomJoinable()) showLinkModal();
});
}
}, [isRoomJoinable, showLinkModal, room, canInvite]);
return (
<>
{canInviteGuests && (
<Tooltip label={_t("voip|get_call_link")}>
<IconButton onClick={shareClick}>
<ExternalLinkIcon />
</IconButton>
</Tooltip>
)}
</>
);
};
/**
* A dialog to change the join rule of a room to public or knock.
* @param room The room to change the join rule of.
* @param onFinished Callback that is getting called if the dialog wants to close.
*/
export const JoinRuleDialog: React.FC<{
onFinished(): void;
room: Room;
canInvite: boolean;
}> = ({ onFinished, room, canInvite }) => {
const askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join");
const [isUpdating, setIsUpdating] = React.useState<undefined | JoinRule>(undefined);
const changeJoinRule = useCallback(
async (newRule: JoinRule) => {
if (isUpdating !== undefined) return;
setIsUpdating(newRule);
await room.client.sendStateEvent(
room.roomId,
EventType.RoomJoinRules,
{
join_rule: newRule,
},
"",
);
// Show the dialog for a bit to give the user feedback
setTimeout(() => onFinished(), 500);
},
[isUpdating, onFinished, room.client, room.roomId],
);
return (
<BaseDialog title={_t("update_room_access_modal|title")} onFinished={onFinished} className="mx_JoinRuleDialog">
<p>{_t("update_room_access_modal|description")}</p>
<div className="mx_JoinRuleDialogButtons">
{askToJoinEnabled && canInvite && (
<Button
kind="secondary"
className="mx_Dialog_nonDialogButton"
disabled={isUpdating === JoinRule.Knock}
onClick={() => changeJoinRule(JoinRule.Knock)}
>
{_t("action|ask_to_join")}
</Button>
)}
<Button
className="mx_Dialog_nonDialogButton"
kind="destructive"
disabled={isUpdating === JoinRule.Public}
onClick={() => changeJoinRule(JoinRule.Public)}
>
{_t("common|public")}
</Button>
</div>
<p>{_t("update_room_access_modal|dont_change_description")}</p>
<div className="mx_JoinRuleDialogButtons">
<Button
kind="tertiary"
className="mx_Dialog_nonDialogButton"
onClick={() => {
if (isUpdating === undefined) onFinished();
}}
>
{_t("update_room_access_modal|no_change")}
</Button>
</div>
</BaseDialog>
);
};

View file

@ -0,0 +1,61 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext } from "react";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat-solid";
import { Room } from "matrix-js-sdk/src/matrix";
import { IconButton, Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../../languageHandler";
import { useEventEmitterState } from "../../../../hooks/useEventEmitter";
import { NotificationStateEvents } from "../../../../stores/notifications/NotificationState";
import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel";
import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases";
import { SDKContext } from "../../../../contexts/SDKContext";
import { ButtonEvent } from "../../elements/AccessibleButton";
/**
* Display a button to toggle timeline for video rooms
* @param room
* @returns A button to toggle timeline in the right panel.
*/
export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => {
const sdkContext = useContext(SDKContext);
const notificationState = sdkContext.roomNotificationStateStore.getRoomState(room);
const notificationColor = useEventEmitterState(
notificationState,
NotificationStateEvents.Update,
() => notificationState?.level,
);
const displayUnreadIndicator =
!!notificationColor &&
[NotificationLevel.Activity, NotificationLevel.Notification, NotificationLevel.Highlight].includes(
notificationColor,
);
const onClick = (event: ButtonEvent): void => {
// stop event propagating up and triggering RoomHeader bar click
// which will open RoomSummary
event.stopPropagation();
sdkContext.rightPanelStore.showOrHidePhase(RightPanelPhases.Timeline);
};
return (
<Tooltip label={_t("right_panel|video_room_chat|title")}>
<IconButton
aria-label={_t("right_panel|video_room_chat|title")}
onClick={onClick}
indicator={displayUnreadIndicator ? "default" : undefined}
>
<ChatIcon />
</IconButton>
</Tooltip>
);
};

View file

@ -0,0 +1,85 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { FC } from "react";
import { Room, JoinRule, MatrixClient } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { _t } from "../../../languageHandler";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useRoomState } from "../../../hooks/useRoomState";
import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers";
import AccessibleButton from "../elements/AccessibleButton";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
interface IProps {
room: Room;
}
const RoomInfoLine: FC<IProps> = ({ room }) => {
// summary will begin as undefined whilst loading and go null if it fails to load or we are not invited.
const summary = useAsyncMemo(async (): Promise<Awaited<ReturnType<MatrixClient["getRoomSummary"]>> | null> => {
if (room.getMyMembership() !== KnownMembership.Invite) return null;
try {
return await room.client.getRoomSummary(room.roomId);
} catch (e) {
return null;
}
}, [room]);
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const membership = useMyRoomMembership(room);
const memberCount = useRoomMemberCount(room);
const isVideoRoom = calcIsVideoRoom(room);
let iconClass: string;
let roomType: string;
if (isVideoRoom) {
iconClass = "mx_RoomInfoLine_video";
roomType = _t("common|video_room");
} else if (joinRule === JoinRule.Public) {
iconClass = "mx_RoomInfoLine_public";
roomType = room.isSpaceRoom() ? _t("common|public_space") : _t("common|public_room");
} else {
iconClass = "mx_RoomInfoLine_private";
roomType = room.isSpaceRoom() ? _t("common|private_space") : _t("common|private_room");
}
let members: JSX.Element | undefined;
if (membership === KnownMembership.Invite && summary) {
// Don't trust local state and instead use the summary API
members = (
<span className="mx_RoomInfoLine_members">
{_t("common|n_members", { count: summary.num_joined_members })}
</span>
);
} else if (memberCount && summary !== undefined) {
// summary is not still loading
const viewMembers = (): void =>
RightPanelStore.instance.setCard({
phase: room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList,
});
members = (
<AccessibleButton kind="link" className="mx_RoomInfoLine_members" onClick={viewMembers}>
{_t("common|n_members", { count: memberCount })}
</AccessibleButton>
);
}
return (
<div className={`mx_RoomInfoLine ${iconClass}`}>
{roomType}
{members}
</div>
);
};
export default RoomInfoLine;

View file

@ -0,0 +1,141 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Nordeck IT + Consulting GmbH
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventTimeline, JoinRule, MatrixError, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import React, { ReactElement, ReactNode, useCallback, useState, VFC } from "react";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { Icon as CheckIcon } from "../../../../res/img/feather-customised/check.svg";
import dis from "../../../dispatcher/dispatcher";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MemberAvatar from "../avatars/MemberAvatar";
import ErrorDialog from "../dialogs/ErrorDialog";
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
import AccessibleButton from "../elements/AccessibleButton";
import Heading from "../typography/Heading";
import { formatList } from "../../../utils/FormattingUtils";
export const RoomKnocksBar: VFC<{ room: Room }> = ({ room }) => {
const [disabled, setDisabled] = useState(false);
const knockMembers = useTypedEventEmitterState(
room,
RoomStateEvent.Update,
useCallback(() => room.getMembersWithMembership(KnownMembership.Knock), [room]),
);
const knockMembersCount = knockMembers.length;
if (room.getJoinRule() !== JoinRule.Knock || knockMembersCount === 0) return null;
const client = room.client;
const userId = client.getUserId() || "";
const canInvite = room.canInvite(userId);
const member = room.getMember(userId);
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const canKick = member && state ? state.hasSufficientPowerLevelFor("kick", member.powerLevel) : false;
if (!canInvite && !canKick) return null;
const onError = (error: MatrixError): void => {
Modal.createDialog(ErrorDialog, { title: error.name, description: error.message });
};
const handleApprove = (userId: string): void => {
setDisabled(true);
client
.invite(room.roomId, userId)
.catch(onError)
.finally(() => setDisabled(false));
};
const handleDeny = (userId: string): void => {
setDisabled(true);
client
.kick(room.roomId, userId)
.catch(onError)
.finally(() => setDisabled(false));
};
const handleOpenRoomSettings = (): void =>
dis.dispatch({ action: "open_room_settings", room_id: room.roomId, initial_tab_id: RoomSettingsTab.People });
let buttons: ReactElement = (
<AccessibleButton
className="mx_RoomKnocksBar_action"
kind="primary"
onClick={handleOpenRoomSettings}
title={_t("action|view")}
>
{_t("action|view")}
</AccessibleButton>
);
let names = formatList(
knockMembers.map((knockMember) => knockMember.name),
3,
true,
);
let link: ReactNode = null;
if (knockMembersCount === 1) {
buttons = (
<>
<AccessibleButton
className="mx_RoomKnocksBar_action"
disabled={!canKick || disabled}
kind="icon_primary_outline"
onClick={() => handleDeny(knockMembers[0].userId)}
title={_t("action|deny")}
>
<CloseIcon width={18} height={18} />
</AccessibleButton>
<AccessibleButton
className="mx_RoomKnocksBar_action"
disabled={!canInvite || disabled}
kind="icon_primary"
onClick={() => handleApprove(knockMembers[0].userId)}
title={_t("action|approve")}
>
<CheckIcon width={18} height={18} />
</AccessibleButton>
</>
);
names = `${knockMembers[0].name} (${knockMembers[0].userId})`;
link = knockMembers[0].events.member?.getContent().reason && (
<AccessibleButton
className="mx_RoomKnocksBar_link"
element="a"
kind="link_inline"
onClick={handleOpenRoomSettings}
>
{_t("action|view_message")}
</AccessibleButton>
);
}
return (
<div className="mx_RoomKnocksBar">
{knockMembers.slice(0, 2).map((knockMember) => (
<MemberAvatar
className="mx_RoomKnocksBar_avatar"
key={knockMember.userId}
member={knockMember}
size="32px"
/>
))}
<div className="mx_RoomKnocksBar_content">
<Heading size="4">{_t("room|header|n_people_asking_to_join", { count: knockMembersCount })}</Heading>
<p className="mx_RoomKnocksBar_paragraph">
{names}
{link}
</p>
</div>
{buttons}
</div>
);
};

View file

@ -0,0 +1,677 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2018 , 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventType, RoomType, Room } from "matrix-js-sdk/src/matrix";
import React, { ComponentType, createRef, ReactComponentElement, SyntheticEvent } from "react";
import { IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import PosthogTrackers from "../../../PosthogTrackers";
import SettingsStore from "../../../settings/SettingsStore";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { UIComponent } from "../../../settings/UIFeature";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import {
isMetaSpace,
ISuggestedRoom,
MetaSpace,
SpaceKey,
UPDATE_SELECTED_SPACE,
UPDATE_SUGGESTED_ROOMS,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { shouldShowSpaceInvite, showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space";
import { ChevronFace, ContextMenuTooltipButton, MenuProps, useContextMenu } from "../../structures/ContextMenu";
import RoomAvatar from "../avatars/RoomAvatar";
import { BetaPill } from "../beta/BetaCard";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import ExtraTile from "./ExtraTile";
import RoomSublist, { IAuxButtonProps } from "./RoomSublist";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
onListCollapse?: (isExpanded: boolean) => void;
resizeNotifier: ResizeNotifier;
isMinimized: boolean;
activeSpace: SpaceKey;
}
interface IState {
sublists: ITagMap;
currentRoomId?: string;
suggestedRooms: ISuggestedRoom[];
}
export const TAG_ORDER: TagID[] = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
DefaultTagID.Conference,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
// DefaultTagID.Archived isn't here any more: we don't show it at all.
// The section still exists in the code as a place for rooms that we know
// about but aren't joined. At some point it could be removed entirely
// but we'd have to make sure that rooms you weren't in were hidden.
];
const ALWAYS_VISIBLE_TAGS: TagID[] = [DefaultTagID.DM, DefaultTagID.Untagged];
interface ITagAesthetics {
sectionLabel: TranslationKey;
sectionLabelRaw?: string;
AuxButtonComponent?: ComponentType<IAuxButtonProps>;
isInvite: boolean;
defaultHidden: boolean;
}
type TagAestheticsMap = Partial<{
[tagId in TagID]: ITagAesthetics;
}>;
const auxButtonContextMenuPosition = (handle: HTMLDivElement): MenuProps => {
const rect = handle.getBoundingClientRect();
return {
chevronFace: ChevronFace.None,
left: rect.left - 7,
top: rect.top + rect.height,
};
};
const DmAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex, dispatcher = defaultDispatcher }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
const showCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
const showInviteUsers = shouldShowComponent(UIComponent.InviteUsers);
if (activeSpace && (showCreateRooms || showInviteUsers)) {
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && handle.current) {
const canInvite = shouldShowSpaceInvite(activeSpace);
contextMenu = (
<IconizedContextMenu {...auxButtonContextMenuPosition(handle.current)} onFinished={closeMenu} compact>
<IconizedContextMenuOptionList first>
{showCreateRooms && (
<IconizedContextMenuOption
label={_t("action|start_new_chat")}
iconClassName="mx_RoomList_iconStartChat"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: "view_create_chat" });
PosthogTrackers.trackInteraction(
"WebRoomListRoomsSublistPlusMenuCreateChatItem",
e,
);
}}
/>
)}
{showInviteUsers && (
<IconizedContextMenuOption
label={_t("action|invite_to_space")}
iconClassName="mx_RoomList_iconInvite"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showSpaceInvite(activeSpace);
}}
disabled={!canInvite}
title={canInvite ? undefined : _t("spaces|error_no_permission_invite")}
/>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return (
<>
<ContextMenuTooltipButton
tabIndex={tabIndex}
onClick={openMenu}
className="mx_RoomSublist_auxButton"
aria-label={_t("action|add_people")}
title={_t("action|add_people")}
isExpanded={menuDisplayed}
ref={handle}
/>
{contextMenu}
</>
);
} else if (!activeSpace && showCreateRooms) {
return (
<AccessibleButton
tabIndex={tabIndex}
onClick={(e) => {
dispatcher.dispatch({ action: "view_create_chat" });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e);
}}
className="mx_RoomSublist_auxButton"
aria-label={_t("action|start_chat")}
title={_t("action|start_chat")}
/>
);
}
return null;
};
const UntaggedAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace = useEventEmitterState<Room | null>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const showExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
let contextMenuContent: JSX.Element | undefined;
if (menuDisplayed && activeSpace) {
const canAddRooms = activeSpace.currentState.maySendStateEvent(
EventType.SpaceChild,
MatrixClientPeg.safeGet().getSafeUserId(),
);
contextMenuContent = (
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("action|explore_rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined, // other
});
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
}}
/>
{showCreateRoom ? (
<>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
>
<BetaPill />
</IconizedContextMenuOption>
)}
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
iconClassName="mx_RoomList_iconAddExistingRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showAddExistingRooms(activeSpace);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_add_room")}
/>
</>
) : null}
</IconizedContextMenuOptionList>
);
} else if (menuDisplayed) {
contextMenuContent = (
<IconizedContextMenuOptionList first>
{showCreateRoom && (
<>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: "view_create_room" });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({
action: "view_create_room",
type: elementCallVideoRoomsEnabled
? RoomType.UnstableCall
: RoomType.ElementVideo,
});
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
)}
{showExploreRooms ? (
<IconizedContextMenuOption
label={_t("action|explore_public_rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
) : null}
</IconizedContextMenuOptionList>
);
}
let contextMenu: JSX.Element | null = null;
if (menuDisplayed && handle.current) {
contextMenu = (
<IconizedContextMenu {...auxButtonContextMenuPosition(handle.current)} onFinished={closeMenu} compact>
{contextMenuContent}
</IconizedContextMenu>
);
}
if (showCreateRoom || showExploreRooms) {
return (
<>
<ContextMenuTooltipButton
tabIndex={tabIndex}
onClick={openMenu}
className="mx_RoomSublist_auxButton"
aria-label={_t("room_list|add_room_label")}
title={_t("room_list|add_room_label")}
isExpanded={menuDisplayed}
ref={handle}
/>
{contextMenu}
</>
);
}
return null;
};
const TAG_AESTHETICS: TagAestheticsMap = {
[DefaultTagID.Invite]: {
sectionLabel: _td("action|invites_list"),
isInvite: true,
defaultHidden: false,
},
[DefaultTagID.Favourite]: {
sectionLabel: _td("common|favourites"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.DM]: {
sectionLabel: _td("common|people"),
isInvite: false,
defaultHidden: false,
AuxButtonComponent: DmAuxButton,
},
[DefaultTagID.Conference]: {
sectionLabel: _td("voip|metaspace_video_rooms|conference_room_section"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("common|rooms"),
isInvite: false,
defaultHidden: false,
AuxButtonComponent: UntaggedAuxButton,
},
[DefaultTagID.LowPriority]: {
sectionLabel: _td("common|low_priority"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.ServerNotice]: {
sectionLabel: _td("common|system_alerts"),
isInvite: false,
defaultHidden: false,
},
// TODO: Replace with archived view: https://github.com/vector-im/element-web/issues/14038
[DefaultTagID.Archived]: {
sectionLabel: _td("common|historical"),
isInvite: false,
defaultHidden: true,
},
[DefaultTagID.Suggested]: {
sectionLabel: _td("room_list|suggested_rooms_heading"),
isInvite: false,
defaultHidden: false,
},
};
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef?: string;
private treeRef = createRef<HTMLDivElement>();
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
sublists: {},
suggestedRooms: SpaceStore.instance.suggestedRooms,
};
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate);
SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
this.updateLists(); // trigger the first update
}
public componentWillUnmount(): void {
SpaceStore.instance.off(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
}
private onRoomViewStoreUpdate = (): void => {
this.setState({
currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined,
});
};
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
if (!currentRoomId) return;
const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
if (room) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
});
}
} else if (payload.action === Action.PstnSupportUpdated) {
this.updateLists();
}
};
private getRoomDelta = (roomId: string, delta: number, unread = false): Room => {
const lists = RoomListStore.instance.orderedLists;
const rooms: Room[] = [];
TAG_ORDER.forEach((t) => {
let listRooms = lists[t];
if (unread) {
// filter to only notification rooms (and our current active room so we can index properly)
listRooms = listRooms.filter((r) => {
const state = RoomNotificationStateStore.instance.getRoomState(r);
return state.room.roomId === roomId || state.isUnread;
});
}
rooms.push(...listRooms);
});
const currentIndex = rooms.findIndex((r) => r.roomId === roomId);
// use slice to account for looping around the start
const [room] = rooms.slice((currentIndex + delta) % rooms.length);
return room;
};
private updateSuggestedRooms = (suggestedRooms: ISuggestedRoom[]): void => {
this.setState({ suggestedRooms });
};
private updateLists = (): void => {
const newLists = RoomListStore.instance.orderedLists;
const previousListIds = Object.keys(this.state.sublists);
const newListIds = Object.keys(newLists);
let doUpdate = arrayHasDiff(previousListIds, newListIds);
if (!doUpdate) {
// so we didn't have the visible sublists change, but did the contents of those
// sublists change significantly enough to break the sticky headers? Probably, so
// let's check the length of each.
for (const tagId of newListIds) {
const oldRooms = this.state.sublists[tagId];
const newRooms = newLists[tagId];
if (oldRooms.length !== newRooms.length) {
doUpdate = true;
break;
}
}
}
if (doUpdate) {
// We have to break our reference to the room list store if we want to be able to
// diff the object for changes, so do that.
// @ts-ignore - ITagMap is ts-ignored so this will have to be too
const newSublists = objectWithOnly(newLists, newListIds);
const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
this.setState({ sublists }, () => {
this.props.onResize();
});
}
};
private renderSuggestedRooms(): ReactComponentElement<typeof ExtraTile>[] {
return this.state.suggestedRooms.map((room) => {
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("empty_room");
const avatar = (
<RoomAvatar
oobData={{
name,
avatarUrl: room.avatar_url,
}}
size="32px"
/>
);
const viewRoom = (ev: SyntheticEvent): void => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: room.canonical_alias || room.aliases?.[0],
room_id: room.room_id,
via_servers: room.viaServers,
oob_data: {
avatarUrl: room.avatar_url,
name,
},
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
};
return (
<ExtraTile
isMinimized={this.props.isMinimized}
isSelected={this.state.currentRoomId === room.room_id}
displayName={name}
avatar={avatar}
onClick={viewRoom}
key={`suggestedRoomTile_${room.room_id}`}
/>
);
});
}
private renderSublists(): React.ReactElement[] {
// show a skeleton UI if the user is in no rooms and they are not filtering and have no suggested rooms
const showSkeleton =
!this.state.suggestedRooms?.length &&
Object.values(RoomListStore.instance.orderedLists).every((list) => !list?.length);
return TAG_ORDER.map((orderedTagId) => {
let extraTiles: ReactComponentElement<typeof ExtraTile>[] | undefined;
if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
}
const aesthetics = TAG_AESTHETICS[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
let alwaysVisible = ALWAYS_VISIBLE_TAGS.includes(orderedTagId);
if (
(this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) ||
(this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) ||
(this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) ||
(this.props.activeSpace === MetaSpace.VideoRooms && orderedTagId === DefaultTagID.DM) ||
(!isMetaSpace(this.props.activeSpace) &&
orderedTagId === DefaultTagID.DM &&
!SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace))
) {
alwaysVisible = false;
}
let forceExpanded = false;
if (
(this.props.activeSpace === MetaSpace.Favourites && orderedTagId === DefaultTagID.Favourite) ||
(this.props.activeSpace === MetaSpace.People && orderedTagId === DefaultTagID.DM)
) {
forceExpanded = true;
}
// The cost of mounting/unmounting this component offsets the cost
// of keeping it in the DOM and hiding it when it is not required
return (
<RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
AuxButtonComponent={aesthetics.AuxButtonComponent}
isMinimized={this.props.isMinimized}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
resizeNotifier={this.props.resizeNotifier}
alwaysVisible={alwaysVisible}
onListCollapse={this.props.onListCollapse}
forceExpanded={forceExpanded}
/>
);
});
}
public focus(): void {
// focus the first focusable element in this aria treeview widget
const treeItems = this.treeRef.current?.querySelectorAll<HTMLElement>('[role="treeitem"]');
if (!treeItems) return;
[...treeItems].find((e) => e.offsetParent !== null)?.focus();
}
public render(): React.ReactNode {
const sublists = this.renderSublists();
return (
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.props.onKeyDown}>
{({ onKeyDownHandler }) => (
<div
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (
navAction === KeyBindingAction.NextLandmark ||
navAction === KeyBindingAction.PreviousLandmark
) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
className="mx_RoomList"
role="tree"
aria-label={_t("common|rooms")}
ref={this.treeRef}
>
{sublists}
</div>
)}
</RovingTabIndexProvider>
);
}
}

View file

@ -0,0 +1,426 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventType, RoomType, Room, RoomEvent, ClientEvent } from "matrix-js-sdk/src/matrix";
import React, { useContext, useEffect, useState } from "react";
import { Tooltip } from "@vector-im/compound-web";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { useEventEmitterState, useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { _t } from "../../../languageHandler";
import PosthogTrackers from "../../../PosthogTrackers";
import { UIComponent } from "../../../settings/UIFeature";
import {
getMetaSpaceName,
MetaSpace,
SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_SELECTED_SPACE,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
shouldShowSpaceInvite,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
} from "../../../utils/space";
import {
ChevronFace,
ContextMenuTooltipButton,
useContextMenu,
MenuProps,
ContextMenuButton,
} from "../../structures/ContextMenu";
import { BetaPill } from "../beta/BetaCard";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import InlineSpinner from "../elements/InlineSpinner";
import { HomeButtonContextMenu } from "../spaces/SpacePanel";
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX;
const top = elementRect.bottom + window.scrollY + 12;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
// Long-running actions that should trigger a spinner
enum PendingActionType {
JoinRoom,
BulkRedact,
}
const usePendingActions = (): Map<PendingActionType, Set<string>> => {
const cli = useContext(MatrixClientContext);
const [actions, setActions] = useState(new Map<PendingActionType, Set<string>>());
const addAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
keys.add(key);
setActions(new Map(actions).set(type, keys));
};
const removeAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
if (keys.delete(key)) {
setActions(new Map(actions).set(type, keys));
}
};
useDispatcher(defaultDispatcher, (payload) => {
switch (payload.action) {
case Action.JoinRoom:
addAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
removeAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.BulkRedactStart:
addAction(PendingActionType.BulkRedact, payload.roomId);
break;
case Action.BulkRedactEnd:
removeAction(PendingActionType.BulkRedact, payload.roomId);
break;
}
});
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => removeAction(PendingActionType.JoinRoom, room.roomId));
return actions;
};
interface IProps {
onVisibilityChange?(): void;
}
const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
const cli = useContext(MatrixClientContext);
const [mainMenuDisplayed, mainMenuHandle, openMainMenu, closeMainMenu] = useContextMenu<HTMLDivElement>();
const [plusMenuDisplayed, plusMenuHandle, openPlusMenu, closePlusMenu] = useContextMenu<HTMLDivElement>();
const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom],
);
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const pendingActions = usePendingActions();
const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home;
useEffect(() => {
if (mainMenuDisplayed && !canShowMainMenu) {
// Space changed under us and we no longer has a main menu to draw
closeMainMenu();
}
}, [closeMainMenu, canShowMainMenu, mainMenuDisplayed]);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
useEffect(() => {
onVisibilityChange?.();
}, [onVisibilityChange]);
const canExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
const canCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
const canCreateSpaces = shouldShowComponent(UIComponent.CreateSpaces);
const hasPermissionToAddSpaceChild = activeSpace?.currentState?.maySendStateEvent(
EventType.SpaceChild,
cli.getUserId()!,
);
const canAddSubRooms = hasPermissionToAddSpaceChild && canCreateRooms;
const canAddSubSpaces = hasPermissionToAddSpaceChild && canCreateSpaces;
// If the user can't do anything on the plus menu, don't show it. This aims to target the
// plus menu shown on the Home tab primarily: the user has options to use the menu for
// communities and spaces, but is at risk of no options on the Home tab.
const canShowPlusMenu = canCreateRooms || canExploreRooms || canCreateSpaces || activeSpace;
let contextMenu: JSX.Element | undefined;
if (mainMenuDisplayed && mainMenuHandle.current) {
let ContextMenuComponent;
if (activeSpace) {
ContextMenuComponent = SpaceContextMenu;
} else {
ContextMenuComponent = HomeButtonContextMenu;
}
contextMenu = (
<ContextMenuComponent
{...contextMenuBelow(mainMenuHandle.current.getBoundingClientRect())}
space={activeSpace!}
onFinished={closeMainMenu}
hideHeader={true}
/>
);
} else if (plusMenuDisplayed && activeSpace) {
let inviteOption: JSX.Element | undefined;
if (shouldShowSpaceInvite(activeSpace)) {
inviteOption = (
<IconizedContextMenuOption
label={_t("action|invite")}
iconClassName="mx_RoomListHeader_iconInvite"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showSpaceInvite(activeSpace);
closePlusMenu();
}}
/>
);
}
let newRoomOptions: JSX.Element | undefined;
if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()!)) {
newRoomOptions = (
<>
<IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconNewRoom"
label={_t("action|new_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
label={_t("action|new_video_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{inviteOption}
{newRoomOptions}
<IconizedContextMenuOption
label={_t("action|explore_rooms")}
iconClassName="mx_RoomListHeader_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined, // other
});
closePlusMenu();
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
}}
/>
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
iconClassName="mx_RoomListHeader_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showAddExistingRooms(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubRooms}
title={!canAddSubRooms ? _t("spaces|error_no_permission_add_room") : undefined}
/>
{canCreateSpaces && (
<IconizedContextMenuOption
label={_t("room_list|add_space_label")}
iconClassName="mx_RoomListHeader_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewSubspace(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubSpaces}
title={!canAddSubSpaces ? _t("spaces|error_no_permission_add_space") : undefined}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
} else if (plusMenuDisplayed) {
let newRoomOpts: JSX.Element | undefined;
let joinRoomOpt: JSX.Element | undefined;
if (canCreateRooms) {
newRoomOpts = (
<>
<IconizedContextMenuOption
label={_t("action|start_new_chat")}
iconClassName="mx_RoomListHeader_iconStartChat"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: "view_create_chat" });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
closePlusMenu();
}}
/>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_RoomListHeader_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: "view_create_room" });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({
action: "view_create_room",
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
});
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
if (canExploreRooms) {
joinRoomOpt = (
<IconizedContextMenuOption
label={_t("room_list|join_public_room_label")}
iconClassName="mx_RoomListHeader_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.ViewRoomDirectory });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
closePlusMenu();
}}
/>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{newRoomOpts}
{joinRoomOpt}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
let title: string;
if (activeSpace && spaceName) {
title = spaceName;
} else {
title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
}
const pendingActionSummary = [...pendingActions.entries()]
.filter(([type, keys]) => keys.size > 0)
.map(([type, keys]) => {
switch (type) {
case PendingActionType.JoinRoom:
return _t("room_list|joining_rooms_status", { count: keys.size });
case PendingActionType.BulkRedact:
return _t("room_list|redacting_messages_status", { count: keys.size });
}
})
.join("\n");
let contextMenuButton: JSX.Element = <div className="mx_RoomListHeader_contextLessTitle">{title}</div>;
if (canShowMainMenu) {
const commonProps = {
ref: mainMenuHandle,
onClick: openMainMenu,
isExpanded: mainMenuDisplayed,
className: "mx_RoomListHeader_contextMenuButton",
children: title,
};
if (!!activeSpace) {
contextMenuButton = (
<ContextMenuButton
{...commonProps}
label={_t("room_list|space_menu_label", { spaceName: spaceName ?? activeSpace.name })}
/>
);
} else {
contextMenuButton = <ContextMenuTooltipButton {...commonProps} title={_t("room_list|home_menu_label")} />;
}
}
return (
<aside className="mx_RoomListHeader" aria-label={_t("room|context_menu|title")}>
{contextMenuButton}
{pendingActionSummary ? (
<Tooltip label={pendingActionSummary} isTriggerInteractive={false}>
<InlineSpinner />
</Tooltip>
) : null}
{canShowPlusMenu && (
<ContextMenuTooltipButton
ref={plusMenuHandle}
onClick={openPlusMenu}
isExpanded={plusMenuDisplayed}
className="mx_RoomListHeader_plusButton"
title={_t("action|add")}
/>
)}
{contextMenu}
</aside>
);
};
export default RoomListHeader;

View file

@ -0,0 +1,731 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, ReactNode } from "react";
import { Room, RoomMember, EventType, RoomType, JoinRule, MatrixError } from "matrix-js-sdk/src/matrix";
import { KnownMembership, RoomJoinRulesEventContent } from "matrix-js-sdk/src/types";
import classNames from "classnames";
import { RoomPreviewOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import { _t, UserFriendlyError } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import IdentityAuthClient from "../../../IdentityAuthClient";
import InviteReason from "../elements/InviteReason";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
import RoomAvatar from "../avatars/RoomAvatar";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { ModuleRunner } from "../../../modules/ModuleRunner";
import { Icon as AskToJoinIcon } from "../../../../res/img/element-icons/ask-to-join.svg";
import Field from "../elements/Field";
const MemberEventHtmlReasonField = "io.element.html_reason";
enum MessageCase {
NotLoggedIn = "NotLoggedIn",
Joining = "Joining",
Loading = "Loading",
Rejecting = "Rejecting",
Kicked = "Kicked",
Banned = "Banned",
OtherThreePIDError = "OtherThreePIDError",
InvitedEmailNotFoundInAccount = "InvitedEmailNotFoundInAccount",
InvitedEmailNoIdentityServer = "InvitedEmailNoIdentityServer",
InvitedEmailMismatch = "InvitedEmailMismatch",
Invite = "Invite",
ViewingRoom = "ViewingRoom",
RoomNotFound = "RoomNotFound",
OtherError = "OtherError",
PromptAskToJoin = "PromptAskToJoin",
Knocked = "Knocked",
RequestDenied = "requestDenied",
}
interface IProps {
// if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifying inviterName
inviterName?: string;
// If invited by 3rd party invite, the email address the invite was sent to
invitedEmail?: string;
// For third party invites, information passed about the room out-of-band
oobData?: IOOBData;
// For third party invites, a URL for a 3pid invite signing service
signUrl?: string;
// A standard client/server API error object. If supplied, indicates that the
// caller was unable to fetch details about the room for the given reason.
error?: MatrixError;
canPreview?: boolean;
previewLoading?: boolean;
// The id of the room to be previewed, if it is known.
// (It may be unknown if we are waiting for an alias to be resolved.)
roomId?: string;
// A `Room` object for the room to be previewed, if we have one.
room?: Room;
loading?: boolean;
joining?: boolean;
rejecting?: boolean;
// The alias that was used to access this room, if appropriate
// If given, this will be how the room is referred to (eg.
// in error messages).
roomAlias?: string;
onJoinClick?(): void;
onRejectClick?(): void;
onRejectAndIgnoreClick?(): void;
onForgetClick?(): void;
canAskToJoinAndMembershipIsLeave?: boolean;
promptAskToJoin?: boolean;
knocked?: boolean;
onSubmitAskToJoin?(reason?: string): void;
onCancelAskToJoin?(): void;
}
interface IState {
busy: boolean;
accountEmails?: string[];
invitedEmailMxid?: string;
threePidFetchError?: MatrixError;
reason?: string;
}
export default class RoomPreviewBar extends React.Component<IProps, IState> {
public static defaultProps = {
onJoinClick() {},
};
public constructor(props: IProps) {
super(props);
this.state = {
busy: false,
};
}
public componentDidMount(): void {
this.checkInvitedEmail();
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) {
this.checkInvitedEmail();
}
}
private async checkInvitedEmail(): Promise<void> {
// If this is an invite and we've been told what email address was
// invited, fetch the user's account emails and discovery bindings so we
// can check them against the email that was invited.
if (this.props.inviterName && this.props.invitedEmail) {
this.setState({ busy: true });
try {
// Gather the account 3PIDs
const account3pids = await MatrixClientPeg.safeGet().getThreePids();
this.setState({
accountEmails: account3pids.threepids.filter((b) => b.medium === "email").map((b) => b.address),
});
// If we have an IS connected, use that to lookup the email and
// check the bound MXID.
if (!MatrixClientPeg.safeGet().getIdentityServerUrl()) {
this.setState({ busy: false });
return;
}
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
const result = await MatrixClientPeg.safeGet().lookupThreePid(
"email",
this.props.invitedEmail,
identityAccessToken!,
);
if (!("mxid" in result)) {
throw new UserFriendlyError("room|error_3pid_invite_email_lookup");
}
this.setState({ invitedEmailMxid: result.mxid });
} catch (err) {
this.setState({ threePidFetchError: err as MatrixError });
}
this.setState({ busy: false });
}
}
private getMessageCase(): MessageCase {
const isGuest = MatrixClientPeg.safeGet().isGuest();
if (isGuest) {
return MessageCase.NotLoggedIn;
}
const myMember = this.getMyMember();
if (myMember) {
const previousMembership = myMember.events.member?.getPrevContent().membership;
if (myMember.isKicked()) {
if (previousMembership === KnownMembership.Knock) {
return MessageCase.RequestDenied;
} else if (this.props.promptAskToJoin) {
return MessageCase.PromptAskToJoin;
}
return MessageCase.Kicked;
} else if (myMember.membership === KnownMembership.Ban) {
return MessageCase.Banned;
}
}
if (this.props.joining) {
return MessageCase.Joining;
} else if (this.props.rejecting) {
return MessageCase.Rejecting;
} else if (this.props.loading || this.state.busy) {
return MessageCase.Loading;
} else if (this.props.knocked) {
return MessageCase.Knocked;
} else if (this.props.canAskToJoinAndMembershipIsLeave || this.props.promptAskToJoin) {
return MessageCase.PromptAskToJoin;
}
if (this.props.inviterName) {
if (this.props.invitedEmail) {
if (this.state.threePidFetchError) {
return MessageCase.OtherThreePIDError;
} else if (this.state.accountEmails && !this.state.accountEmails.includes(this.props.invitedEmail)) {
return MessageCase.InvitedEmailNotFoundInAccount;
} else if (!MatrixClientPeg.safeGet().getIdentityServerUrl()) {
return MessageCase.InvitedEmailNoIdentityServer;
} else if (this.state.invitedEmailMxid != MatrixClientPeg.safeGet().getUserId()) {
return MessageCase.InvitedEmailMismatch;
}
}
return MessageCase.Invite;
} else if (this.props.error) {
if ((this.props.error as MatrixError).errcode == "M_NOT_FOUND") {
return MessageCase.RoomNotFound;
} else {
return MessageCase.OtherError;
}
} else {
return MessageCase.ViewingRoom;
}
}
private getKickOrBanInfo(): { memberName?: string; reason?: string } {
const myMember = this.getMyMember();
if (!myMember) {
return {};
}
const kickerUserId = myMember.events.member?.getSender();
const kickerMember = kickerUserId ? this.props.room?.currentState.getMember(kickerUserId) : undefined;
const memberName = kickerMember?.name ?? kickerUserId;
const reason = myMember.events.member?.getContent().reason;
return { memberName, reason };
}
private joinRule(): JoinRule | null {
return (
this.props.room?.currentState
.getStateEvents(EventType.RoomJoinRules, "")
?.getContent<RoomJoinRulesEventContent>().join_rule ?? null
);
}
private getMyMember(): RoomMember | null {
return this.props.room?.getMember(MatrixClientPeg.safeGet().getSafeUserId()) ?? null;
}
private getInviteMember(): RoomMember | null {
const { room } = this.props;
if (!room) {
return null;
}
const myUserId = MatrixClientPeg.safeGet().getSafeUserId();
const inviteEvent = room.currentState.getMember(myUserId);
if (!inviteEvent) {
return null;
}
const inviterUserId = inviteEvent.events.member?.getSender();
return inviterUserId ? room.currentState.getMember(inviterUserId) : null;
}
private isDMInvite(): boolean {
const myMember = this.getMyMember();
if (!myMember) {
return false;
}
const memberContent = myMember.events.member?.getContent();
return memberContent?.membership === KnownMembership.Invite && memberContent.is_direct;
}
private makeScreenAfterLogin(): { screen: string; params: Record<string, any> } {
return {
screen: "room",
params: {
email: this.props.invitedEmail,
signurl: this.props.signUrl,
room_name: this.props.oobData?.name ?? null,
room_avatar_url: this.props.oobData?.avatarUrl ?? null,
inviter_name: this.props.oobData?.inviterName ?? null,
},
};
}
private onLoginClick = (): void => {
dis.dispatch({ action: "start_login", screenAfterLogin: this.makeScreenAfterLogin() });
};
private onRegisterClick = (): void => {
dis.dispatch({ action: "start_registration", screenAfterLogin: this.makeScreenAfterLogin() });
};
private onChangeReason = (event: ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ reason: event.target.value });
};
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
const roomName = this.props.room?.name ?? this.props.roomAlias ?? "";
const isSpace = this.props.room?.isSpaceRoom() ?? this.props.oobData?.roomType === RoomType.Space;
let showSpinner = false;
let title: string | undefined;
let subTitle: string | ReactNode[] | undefined;
let reasonElement: JSX.Element | undefined;
let primaryActionHandler: (() => void) | undefined;
let primaryActionLabel: string | undefined;
let secondaryActionHandler: (() => void) | undefined;
let secondaryActionLabel: string | undefined;
let footer: JSX.Element | undefined;
const extraComponents: JSX.Element[] = [];
const messageCase = this.getMessageCase();
switch (messageCase) {
case MessageCase.Joining: {
if (this.props.oobData?.roomType || isSpace) {
title = isSpace ? _t("room|joining_space") : _t("room|joining_room");
} else {
title = _t("room|joining");
}
showSpinner = true;
break;
}
case MessageCase.Loading: {
title = _t("common|loading");
showSpinner = true;
break;
}
case MessageCase.Rejecting: {
title = _t("room|rejecting");
showSpinner = true;
break;
}
case MessageCase.NotLoggedIn: {
const opts: RoomPreviewOpts = { canJoin: false };
if (this.props.roomId) {
ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.roomId);
}
if (opts.canJoin) {
title = _t("room|join_title");
primaryActionLabel = _t("action|join");
primaryActionHandler = () => {
ModuleRunner.instance.invoke(RoomViewLifecycle.JoinFromRoomPreview, this.props.roomId);
};
} else {
title = _t("room|join_title_account");
if (SettingsStore.getValue(UIFeature.Registration)) {
primaryActionLabel = _t("room|join_button_account");
primaryActionHandler = this.onRegisterClick;
}
secondaryActionLabel = _t("action|sign_in");
secondaryActionHandler = this.onLoginClick;
}
if (this.props.previewLoading) {
footer = (
<div>
<Spinner w={20} h={20} />
{_t("room|loading_preview")}
</div>
);
}
break;
}
case MessageCase.Kicked: {
const { memberName, reason } = this.getKickOrBanInfo();
if (roomName) {
title = _t("room|kicked_from_room_by", { memberName, roomName });
} else {
title = _t("room|kicked_by", { memberName });
}
subTitle = reason ? _t("room|kick_reason", { reason }) : undefined;
if (isSpace) {
primaryActionLabel = _t("room|forget_space");
} else {
primaryActionLabel = _t("room|forget_room");
}
primaryActionHandler = this.props.onForgetClick;
if (this.joinRule() !== JoinRule.Invite) {
secondaryActionLabel = primaryActionLabel;
secondaryActionHandler = primaryActionHandler;
primaryActionLabel = _t("room|rejoin_button");
primaryActionHandler = this.props.onJoinClick;
}
break;
}
case MessageCase.RequestDenied: {
title = _t("room|knock_denied_title");
subTitle = _t("room|knock_denied_subtitle");
if (isSpace) {
primaryActionLabel = _t("room|forget_space");
} else {
primaryActionLabel = _t("room|forget_room");
}
primaryActionHandler = this.props.onForgetClick;
break;
}
case MessageCase.Banned: {
const { memberName, reason } = this.getKickOrBanInfo();
if (roomName) {
title = _t("room|banned_from_room_by", { memberName, roomName });
} else {
title = _t("room|banned_by", { memberName });
}
subTitle = reason ? _t("room|kick_reason", { reason }) : undefined;
if (isSpace) {
primaryActionLabel = _t("room|forget_space");
} else {
primaryActionLabel = _t("room|forget_room");
}
primaryActionHandler = this.props.onForgetClick;
break;
}
case MessageCase.OtherThreePIDError: {
if (roomName) {
title = _t("room|3pid_invite_error_title_room", { roomName });
} else {
title = _t("room|3pid_invite_error_title");
}
const joinRule = this.joinRule();
const errCodeMessage = _t("room|3pid_invite_error_description", {
errcode: this.state.threePidFetchError?.errcode || _t("error|unknown_error_code"),
});
switch (joinRule) {
case "invite":
subTitle = [_t("room|3pid_invite_error_invite_subtitle"), errCodeMessage];
primaryActionLabel = _t("room|3pid_invite_error_invite_action");
primaryActionHandler = this.props.onJoinClick;
break;
case "public":
subTitle = _t("room|3pid_invite_error_public_subtitle");
primaryActionLabel = _t("room|join_the_discussion");
primaryActionHandler = this.props.onJoinClick;
break;
default:
subTitle = errCodeMessage;
primaryActionLabel = _t("room|3pid_invite_error_invite_action");
primaryActionHandler = this.props.onJoinClick;
break;
}
break;
}
case MessageCase.InvitedEmailNotFoundInAccount: {
if (roomName) {
title = _t("room|3pid_invite_email_not_found_account_room", {
roomName,
email: this.props.invitedEmail,
});
} else {
title = _t("room|3pid_invite_email_not_found_account", {
email: this.props.invitedEmail,
});
}
subTitle = _t("room|link_email_to_receive_3pid_invite", { brand });
primaryActionLabel = _t("room|join_the_discussion");
primaryActionHandler = this.props.onJoinClick;
break;
}
case MessageCase.InvitedEmailNoIdentityServer: {
if (roomName) {
title = _t("room|invite_sent_to_email_room", {
roomName,
email: this.props.invitedEmail,
});
} else {
title = _t("room|invite_sent_to_email", { email: this.props.invitedEmail });
}
subTitle = _t("room|3pid_invite_no_is_subtitle", {
brand,
});
primaryActionLabel = _t("room|join_the_discussion");
primaryActionHandler = this.props.onJoinClick;
break;
}
case MessageCase.InvitedEmailMismatch: {
if (roomName) {
title = _t("room|invite_sent_to_email_room", {
roomName,
email: this.props.invitedEmail,
});
} else {
title = _t("room|invite_sent_to_email", { email: this.props.invitedEmail });
}
subTitle = _t("room|invite_email_mismatch_suggestion", { brand });
primaryActionLabel = _t("room|join_the_discussion");
primaryActionHandler = this.props.onJoinClick;
break;
}
case MessageCase.Invite: {
const isDM = this.isDMInvite();
const avatar = <RoomAvatar room={this.props.room} oobData={this.props.oobData} />;
const inviteMember = this.getInviteMember();
const userName = (
<span className="mx_RoomPreviewBar_inviter">
{inviteMember?.rawDisplayName ?? this.props.inviterName}
</span>
);
const inviterElement = (
<>
{isDM
? _t("room|dm_invite_subtitle", {}, { userName })
: _t("room|invite_subtitle", {}, { userName })}
{inviteMember && (
<>
<br />
<span className="mx_RoomPreviewBar_inviter_mxid">{inviteMember.userId}</span>
</>
)}
</>
);
if (isDM) {
title = _t("room|dm_invite_title", {
user: inviteMember?.name ?? this.props.inviterName,
});
primaryActionLabel = _t("room|dm_invite_action");
} else {
title = _t("room|invite_title", { roomName });
primaryActionLabel = _t("action|accept");
}
subTitle = [avatar, inviterElement];
const myUserId = MatrixClientPeg.safeGet().getSafeUserId();
const member = this.props.room?.currentState.getMember(myUserId);
const memberEventContent = member?.events.member?.getContent();
if (memberEventContent?.reason) {
reasonElement = (
<InviteReason
reason={memberEventContent.reason}
htmlReason={memberEventContent[MemberEventHtmlReasonField]}
/>
);
}
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("action|reject");
secondaryActionHandler = this.props.onRejectClick;
if (this.props.onRejectAndIgnoreClick) {
extraComponents.push(
<AccessibleButton kind="secondary" onClick={this.props.onRejectAndIgnoreClick} key="ignore">
{_t("room|invite_reject_ignore")}
</AccessibleButton>,
);
}
break;
}
case MessageCase.ViewingRoom: {
if (this.props.canPreview) {
title = _t("room|peek_join_prompt", { roomName });
} else if (roomName) {
title = _t("room|no_peek_join_prompt", { roomName });
} else {
title = _t("room|no_peek_no_name_join_prompt");
}
primaryActionLabel = _t("room|join_the_discussion");
primaryActionHandler = this.props.onJoinClick;
break;
}
case MessageCase.RoomNotFound: {
if (roomName) {
title = _t("room|not_found_title_name", { roomName });
} else {
title = _t("room|not_found_title");
}
subTitle = _t("room|not_found_subtitle");
break;
}
case MessageCase.OtherError: {
if (roomName) {
title = _t("room|inaccessible_name", { roomName });
} else {
title = _t("room|inaccessible");
}
subTitle = [
_t("room|inaccessible_subtitle_1"),
_t(
"room|inaccessible_subtitle_2",
{ errcode: String(this.props.error?.errcode) },
{
issueLink: (label) => (
<a
href={SdkConfig.get().feedback.new_issue_url}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
),
},
),
];
break;
}
case MessageCase.PromptAskToJoin: {
if (roomName) {
title = _t("room|knock_prompt_name", { roomName });
} else {
title = _t("room|knock_prompt");
}
const avatar = <RoomAvatar room={this.props.room} oobData={this.props.oobData} />;
subTitle = [avatar, _t("room|knock_subtitle")];
reasonElement = (
<Field
autoFocus
className="mx_RoomPreviewBar_fullWidth"
element="textarea"
onChange={this.onChangeReason}
placeholder={_t("room|knock_message_field_placeholder")}
type="text"
value={this.state.reason ?? ""}
/>
);
primaryActionHandler = () =>
this.props.onSubmitAskToJoin && this.props.onSubmitAskToJoin(this.state.reason);
primaryActionLabel = _t("room|knock_send_action");
break;
}
case MessageCase.Knocked: {
title = _t("room|knock_sent");
subTitle = [
<>
<AskToJoinIcon className="mx_Icon mx_Icon_16 mx_RoomPreviewBar_icon" />
{_t("room|knock_sent_subtitle")}
</>,
];
secondaryActionHandler = this.props.onCancelAskToJoin;
secondaryActionLabel = _t("room|knock_cancel_action");
break;
}
}
let subTitleElements;
if (subTitle) {
if (!Array.isArray(subTitle)) {
subTitle = [subTitle];
}
subTitleElements = subTitle.map((t, i) => <p key={`subTitle${i}`}>{t}</p>);
}
let titleElement;
if (showSpinner) {
titleElement = (
<h3 className="mx_RoomPreviewBar_spinnerTitle">
<Spinner />
{title}
</h3>
);
} else {
titleElement = <h3>{title}</h3>;
}
let primaryButton;
if (primaryActionHandler) {
primaryButton = (
<AccessibleButton kind="primary" onClick={primaryActionHandler}>
{primaryActionLabel}
</AccessibleButton>
);
}
let secondaryButton;
if (secondaryActionHandler) {
secondaryButton = (
<AccessibleButton kind="secondary" onClick={secondaryActionHandler}>
{secondaryActionLabel}
</AccessibleButton>
);
}
const isPanel = this.props.canPreview;
const classes = classNames("mx_RoomPreviewBar", `mx_RoomPreviewBar_${messageCase}`, {
mx_RoomPreviewBar_panel: isPanel,
mx_RoomPreviewBar_dialog: !isPanel,
});
// ensure correct tab order for both views
const actions = isPanel ? (
<>
{secondaryButton}
{extraComponents}
{primaryButton}
</>
) : (
<>
{primaryButton}
{extraComponents}
{secondaryButton}
</>
);
return (
<div role="complementary" className={classes}>
<div className="mx_RoomPreviewBar_message">
{titleElement}
{subTitleElements}
</div>
{reasonElement}
<div
className={classNames("mx_RoomPreviewBar_actions", {
mx_RoomPreviewBar_fullWidth: messageCase === MessageCase.PromptAskToJoin,
})}
>
{actions}
</div>
<div className="mx_RoomPreviewBar_footer">{footer}</div>
</div>
);
}
}

View file

@ -0,0 +1,185 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { FC, useContext, useState } from "react";
import { Room, JoinRule } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
import { EffectiveMembership, getEffectiveMembership } from "../../../utils/membership";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { useRoomState } from "../../../hooks/useRoomState";
import { useMyRoomMembership } from "../../../hooks/useRoomMembers";
import AccessibleButton from "../elements/AccessibleButton";
import InlineSpinner from "../elements/InlineSpinner";
import RoomName from "../elements/RoomName";
import RoomTopic from "../elements/RoomTopic";
import RoomFacePile from "../elements/RoomFacePile";
import RoomAvatar from "../avatars/RoomAvatar";
import MemberAvatar from "../avatars/MemberAvatar";
import { BetaPill } from "../beta/BetaCard";
import RoomInfoLine from "./RoomInfoLine";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
interface IProps {
room: Room;
onJoinButtonClicked: () => void;
onRejectButtonClicked: () => void;
}
// XXX This component is currently only used for spaces and video rooms, though
// surely we should expand its use to all rooms for consistency? This already
// handles the text room case, though we would need to add support for ignoring
// and viewing invite reasons to achieve parity with the default invite screen.
const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext);
const isVideoRoom = calcIsVideoRoom(room);
const myMembership = useMyRoomMembership(room);
useDispatcher(defaultDispatcher, (payload) => {
if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) {
setBusy(false); // stop the spinner, join failed
}
});
const [busy, setBusy] = useState(false);
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const cannotJoin =
getEffectiveMembership(myMembership) === EffectiveMembership.Leave && joinRule !== JoinRule.Public;
const viewLabs = (): void =>
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
let inviterSection: JSX.Element | null = null;
let joinButtons: JSX.Element;
if (myMembership === KnownMembership.Join) {
joinButtons = (
<AccessibleButton
kind="danger_outline"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: room.roomId,
});
}}
>
{_t("action|leave")}
</AccessibleButton>
);
} else if (myMembership === KnownMembership.Invite) {
const inviteSender = room.getMember(cli.getUserId()!)?.events.member?.getSender();
if (inviteSender) {
const inviter = room.getMember(inviteSender);
inviterSection = (
<div className="mx_RoomPreviewCard_inviter">
<MemberAvatar member={inviter} fallbackUserId={inviteSender} size="32px" />
<div>
<div className="mx_RoomPreviewCard_inviter_name">
{_t(
"room|invites_you_text",
{},
{
inviter: () => <strong>{inviter?.name || inviteSender}</strong>,
},
)}
</div>
{inviter ? <div className="mx_RoomPreviewCard_inviter_mxid">{inviteSender}</div> : null}
</div>
</div>
);
}
joinButtons = (
<>
<AccessibleButton
kind="primary_outline"
onClick={() => {
setBusy(true);
onRejectButtonClicked();
}}
>
{_t("action|reject")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={() => {
setBusy(true);
onJoinButtonClicked();
}}
>
{_t("action|accept")}
</AccessibleButton>
</>
);
} else {
joinButtons = (
<AccessibleButton
kind="primary"
onClick={() => {
onJoinButtonClicked();
if (!cli.isGuest()) {
// user will be shown a modal that won't fire a room join error
setBusy(true);
}
}}
disabled={cannotJoin}
>
{_t("action|join")}
</AccessibleButton>
);
}
if (busy) {
joinButtons = <InlineSpinner />;
}
let avatarRow: JSX.Element;
if (isVideoRoom) {
avatarRow = (
<>
<RoomAvatar room={room} size="50px" viewAvatarOnClick />
<div className="mx_RoomPreviewCard_video" />
<BetaPill onClick={viewLabs} tooltipTitle={_t("labs|video_rooms_beta")} />
</>
);
} else if (room.isSpaceRoom()) {
avatarRow = <RoomAvatar room={room} size="80px" viewAvatarOnClick />;
} else {
avatarRow = <RoomAvatar room={room} size="50px" viewAvatarOnClick />;
}
return (
<div className="mx_RoomPreviewCard">
{inviterSection}
<div className="mx_RoomPreviewCard_avatar">{avatarRow}</div>
<h1 className="mx_RoomPreviewCard_name">
<RoomName room={room} />
</h1>
<RoomInfoLine room={room} />
<RoomTopic room={room} className="mx_RoomPreviewCard_topic" />
{room.getJoinRule() === "public" && <RoomFacePile room={room} />}
{cannotJoin ? (
<div className="mx_RoomPreviewCard_notice">
{_t("room|join_failed_needs_invite", { roomName: room.name })}
</div>
) : null}
<div className="mx_RoomPreviewCard_joinButtons">{joinButtons}</div>
</div>
);
};
export default RoomPreviewCard;

View file

@ -0,0 +1,74 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { IconButton, Link } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { PosthogScreenTracker } from "../../../PosthogTrackers";
import SearchWarning, { WarningKind } from "../elements/SearchWarning";
import { SearchInfo, SearchScope } from "../../../Searching";
import InlineSpinner from "../elements/InlineSpinner";
interface Props {
searchInfo?: SearchInfo;
isRoomEncrypted: boolean;
onSearchScopeChange(scope: SearchScope): void;
onCancelClick(): void;
}
const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSearchScopeChange, onCancelClick }) => {
const scope = searchInfo?.scope ?? SearchScope.Room;
return (
<>
<PosthogScreenTracker screenName="RoomSearch" />
<div className="mx_RoomSearchAuxPanel">
<div className="mx_RoomSearchAuxPanel_summary">
<SearchIcon width="24px" height="24px" />
<div className="mx_RoomSearchAuxPanel_summary_text">
{searchInfo?.count !== undefined ? (
_t(
"room|search|summary",
{ count: searchInfo.count },
{ query: () => <strong>{searchInfo.term}</strong> },
)
) : (
<InlineSpinner />
)}
<SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} />
</div>
</div>
<div className="mx_RoomSearchAuxPanel_buttons">
<Link
onClick={() =>
onSearchScopeChange(scope === SearchScope.Room ? SearchScope.All : SearchScope.Room)
}
kind="primary"
>
{scope === SearchScope.All
? _t("room|search|this_room_button")
: _t("room|search|all_rooms_button")}
</Link>
<IconButton
onClick={onCancelClick}
destructive
tooltip={_t("action|cancel")}
aria-label={_t("action|cancel")}
>
<CloseIcon width="20px" height="20px" />
</IconButton>
</div>
</div>
</>
);
};
export default RoomSearchAuxPanel;

View file

@ -0,0 +1,883 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import * as React from "react";
import { ComponentType, createRef, ReactComponentElement, ReactNode } from "react";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher, { MatrixDispatcher } from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { _t } from "../../../languageHandler";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import RoomListStore, { LISTS_UPDATE_EVENT, LISTS_LOADING_EVENT } from "../../../stores/room-list/RoomListStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
import { objectExcluding, objectHasDiff } from "../../../utils/objects";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import ContextMenu, {
ChevronFace,
ContextMenuTooltipButton,
StyledMenuItemCheckbox,
StyledMenuItemRadio,
} from "../../structures/ContextMenu";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import ExtraTile from "./ExtraTile";
import SettingsStore from "../../../settings/SettingsStore";
import { SlidingSyncManager } from "../../../SlidingSyncManager";
import NotificationBadge from "./NotificationBadge";
import RoomTile from "./RoomTile";
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
export const HEADER_HEIGHT = 32; // As defined by CSS
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
// HACK: We really shouldn't have to do this.
polyfillTouchEvent();
export interface IAuxButtonProps {
tabIndex: number;
dispatcher?: MatrixDispatcher;
}
interface IProps {
forRooms: boolean;
startAsHidden: boolean;
label: string;
AuxButtonComponent?: ComponentType<IAuxButtonProps>;
isMinimized: boolean;
tagId: TagID;
showSkeleton?: boolean;
alwaysVisible?: boolean;
forceExpanded?: boolean;
resizeNotifier: ResizeNotifier;
extraTiles?: ReactComponentElement<typeof ExtraTile>[] | null;
onListCollapse?: (isExpanded: boolean) => void;
}
function getLabelId(tagId: TagID): string {
return `mx_RoomSublist_label_${tagId}`;
}
// TODO: Use re-resizer's NumberSize when it is exposed as the type
interface ResizeDelta {
width: number;
height: number;
}
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState {
contextMenuPosition?: PartialDOMRect;
isResizing: boolean;
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number;
rooms: Room[];
roomsLoading: boolean;
}
export default class RoomSublist extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
private tilesRef = createRef<HTMLDivElement>();
private dispatcherRef?: string;
private layout: ListLayout;
private heightAtStart: number;
private notificationState: ListNotificationState;
private slidingSyncMode: boolean;
public constructor(props: IProps) {
super(props);
// when this setting is toggled it restarts the app so it's safe to not watch this.
this.slidingSyncMode = SettingsStore.getValue("feature_sliding_sync");
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0;
this.notificationState = RoomNotificationStateStore.instance.getListState(this.props.tagId);
this.state = {
isResizing: false,
isExpanded: !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
rooms: arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []),
roomsLoading: false,
};
// Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, { height: this.calculateInitialHeight() });
}
private calculateInitialHeight(): number {
const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
}
private get padding(): number {
let padding = RESIZE_HANDLE_HEIGHT;
// this is used for calculating the max height of the whole container,
// and takes into account whether there should be room reserved for the show more/less button
// when fully expanded. We can't rely purely on the layout's defaultVisible tile count
// because there are conditions in which we need to know that the 'show more' button
// is present while well under the default tile limit.
const needsShowMore = this.numTiles > this.numVisibleTiles;
// ...but also check this or we'll miss if the section is expanded and we need a
// 'show less'
const needsShowLess = this.numTiles > this.layout.defaultVisibleTiles;
if (needsShowMore || needsShowLess) {
padding += SHOW_N_BUTTON_HEIGHT;
}
return padding;
}
private get extraTiles(): ReactComponentElement<typeof ExtraTile>[] | null {
return this.props.extraTiles ?? null;
}
private get numTiles(): number {
return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles);
}
private static calcNumTiles(rooms: Room[], extraTiles?: any[] | null): number {
return (rooms || []).length + (extraTiles || []).length;
}
private get numVisibleTiles(): number {
if (this.slidingSyncMode) {
return this.state.rooms.length;
}
const nVisible = Math.ceil(this.layout.visibleTiles);
return Math.min(nVisible, this.numTiles);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
const prevExtraTiles = prevProps.extraTiles;
// as the rooms can come in one by one we need to reevaluate
// the amount of available rooms to cap the amount of requested visible rooms by the layout
if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) {
this.setState({ height: this.calculateInitialHeight() });
}
}
public shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>): boolean {
if (objectHasDiff(this.props, nextProps)) {
// Something we don't care to optimize has updated, so update.
return true;
}
// Do the same check used on props for state, without the rooms we're going to no-op
const prevStateNoRooms = objectExcluding(this.state, ["rooms"]);
const nextStateNoRooms = objectExcluding(nextState, ["rooms"]);
if (objectHasDiff(prevStateNoRooms, nextStateNoRooms)) {
return true;
}
// If we're supposed to handle extra tiles, take the performance hit and re-render all the
// time so we don't have to consider them as part of the visible room optimization.
const prevExtraTiles = this.props.extraTiles || [];
const nextExtraTiles = nextProps.extraTiles || [];
if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) {
return true;
}
// If we're about to update the height of the list, we don't really care about which rooms
// are visible or not for no-op purposes, so ensure that the height calculation runs through.
if (RoomSublist.calcNumTiles(nextState.rooms, nextExtraTiles) !== this.numTiles) {
return true;
}
// Before we go analyzing the rooms, we can see if we're collapsed. If we're collapsed, we don't need
// to render anything. We do this after the height check though to ensure that the height gets appropriately
// calculated for when/if we become uncollapsed.
if (!nextState.isExpanded) {
return false;
}
// Quickly double check we're not about to break something due to the number of rooms changing.
if (this.state.rooms.length !== nextState.rooms.length) {
return true;
}
// Finally, determine if the room update (as presumably that's all that's left) is within
// our visible range. If it is, then do a render. If the update is outside our visible range
// then we can skip the update.
//
// We also optimize for order changing here: if the update did happen in our visible range
// but doesn't result in the list re-sorting itself then there's no reason for us to update
// on our own.
const prevSlicedRooms = this.state.rooms.slice(0, this.numVisibleTiles);
const nextSlicedRooms = nextState.rooms.slice(0, this.numVisibleTiles);
if (arrayHasOrderChange(prevSlicedRooms, nextSlicedRooms)) {
return true;
}
// Finally, nothing happened so no-op the update
return false;
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
RoomListStore.instance.on(LISTS_LOADING_EVENT, this.onListsLoading);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true });
}
public componentWillUnmount(): void {
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
RoomListStore.instance.off(LISTS_LOADING_EVENT, this.onListsLoading);
this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent);
}
private onListsLoading = (tagId: TagID, isLoading: boolean): void => {
if (this.props.tagId !== tagId) {
return;
}
this.setState({
roomsLoading: isLoading,
});
};
private onListsUpdated = (): void => {
const stateUpdates = {} as IState;
const currentRooms = this.state.rooms;
const newRooms = arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []);
if (arrayHasOrderChange(currentRooms, newRooms)) {
stateUpdates.rooms = newRooms;
}
if (Object.keys(stateUpdates).length > 0) {
this.setState(stateUpdates);
}
};
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.ViewRoom && payload.show_room_tile && this.state.rooms) {
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
setTimeout(() => {
const roomIndex = this.state.rooms.findIndex((r) => r.roomId === payload.room_id);
if (!this.state.isExpanded && roomIndex > -1) {
this.toggleCollapsed();
}
// extend the visible section to include the room if it is entirely invisible
if (roomIndex >= this.numVisibleTiles) {
this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
}
}, 0);
}
};
private applyHeightChange(newHeight: number): void {
const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
}
private onResize = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLElement,
delta: ResizeDelta,
): void => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({ height: newHeight });
};
private onResizeStart = (): void => {
this.heightAtStart = this.state.height;
this.setState({ isResizing: true });
};
private onResizeStop = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLElement,
delta: ResizeDelta,
): void => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({ isResizing: false, height: newHeight });
};
private onShowAllClick = async (): Promise<void> => {
if (this.slidingSyncMode) {
const count = RoomListStore.instance.getCount(this.props.tagId);
await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, {
ranges: [[0, count]],
});
}
// read number of visible tiles before we mutate it
const numVisibleTiles = this.numVisibleTiles;
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({ height: newHeight }, () => {
// focus the top-most new room
this.focusRoomTile(numVisibleTiles);
});
};
private onShowLessClick = (): void => {
const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({ height: newHeight });
};
private focusRoomTile = (index: number): void => {
if (!this.sublistRef.current) return;
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile");
const element = elements && elements[index];
if (element) {
element.focus();
}
};
private onOpenMenuClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
height: 0,
},
});
};
private onCloseMenu = (): void => {
this.setState({ contextMenuPosition: undefined });
};
private onUnreadFirstChanged = (): void => {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
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
};
private onTagSortChanged = async (sort: SortAlgorithm): Promise<void> => {
RoomListStore.instance.setTagSorting(this.props.tagId, sort);
this.forceUpdate();
};
private onMessagePreviewChanged = (): void => {
this.layout.showPreviews = !this.layout.showPreviews;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onBadgeClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
let room;
if (this.props.tagId === DefaultTagID.Invite) {
// switch to first room as that'll be the top of the list for the user
room = this.state.rooms && this.state.rooms[0];
} else {
// find the first room with a count of the same colour as the badge count
room = RoomListStore.instance.orderedLists[this.props.tagId].find((r: Room) => {
const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.level === this.notificationState.level;
});
}
if (room) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebRoomListNotificationBadge",
metricsViaKeyboard: ev.type !== "click",
});
}
};
private onHeaderClick = (): void => {
const possibleSticky = this.headerButton.current?.parentElement;
const sublist = possibleSticky?.parentElement?.parentElement;
const list = sublist?.parentElement?.parentElement;
if (!possibleSticky || !list) return;
// the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky
const listScrollTop = Math.round(list.scrollTop);
const isAtTop = listScrollTop <= Math.round(HEADER_HEIGHT);
const isAtBottom = listScrollTop >= Math.round(list.scrollHeight - list.offsetHeight);
const isStickyTop = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyTop");
const isStickyBottom = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyBottom");
if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) {
// is sticky - jump to list
sublist.scrollIntoView({ behavior: "smooth" });
} else {
// on screen - toggle collapse
const isExpanded = this.state.isExpanded;
this.toggleCollapsed();
// if the bottom list is collapsed then scroll it in so it doesn't expand off screen
if (!isExpanded && isStickyBottom) {
setTimeout(() => {
sublist.scrollIntoView({ behavior: "smooth" });
}, 0);
}
}
};
private toggleCollapsed = (): void => {
if (this.props.forceExpanded) return;
this.layout.isCollapsed = this.state.isExpanded;
this.setState({ isExpanded: !this.layout.isCollapsed });
if (this.props.onListCollapse) {
this.props.onListCollapse(!this.layout.isCollapsed);
}
};
private onHeaderKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case KeyBindingAction.CollapseRoomListSection:
ev.stopPropagation();
if (this.state.isExpanded) {
// Collapse the room sublist if it isn't already
this.toggleCollapsed();
}
break;
case KeyBindingAction.ExpandRoomListSection: {
ev.stopPropagation();
if (!this.state.isExpanded) {
// Expand the room sublist if it isn't already
this.toggleCollapsed();
} else if (this.sublistRef.current) {
// otherwise focus the first room
const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement;
if (element) {
element.focus();
}
}
break;
}
}
};
private onKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
// On ArrowLeft go to the sublist header
case KeyBindingAction.ArrowLeft:
ev.stopPropagation();
this.headerButton.current?.focus();
break;
// Consume ArrowRight so it doesn't cause focus to get sent to composer
case KeyBindingAction.ArrowRight:
ev.stopPropagation();
}
};
private renderVisibleTiles(): React.ReactElement[] {
if (!this.state.isExpanded && !this.props.forceExpanded) {
// don't waste time on rendering
return [];
}
const tiles: React.ReactElement[] = [];
if (this.state.rooms) {
let visibleRooms = this.state.rooms;
if (!this.props.forceExpanded) {
visibleRooms = visibleRooms.slice(0, this.numVisibleTiles);
}
for (const room of visibleRooms) {
tiles.push(
<RoomTile
room={room}
key={`room-${room.roomId}`}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}
/>,
);
}
}
if (this.extraTiles) {
// HACK: We break typing here, but this 'extra tiles' property shouldn't exist.
(tiles as any[]).push(...this.extraTiles);
}
// We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra
// tiles are used.
if (tiles.length > this.numVisibleTiles && !this.props.forceExpanded) {
return tiles.slice(0, this.numVisibleTiles);
}
return tiles;
}
private renderMenu(): ReactNode {
if (this.props.tagId === DefaultTagID.Suggested) return null; // not sortable
let contextMenu: JSX.Element | undefined;
if (this.state.contextMenuPosition) {
let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
let isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
if (this.slidingSyncMode) {
const slidingList = SlidingSyncManager.instance.slidingSync?.getListParams(this.props.tagId);
isAlphabetical = (slidingList?.sort || [])[0] === "by_name";
isUnreadFirst = (slidingList?.sort || [])[0] === "by_notification_level";
}
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections: JSX.Element | undefined;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
<hr />
<fieldset>
<legend className="mx_RoomSublist_contextMenu_title">{_t("common|appearance")}</legend>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("room_list|sort_unread_first")}
</StyledMenuItemCheckbox>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.layout.showPreviews}
>
{_t("room_list|show_previews")}
</StyledMenuItemCheckbox>
</fieldset>
</React.Fragment>
);
}
contextMenu = (
<ContextMenu
chevronFace={ChevronFace.None}
left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
>
<div className="mx_RoomSublist_contextMenu">
<fieldset>
<legend className="mx_RoomSublist_contextMenu_title">{_t("room_list|sort_by")}</legend>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("room_list|sort_by_activity")}
</StyledMenuItemRadio>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("room_list|sort_by_alphabet")}
</StyledMenuItemRadio>
</fieldset>
{otherSections}
</div>
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomSublist_menuButton"
onClick={this.onOpenMenuClick}
title={_t("room_list|sublist_options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{contextMenu}
</React.Fragment>
);
}
private renderHeader(): React.ReactElement {
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
{({ onFocus, isActive, ref }) => {
const tabIndex = isActive ? 0 : -1;
let ariaLabel = _t("a11y_jump_first_unread_room");
if (this.props.tagId === DefaultTagID.Invite) {
ariaLabel = _t("a11y|jump_first_invite");
}
const badge = (
<NotificationBadge
hideIfDot={true}
notification={this.notificationState}
onClick={this.onBadgeClick}
tabIndex={tabIndex}
aria-label={ariaLabel}
showUnsentTooltip={true}
/>
);
let addRoomButton: JSX.Element | undefined;
if (this.props.AuxButtonComponent) {
const AuxButtonComponent = this.props.AuxButtonComponent;
addRoomButton = <AuxButtonComponent tabIndex={tabIndex} />;
}
const collapseClasses = classNames({
mx_RoomSublist_collapseBtn: true,
mx_RoomSublist_collapseBtn_collapsed: !this.state.isExpanded && !this.props.forceExpanded,
});
const classes = classNames({
mx_RoomSublist_headerContainer: true,
mx_RoomSublist_headerContainer_withAux: !!addRoomButton,
});
const badgeContainer = <div className="mx_RoomSublist_badgeContainer">{badge}</div>;
// Note: the addRoomButton conditionally gets moved around
// the DOM depending on whether or not the list is minimized.
// If we're minimized, we want it below the header so it
// doesn't become sticky.
// The same applies to the notification badge.
return (
<div
className={classes}
onKeyDown={this.onHeaderKeyDown}
onFocus={onFocus}
aria-label={this.props.label}
role="treeitem"
aria-expanded={this.state.isExpanded}
aria-level={1}
aria-selected="false"
>
<div className="mx_RoomSublist_stickableContainer">
<div className="mx_RoomSublist_stickable">
<AccessibleButton
onFocus={onFocus}
ref={ref}
tabIndex={tabIndex}
className="mx_RoomSublist_headerText"
aria-expanded={this.state.isExpanded}
onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
title={this.props.isMinimized ? this.props.label : undefined}
>
<span className={collapseClasses} />
<span id={getLabelId(this.props.tagId)}>{this.props.label}</span>
</AccessibleButton>
{this.renderMenu()}
{this.props.isMinimized ? null : badgeContainer}
{this.props.isMinimized ? null : addRoomButton}
</div>
</div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
</div>
);
}}
</RovingTabIndexWrapper>
);
}
private onScrollPrevent(e: Event): void {
// the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
// this fixes https://github.com/vector-im/element-web/issues/14413
(e.target as HTMLDivElement).scrollTop = 0;
}
public render(): React.ReactElement {
const visibleTiles = this.renderVisibleTiles();
const hidden = !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true;
const classes = classNames({
mx_RoomSublist: true,
mx_RoomSublist_hasMenuOpen: !!this.state.contextMenuPosition,
mx_RoomSublist_minimized: this.props.isMinimized,
mx_RoomSublist_hidden: hidden,
});
let content: JSX.Element | undefined;
if (this.state.roomsLoading) {
content = <div className="mx_RoomSublist_skeletonUI" />;
} else if (visibleTiles.length > 0 && this.props.forceExpanded) {
content = (
<div className="mx_RoomSublist_resizeBox mx_RoomSublist_resizeBox_forceExpanded">
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
</div>
</div>
);
} else if (visibleTiles.length > 0) {
const layout = this.layout; // to shorten calls
const minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
const showMoreAtMinHeight = minTiles < this.numTiles;
const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
const showMoreBtnClasses = classNames({
mx_RoomSublist_showNButton: true,
});
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton: JSX.Element | undefined;
const hasMoreSlidingSync =
this.slidingSyncMode && RoomListStore.instance.getCount(this.props.tagId) > this.state.rooms.length;
if (maxTilesPx > this.state.height || hasMoreSlidingSync) {
// the height of all the tiles is greater than the section height: we need a 'show more' button
const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
let numMissing = this.numTiles - amountFullyShown;
if (this.slidingSyncMode) {
numMissing = RoomListStore.instance.getCount(this.props.tagId) - amountFullyShown;
}
const label = _t("room_list|show_n_more", { count: numMissing });
let showMoreText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showMoreText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowAllClick}
className={showMoreBtnClasses}
aria-label={label}
>
<span className="mx_RoomSublist_showMoreButtonChevron mx_RoomSublist_showNButtonChevron">
{/* set by CSS masking */}
</span>
{showMoreText}
</RovingAccessibleButton>
);
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
const label = _t("room_list|show_less");
let showLessText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showLessText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowLessClick}
className={showMoreBtnClasses}
aria-label={label}
>
<span className="mx_RoomSublist_showLessButtonChevron mx_RoomSublist_showNButtonChevron">
{/* set by CSS masking */}
</span>
{showLessText}
</RovingAccessibleButton>
);
}
// Figure out if we need a handle
const handles: Enable = {
bottom: true, // the only one we need, but the others must be explicitly false
bottomLeft: false,
bottomRight: false,
left: false,
right: false,
top: false,
topLeft: false,
topRight: false,
};
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
// we're at a minimum, don't have a bottom handle
handles.bottom = false;
}
// We have to account for padding so we can accommodate a 'show more' button and
// the resize handle, which are pinned to the bottom of the container. This is the
// easiest way to have a resize handle below the button as otherwise we're writing
// our own resize handling and that doesn't sound fun.
//
// The layout class has some helpers for dealing with padding, as we don't want to
// apply it in all cases. If we apply it in all cases, the resizing feels like it
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
// only mathematically 7 possible).
const handleWrapperClasses = classNames({
mx_RoomSublist_resizerHandles: true,
mx_RoomSublist_resizerHandles_showNButton: !!showNButton,
});
content = (
<React.Fragment>
<Resizable
size={{ height: this.state.height } as any}
minHeight={minTilesPx}
maxHeight={maxTilesPx}
onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop}
onResize={this.onResize}
handleWrapperClass={handleWrapperClasses}
handleClasses={{ bottom: "mx_RoomSublist_resizerHandle" }}
className="mx_RoomSublist_resizeBox"
enable={handles}
>
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
</div>
{showNButton}
</Resizable>
</React.Fragment>
);
} else if (this.props.showSkeleton && this.state.isExpanded) {
content = <div className="mx_RoomSublist_skeletonUI" />;
}
return (
<div
ref={this.sublistRef}
className={classes}
role="group"
aria-hidden={hidden}
aria-labelledby={getLabelId(this.props.tagId)}
onKeyDown={this.onKeyDown}
>
{this.renderHeader()}
{content}
</div>
);
}
}

View file

@ -0,0 +1,498 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015-2017 , 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import classNames from "classnames";
import type { Call } from "../../../models/Call";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton, MenuProps } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { RoomNotificationContextMenu } from "../context_menus/RoomNotificationContextMenu";
import NotificationBadge from "./NotificationBadge";
import { ActionPayload } from "../../../dispatcher/payloads";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { CachedRoomKey, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber";
import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber";
import PosthogTrackers from "../../../PosthogTrackers";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useHasRoomLiveVoiceBroadcast } from "../../../voice-broadcast";
import { RoomTileSubtitle } from "./RoomTileSubtitle";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { isKnockDenied } from "../../../utils/membership";
import SettingsStore from "../../../settings/SettingsStore";
interface Props {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
}
interface ClassProps extends Props {
hasLiveVoiceBroadcast: boolean;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface State {
selected: boolean;
notificationsMenuPosition: PartialDOMRect | null;
generalMenuPosition: PartialDOMRect | null;
call: Call | null;
messagePreview: MessagePreview | null;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
export const contextMenuBelow = (elementRect: PartialDOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX - 9;
const top = elementRect.bottom + window.scrollY + 17;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
export class RoomTile extends React.PureComponent<ClassProps, State> {
private dispatcherRef?: string;
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
public constructor(props: ClassProps) {
super(props);
this.state = {
selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
call: CallStore.instance.getCall(this.props.room.roomId),
// generatePreview() will return nothing if the user has previews disabled
messagePreview: null,
};
this.generatePreview();
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
}
private onRoomNameUpdate = (room: Room): void => {
this.forceUpdate();
};
private onNotificationUpdate = (): void => {
this.forceUpdate(); // notification state changed - update
};
private onRoomPropertyUpdate = (property: CachedRoomKey): void => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
};
private get showContextMenu(): boolean {
return (
this.props.tag !== DefaultTagID.Invite &&
this.props.room.getMyMembership() !== KnownMembership.Knock &&
!isKnockDenied(this.props.room) &&
shouldShowComponent(UIComponent.RoomOptionsMenu)
);
}
private get showMessagePreview(): boolean {
return !this.props.isMinimized && this.props.showMessagePreview;
}
public componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview;
const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized;
if (showMessageChanged || minimizedChanged) {
this.generatePreview();
}
if (prevProps.room?.roomId !== this.props.room?.roomId) {
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(prevProps.room),
this.onRoomPreviewChanged,
);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate);
this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate);
}
}
public componentDidMount(): void {
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
if (this.state.selected) {
this.scrollIntoView();
}
SdkContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate);
CallStore.instance.on(CallStoreEvent.Call, this.onCallChanged);
// Recalculate the call for this room, since it could've changed between
// construction and mounting
this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) });
}
public componentWillUnmount(): void {
SdkContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged);
}
private onAction = (payload: ActionPayload): void => {
if (
payload.action === Action.ViewRoom &&
payload.room_id === this.props.room.roomId &&
payload.show_room_tile
) {
setTimeout(() => {
this.scrollIntoView();
});
}
};
private onRoomPreviewChanged = (room: Room): void => {
if (this.props.room && room.roomId === this.props.room.roomId) {
this.generatePreview();
}
};
private onCallChanged = (call: Call, roomId: string): void => {
if (roomId === this.props.room?.roomId) this.setState({ call });
};
private async generatePreview(): Promise<void> {
if (!this.showMessagePreview) {
return;
}
const messagePreview =
(await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? null;
this.setState({ messagePreview });
}
private scrollIntoView = (): void => {
if (!this.roomTileRef.current) return;
this.roomTileRef.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
};
private onTileClick = async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array<string | undefined>).includes(
action,
);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId,
clear_search: clearSearch,
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
};
private onActiveRoomUpdate = (isActive: boolean): void => {
this.setState({ selected: isActive });
};
private onNotificationsMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ notificationsMenuPosition: target.getBoundingClientRect() });
PosthogTrackers.trackInteraction("WebRoomListRoomTileNotificationsMenu", ev);
};
private onCloseNotificationsMenu = (): void => {
this.setState({ notificationsMenuPosition: null });
};
private onGeneralMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ generalMenuPosition: target.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent): void => {
// If we don't have a context menu to show, ignore the action.
if (!this.showContextMenu) return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
generalMenuPosition: {
left: ev.clientX,
bottom: ev.clientY,
},
});
};
private onCloseGeneralMenu = (): void => {
this.setState({ generalMenuPosition: null });
};
private renderNotificationsMenu(isActive: boolean): React.ReactElement | null {
if (
MatrixClientPeg.safeGet().isGuest() ||
this.props.tag === DefaultTagID.Archived ||
!this.showContextMenu ||
this.props.isMinimized
) {
// the menu makes no sense in these cases so do not show one
return null;
}
const state = this.roomProps.notificationVolume;
const classes = classNames("mx_RoomTile_notificationsButton", {
// Show bell icon for the default case too.
mx_RoomNotificationContextMenu_iconBell: state === RoomNotifState.AllMessages,
mx_RoomNotificationContextMenu_iconBellDot: state === RoomNotifState.AllMessagesLoud,
mx_RoomNotificationContextMenu_iconBellMentions: state === RoomNotifState.MentionsOnly,
mx_RoomNotificationContextMenu_iconBellCrossed: state === RoomNotifState.Mute,
// Only show the icon by default if the room is overridden to muted.
// TODO: [FTUE Notifications] Probably need to detect global mute state
mx_RoomTile_notificationsButton_show: state === RoomNotifState.Mute,
});
return (
<React.Fragment>
<ContextMenuTooltipButton
className={classes}
onClick={this.onNotificationsMenuOpenClick}
title={_t("room_list|notification_options")}
isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1}
/>
{this.state.notificationsMenuPosition && (
<RoomNotificationContextMenu
{...contextMenuBelow(this.state.notificationsMenuPosition)}
onFinished={this.onCloseNotificationsMenu}
room={this.props.room}
/>
)}
</React.Fragment>
);
}
private renderGeneralMenu(): React.ReactElement | null {
if (!this.showContextMenu) return null; // no menu to show
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomTile_menuButton"
onClick={this.onGeneralMenuOpenClick}
title={_t("room|context_menu|title")}
isExpanded={!!this.state.generalMenuPosition}
/>
{this.state.generalMenuPosition && (
<RoomGeneralContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
room={this.props.room}
onPostFavoriteClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", ev)
}
onPostInviteClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", ev)
}
onPostSettingsClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuSettingsItem", ev)
}
onPostLeaveClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev)
}
onPostMarkAsReadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", ev)
}
onPostMarkAsUnreadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", ev)
}
/>
)}
</React.Fragment>
);
}
/**
* RoomTile has a subtile if one of the following applies:
* - there is a call
* - there is a live voice broadcast
* - message previews are enabled and there is a previewable message
*/
private get shouldRenderSubtitle(): boolean {
return (
!!this.state.call ||
this.props.hasLiveVoiceBroadcast ||
(this.props.showMessagePreview && !!this.state.messagePreview)
);
}
public render(): React.ReactElement {
const classes = classNames({
mx_RoomTile: true,
mx_RoomTile_sticky:
SettingsStore.getValue("feature_ask_to_join") &&
(this.props.room.getMyMembership() === KnownMembership.Knock || isKnockDenied(this.props.room)),
mx_RoomTile_selected: this.state.selected,
mx_RoomTile_hasMenuOpen: !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
mx_RoomTile_minimized: this.props.isMinimized,
});
let name = this.props.room.name;
if (typeof name !== "string") name = "";
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let badge: React.ReactNode;
if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge notification={this.notificationState} roomId={this.props.room.roomId} />
</div>
);
}
const subtitle = this.shouldRenderSubtitle ? (
<RoomTileSubtitle
call={this.state.call}
hasLiveVoiceBroadcast={this.props.hasLiveVoiceBroadcast}
messagePreview={this.state.messagePreview}
roomId={this.props.room.roomId}
showMessagePreview={this.props.showMessagePreview}
/>
) : null;
const titleClasses = classNames({
mx_RoomTile_title: true,
mx_RoomTile_titleWithSubtitle: !!subtitle,
mx_RoomTile_titleHasUnreadEvents: this.notificationState.isUnread,
});
const titleContainer = this.props.isMinimized ? null : (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={titleClasses} tabIndex={-1}>
<span dir="auto">{name}</span>
</div>
{subtitle}
</div>
);
let ariaLabel = name;
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) {
// append nothing
} else if (this.notificationState.hasMentions) {
ariaLabel +=
" " +
_t("a11y|n_unread_messages_mentions", {
count: this.notificationState.count,
});
} else if (this.notificationState.hasUnreadCount) {
ariaLabel +=
" " +
_t("a11y|n_unread_messages", {
count: this.notificationState.count,
});
} else if (this.notificationState.isUnread) {
ariaLabel += " " + _t("a11y|unread_messages");
}
let ariaDescribedBy: string;
if (this.showMessagePreview) {
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
}
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({ onFocus, isActive, ref }) => (
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
ref={ref}
className={classes}
onClick={this.onTileClick}
onContextMenu={this.onContextMenu}
role="treeitem"
aria-label={ariaLabel}
aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy}
title={this.props.isMinimized && !this.state.generalMenuPosition ? name : undefined}
>
<DecoratedRoomAvatar
room={this.props.room}
size="32px"
displayBadge={this.props.isMinimized}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
{titleContainer}
{badge}
{this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
</AccessibleButton>
)}
</RovingTabIndexWrapper>
</React.Fragment>
);
}
}
const RoomTileHOC: React.FC<Props> = (props: Props) => {
const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room);
return <RoomTile {...props} hasLiveVoiceBroadcast={hasLiveVoiceBroadcast} />;
};
export default RoomTileHOC;

View file

@ -0,0 +1,57 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { FC } from "react";
import type { Call } from "../../../models/Call";
import { _t } from "../../../languageHandler";
import { useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { ConnectionState } from "../../../models/Call";
import { LiveContentSummary, LiveContentType } from "./LiveContentSummary";
interface Props {
call: Call;
}
export const RoomTileCallSummary: FC<Props> = ({ call }) => {
let text: string;
let active: boolean;
switch (useConnectionState(call)) {
case ConnectionState.Disconnected:
text = _t("common|video");
active = false;
break;
case ConnectionState.WidgetLoading:
text = _t("common|loading");
active = false;
break;
case ConnectionState.Lobby:
text = _t("common|lobby");
active = false;
break;
case ConnectionState.Connecting:
text = _t("room|joining");
active = true;
break;
case ConnectionState.Connected:
case ConnectionState.Disconnecting:
text = _t("common|joined");
active = true;
break;
}
return (
<LiveContentSummary
type={LiveContentType.Video}
text={text}
active={active}
participantCount={useParticipantCount(call)}
/>
);
};

View file

@ -0,0 +1,63 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { MessagePreview } from "../../../stores/room-list/MessagePreviewStore";
import { Call } from "../../../models/Call";
import { RoomTileCallSummary } from "./RoomTileCallSummary";
import { VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast";
import { Icon as ThreadIcon } from "../../../../res/img/compound/thread-16px.svg";
interface Props {
call: Call | null;
hasLiveVoiceBroadcast: boolean;
messagePreview: MessagePreview | null;
roomId: string;
showMessagePreview: boolean;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
export const RoomTileSubtitle: React.FC<Props> = ({
call,
hasLiveVoiceBroadcast,
messagePreview,
roomId,
showMessagePreview,
}) => {
if (call) {
return (
<div className="mx_RoomTile_subtitle">
<RoomTileCallSummary call={call} />
</div>
);
}
if (hasLiveVoiceBroadcast) {
return <VoiceBroadcastRoomSubtitle />;
}
if (showMessagePreview && messagePreview) {
const className = classNames("mx_RoomTile_subtitle", {
"mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply,
});
const icon = messagePreview.isThreadReply ? <ThreadIcon className="mx_Icon mx_Icon_12" /> : null;
return (
<div className={className} id={messagePreviewId(roomId)} title={messagePreview.text}>
{icon}
<span className="mx_RoomTile_subtitle_text">{messagePreview.text}</span>
</div>
);
}
return null;
};

View file

@ -0,0 +1,113 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixEvent, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import RoomUpgradeDialog from "../dialogs/RoomUpgradeDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps {
room: Room;
}
interface IState {
upgraded?: boolean;
}
export default class RoomUpgradeWarningBar extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.state = {
upgraded: tombstone?.getContent().replacement_room,
};
}
public componentDidMount(): void {
this.context.on(RoomStateEvent.Events, this.onStateEvents);
}
public componentWillUnmount(): void {
this.context.removeListener(RoomStateEvent.Events, this.onStateEvents);
}
private onStateEvents = (event: MatrixEvent): void => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return;
}
if (event.getType() !== "m.room.tombstone") return;
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
};
private onUpgradeClick = (): void => {
Modal.createDialog(RoomUpgradeDialog, { room: this.props.room });
};
public render(): React.ReactNode {
let doUpgradeWarnings = (
<div>
<div className="mx_RoomUpgradeWarningBar_body">
<p>{_t("room|upgrade_warning_bar")}</p>
<p>
{_t(
"room_settings|advanced|room_upgrade_warning",
{},
{
b: (sub) => <strong>{sub}</strong>,
i: (sub) => <i>{sub}</i>,
},
)}
</p>
</div>
<p className="mx_RoomUpgradeWarningBar_upgradelink">
<AccessibleButton onClick={this.onUpgradeClick}>
{_t("room_settings|advanced|room_upgrade_button")}
</AccessibleButton>
</p>
</div>
);
if (this.state.upgraded) {
doUpgradeWarnings = (
<div className="mx_RoomUpgradeWarningBar_body">
<p>{_t("room|upgrade_warning_bar_upgraded")}</p>
</div>
);
}
return (
<div className="mx_RoomUpgradeWarningBar">
<div className="mx_RoomUpgradeWarningBar_wrapped">
<div className="mx_RoomUpgradeWarningBar_header">
{_t(
"room|upgrade_warning_bar_unstable",
{},
{
roomVersion: () => <code>{this.props.room.getVersion()}</code>,
i: (sub) => <i>{sub}</i>,
},
)}
</div>
{doUpgradeWarnings}
<div className="mx_RoomUpgradeWarningBar_small">{_t("room|upgrade_warning_bar_admins")}</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,134 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
Copyright 2015 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import SettingsStore from "../../../settings/SettingsStore";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import DateSeparator from "../messages/DateSeparator";
import EventTile from "./EventTile";
import { shouldFormContinuation } from "../../structures/MessagePanel";
import { wantsDateSeparator } from "../../../DateUtils";
import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "../../structures/LegacyCallEventGrouper";
import { haveRendererForEvent } from "../../../events/EventTileFactory";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
// a list of strings to be highlighted in the results
searchHighlights?: string[];
// href for the highlights in this result
resultLink?: string;
// timeline of the search result
timeline: MatrixEvent[];
// indexes of the matching events (not contextual ones)
ourEventsIndexes: number[];
onHeightChanged?: () => void;
permalinkCreator?: RoomPermalinkCreator;
}
export default class SearchResultTile extends React.Component<IProps> {
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.buildLegacyCallEventGroupers(this.props.timeline);
}
private buildLegacyCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildLegacyCallEventGroupers(this.callEventGroupers, events);
}
public render(): React.ReactNode {
const timeline = this.props.timeline;
const resultEvent = timeline[this.props.ourEventsIndexes[0]];
const eventId = resultEvent.getId();
const ts1 = resultEvent.getTs();
const ret = [<DateSeparator key={ts1 + "-search"} roomId={resultEvent.getRoomId()!} ts={ts1} />];
const layout = SettingsStore.getValue("layout");
const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
const cli = MatrixClientPeg.safeGet();
for (let j = 0; j < timeline.length; j++) {
const mxEv = timeline[j];
let highlights: string[] | undefined;
const contextual = !this.props.ourEventsIndexes.includes(j);
if (!contextual) {
highlights = this.props.searchHighlights;
}
if (haveRendererForEvent(mxEv, cli, this.context?.showHiddenEvents)) {
// do we need a date separator since the last event?
const prevEv = timeline[j - 1];
// is this a continuation of the previous message?
const continuation =
prevEv &&
!wantsDateSeparator(prevEv.getDate() || undefined, mxEv.getDate() || undefined) &&
shouldFormContinuation(
prevEv,
mxEv,
cli,
this.context?.showHiddenEvents,
TimelineRenderingType.Search,
);
let lastInSection = true;
const nextEv = timeline[j + 1];
if (nextEv) {
const willWantDateSeparator = wantsDateSeparator(
mxEv.getDate() || undefined,
nextEv.getDate() || undefined,
);
lastInSection =
willWantDateSeparator ||
mxEv.getSender() !== nextEv.getSender() ||
!shouldFormContinuation(
mxEv,
nextEv,
cli,
this.context?.showHiddenEvents,
TimelineRenderingType.Search,
);
}
ret.push(
<EventTile
key={`${eventId}+${j}`}
mxEvent={mxEv}
layout={layout}
contextual={contextual}
highlights={highlights}
permalinkCreator={this.props.permalinkCreator}
highlightLink={this.props.resultLink}
onHeightChanged={this.props.onHeightChanged}
isTwelveHour={isTwelveHour}
alwaysShowTimestamps={alwaysShowTimestamps}
lastInSection={lastInSection}
continuation={continuation}
callEventGrouper={this.callEventGroupers.get(mxEv.getContent().call_id)}
/>,
);
}
}
return (
<li data-scroll-tokens={eventId}>
<ol>{ret}</ol>
</li>
);
}
}

View file

@ -0,0 +1,786 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, KeyboardEvent, SyntheticEvent } from "react";
import {
IContent,
MatrixEvent,
IEventRelation,
IMentions,
Room,
EventType,
MsgType,
RelationType,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import { DebouncedFunc, throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
import { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import dis from "../../../dispatcher/dispatcher";
import EditorModel from "../../../editor/model";
import {
containsEmote,
htmlSerializeIfNeeded,
startsWith,
stripEmoteCommand,
stripPrefix,
textSerialize,
unescapeMessage,
} from "../../../editor/serialize";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from "../../../editor/parts";
import { findEditableEvent } from "../../../utils/EventUtils";
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from "../../../SlashCommands";
import ContentMessages from "../../../ContentMessages";
import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions";
import { containsEmoji } from "../../../effects/utils";
import { CHAT_EFFECTS } from "../../../effects";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import SettingsStore from "../../../settings/SettingsStore";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import DocumentPosition from "../../../editor/position";
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { addReplyToMessageContent } from "../../../utils/Reply";
import { doMaybeLocalRoomAction } from "../../../utils/local-room";
import { Caret } from "../../../editor/caret";
import { IDiff } from "../../../editor/diff";
import { getBlobSafeMimeType } from "../../../utils/blobs";
import { EMOJI_REGEX } from "../../../HtmlUtils";
// The prefix used when persisting editor drafts to localstorage.
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
/**
* Build the mentions information based on the editor model (and any related events):
*
* 1. Search the model parts for room or user pills and fill in the mentions object.
* 2. If this is a reply to another event, include any user mentions from that
* (but do not include a room mention).
*
* @param sender - The Matrix ID of the user sending the event.
* @param content - The event content.
* @param model - The editor model to search for mentions, null if there is no editor.
* @param replyToEvent - The event being replied to or undefined if it is not a reply.
* @param editedContent - The content of the parent event being edited.
*/
export function attachMentions(
sender: string,
content: IContent,
model: EditorModel | null,
replyToEvent: MatrixEvent | undefined,
editedContent: IContent | null = null,
): void {
// We always attach the mentions even if the home server doesn't yet support
// intentional mentions. This is safe because m.mentions is an additive change
// that should simply be ignored by incapable home servers.
// The mentions property *always* gets included to disable legacy push rules.
const mentions: IMentions = (content["m.mentions"] = {});
const userMentions = new Set<string>();
let roomMention = false;
// If there's a reply, initialize the mentioned users as the sender of that
// event + any mentioned users in that event.
if (replyToEvent) {
userMentions.add(replyToEvent.sender!.userId);
// TODO What do we do if the reply event *doeesn't* have this property?
// Try to fish out replies from the contents?
const userIds = replyToEvent.getContent()["m.mentions"]?.user_ids;
if (Array.isArray(userIds)) {
userIds.forEach((userId) => userMentions.add(userId));
}
}
// If user provided content is available, check to see if any users are mentioned.
if (model) {
// Add any mentioned users in the current content.
for (const part of model.parts) {
if (part.type === Type.UserPill) {
userMentions.add(part.resourceId);
} else if (part.type === Type.AtRoomPill) {
roomMention = true;
}
}
}
// Ensure the *current* user isn't listed in the mentioned users.
userMentions.delete(sender);
// Finally, if this event is editing a previous event, only include users who
// were not previously mentioned and a room mention if the previous event was
// not a room mention.
if (editedContent) {
// First, the new event content gets the *full* set of users.
const newContent = content["m.new_content"];
const newMentions: IMentions = (newContent["m.mentions"] = {});
// Only include the users/room if there is any content.
if (userMentions.size) {
newMentions.user_ids = [...userMentions];
}
if (roomMention) {
newMentions.room = true;
}
// Fetch the mentions from the original event and remove any previously
// mentioned users.
const prevMentions = editedContent["m.mentions"];
if (Array.isArray(prevMentions?.user_ids)) {
prevMentions!.user_ids.forEach((userId) => userMentions.delete(userId));
}
// If the original event mentioned the room, nothing to do here.
if (prevMentions?.room) {
roomMention = false;
}
}
// Only include the users/room if there is any content.
if (userMentions.size) {
mentions.user_ids = [...userMentions];
}
if (roomMention) {
mentions.room = true;
}
}
// Merges favouring the given relation
export function attachRelation(content: IContent, relation?: IEventRelation): void {
if (relation) {
content["m.relates_to"] = {
...(content["m.relates_to"] || {}),
...relation,
};
}
}
// exported for tests
export function createMessageContent(
sender: string,
model: EditorModel,
replyToEvent: MatrixEvent | undefined,
relation: IEventRelation | undefined,
permalinkCreator?: RoomPermalinkCreator,
includeReplyLegacyFallback = true,
): RoomMessageEventContent {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
}
if (startsWith(model, "//")) {
model = stripPrefix(model, "/");
}
model = unescapeMessage(model);
const body = textSerialize(model);
const content: RoomMessageEventContent = {
msgtype: isEmote ? MsgType.Emote : MsgType.Text,
body: body,
};
const formattedBody = htmlSerializeIfNeeded(model, {
forceHTML: !!replyToEvent,
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
if (formattedBody) {
content.format = "org.matrix.custom.html";
content.formatted_body = formattedBody;
}
// Build the mentions property and add it to the event content.
attachMentions(sender, content, model, replyToEvent);
attachRelation(content, relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
includeLegacyFallback: includeReplyLegacyFallback,
});
}
return content;
}
// exported for tests
export function isQuickReaction(model: EditorModel): boolean {
const parts = model.parts;
if (parts.length == 0) return false;
const text = textSerialize(model);
// shortcut takes the form "+:emoji:" or "+ :emoji:""
// can be in 1 or 2 parts
if (parts.length <= 2) {
const hasShortcut = text.startsWith("+") || text.startsWith("+ ");
const emojiMatch = text.match(EMOJI_REGEX);
if (hasShortcut && emojiMatch && emojiMatch.length == 1) {
return emojiMatch[0] === text.substring(1) || emojiMatch[0] === text.substring(2);
}
}
return false;
}
interface ISendMessageComposerProps extends MatrixClientProps {
room: Room;
placeholder?: string;
permalinkCreator?: RoomPermalinkCreator;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
disabled?: boolean;
onChange?(model: EditorModel): void;
includeReplyLegacyFallback?: boolean;
toggleStickerPickerOpen: () => void;
}
export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
private readonly editorRef = createRef<BasicMessageComposer>();
private model: EditorModel;
private currentlyComposedEditorState: SerializedPart[] | null = null;
private dispatcherRef: string;
private sendHistoryManager: SendHistoryManager;
public static defaultProps = {
includeReplyLegacyFallback: true,
};
public constructor(props: ISendMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
if (this.props.mxClient.isCryptoEnabled() && this.props.mxClient.isRoomEncrypted(this.props.room.roomId)) {
this.prepareToEncrypt = throttle(
() => {
this.props.mxClient.getCrypto()?.prepareToEncrypt(this.props.room);
},
60000,
{ leading: true, trailing: false },
);
}
window.addEventListener("beforeunload", this.saveStoredEditorState);
const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient);
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_");
}
public componentDidUpdate(prevProps: ISendMessageComposerProps): void {
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
const differentEventTarget = this.props.relation?.event_id !== prevProps.relation?.event_id;
const threadChanged = replyingToThread && differentEventTarget;
if (threadChanged) {
const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient);
const parts = this.restoreStoredEditorState(partCreator) || [];
this.model.reset(parts);
this.editorRef.current?.focus();
}
}
private onKeyDown = (event: KeyboardEvent): void => {
// ignore any keypress while doing IME compositions
if (this.editorRef.current?.isComposing(event)) {
return;
}
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case KeyBindingAction.SendMessage:
this.sendMessage();
event.preventDefault();
break;
case KeyBindingAction.SelectPrevSendHistory:
case KeyBindingAction.SelectNextSendHistory: {
// Try select composer history
const selected = this.selectSendHistory(action === KeyBindingAction.SelectPrevSendHistory);
if (selected) {
// We're selecting history, so prevent the key event from doing anything else
event.preventDefault();
}
break;
}
case KeyBindingAction.ShowStickerPicker: {
if (!SettingsStore.getValue("MessageComposerInput.showStickersButton")) {
return; // Do nothing if there is no Stickers button
}
this.props.toggleStickerPickerOpen();
event.preventDefault();
break;
}
case KeyBindingAction.EditPrevMessage:
// selection must be collapsed and caret at start
if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) {
const events = this.context.liveTimeline
?.getEvents()
.concat(replyingToThread ? [] : this.props.room.getPendingEvents());
const editEvent = events
? findEditableEvent({
events,
isForward: false,
matrixClient: MatrixClientPeg.safeGet(),
})
: undefined;
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
event.preventDefault();
dis.dispatch({
action: Action.EditEvent,
event: editEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
}
}
break;
case KeyBindingAction.CancelReplyOrEdit:
if (!!this.context.replyToEvent) {
dis.dispatch({
action: "reply_to_event",
event: null,
context: this.context.timelineRenderingType,
});
event.preventDefault();
event.stopPropagation();
}
break;
}
};
// we keep sent messages/commands in a separate history (separate from undo history)
// so you can alt+up/down in them
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 false;
}
this.currentlyComposedEditorState = this.model.serializeParts();
} else if (
this.currentlyComposedEditorState &&
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 true;
}
const { parts, replyEventId } = this.sendHistoryManager.getItem(delta);
dis.dispatch({
action: "reply_to_event",
event: replyEventId ? this.props.room.findEventById(replyEventId) : null,
context: this.context.timelineRenderingType,
});
if (parts) {
this.model.reset(parts);
this.editorRef.current?.focus();
}
return true;
}
private sendQuickReaction(): void {
const timeline = this.context.liveTimeline;
if (!timeline) return;
const events = timeline.getEvents();
const reaction = this.model.parts[1].text;
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].getType() === EventType.RoomMessage) {
let shouldReact = true;
const lastMessage = events[i];
const userId = MatrixClientPeg.safeGet().getSafeUserId();
const messageReactions = this.props.room.relations.getChildEventsForEvent(
lastMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
);
// if we have already sent this reaction, don't redact but don't re-send
if (messageReactions) {
const myReactionEvents =
messageReactions.getAnnotationsBySender()?.[userId] || new Set<MatrixEvent>();
const myReactionKeys = [...myReactionEvents]
.filter((event) => !event.isRedacted())
.map((event) => event.getRelation()?.key);
shouldReact = !myReactionKeys.includes(reaction);
}
if (shouldReact) {
MatrixClientPeg.safeGet().sendEvent(lastMessage.getRoomId()!, EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: lastMessage.getId()!,
key: reaction,
},
});
dis.dispatch({ action: "message_sent" });
}
break;
}
}
}
public async sendMessage(): Promise<void> {
const model = this.model;
if (model.isEmpty) {
return;
}
const posthogEvent: ComposerEvent = {
eventName: "Composer",
isEditing: false,
messageType: "Text",
isReply: !!this.props.replyToEvent,
inThread: this.props.relation?.rel_type === THREAD_RELATION_TYPE.name,
};
if (posthogEvent.inThread && this.props.relation!.event_id) {
const threadRoot = this.props.room.findEventById(this.props.relation!.event_id);
posthogEvent.startsThread = threadRoot?.getThread()?.events.length === 1;
}
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
// Replace emoticon at the end of the message
if (SettingsStore.getValue("MessageComposerInput.autoReplaceEmoji")) {
const indexOfLastPart = model.parts.length - 1;
const positionInLastPart = model.parts[indexOfLastPart].text.length;
this.editorRef.current?.replaceEmoticon(
new DocumentPosition(indexOfLastPart, positionInLastPart),
REGEX_EMOTICON,
);
}
const replyToEvent = this.props.replyToEvent;
let shouldSend = true;
let content: RoomMessageEventContent | null = null;
if (!containsEmote(model) && isSlashCommand(this.model)) {
const [cmd, args, commandText] = getSlashCommand(this.model);
if (cmd) {
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation?.event_id : null;
let commandSuccessful: boolean;
[content, commandSuccessful] = await runSlashCommand(
MatrixClientPeg.safeGet(),
cmd,
args,
this.props.room.roomId,
threadId ?? null,
);
if (!commandSuccessful) {
return; // errored
}
if (
content &&
[CommandCategories.messages as string, CommandCategories.effects as string].includes(cmd.category)
) {
// Attach any mentions which might be contained in the command content.
attachMentions(this.props.mxClient.getSafeUserId(), content, model, replyToEvent);
attachRelation(content, this.props.relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator: this.props.permalinkCreator,
// Exclude the legacy fallback for custom event types such as those used by /fireworks
includeLegacyFallback: content.msgtype?.startsWith("m.") ?? true,
});
}
} else {
shouldSend = false;
}
} else {
const sendAnyway = await shouldSendAnyway(commandText);
// re-focus the composer after QuestionDialog is closed
dis.dispatch({
action: Action.FocusAComposer,
context: this.context.timelineRenderingType,
});
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (isQuickReaction(model)) {
shouldSend = false;
this.sendQuickReaction();
}
if (shouldSend) {
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(
this.props.mxClient.getSafeUserId(),
model,
replyToEvent,
this.props.relation,
this.props.permalinkCreator,
this.props.includeReplyLegacyFallback,
);
}
// don't bother sending an empty message
if (!content.body.trim()) return;
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null;
const prom = doMaybeLocalRoomAction(
roomId,
(actualRoomId: string) => this.props.mxClient.sendMessage(actualRoomId, threadId ?? null, content!),
this.props.mxClient,
);
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
dis.dispatch({
action: "reply_to_event",
event: null,
context: this.context.timelineRenderingType,
});
}
dis.dispatch({ action: "message_sent" });
CHAT_EFFECTS.forEach((effect) => {
if (containsEmoji(content!, effect.emojis)) {
// For initial threads launch, chat effects are disabled
// see #19731
const isNotThread = this.props.relation?.rel_type !== THREAD_RELATION_TYPE.name;
if (isNotThread) {
dis.dispatch({ action: `effects.${effect.command}` });
}
}
});
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then((resp) => {
sendRoundTripMetric(this.props.mxClient, roomId, resp.event_id);
});
}
}
this.sendHistoryManager.save(model, replyToEvent);
// clear composer
model.reset([]);
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();
if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({
action: "scroll_to_bottom",
timelineRenderingType: this.context.timelineRenderingType,
});
}
}
public componentWillUnmount(): void {
dis.unregister(this.dispatcherRef);
window.removeEventListener("beforeunload", this.saveStoredEditorState);
this.saveStoredEditorState();
}
private get editorStateKey(): string {
let key = EDITOR_STATE_STORAGE_PREFIX + this.props.room.roomId;
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
key += `_${this.props.relation.event_id}`;
}
return key;
}
private clearStoredEditorState(): void {
localStorage.removeItem(this.editorStateKey);
}
private restoreStoredEditorState(partCreator: PartCreator): Part[] | null {
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
if (replyingToThread) {
return null;
}
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
const { parts: serializedParts, replyEventId } = JSON.parse(json);
const parts: Part[] = serializedParts.map((p: SerializedPart) => partCreator.deserializePart(p));
if (replyEventId) {
dis.dispatch({
action: "reply_to_event",
event: this.props.room.findEventById(replyEventId),
context: this.context.timelineRenderingType,
});
}
return parts;
} catch (e) {
logger.error(e);
}
}
return null;
}
// should save state when editor has contents or reply is open
private shouldSaveStoredEditorState = (): boolean => {
return !this.model.isEmpty || !!this.props.replyToEvent;
};
private saveStoredEditorState = (): void => {
if (this.shouldSaveStoredEditorState()) {
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} else {
this.clearStoredEditorState();
}
};
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.FocusSendMessageComposer:
if ((payload.context ?? TimelineRenderingType.Room) === this.context.timelineRenderingType) {
this.editorRef.current?.focus();
}
break;
case Action.ComposerInsert:
if (payload.timelineRenderingType !== this.context.timelineRenderingType) break;
if (payload.composerType !== ComposerType.Send) break;
if (payload.userId) {
this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) {
this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) {
this.editorRef.current?.insertPlaintext(payload.text);
}
break;
}
};
private onPaste = (event: Event | SyntheticEvent, data: DataTransfer): boolean => {
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
// it puts the filename in as text/plain which we want to ignore.
if (data.files.length && !data.types.includes("text/rtf")) {
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(data.files),
this.props.room.roomId,
this.props.relation,
this.props.mxClient,
this.context.timelineRenderingType,
);
return true; // to skip internal onPaste handler
}
// Safari `Insert from iPhone or iPad`
// data.getData("text/html") returns a string like: <img src="blob:https://...">
if (data.types.includes("text/html")) {
const imgElementStr = data.getData("text/html");
const parser = new DOMParser();
const imgDoc = parser.parseFromString(imgElementStr, "text/html");
if (
imgDoc.getElementsByTagName("img").length !== 1 ||
!imgDoc.querySelector("img")?.src.startsWith("blob:") ||
imgDoc.childNodes.length !== 1
) {
console.log("Failed to handle pasted content as Safari inserted content");
// Fallback to internal onPaste handler
return false;
}
const imgSrc = imgDoc!.querySelector("img")!.src;
fetch(imgSrc).then(
(response) => {
response.blob().then(
(imgBlob) => {
const type = imgBlob.type;
const safetype = getBlobSafeMimeType(type);
const ext = type.split("/")[1];
const parts = response.url.split("/");
const filename = parts[parts.length - 1];
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
ContentMessages.sharedInstance().sendContentToRoom(
file,
this.props.room.roomId,
this.props.relation,
this.props.mxClient,
this.context.replyToEvent,
);
},
(error) => {
console.log(error);
},
);
},
(error) => {
console.log(error);
},
);
// Skip internal onPaste handler
return true;
}
return false;
};
private onChange = (selection?: Caret, inputType?: string, diff?: IDiff): void => {
// We call this in here rather than onKeyDown as that would trip it on global shortcuts e.g. Ctrl-k also
if (!!diff) {
this.prepareToEncrypt?.();
}
this.props.onChange?.(this.model);
};
private focusComposer = (): void => {
this.editorRef.current?.focus();
};
public render(): React.ReactNode {
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : undefined;
return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this.onKeyDown}>
<BasicMessageComposer
onChange={this.onChange}
ref={this.editorRef}
model={this.model}
room={this.props.room}
threadId={threadId}
label={this.props.placeholder}
placeholder={this.props.placeholder}
onPaste={this.onPaste}
disabled={this.props.disabled}
/>
</div>
);
}
}
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
export default SendMessageComposerWithMatrixClient;

View file

@ -0,0 +1,357 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Room, ClientEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { IWidget } from "matrix-widget-api";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import AppTile from "../elements/AppTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
import WidgetUtils, { UserWidget } from "../../../utils/WidgetUtils";
import PersistedElement from "../elements/PersistedElement";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import ContextMenu, { ChevronFace } from "../../structures/ContextMenu";
import { WidgetType } from "../../../widgets/WidgetType";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { ActionPayload } from "../../../dispatcher/payloads";
import ScalarAuthClient from "../../../ScalarAuthClient";
import GenericElementContextMenu from "../context_menus/GenericElementContextMenu";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
// We sit in a context menu, so this should be given to the context menu.
const STICKERPICKER_Z_INDEX = 3500;
// Key to store the widget's AppTile under in PersistedElement
const PERSISTED_ELEMENT_KEY = "stickerPicker";
interface IProps {
room: Room;
threadId?: string | null;
isStickerPickerOpen: boolean;
menuPosition?: any;
setStickerPickerOpen: (isStickerPickerOpen: boolean) => void;
}
interface IState {
imError: string | null;
stickerpickerWidget: UserWidget | null;
widgetId: string | null;
}
export default class Stickerpicker extends React.PureComponent<IProps, IState> {
public static defaultProps: Partial<IProps> = {
threadId: null,
};
public static currentWidget?: UserWidget;
private dispatcherRef?: string;
private prevSentVisibility?: boolean;
private popoverWidth = 300;
private popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
private scalarClient: ScalarAuthClient | null = null;
public constructor(props: IProps) {
super(props);
this.state = {
imError: null,
stickerpickerWidget: null,
widgetId: null,
};
}
private async acquireScalarClient(): Promise<void | undefined | null | ScalarAuthClient> {
if (this.scalarClient) return Promise.resolve(this.scalarClient);
// TODO: Pick the right manager for the widget
if (IntegrationManagers.sharedInstance().hasManager()) {
this.scalarClient = IntegrationManagers.sharedInstance().getPrimaryManager()?.getScalarClient() ?? null;
return this.scalarClient
?.connect()
.then(() => {
this.forceUpdate();
return this.scalarClient;
})
.catch((e) => {
this.imError(_td("integration_manager|error_connecting_heading"), e);
});
} else {
IntegrationManagers.sharedInstance().openNoManagerDialog();
}
}
private removeStickerpickerWidgets = async (): Promise<void> => {
const scalarClient = await this.acquireScalarClient();
logger.log("Removing Stickerpicker widgets");
if (this.state.widgetId) {
if (scalarClient) {
scalarClient
.disableWidgetAssets(WidgetType.STICKERPICKER, this.state.widgetId)
.then(() => {
logger.log("Assets disabled");
})
.catch(() => {
logger.error("Failed to disable assets");
});
} else {
logger.error("Cannot disable assets: no scalar client");
}
} else {
logger.warn("No widget ID specified, not disabling assets");
}
this.props.setStickerPickerOpen(false);
WidgetUtils.removeStickerpickerWidgets(this.props.room.client)
.then(() => {
this.forceUpdate();
})
.catch((e) => {
logger.error("Failed to remove sticker picker widget", e);
});
};
public componentDidMount(): void {
// Close the sticker picker when the window resizes
window.addEventListener("resize", this.onResize);
this.dispatcherRef = dis.register(this.onAction);
// Track updates to widget state in account data
MatrixClientPeg.safeGet().on(ClientEvent.AccountData, this.updateWidget);
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
// Initialise widget state from current account data
this.updateWidget();
}
public componentWillUnmount(): void {
const client = MatrixClientPeg.get();
if (client) client.removeListener(ClientEvent.AccountData, this.updateWidget);
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
window.removeEventListener("resize", this.onResize);
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
}
public componentDidUpdate(): void {
this.sendVisibilityToWidget(this.props.isStickerPickerOpen);
}
private imError(errorMsg: TranslationKey, e: Error): void {
logger.error(errorMsg, e);
this.setState({
imError: _t(errorMsg),
});
this.props.setStickerPickerOpen(false);
}
private updateWidget = (): void => {
const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets(this.props.room.client)[0];
if (!stickerpickerWidget) {
Stickerpicker.currentWidget = undefined;
this.setState({ stickerpickerWidget: null, widgetId: null });
return;
}
const currentWidget = Stickerpicker.currentWidget;
const currentUrl = currentWidget?.content?.url ?? null;
const newUrl = stickerpickerWidget?.content?.url ?? null;
if (newUrl !== currentUrl) {
// Destroy the existing frame so a new one can be created
PersistedElement.destroyElement(PERSISTED_ELEMENT_KEY);
}
Stickerpicker.currentWidget = stickerpickerWidget;
this.setState({
stickerpickerWidget,
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
});
};
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case "user_widget_updated":
this.forceUpdate();
break;
case "stickerpicker_close":
this.props.setStickerPickerOpen(false);
break;
case "show_left_panel":
case "hide_left_panel":
this.props.setStickerPickerOpen(false);
break;
}
};
private onRightPanelStoreUpdate = (): void => {
this.props.setStickerPickerOpen(false);
};
private defaultStickerpickerContent(): JSX.Element {
return (
<AccessibleButton onClick={this.launchManageIntegrations} className="mx_Stickers_contentPlaceholder">
<p>{_t("stickers|empty")}</p>
<p className="mx_Stickers_addLink">{_t("stickers|empty_add_prompt")}</p>
<img src={require("../../../../res/img/stickerpack-placeholder.png")} alt="" />
</AccessibleButton>
);
}
private errorStickerpickerContent(): JSX.Element {
return (
<div style={{ textAlign: "center" }} className="error">
<p> {this.state.imError} </p>
</div>
);
}
private sendVisibilityToWidget(visible: boolean): void {
if (!this.state.stickerpickerWidget) return;
const messaging = WidgetMessagingStore.instance.getMessagingForUid(
WidgetUtils.calcWidgetUid(this.state.stickerpickerWidget.id),
);
if (messaging && visible !== this.prevSentVisibility) {
messaging.updateVisibility(visible).catch((err) => {
logger.error("Error updating widget visibility: ", err);
});
this.prevSentVisibility = visible;
}
}
public getStickerpickerContent(): JSX.Element {
// Handle integration manager errors
if (this.state.imError) {
return this.errorStickerpickerContent();
}
// Stickers
// TODO - Add support for Stickerpickers from multiple app stores.
// Render content from multiple stickerpack sources, each within their
// own iframe, within the stickerpicker UI element.
const stickerpickerWidget = this.state.stickerpickerWidget;
let stickersContent: JSX.Element | undefined;
// Use a separate ReactDOM tree to render the AppTile separately so that it persists and does
// not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still
// updated.
// Load stickerpack content
if (!!stickerpickerWidget?.content?.url) {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("common|stickerpack");
// FIXME: could this use the same code as other apps?
const stickerApp: IWidget = {
id: stickerpickerWidget.id,
url: stickerpickerWidget.content.url,
name: stickerpickerWidget.content.name,
type: stickerpickerWidget.content.type,
data: stickerpickerWidget.content.data,
creatorUserId: stickerpickerWidget.content.creatorUserId || stickerpickerWidget.sender,
};
stickersContent = (
<div className="mx_Stickers_content_container">
<div
id="stickersContent"
className="mx_Stickers_content"
style={{
border: "none",
height: this.popoverHeight,
width: this.popoverWidth,
}}
>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} zIndex={STICKERPICKER_Z_INDEX}>
<AppTile
app={stickerApp}
room={this.props.room}
threadId={this.props.threadId}
fullWidth={true}
userId={MatrixClientPeg.safeGet().credentials.userId!}
creatorUserId={
stickerpickerWidget.sender || MatrixClientPeg.safeGet().credentials.userId!
}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this.launchManageIntegrations}
onDeleteClick={this.removeStickerpickerWidgets}
showTitle={false}
showPopout={false}
handleMinimisePointerEvents={true}
userWidget={true}
showLayoutButtons={false}
/>
</PersistedElement>
</div>
</div>
);
} else {
// Default content to show if stickerpicker widget not added
stickersContent = this.defaultStickerpickerContent();
}
return stickersContent;
}
/**
* Called when the window is resized
*/
private onResize = (): void => {
if (this.props.isStickerPickerOpen) {
this.props.setStickerPickerOpen(false);
}
};
/**
* The stickers picker was hidden
*/
private onFinished = (): void => {
if (this.props.isStickerPickerOpen) {
this.props.setStickerPickerOpen(false);
}
};
/**
* Launch the integration manager on the stickers integration page
*/
private launchManageIntegrations = (): void => {
// noinspection JSIgnoredPromiseFromCall
IntegrationManagers.sharedInstance()
?.getPrimaryManager()
?.open(this.props.room, `type_${WidgetType.STICKERPICKER.preferred}`, this.state.widgetId ?? undefined);
};
public render(): React.ReactNode {
if (!this.props.isStickerPickerOpen) return null;
return (
<ContextMenu
chevronFace={ChevronFace.Bottom}
menuWidth={this.popoverWidth}
menuHeight={this.popoverHeight}
onFinished={this.onFinished}
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
zIndex={STICKERPICKER_Z_INDEX}
mountAsChild={true}
{...this.props.menuPosition}
>
<GenericElementContextMenu element={this.getStickerpickerContent()} onResize={this.onFinished} />
</ContextMenu>
);
}
}

View file

@ -0,0 +1,142 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { EventType, MatrixEvent, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Button, Text } from "@vector-im/compound-web";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import { isValid3pidInvite } from "../../../RoomInvite";
import { Action } from "../../../dispatcher/actions";
import ErrorDialog from "../dialogs/ErrorDialog";
import BaseCard from "../right_panel/BaseCard";
import { Flex } from "../../utils/Flex";
interface IProps {
event: MatrixEvent;
onClose?: () => void;
}
interface IState {
stateKey: string;
roomId: string;
displayName: string;
invited: boolean;
canKick: boolean;
senderName: string;
}
export default class ThirdPartyMemberInfo extends React.Component<IProps, IState> {
private readonly room: Room | null;
public constructor(props: IProps) {
super(props);
this.room = MatrixClientPeg.safeGet().getRoom(this.props.event.getRoomId());
const me = this.room?.getMember(MatrixClientPeg.safeGet().getSafeUserId());
const powerLevels = this.room?.currentState.getStateEvents("m.room.power_levels", "");
const senderId = this.props.event.getSender()!;
let kickLevel = powerLevels ? powerLevels.getContent().kick : 50;
if (typeof kickLevel !== "number") kickLevel = 50;
const sender = this.room?.getMember(senderId);
this.state = {
stateKey: this.props.event.getStateKey()!,
roomId: this.props.event.getRoomId()!,
displayName: this.props.event.getContent().display_name,
invited: true,
canKick: me ? me.powerLevel > kickLevel : false,
senderName: sender?.name ?? senderId,
};
}
public componentDidMount(): void {
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents);
}
public componentWillUnmount(): void {
const client = MatrixClientPeg.get();
if (client) {
client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
}
public onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() === EventType.RoomThirdPartyInvite && ev.getStateKey() === this.state.stateKey) {
const newDisplayName = ev.getContent().display_name;
const isInvited = isValid3pidInvite(ev);
const newState = { invited: isInvited } as IState;
if (newDisplayName) newState["displayName"] = newDisplayName;
this.setState(newState);
}
};
public onCancel = (): void => {
dis.dispatch({
action: Action.View3pidInvite,
event: null,
});
};
public onKickClick = (): void => {
MatrixClientPeg.safeGet()
.sendStateEvent(this.state.roomId, EventType.RoomThirdPartyInvite, {}, this.state.stateKey)
.catch((err) => {
logger.error(err);
// Revert echo because of error
this.setState({ invited: true });
Modal.createDialog(ErrorDialog, {
title: _t("user_info|error_revoke_3pid_invite_title"),
description: _t("user_info|error_revoke_3pid_invite_description"),
});
});
// Local echo
this.setState({ invited: false });
};
public render(): React.ReactNode {
let adminTools: JSX.Element | undefined;
if (this.state.canKick && this.state.invited) {
adminTools = (
<Flex direction="column" as="section" justify="start" gap="var(--cpd-space-2x)">
<Text as="span" role="heading" size="lg" weight="semibold">
{_t("user_info|admin_tools_section")}
</Text>
<Button size="sm" kind="destructive" className="mx_MemberInfo_field" onClick={this.onKickClick}>
{_t("user_info|revoke_invite")}
</Button>
</Flex>
);
}
return (
<BaseCard onClose={this.props.onClose} header={_t("common|profile")}>
<Flex className="mx_ThirdPartyMemberInfo" direction="column" gap="var(--cpd-space-4x)">
<Flex direction="column" as="section" justify="start" gap="var(--cpd-space-2x)">
{/* same as userinfo name style */}
<Text as="span" role="heading" size="lg" weight="semibold">
{this.state.displayName}
</Text>
<Text as="span">{_t("user_info|invited_by", { sender: this.state.senderName })}</Text>
</Flex>
{adminTools}
</Flex>
</BaseCard>
);
}
}

View file

@ -0,0 +1,130 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useState } from "react";
import { Thread, ThreadEvent, IContent, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { IndicatorIcon } from "@vector-im/compound-web";
import ThreadIconSolid from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
import { _t } from "../../../languageHandler";
import { CardContext } from "../right_panel/context";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import PosthogTrackers from "../../../PosthogTrackers";
import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import RoomContext from "../../../contexts/RoomContext";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import MemberAvatar from "../avatars/MemberAvatar";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
import { notificationLevelToIndicator } from "../../../utils/notifications";
interface IProps {
mxEvent: MatrixEvent;
thread: Thread;
}
const ThreadSummary: React.FC<IProps> = ({ mxEvent, thread, ...props }) => {
const roomContext = useContext(RoomContext);
const cardContext = useContext(CardContext);
const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length);
const { level } = useUnreadNotifications(thread.room, thread.id);
if (!count) return null; // We don't want to show a thread summary if the thread doesn't have replies yet
let countSection: string | number = count;
if (!roomContext.narrow) {
countSection = _t("threads|count_of_reply", { count });
}
return (
<AccessibleButton
{...props}
className="mx_ThreadSummary"
onClick={(ev: ButtonEvent) => {
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: mxEvent,
push: cardContext.isCard,
});
PosthogTrackers.trackInteraction("WebRoomTimelineThreadSummaryButton", ev);
}}
aria-label={_t("threads|open_thread")}
>
<IndicatorIcon size="24px" indicator={notificationLevelToIndicator(level)}>
<ThreadIconSolid />
</IndicatorIcon>
<span className="mx_ThreadSummary_replies_amount">{countSection}</span>
<ThreadMessagePreview thread={thread} showDisplayname={!roomContext.narrow} />
<div className="mx_ThreadSummary_chevron" />
</AccessibleButton>
);
};
interface IPreviewProps {
thread: Thread;
showDisplayname?: boolean;
}
export const ThreadMessagePreview: React.FC<IPreviewProps> = ({ thread, showDisplayname = false }) => {
const cli = useContext(MatrixClientContext);
const lastReply = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.replyToEvent) ?? undefined;
// track the content as a means to regenerate the thread message preview upon edits & decryption
const [content, setContent] = useState<IContent | undefined>(lastReply?.getContent());
useTypedEventEmitter(lastReply, MatrixEventEvent.Replaced, () => {
setContent(lastReply!.getContent());
});
const awaitDecryption = lastReply?.shouldAttemptDecryption() || lastReply?.isBeingDecrypted();
useTypedEventEmitter(awaitDecryption ? lastReply : undefined, MatrixEventEvent.Decrypted, () => {
setContent(lastReply!.getContent());
});
const preview = useAsyncMemo(async (): Promise<string | undefined> => {
if (!lastReply) return;
await cli.decryptEventIfNeeded(lastReply);
return MessagePreviewStore.instance.generatePreviewForEvent(lastReply);
}, [lastReply, content]);
if (!preview || !lastReply) {
return null;
}
return (
<>
<MemberAvatar
member={lastReply.sender}
fallbackUserId={lastReply.getSender()}
size="24px"
className="mx_ThreadSummary_avatar"
/>
{showDisplayname && (
<div className="mx_ThreadSummary_sender">{lastReply.sender?.name ?? lastReply.getSender()}</div>
)}
{lastReply.isDecryptionFailure() ? (
<div
className="mx_ThreadSummary_content mx_DecryptionFailureBody"
title={_t("timeline|decryption_failure|unable_to_decrypt")}
>
<span className="mx_ThreadSummary_message-preview">
{_t("timeline|decryption_failure|unable_to_decrypt")}
</span>
</div>
) : (
<div className="mx_ThreadSummary_content" title={preview}>
<span className="mx_ThreadSummary_message-preview">{preview}</span>
</div>
)}
</>
);
};
export default ThreadSummary;

View file

@ -0,0 +1,36 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
interface IProps {
onScrollUpClick: (e: ButtonEvent) => void;
onCloseClick: (e: ButtonEvent) => void;
}
export default class TopUnreadMessagesBar extends React.PureComponent<IProps> {
public render(): React.ReactNode {
return (
<div className="mx_TopUnreadMessagesBar">
<AccessibleButton
className="mx_TopUnreadMessagesBar_scrollUp"
title={_t("room|jump_read_marker")}
onClick={this.props.onScrollUpClick}
/>
<AccessibleButton
className="mx_TopUnreadMessagesBar_markAsRead"
title={_t("notifications|mark_all_read")}
onClick={this.props.onCloseClick}
/>
</div>
);
}
}

View file

@ -0,0 +1,317 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import { Room, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Optional } from "matrix-events-sdk";
import { _t } from "../../../languageHandler";
import { RecordingState } from "../../../audio/VoiceRecording";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import RecordingPlayback, { PlaybackLayout } from "../audio_messages/RecordingPlayback";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
import NotificationBadge from "./NotificationBadge";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import InlineSpinner from "../elements/InlineSpinner";
import { PlaybackManager } from "../../../audio/PlaybackManager";
import { doMaybeLocalRoomAction } from "../../../utils/local-room";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { attachMentions, attachRelation } from "./SendMessageComposer";
import { addReplyToMessageContent } from "../../../utils/Reply";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import RoomContext from "../../../contexts/RoomContext";
import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
import { createVoiceMessageContent } from "../../../utils/createVoiceMessageContent";
import AccessibleButton from "../elements/AccessibleButton";
interface IProps {
room: Room;
permalinkCreator?: RoomPermalinkCreator;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
}
interface IState {
recorder?: VoiceMessageRecording;
recordingPhase?: RecordingState;
didUploadFail?: boolean;
}
/**
* Container tile for rendering the voice message recorder in the composer.
*/
export default class VoiceRecordComposerTile extends React.PureComponent<IProps, IState> {
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
private voiceRecordingId: string;
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {};
this.voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
}
public componentDidMount(): void {
const recorder = VoiceRecordingStore.instance.getActiveRecording(this.voiceRecordingId);
if (recorder) {
if (recorder.isRecording || !recorder.hasRecording) {
logger.warn("Cached recording hasn't ended yet and might cause issues");
}
this.bindNewRecorder(recorder);
this.setState({ recorder, recordingPhase: RecordingState.Ended });
}
}
public async componentWillUnmount(): Promise<void> {
// Stop recording, but keep the recording memory (don't dispose it). This is to let the user
// come back and finish working with it.
const recording = VoiceRecordingStore.instance.getActiveRecording(this.voiceRecordingId);
await recording?.stop();
// Clean up our listeners by binding a falsy recorder
this.bindNewRecorder(null);
}
// called by composer
public async send(): Promise<void> {
if (!this.state.recorder) {
throw new Error("No recording started - cannot send anything");
}
const { replyToEvent, relation, permalinkCreator } = this.props;
await this.state.recorder.stop();
let upload: IUpload;
try {
upload = await this.state.recorder.upload(this.voiceRecordingId);
} catch (e) {
logger.error("Error uploading voice message:", e);
// Flag error and move on. The recording phase will be reset by the upload function.
this.setState({ didUploadFail: true });
return; // don't dispose the recording: the user has a chance to re-upload
}
try {
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
const content = createVoiceMessageContent(
upload.mxc,
this.state.recorder.contentType,
Math.round(this.state.recorder.durationSeconds * 1000),
this.state.recorder.contentLength,
upload.encrypted,
this.state.recorder.getPlayback().thumbnailWaveform.map((v) => Math.round(v * 1024)),
);
// Attach mentions, which really only applies if there's a replyToEvent.
attachMentions(MatrixClientPeg.safeGet().getSafeUserId(), content, null, replyToEvent);
attachRelation(content, relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
includeLegacyFallback: true,
});
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
defaultDispatcher.dispatch({
action: "reply_to_event",
event: null,
context: this.context.timelineRenderingType,
});
}
doMaybeLocalRoomAction(
this.props.room.roomId,
(actualRoomId: string) => MatrixClientPeg.safeGet().sendMessage(actualRoomId, content),
this.props.room.client,
);
} catch (e) {
logger.error("Error sending voice message:", e);
// Voice message should be in the timeline at this point, so let other things take care
// of error handling. We also shouldn't need the recording anymore, so fall through to
// disposal.
}
await this.disposeRecording();
}
private async disposeRecording(): Promise<void> {
await VoiceRecordingStore.instance.disposeRecording(this.voiceRecordingId);
// Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({ recorder: undefined, recordingPhase: undefined, didUploadFail: false });
}
private onCancel = async (): Promise<void> => {
await this.disposeRecording();
};
public onRecordStartEndClick = async (): Promise<void> => {
if (this.state.recorder) {
await this.state.recorder.stop();
return;
}
// The "microphone access error" dialogs are used a lot, so let's functionify them
const accessError = (): void => {
Modal.createDialog(ErrorDialog, {
title: _t("voip|unable_to_access_audio_input_title"),
description: (
<>
<p>{_t("voip|unable_to_access_audio_input_description")}</p>
</>
),
});
};
// Do a sanity test to ensure we're about to grab a valid microphone reference. Things might
// change between this and recording, but at least we will have tried.
try {
const devices = await MediaDeviceHandler.getDevices();
if (!devices?.[MediaDeviceKindEnum.AudioInput]?.length) {
Modal.createDialog(ErrorDialog, {
title: _t("voip|no_audio_input_title"),
description: (
<>
<p>{_t("voip|no_audio_input_description")}</p>
</>
),
});
return;
}
// else we probably have a device that is good enough
} catch (e) {
logger.error("Error getting devices: ", e);
accessError();
return;
}
try {
// stop any noises which might be happening
PlaybackManager.instance.pauseAllExcept();
const recorder = VoiceRecordingStore.instance.startRecording(this.voiceRecordingId);
await recorder.start();
this.bindNewRecorder(recorder);
this.setState({ recorder, recordingPhase: RecordingState.Started });
} catch (e) {
logger.error("Error starting recording: ", e);
accessError();
// noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack
VoiceRecordingStore.instance.disposeRecording(this.voiceRecordingId);
}
};
private bindNewRecorder(recorder: Optional<VoiceMessageRecording>): void {
if (this.state.recorder) {
this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate);
}
if (recorder) {
recorder.on(UPDATE_EVENT, this.onRecordingUpdate);
}
}
private onRecordingUpdate = (ev: RecordingState): void => {
if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here
this.setState({ recordingPhase: ev });
};
private renderWaveformArea(): ReactNode {
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
if (this.state.recordingPhase !== RecordingState.Started) {
return <RecordingPlayback playback={this.state.recorder.getPlayback()} layout={PlaybackLayout.Composer} />;
}
// only other UI is the recording-in-progress UI
return (
<div className="mx_MediaBody mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
<LiveRecordingClock recorder={this.state.recorder} />
<LiveRecordingWaveform recorder={this.state.recorder} />
</div>
);
}
public render(): ReactNode {
if (!this.state.recordingPhase) return null;
let stopBtn;
let deleteButton;
if (this.state.recordingPhase === RecordingState.Started) {
let tooltip = _t("composer|send_voice_message");
if (!!this.state.recorder) {
tooltip = _t("composer|stop_voice_message");
}
stopBtn = (
<AccessibleButton
className="mx_VoiceRecordComposerTile_stop"
onClick={this.onRecordStartEndClick}
title={tooltip}
/>
);
if (this.state.recorder && !this.state.recorder?.isRecording) {
stopBtn = null;
}
}
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
deleteButton = (
<AccessibleButton
className="mx_VoiceRecordComposerTile_delete"
title={_t("action|delete")}
onClick={this.onCancel}
/>
);
}
let uploadIndicator;
if (this.state.recordingPhase === RecordingState.Uploading) {
uploadIndicator = (
<span className="mx_VoiceRecordComposerTile_uploadingState">
<InlineSpinner w={16} h={16} />
</span>
);
} else if (this.state.didUploadFail && this.state.recordingPhase === RecordingState.Ended) {
uploadIndicator = (
<span className="mx_VoiceRecordComposerTile_failedState">
<span className="mx_VoiceRecordComposerTile_uploadState_badge">
{/* Need to stick the badge in a span to ensure it doesn't create a block component */}
<NotificationBadge
notification={StaticNotificationState.forSymbol("!", NotificationLevel.Highlight)}
/>
</span>
<span className="text-warning">{_t("timeline|send_state_failed")}</span>
</span>
);
}
return (
<>
{uploadIndicator}
{deleteButton}
{stopBtn}
{this.renderWaveformArea()}
</>
);
}
}

View file

@ -0,0 +1,218 @@
/*
Copyright 2017-2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import * as WhoIsTyping from "../../../WhoIsTyping";
import Timer from "../../../utils/Timer";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MemberAvatar from "../avatars/MemberAvatar";
interface IProps {
// the room this statusbar is representing.
room: Room;
onShown?: () => void;
onHidden?: () => void;
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: number;
}
interface IState {
usersTyping: RoomMember[];
// a map with userid => Timer to delay
// hiding the "x is typing" message for a
// user so hiding it can coincide
// with the sent message by the other side
// resulting in less timeline jumpiness
delayedStopTypingTimers: Record<string, Timer>;
}
export default class WhoIsTypingTile extends React.Component<IProps, IState> {
public static defaultProps = {
whoIsTypingLimit: 3,
};
public state: IState = {
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
delayedStopTypingTimers: {},
};
public componentDidMount(): void {
MatrixClientPeg.safeGet().on(RoomMemberEvent.Typing, this.onRoomMemberTyping);
MatrixClientPeg.safeGet().on(RoomEvent.Timeline, this.onRoomTimeline);
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
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) {
this.props.onHidden();
}
}
public componentWillUnmount(): void {
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
const client = MatrixClientPeg.get();
if (client) {
client.removeListener(RoomMemberEvent.Typing, this.onRoomMemberTyping);
client.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
}
Object.values(this.state.delayedStopTypingTimers).forEach((t) => (t as Timer).abort());
}
private static isVisible(state: IState): boolean {
return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0;
}
public isVisible = (): boolean => {
return WhoIsTypingTile.isVisible(this.state);
};
private onRoomTimeline = (event: MatrixEvent, room?: Room): void => {
if (room?.roomId === this.props.room.roomId) {
const userId = event.getSender()!;
// remove user from usersTyping
const usersTyping = this.state.usersTyping.filter((m) => m.userId !== userId);
if (usersTyping.length !== this.state.usersTyping.length) {
this.setState({ usersTyping });
}
// abort timer if any
this.abortUserTimer(userId);
}
};
private onRoomMemberTyping = (): void => {
const usersTyping = WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room);
this.setState({
delayedStopTypingTimers: this.updateDelayedStopTypingTimers(usersTyping),
usersTyping,
});
};
private updateDelayedStopTypingTimers(usersTyping: RoomMember[]): Record<string, Timer> {
const usersThatStoppedTyping = this.state.usersTyping.filter((a) => {
return !usersTyping.some((b) => a.userId === b.userId);
});
const usersThatStartedTyping = usersTyping.filter((a) => {
return !this.state.usersTyping.some((b) => a.userId === b.userId);
});
// abort all the timers for the users that started typing again
usersThatStartedTyping.forEach((m) => {
const timer = this.state.delayedStopTypingTimers[m.userId];
if (timer) {
timer.abort();
}
});
// prepare new delayedStopTypingTimers object to update state with
let delayedStopTypingTimers = Object.assign({}, this.state.delayedStopTypingTimers);
// remove members that started typing again
delayedStopTypingTimers = usersThatStartedTyping.reduce((delayedStopTypingTimers, m) => {
delete delayedStopTypingTimers[m.userId];
return delayedStopTypingTimers;
}, delayedStopTypingTimers);
// start timer for members that stopped typing
delayedStopTypingTimers = usersThatStoppedTyping.reduce((delayedStopTypingTimers, m) => {
if (!delayedStopTypingTimers[m.userId]) {
const timer = new Timer(5000);
delayedStopTypingTimers[m.userId] = timer;
timer.start();
timer.finished().then(
() => this.removeUserTimer(m.userId), // on elapsed
() => {
/* aborted */
},
);
}
return delayedStopTypingTimers;
}, delayedStopTypingTimers);
return delayedStopTypingTimers;
}
private abortUserTimer(userId: string): void {
const timer = this.state.delayedStopTypingTimers[userId];
if (timer) {
timer.abort();
this.removeUserTimer(userId);
}
}
private removeUserTimer(userId: string): void {
const timer = this.state.delayedStopTypingTimers[userId];
if (timer) {
const delayedStopTypingTimers = Object.assign({}, this.state.delayedStopTypingTimers);
delete delayedStopTypingTimers[userId];
this.setState({ delayedStopTypingTimers });
}
}
private renderTypingIndicatorAvatars(users: RoomMember[], limit: number): JSX.Element[] {
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
users = users.slice(0, limit - 1);
}
const avatars = users.map((u) => {
return (
<MemberAvatar
key={u.userId}
member={u}
size="24px"
resizeMethod="crop"
viewUserOnClick={true}
aria-live="off"
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_WhoIsTypingTile_remainingAvatarPlaceholder" key="others">
+{othersCount}
</span>,
);
}
return avatars;
}
public render(): React.ReactNode {
const usersTyping = [...this.state.usersTyping];
// append the users that have been reported not typing anymore
// but have a timeout timer running so they can disappear
// when a message comes in
for (const userId in this.state.delayedStopTypingTimers) {
const member = this.props.room.getMember(userId);
if (member) usersTyping.push(member);
}
// sort them so the typing members don't change order when
// moved to delayedStopTypingTimers
const collator = new Intl.Collator();
usersTyping.sort((a, b) => collator.compare(a.name, b.name));
const typingString = WhoIsTyping.whoIsTypingString(usersTyping, this.props.whoIsTypingLimit);
if (!typingString) {
return null;
}
return (
<li className="mx_WhoIsTypingTile" aria-atomic="true">
<div className="mx_WhoIsTypingTile_avatars">
{this.renderTypingIndicatorAvatars(usersTyping, this.props.whoIsTypingLimit)}
</div>
<div className="mx_WhoIsTypingTile_label">{typingString}</div>
</li>
);
}
}

View file

@ -0,0 +1,33 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { createContext, useContext } from "react";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { SubSelection } from "./types";
import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
export function getDefaultContextValue(defaultValue?: Partial<ComposerContextState>): { selection: SubSelection } {
return {
selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, isForward: true },
...defaultValue,
};
}
export interface ComposerContextState {
selection: SubSelection;
editorStateTransfer?: EditorStateTransfer;
eventRelation?: IEventRelation;
}
export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue());
ComposerContext.displayName = "ComposerContext";
export function useComposerContext(): ComposerContextState {
return useContext(ComposerContext);
}

View file

@ -0,0 +1,77 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps, lazy, Suspense } from "react";
import { ISendEventResponse } from "matrix-js-sdk/src/matrix";
// we need to import the types for TS, but do not import the sendMessage
// function to avoid importing from "@vector-im/matrix-wysiwyg"
import { SendMessageParams } from "./utils/message";
import { retry } from "../../../../utils/promise";
// Due to issues such as https://github.com/vector-im/element-web/issues/25277, we add retry
// attempts to all of the dynamic imports in this file
const RETRY_COUNT = 3;
const SendComposer = lazy(() => retry(() => import("./SendWysiwygComposer"), RETRY_COUNT));
const EditComposer = lazy(() => retry(() => import("./EditWysiwygComposer"), RETRY_COUNT));
export const dynamicImportSendMessage = async (
message: string,
isHTML: boolean,
params: SendMessageParams,
): Promise<ISendEventResponse | undefined> => {
const { sendMessage } = await retry(() => import("./utils/message"), RETRY_COUNT);
return sendMessage(message, isHTML, params);
};
export const dynamicImportConversionFunctions = async (): Promise<{
/**
* Creates a rust model from rich text input (html) and uses it to generate the plain text equivalent (which may
* contain markdown). The return value must be used to set `.innerHTML` (rather than `.innerText`) to
* ensure that HTML entities are correctly interpreted, and to prevent newline characters being turned into `<br>`.
*
* @param rich - html to convert
* @param inMessageFormat - `true` to format the return value for use as a message `formatted_body`.
* `false` to format it for writing to an editor element.
* @returns a string of plain text that may contain markdown
*/
richToPlain(rich: string, inMessageFormat: boolean): Promise<string>;
/**
* Creates a rust model from plain text input (interpreted as markdown) and uses it to generate the rich text
* equivalent. Output can be formatted for display in the composer or for sending in a Matrix message.
*
* @param plain - plain text to convert. Note: when reading the plain text from the editor element, be sure to
* use `.innerHTML` (rather than `.innerText`) to ensure that punctuation characters are correctly HTML-encoded.
* @param inMessageFormat - `true` to format the return value for use as a message `formatted_body`.
* `false` to format it for writing to an editor element.
* @returns a string of html
*/
plainToRich(plain: string, inMessageFormat: boolean): Promise<string>;
}> => {
const { richToPlain, plainToRich } = await retry(() => import("@vector-im/matrix-wysiwyg"), RETRY_COUNT);
return { richToPlain, plainToRich };
};
export function DynamicImportSendWysiwygComposer(props: ComponentProps<typeof SendComposer>): JSX.Element {
return (
<Suspense fallback={<div />}>
<SendComposer {...props} />
</Suspense>
);
}
export function DynamicImportEditWysiwygComposer(props: ComponentProps<typeof EditComposer>): JSX.Element {
return (
<Suspense fallback={<div />}>
<EditComposer {...props} />
</Suspense>
);
}

View file

@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
import classNames from "classnames";
import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
import { WysiwygComposer } from "./components/WysiwygComposer";
import { EditionButtons } from "./components/EditionButtons";
import { useWysiwygEditActionHandler } from "./hooks/useWysiwygEditActionHandler";
import { useEditing } from "./hooks/useEditing";
import { useInitialContent } from "./hooks/useInitialContent";
import { ComposerContext, getDefaultContextValue } from "./ComposerContext";
import { ComposerFunctions } from "./types";
interface ContentProps {
disabled?: boolean;
composerFunctions: ComposerFunctions;
}
const Content = forwardRef<HTMLElement, ContentProps>(function Content(
{ disabled = false, composerFunctions }: ContentProps,
forwardRef: ForwardedRef<HTMLElement>,
) {
useWysiwygEditActionHandler(disabled, forwardRef as MutableRefObject<HTMLElement>, composerFunctions);
return null;
});
interface EditWysiwygComposerProps {
disabled?: boolean;
onChange?: (content: string) => void;
editorStateTransfer: EditorStateTransfer;
className?: string;
}
// Default needed for React.lazy
export default function EditWysiwygComposer({
editorStateTransfer,
className,
...props
}: EditWysiwygComposerProps): JSX.Element {
const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer }));
const initialContent = useInitialContent(editorStateTransfer);
const isReady = !editorStateTransfer || initialContent !== undefined;
const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(editorStateTransfer, initialContent);
if (!isReady) {
return <></>;
}
return (
<ComposerContext.Provider value={defaultContextValue.current}>
<WysiwygComposer
className={classNames("mx_EditWysiwygComposer", className)}
initialContent={initialContent}
onChange={onChange}
onSend={editMessage}
{...props}
>
{(ref, composerFunctions) => (
<>
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
<EditionButtons
onCancelClick={endEditing}
onSaveClick={editMessage}
isSaveDisabled={isSaveDisabled}
/>
</>
)}
</WysiwygComposer>
</ComposerContext.Provider>
);
}

View file

@ -0,0 +1,71 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler";
import { WysiwygComposer } from "./components/WysiwygComposer";
import { PlainTextComposer } from "./components/PlainTextComposer";
import { ComposerFunctions } from "./types";
import { E2EStatus } from "../../../../utils/ShieldUtils";
import E2EIcon from "../E2EIcon";
import { MenuProps } from "../../../structures/ContextMenu";
import { Emoji } from "./components/Emoji";
import { ComposerContext, getDefaultContextValue } from "./ComposerContext";
interface ContentProps {
disabled?: boolean;
composerFunctions: ComposerFunctions;
}
const Content = forwardRef<HTMLElement, ContentProps>(function Content(
{ disabled = false, composerFunctions }: ContentProps,
forwardRef: ForwardedRef<HTMLElement>,
) {
useWysiwygSendActionHandler(disabled, forwardRef as MutableRefObject<HTMLElement>, composerFunctions);
return null;
});
export interface SendWysiwygComposerProps {
initialContent?: string;
isRichTextEnabled: boolean;
placeholder?: string;
disabled?: boolean;
e2eStatus?: E2EStatus;
onChange: (content: string) => void;
onSend: () => void;
menuPosition: MenuProps;
eventRelation?: IEventRelation;
}
// Default needed for React.lazy
export default function SendWysiwygComposer({
isRichTextEnabled,
e2eStatus,
menuPosition,
...props
}: SendWysiwygComposerProps): JSX.Element {
const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
const defaultContextValue = useRef(getDefaultContextValue({ eventRelation: props.eventRelation }));
return (
<ComposerContext.Provider value={defaultContextValue.current}>
<Composer
className="mx_SendWysiwygComposer"
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
rightComponent={<Emoji menuPosition={menuPosition} />}
{...props}
>
{(ref, composerFunctions) => (
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
)}
</Composer>
</ComposerContext.Provider>
);
}

View file

@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
interface EditionButtonsProps {
onCancelClick: (e: ButtonEvent) => void;
onSaveClick: (e: ButtonEvent) => void;
isSaveDisabled?: boolean;
}
export function EditionButtons({
onCancelClick,
onSaveClick,
isSaveDisabled = false,
}: EditionButtonsProps): JSX.Element {
return (
<div className="mx_EditWysiwygComposer_buttons">
<AccessibleButton kind="secondary" onClick={onCancelClick}>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={onSaveClick} disabled={isSaveDisabled}>
{_t("action|save")}
</AccessibleButton>
</div>
);
}

View file

@ -0,0 +1,62 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { CSSProperties, forwardRef, memo, MutableRefObject, ReactNode } from "react";
import { useIsExpanded } from "../hooks/useIsExpanded";
import { useSelection } from "../hooks/useSelection";
const HEIGHT_BREAKING_POINT = 24;
interface EditorProps {
disabled: boolean;
placeholder?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
}
export const Editor = memo(
forwardRef<HTMLDivElement, EditorProps>(function Editor(
{ disabled, placeholder, leftComponent, rightComponent }: EditorProps,
ref,
) {
const isExpanded = useIsExpanded(ref as MutableRefObject<HTMLDivElement | null>, HEIGHT_BREAKING_POINT);
const { onFocus, onBlur, onInput } = useSelection();
return (
<div
data-testid="WysiwygComposerEditor"
className="mx_WysiwygComposer_Editor"
data-is-expanded={isExpanded}
>
{leftComponent}
<div className="mx_WysiwygComposer_Editor_container">
<div
className={classNames("mx_WysiwygComposer_Editor_content", {
mx_WysiwygComposer_Editor_content_placeholder: Boolean(placeholder),
})}
style={{ "--placeholder": `"${placeholder}"` } as CSSProperties}
ref={ref}
contentEditable={!disabled}
role="textbox"
aria-multiline="true"
aria-autocomplete="list"
aria-haspopup="listbox"
dir="auto"
aria-disabled={disabled}
onFocus={onFocus}
onBlur={onBlur}
onInput={onInput}
/>
</div>
{rightComponent}
</div>
);
}),
);

View file

@ -0,0 +1,38 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MenuProps } from "../../../../structures/ContextMenu";
import { EmojiButton } from "../../EmojiButton";
import dis from "../../../../../dispatcher/dispatcher";
import { ComposerInsertPayload } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../../../dispatcher/actions";
import { useRoomContext } from "../../../../../contexts/RoomContext";
interface EmojiProps {
menuPosition: MenuProps;
}
export function Emoji({ menuPosition }: EmojiProps): JSX.Element {
const roomContext = useRoomContext();
return (
<EmojiButton
menuPosition={menuPosition}
addEmoji={(emoji) => {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
text: emoji,
timelineRenderingType: roomContext.timelineRenderingType,
});
return true;
}}
/>
);
}

View file

@ -0,0 +1,156 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { MouseEventHandler, ReactNode } from "react";
import { FormattingFunctions, AllActionStates, ActionState } from "@vector-im/matrix-wysiwyg";
import classNames from "classnames";
import BoldIcon from "@vector-im/compound-design-tokens/assets/web/icons/bold";
import BulletedListIcon from "@vector-im/compound-design-tokens/assets/web/icons/list-bulleted";
import CodeBlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/code";
import UnIndentIcon from "@vector-im/compound-design-tokens/assets/web/icons/indent-decrease";
import IndentIcon from "@vector-im/compound-design-tokens/assets/web/icons/indent-increase";
import InlineCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/inline-code";
import ItalicIcon from "@vector-im/compound-design-tokens/assets/web/icons/italic";
import NumberedListIcon from "@vector-im/compound-design-tokens/assets/web/icons/list-numbered";
import QuoteIcon from "@vector-im/compound-design-tokens/assets/web/icons/quote";
import StrikeThroughIcon from "@vector-im/compound-design-tokens/assets/web/icons/strikethrough";
import UnderlineIcon from "@vector-im/compound-design-tokens/assets/web/icons/underline";
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import { _t } from "../../../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
import { openLinkModal } from "./LinkModal";
import { useComposerContext } from "../ComposerContext";
import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
import { KeyCombo } from "../../../../../KeyBindingsManager";
interface ButtonProps {
icon: ReactNode;
actionState: ActionState;
onClick: MouseEventHandler<HTMLButtonElement>;
label: string;
keyCombo?: KeyCombo;
}
function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): JSX.Element {
return (
<AccessibleButton
element="button"
onClick={onClick as (e: ButtonEvent) => void}
aria-label={label}
className={classNames("mx_FormattingButtons_Button", {
mx_FormattingButtons_active: actionState === "reversed",
mx_FormattingButtons_Button_hover: actionState === "enabled",
mx_FormattingButtons_disabled: actionState === "disabled",
})}
title={actionState === "disabled" ? undefined : label}
caption={
keyCombo && (
<KeyboardShortcut value={keyCombo} className="mx_FormattingButtons_Tooltip_KeyboardShortcut" />
)
}
placement="top"
>
{icon}
</AccessibleButton>
);
}
interface FormattingButtonsProps {
composer: FormattingFunctions;
actionStates: AllActionStates;
}
export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps): JSX.Element {
const composerContext = useComposerContext();
const isInList = actionStates.unorderedList === "reversed" || actionStates.orderedList === "reversed";
return (
<div className="mx_FormattingButtons">
<Button
actionState={actionStates.bold}
label={_t("composer|format_bold")}
keyCombo={{ ctrlOrCmdKey: true, key: "b" }}
onClick={() => composer.bold()}
icon={<BoldIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.italic}
label={_t("composer|format_italic")}
keyCombo={{ ctrlOrCmdKey: true, key: "i" }}
onClick={() => composer.italic()}
icon={<ItalicIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.underline}
label={_t("composer|format_underline")}
keyCombo={{ ctrlOrCmdKey: true, key: "u" }}
onClick={() => composer.underline()}
icon={<UnderlineIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.strikeThrough}
label={_t("composer|format_strikethrough")}
onClick={() => composer.strikeThrough()}
icon={<StrikeThroughIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.unorderedList}
label={_t("composer|format_unordered_list")}
onClick={() => composer.unorderedList()}
icon={<BulletedListIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.orderedList}
label={_t("composer|format_ordered_list")}
onClick={() => composer.orderedList()}
icon={<NumberedListIcon className="mx_FormattingButtons_Icon" />}
/>
{isInList && (
<Button
actionState={actionStates.indent}
label={_t("composer|format_increase_indent")}
onClick={() => composer.indent()}
icon={<IndentIcon className="mx_FormattingButtons_Icon" />}
/>
)}
{isInList && (
<Button
actionState={actionStates.unindent}
label={_t("composer|format_decrease_indent")}
onClick={() => composer.unindent()}
icon={<UnIndentIcon className="mx_FormattingButtons_Icon" />}
/>
)}
<Button
actionState={actionStates.quote}
label={_t("action|quote")}
onClick={() => composer.quote()}
icon={<QuoteIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.inlineCode}
label={_t("composer|format_inline_code")}
keyCombo={{ ctrlOrCmdKey: true, key: "e" }}
onClick={() => composer.inlineCode()}
icon={<InlineCodeIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.codeBlock}
label={_t("composer|format_code_block")}
onClick={() => composer.codeBlock()}
icon={<CodeBlockIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.link}
label={_t("composer|format_link")}
onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
/>
</div>
);
}

View file

@ -0,0 +1,136 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { FormattingFunctions } from "@vector-im/matrix-wysiwyg";
import React, { ChangeEvent, useState } from "react";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import Field from "../../../elements/Field";
import { ComposerContextState } from "../ComposerContext";
import { isSelectionEmpty, setSelection } from "../utils/selection";
import BaseDialog from "../../../dialogs/BaseDialog";
import DialogButtons from "../../../elements/DialogButtons";
export function openLinkModal(
composer: FormattingFunctions,
composerContext: ComposerContextState,
isEditing: boolean,
): void {
Modal.createDialog(
LinkModal,
{
composerContext,
composer,
isTextEnabled: isSelectionEmpty(),
isEditing,
},
"mx_CompoundDialog",
false,
true,
);
}
function isEmpty(text: string): boolean {
return text.length < 1;
}
interface LinkModalProps {
composer: FormattingFunctions;
isTextEnabled: boolean;
onFinished: () => void;
composerContext: ComposerContextState;
isEditing: boolean;
}
export const LinkModal: React.FC<LinkModalProps> = ({
composer,
isTextEnabled,
onFinished,
composerContext,
isEditing,
}) => {
const [hasLinkChanged, setHasLinkChanged] = useState(false);
const [fields, setFields] = useState({ text: "", link: isEditing ? composer.getLink() : "" });
const hasText = !isEditing && isTextEnabled;
const isSaveDisabled = !hasLinkChanged || (hasText && isEmpty(fields.text)) || isEmpty(fields.link);
return (
<BaseDialog
className="mx_LinkModal"
title={isEditing ? _t("composer|link_modal|title_edit") : _t("composer|link_modal|title_create")}
hasCancel={true}
onFinished={onFinished}
>
<form
className="mx_LinkModal_content"
onSubmit={async (evt) => {
evt.preventDefault();
evt.stopPropagation();
onFinished();
// When submitting is done when pressing enter when the link field has the focus,
// The link field is getting back the focus (due to react-focus-lock)
// So we are waiting that the focus stuff is done to play with the composer selection
await new Promise((resolve) => setTimeout(resolve, 0));
await setSelection(composerContext.selection);
composer.link(fields.link, isTextEnabled ? fields.text : undefined);
}}
>
{hasText && (
<Field
required={true}
autoFocus={true}
label={_t("composer|link_modal|text_field_label")}
value={fields.text}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFields((fields) => ({ ...fields, text: e.target.value }))
}
/>
)}
<Field
required={true}
autoFocus={!hasText}
label={_t("composer|link_modal|link_field_label")}
value={fields.link}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setFields((fields) => ({ ...fields, link: e.target.value }));
setHasLinkChanged(true);
}}
/>
<div className="mx_LinkModal_buttons">
{isEditing && (
<button
type="button"
className="danger"
onClick={() => {
composer.removeLinks();
onFinished();
}}
>
{_t("action|remove")}
</button>
)}
<DialogButtons
primaryButton={_t("action|save")}
primaryDisabled={isSaveDisabled}
primaryIsSubmit={true}
onCancel={onFinished}
/>
</div>
</form>
</BaseDialog>
);
};

View file

@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import React, { MutableRefObject, ReactNode } from "react";
import { useComposerFunctions } from "../hooks/useComposerFunctions";
import { useIsFocused } from "../hooks/useIsFocused";
import { usePlainTextInitialization } from "../hooks/usePlainTextInitialization";
import { usePlainTextListeners } from "../hooks/usePlainTextListeners";
import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
import { ComposerFunctions } from "../types";
import { Editor } from "./Editor";
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
import { useSettingValue } from "../../../../../hooks/useSettings";
interface PlainTextComposerProps {
disabled?: boolean;
onChange?: (content: string) => void;
onSend?: () => void;
placeholder?: string;
initialContent?: string;
className?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
children?: (ref: MutableRefObject<HTMLDivElement | null>, composerFunctions: ComposerFunctions) => ReactNode;
eventRelation?: IEventRelation;
}
export function PlainTextComposer({
className,
disabled = false,
onSend,
onChange,
children,
placeholder,
initialContent,
leftComponent,
rightComponent,
eventRelation,
}: PlainTextComposerProps): JSX.Element {
const isAutoReplaceEmojiEnabled = useSettingValue<boolean>("MessageComposerInput.autoReplaceEmoji");
const {
ref: editorRef,
autocompleteRef,
onBeforeInput,
onInput,
onPaste,
onKeyDown,
content,
setContent,
suggestion,
onSelect,
handleCommand,
handleMention,
handleAtRoomMention,
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
const composerFunctions = useComposerFunctions(editorRef, setContent);
usePlainTextInitialization(initialContent, editorRef);
useSetCursorPosition(disabled, editorRef);
const { isFocused, onFocus } = useIsFocused();
const computedPlaceholder = (!content && placeholder) || undefined;
return (
<div
data-testid="PlainTextComposer"
className={classNames(className, { [`${className}-focused`]: isFocused })}
onFocus={onFocus}
onBlur={onFocus}
onBeforeInput={onBeforeInput}
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onSelect={onSelect}
>
<WysiwygAutocomplete
ref={autocompleteRef}
suggestion={suggestion}
handleMention={handleMention}
handleCommand={handleCommand}
handleAtRoomMention={handleAtRoomMention}
/>
<Editor
ref={editorRef}
disabled={disabled}
leftComponent={leftComponent}
rightComponent={rightComponent}
placeholder={computedPlaceholder}
/>
{children?.(editorRef, composerFunctions)}
</div>
);
}

View file

@ -0,0 +1,117 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ForwardedRef, forwardRef, FunctionComponent } from "react";
import { FormattingFunctions, MappedSuggestion } from "@vector-im/matrix-wysiwyg";
import { logger } from "matrix-js-sdk/src/logger";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import Autocomplete from "../../Autocomplete";
import { ICompletion } from "../../../../../autocomplete/Autocompleter";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { getMentionDisplayText, getMentionAttributes, buildQuery } from "../utils/autocomplete";
interface WysiwygAutocompleteProps {
/**
* The suggestion output from the rust model is used to build the query that is
* passed to the `<Autocomplete />` component
*/
suggestion: MappedSuggestion | null;
/**
* This handler will be called with the href and display text for a mention on clicking
* a mention in the autocomplete list or pressing enter on a selected item
*/
handleMention: FormattingFunctions["mention"];
/**
* This handler will be called with the display text for a command on clicking
* a command in the autocomplete list or pressing enter on a selected item
*/
handleCommand: FormattingFunctions["command"];
/**
* Handler purely for the at-room mentions special case
*/
handleAtRoomMention: FormattingFunctions["mentionAtRoom"];
}
/**
* Given the current suggestion from the rust model and a handler function, this component
* will display the legacy `<Autocomplete />` component (as used in the BasicMessageComposer)
* and call the handler function with the required arguments when a mention is selected
*
* @param props.ref - the ref will be attached to the rendered `<Autocomplete />` component
*/
const WysiwygAutocomplete = forwardRef(
(
{ suggestion, handleMention, handleCommand, handleAtRoomMention }: WysiwygAutocompleteProps,
ref: ForwardedRef<Autocomplete>,
): JSX.Element | null => {
const { room } = useRoomContext();
const client = useMatrixClientContext();
function handleConfirm(completion: ICompletion): void {
if (client === undefined || room === undefined) {
return;
}
switch (completion.type) {
case "command": {
// TODO determine if utils in SlashCommands.tsx are required.
// Trim the completion as some include trailing spaces, but we always insert a
// trailing space in the rust model anyway
handleCommand(completion.completion.trim());
return;
}
case "at-room": {
handleAtRoomMention(getMentionAttributes(completion, client, room));
return;
}
case "room":
case "user": {
if (typeof completion.href === "string") {
handleMention(
completion.href,
getMentionDisplayText(completion, client),
getMentionAttributes(completion, client, room),
);
}
return;
}
// TODO - handle "community" type
default:
return;
}
}
if (!room) return null;
const autoCompleteQuery = buildQuery(suggestion);
// debug for https://github.com/vector-im/element-web/issues/26037
logger.log(`## 26037 ## Rendering Autocomplete for WysiwygAutocomplete with query: "${autoCompleteQuery}"`);
// TODO - determine if we show all of the /command suggestions, there are some options in the
// list which don't seem to make sense in this context, specifically /html and /plain
return (
<div className="mx_WysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
<Autocomplete
ref={ref}
query={autoCompleteQuery}
onConfirm={handleConfirm}
selection={{ start: 0, end: 0 }}
room={room}
/>
</div>
);
},
);
(WysiwygAutocomplete as FunctionComponent).displayName = "WysiwygAutocomplete";
export { WysiwygAutocomplete };

View file

@ -0,0 +1,138 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { memo, MutableRefObject, ReactNode, useEffect, useMemo, useRef } from "react";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
import { useWysiwyg, FormattingFunctions } from "@vector-im/matrix-wysiwyg";
import classNames from "classnames";
import Autocomplete from "../../Autocomplete";
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
import { FormattingButtons } from "./FormattingButtons";
import { Editor } from "./Editor";
import { useInputEventProcessor } from "../hooks/useInputEventProcessor";
import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
import { useIsFocused } from "../hooks/useIsFocused";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
import { isNotNull } from "../../../../../Typeguards";
import { useSettingValue } from "../../../../../hooks/useSettings";
interface WysiwygComposerProps {
disabled?: boolean;
onChange: (content: string) => void;
onSend: () => void;
placeholder?: string;
initialContent?: string;
className?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
children?: (ref: MutableRefObject<HTMLDivElement | null>, wysiwyg: FormattingFunctions) => ReactNode;
eventRelation?: IEventRelation;
}
function getEmojiSuggestions(enabled: boolean): Map<string, string> {
const emojiSuggestions = new Map(Array.from(EMOTICON_TO_EMOJI, ([key, value]) => [key, value.unicode]));
return enabled ? emojiSuggestions : new Map();
}
export const WysiwygComposer = memo(function WysiwygComposer({
disabled = false,
onChange,
onSend,
placeholder,
initialContent,
className,
leftComponent,
rightComponent,
children,
eventRelation,
}: WysiwygComposerProps) {
const { room } = useRoomContext();
const autocompleteRef = useRef<Autocomplete | null>(null);
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);
const isAutoReplaceEmojiEnabled = useSettingValue<boolean>("MessageComposerInput.autoReplaceEmoji");
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);
const { ref, isWysiwygReady, content, actionStates, wysiwyg, suggestion, messageContent } = useWysiwyg({
initialContent,
inputEventProcessor,
emojiSuggestions,
});
const { isFocused, onFocus } = useIsFocused();
const isReady = isWysiwygReady && !disabled;
const computedPlaceholder = (!content && placeholder) || undefined;
useSetCursorPosition(!isReady, ref);
useEffect(() => {
if (!disabled && isNotNull(messageContent)) {
onChange(messageContent);
}
}, [onChange, messageContent, disabled]);
useEffect(() => {
function handleClick(e: Event): void {
e.preventDefault();
if (
e.target &&
e.target instanceof HTMLAnchorElement &&
e.target.getAttribute("data-mention-type") === "user"
) {
const parsedLink = parsePermalink(e.target.href);
if (room && parsedLink?.userId)
defaultDispatcher.dispatch({
action: Action.ViewUser,
member: room.getMember(parsedLink.userId),
});
}
}
const mentions: NodeList | undefined = ref.current?.querySelectorAll("a[data-mention-type]");
if (mentions) {
mentions.forEach((mention) => mention.addEventListener("click", handleClick));
}
return () => {
if (mentions) mentions.forEach((mention) => mention.removeEventListener("click", handleClick));
};
}, [ref, room, content]);
return (
<div
data-testid="WysiwygComposer"
className={classNames(className, { [`${className}-focused`]: isFocused })}
onFocus={onFocus}
onBlur={onFocus}
>
<WysiwygAutocomplete
ref={autocompleteRef}
suggestion={suggestion}
handleMention={wysiwyg.mention}
handleAtRoomMention={wysiwyg.mentionAtRoom}
handleCommand={wysiwyg.command}
/>
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
<Editor
ref={ref}
disabled={!isReady}
leftComponent={leftComponent}
rightComponent={rightComponent}
placeholder={computedPlaceholder}
/>
{children?.(ref, wysiwyg)}
</div>
);
});

View file

@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RefObject, useMemo } from "react";
import { setSelection } from "../utils/selection";
export function useComposerFunctions(
ref: RefObject<HTMLDivElement>,
setContent: (content: string) => void,
): {
clear(): void;
insertText(text: string): void;
} {
return useMemo(
() => ({
clear: () => {
if (ref.current) {
ref.current.innerHTML = "";
}
},
insertText: (text: string) => {
const selection = document.getSelection();
if (ref.current && selection) {
const content = ref.current.innerHTML;
const { anchorOffset, focusOffset } = selection;
ref.current.innerHTML = `${content.slice(0, anchorOffset)}${text}${content.slice(focusOffset)}`;
setSelection({
anchorNode: ref.current.firstChild,
anchorOffset: anchorOffset + text.length,
focusNode: ref.current.firstChild,
focusOffset: focusOffset + text.length,
isForward: true,
});
setContent(ref.current.innerHTML);
}
},
}),
[ref, setContent],
);
}

View file

@ -0,0 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ISendEventResponse } from "matrix-js-sdk/src/matrix";
import { useCallback, useState } from "react";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { endEditing } from "../utils/editing";
import { editMessage } from "../utils/message";
export function useEditing(
editorStateTransfer: EditorStateTransfer,
initialContent?: string,
): {
isSaveDisabled: boolean;
onChange(content: string): void;
editMessage(): Promise<ISendEventResponse | undefined>;
endEditing(): void;
} {
const roomContext = useRoomContext();
const mxClient = useMatrixClientContext();
const [isSaveDisabled, setIsSaveDisabled] = useState(true);
const [content, setContent] = useState(initialContent);
const onChange = useCallback(
(_content: string) => {
setContent(_content);
setIsSaveDisabled((_isSaveDisabled) => _isSaveDisabled && _content === initialContent);
},
[initialContent],
);
const editMessageMemoized = useCallback(async () => {
if (mxClient === undefined || content === undefined) {
return;
}
return editMessage(content, { roomContext, mxClient, editorStateTransfer });
}, [content, roomContext, mxClient, editorStateTransfer]);
const endEditingMemoized = useCallback(() => endEditing(roomContext), [roomContext]);
return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled };
}

View file

@ -0,0 +1,71 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { useMemo } from "react";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import { parseEvent } from "../../../../../editor/deserialize";
import { CommandPartCreator, Part } from "../../../../../editor/parts";
import SettingsStore from "../../../../../settings/SettingsStore";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
function getFormattedContent(editorStateTransfer: EditorStateTransfer): string {
return (
editorStateTransfer
.getEvent()
.getContent()
.formatted_body?.replace(/<mx-reply>.*<\/mx-reply>/, "") || ""
);
}
export function parseEditorStateTransfer(
editorStateTransfer: EditorStateTransfer,
room: Room,
mxClient: MatrixClient,
): string {
const partCreator = new CommandPartCreator(room, mxClient);
let parts: (Part | undefined)[] = [];
if (editorStateTransfer.hasEditorState()) {
// if restoring state from a previous editor,
// restore serialized parts from the state
const serializedParts = editorStateTransfer.getSerializedParts();
if (serializedParts !== null) {
parts = serializedParts.map((p) => partCreator.deserializePart(p));
}
} else {
// otherwise, either restore serialized parts from localStorage or parse the body of the event
// TODO local storage
// const restoredParts = this.restoreStoredEditorState(partCreator);
if (editorStateTransfer.getEvent().getContent().format === "org.matrix.custom.html") {
return getFormattedContent(editorStateTransfer);
}
parts = parseEvent(editorStateTransfer.getEvent(), partCreator, {
shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
}
return parts.reduce((content, part) => content + part?.text, "");
// Todo local storage
// this.saveStoredEditorState();
}
export function useInitialContent(editorStateTransfer: EditorStateTransfer): string | undefined {
const roomContext = useRoomContext();
const mxClient = useMatrixClientContext();
return useMemo<string | undefined>(() => {
if (editorStateTransfer && roomContext.room && mxClient) {
return parseEditorStateTransfer(editorStateTransfer, roomContext.room, mxClient);
}
}, [editorStateTransfer, roomContext, mxClient]);
}

View file

@ -0,0 +1,226 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Wysiwyg, WysiwygEvent } from "@vector-im/matrix-wysiwyg";
import { useCallback } from "react";
import { IEventRelation, MatrixClient } from "matrix-js-sdk/src/matrix";
import { useSettingValue } from "../../../../../hooks/useSettings";
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
import { findEditableEvent } from "../../../../../utils/EventUtils";
import dis from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
import { ComposerContextState, useComposerContext } from "../ComposerContext";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
import { endEditing } from "../utils/editing";
import Autocomplete from "../../Autocomplete";
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
export function useInputEventProcessor(
onSend: () => void,
autocompleteRef: React.RefObject<Autocomplete>,
initialContent?: string,
eventRelation?: IEventRelation,
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
const roomContext = useRoomContext();
const composerContext = useComposerContext();
const mxClient = useMatrixClientContext();
const isCtrlEnterToSend = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
return useCallback(
(event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => {
const send = (): void => {
event.stopPropagation?.();
event.preventDefault?.();
// do not send the message if we have the autocomplete open, regardless of settings
if (autocompleteRef?.current && !autocompleteRef.current.state.hide) {
return;
}
onSend();
};
if (isEventToHandleAsClipboardEvent(event)) {
const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer;
const handled = handleClipboardEvent(event, data, roomContext, mxClient, eventRelation);
return handled ? null : event;
}
const isKeyboardEvent = event instanceof KeyboardEvent;
if (isKeyboardEvent) {
return handleKeyboardEvent(
event,
send,
initialContent,
composer,
editor,
roomContext,
composerContext,
mxClient,
autocompleteRef,
);
} else {
return handleInputEvent(event, send, isCtrlEnterToSend);
}
},
[
isCtrlEnterToSend,
onSend,
initialContent,
roomContext,
composerContext,
mxClient,
autocompleteRef,
eventRelation,
],
);
}
type Send = () => void;
function handleKeyboardEvent(
event: KeyboardEvent,
send: Send,
initialContent: string | undefined,
composer: Wysiwyg,
editor: HTMLElement,
roomContext: IRoomState,
composerContext: ComposerContextState,
mxClient: MatrixClient | undefined,
autocompleteRef: React.RefObject<Autocomplete>,
): KeyboardEvent | null {
const { editorStateTransfer } = composerContext;
const isEditing = Boolean(editorStateTransfer);
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
const action = getKeyBindingsManager().getMessageComposerAction(event);
// we need autocomplete to take priority when it is open for using enter to select
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
if (isHandledByAutocomplete) {
return event;
}
// taking the client from context gives us an client | undefined type, narrow it down
if (mxClient === undefined) {
return null;
}
switch (action) {
case KeyBindingAction.SendMessage:
send();
return null;
case KeyBindingAction.EditPrevMessage: {
// Or if the caret is not at the beginning of the editor
// Or the editor is modified
if (!isCaretAtStart(editor) || isEditorModified) {
break;
}
const isDispatched = dispatchEditEvent(
event,
false,
editorStateTransfer,
composerContext,
roomContext,
mxClient,
);
if (isDispatched) {
return null;
}
break;
}
case KeyBindingAction.EditNextMessage: {
// If not in edition
// Or if the caret is not at the end of the editor
// Or the editor is modified
if (!editorStateTransfer || !isCaretAtEnd(editor) || isEditorModified) {
break;
}
const isDispatched = dispatchEditEvent(
event,
true,
editorStateTransfer,
composerContext,
roomContext,
mxClient,
);
if (!isDispatched) {
endEditing(roomContext);
event.preventDefault();
event.stopPropagation();
}
return null;
}
}
return event;
}
function dispatchEditEvent(
event: KeyboardEvent,
isForward: boolean,
editorStateTransfer: EditorStateTransfer | undefined,
composerContext: ComposerContextState,
roomContext: IRoomState,
mxClient: MatrixClient,
): boolean {
const foundEvents = editorStateTransfer
? getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient)
: getEventsFromRoom(composerContext, roomContext);
if (!foundEvents) {
return false;
}
const newEvent = findEditableEvent({
events: foundEvents,
isForward,
fromEventId: editorStateTransfer?.getEvent().getId(),
matrixClient: mxClient,
});
if (newEvent) {
dis.dispatch({
action: Action.EditEvent,
event: newEvent,
timelineRenderingType: roomContext.timelineRenderingType,
});
event.stopPropagation();
event.preventDefault();
return true;
}
return false;
}
type InputEvent = Exclude<WysiwygEvent, KeyboardEvent | ClipboardEvent>;
function handleInputEvent(event: InputEvent, send: Send, isCtrlEnterToSend: boolean): InputEvent | null {
switch (event.inputType) {
case "insertParagraph":
if (!isCtrlEnterToSend) {
send();
return null;
}
break;
case "sendMessage":
if (isCtrlEnterToSend) {
send();
return null;
}
break;
}
return event;
}

View file

@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MutableRefObject, useEffect, useState } from "react";
export function useIsExpanded(ref: MutableRefObject<HTMLElement | null>, breakingPoint: number): boolean {
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (ref.current) {
const editor = ref.current;
const resizeObserver = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
const height = entries[0]?.contentBoxSize?.[0].blockSize;
setIsExpanded(height >= breakingPoint);
});
});
resizeObserver.observe(editor);
return () => resizeObserver.unobserve(editor);
}
}, [ref, breakingPoint]);
return isExpanded;
}

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { FocusEvent, useCallback, useEffect, useRef, useState } from "react";
export function useIsFocused(): {
isFocused: boolean;
onFocus(event: FocusEvent<HTMLElement>): void;
} {
const [isFocused, setIsFocused] = useState(false);
const timeoutIDRef = useRef<number>();
useEffect(() => () => clearTimeout(timeoutIDRef.current), [timeoutIDRef]);
const onFocus = useCallback(
(event: FocusEvent<HTMLElement>) => {
clearTimeout(timeoutIDRef.current);
if (event.type === "focus") {
setIsFocused(true);
} else {
// To avoid a blink when we switch mode between plain text and rich text mode
// We delay the unfocused action
timeoutIDRef.current = window.setTimeout(() => setIsFocused(false), 100);
}
},
[setIsFocused, timeoutIDRef],
);
return { isFocused, onFocus };
}

View file

@ -0,0 +1,18 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RefObject, useEffect } from "react";
export function usePlainTextInitialization(initialContent = "", ref: RefObject<HTMLElement>): void {
useEffect(() => {
// always read and write the ref.current using .innerHTML for consistency in linebreak and HTML entity handling
if (ref.current) {
ref.current.innerHTML = initialContent;
}
}, [ref, initialContent]);
}

View file

@ -0,0 +1,182 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react";
import { AllowedMentionAttributes, MappedSuggestion } from "@vector-im/matrix-wysiwyg";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { useSettingValue } from "../../../../../hooks/useSettings";
import { IS_MAC, Key } from "../../../../../Keyboard";
import Autocomplete from "../../Autocomplete";
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
import { useSuggestion } from "./useSuggestion";
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
function isDivElement(target: EventTarget): target is HTMLDivElement {
return target instanceof HTMLDivElement;
}
/**
* React hook which generates all of the listeners and the ref to be attached to the editor.
*
* Also returns pieces of state and utility functions that are required for use in other hooks
* and by the autocomplete component.
*
* @param initialContent - the content of the editor when it is first mounted
* @param onChange - called whenever there is change in the editor content
* @param onSend - called whenever the user sends the message
* @param eventRelation - used to send the event to the correct place eg timeline vs thread
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
* @returns
* - `ref`: a ref object which the caller must attach to the HTML `div` node for the editor
* * `autocompleteRef`: a ref object which the caller must attach to the autocomplete component
* - `content`: state representing the editor's current text content
* - `setContent`: the setter function for `content`
* - `onInput`, `onPaste`, `onKeyDown`: handlers for input, paste and keyDown events
* - the output from the {@link useSuggestion} hook
*/
export function usePlainTextListeners(
initialContent?: string,
onChange?: (content: string) => void,
onSend?: () => void,
eventRelation?: IEventRelation,
isAutoReplaceEmojiEnabled?: boolean,
): {
ref: RefObject<HTMLDivElement>;
autocompleteRef: React.RefObject<Autocomplete>;
content?: string;
onBeforeInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onKeyDown(event: KeyboardEvent<HTMLDivElement>): void;
setContent(text?: string): void;
handleMention: (link: string, text: string, attributes: AllowedMentionAttributes) => void;
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
handleCommand: (text: string) => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const roomContext = useRoomContext();
const mxClient = useMatrixClientContext();
const ref = useRef<HTMLDivElement | null>(null);
const autocompleteRef = useRef<Autocomplete | null>(null);
const [content, setContent] = useState<string | undefined>(initialContent);
const send = useCallback(() => {
if (ref.current) {
ref.current.innerHTML = "";
}
onSend?.();
}, [ref, onSend]);
const setText = useCallback(
(text?: string) => {
if (isNotUndefined(text)) {
setContent(text);
onChange?.(text);
} else if (isNotNull(ref) && isNotNull(ref.current)) {
// if called with no argument, read the current innerHTML from the ref and amend it as per `onInput`
const currentRefContent = ref.current.innerHTML;
setContent(currentRefContent);
onChange?.(currentRefContent);
}
},
[onChange, ref],
);
// For separation of concerns, the suggestion handling is kept in a separate hook but is
// nested here because we do need to be able to update the `content` state in this hook
// when a user selects a suggestion from the autocomplete menu
const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention, handleEmojiReplacement } =
useSuggestion(ref, setText, isAutoReplaceEmojiEnabled);
const onInput = useCallback(
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
if (isDivElement(event.target)) {
setText(event.target.innerHTML);
}
},
[setText],
);
const onPaste = useCallback(
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
const { nativeEvent } = event;
let imagePasteWasHandled = false;
if (isEventToHandleAsClipboardEvent(nativeEvent)) {
const data =
nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer;
imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomContext, mxClient, eventRelation);
}
// prevent default behaviour and skip call to onInput if the image paste event was handled
if (imagePasteWasHandled) {
event.preventDefault();
} else {
onInput(event);
}
},
[eventRelation, mxClient, onInput, roomContext],
);
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
// we need autocomplete to take priority when it is open for using enter to select
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
if (isHandledByAutocomplete) {
return;
}
// handle accepting of plain text emojicon to emoji replacement
if (event.key == Key.ENTER || event.key == Key.SPACE) {
handleEmojiReplacement();
}
// resume regular flow
if (event.key === Key.ENTER) {
// TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
// if enter should send, send if the user is not pushing shift
if (enterShouldSend && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
send();
}
// if enter should not send, send only if the user is pushing ctrl/cmd
if (!enterShouldSend && sendModifierIsPressed) {
event.preventDefault();
event.stopPropagation();
send();
}
}
},
[autocompleteRef, enterShouldSend, send, handleEmojiReplacement],
);
return {
ref,
autocompleteRef,
onBeforeInput: onPaste,
onInput,
onPaste,
onKeyDown,
content,
setContent: setText,
suggestion,
onSelect,
handleCommand,
handleMention,
handleAtRoomMention,
};
}

View file

@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect } from "react";
import useFocus from "../../../../../hooks/useFocus";
import { useComposerContext, ComposerContextState } from "../ComposerContext";
function setSelectionContext(composerContext: ComposerContextState): void {
const selection = document.getSelection();
if (selection) {
const range = selection.getRangeAt(0);
const isForward = range.startContainer === selection.anchorNode && range.startOffset === selection.anchorOffset;
composerContext.selection = {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode,
focusOffset: selection.focusOffset,
isForward,
};
}
}
export function useSelection(): ReturnType<typeof useFocus>[1] & {
onInput(): void;
} {
const composerContext = useComposerContext();
const [isFocused, focusProps] = useFocus();
useEffect(() => {
function onSelectionChange(): void {
setSelectionContext(composerContext);
}
if (isFocused) {
document.addEventListener("selectionchange", onSelectionChange);
}
return () => document.removeEventListener("selectionchange", onSelectionChange);
}, [isFocused, composerContext]);
const onInput = useCallback(() => {
setSelectionContext(composerContext);
}, [composerContext]);
return { ...focusProps, onInput };
}

View file

@ -0,0 +1,19 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RefObject, useEffect } from "react";
import { setCursorPositionAtTheEnd } from "./utils";
export function useSetCursorPosition(disabled: boolean, ref: RefObject<HTMLElement>): void {
useEffect(() => {
if (ref.current && !disabled) {
setCursorPositionAtTheEnd(ref.current);
}
}, [ref, disabled]);
}

View file

@ -0,0 +1,411 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
import { AllowedMentionAttributes, MappedSuggestion } from "@vector-im/matrix-wysiwyg";
import { SyntheticEvent, useState, SetStateAction } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { isNotNull } from "../../../../../Typeguards";
/**
* Information about the current state of the `useSuggestion` hook.
*/
export type Suggestion = {
mappedSuggestion: MappedSuggestion;
/* The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete
component but more information is required to allow manipulation of the correct part of the DOM
when selecting an option from the autocomplete. These three pieces of information allow us to
do that.
*/
node: Node;
startOffset: number;
endOffset: number;
};
type SuggestionState = Suggestion | null;
/**
* React hook to allow tracking and replacing of mentions and commands in a div element
*
* @param editorRef - a ref to the div that is the composer textbox
* @param setText - setter function to set the content of the composer
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
* @returns
* - `handleMention`: a function that will insert @ or # mentions which are selected from
* the autocomplete into the composer, given an href, the text to display, and any additional attributes
* - `handleCommand`: a function that will replace the content of the composer with the given replacement text.
* Can be used to process autocomplete of slash commands
* - `onSelect`: a selection change listener to be attached to the plain text composer
* - `suggestion`: if the cursor is inside something that could be interpreted as a command or a mention,
* this will be an object representing that command or mention, otherwise it is null
*/
export function useSuggestion(
editorRef: React.RefObject<HTMLDivElement>,
setText: (text?: string) => void,
isAutoReplaceEmojiEnabled?: boolean,
): {
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
handleCommand: (text: string) => void;
handleEmojiReplacement: () => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const [suggestionData, setSuggestionData0] = useState<SuggestionState>(null);
// debug for https://github.com/vector-im/element-web/issues/26037
const setSuggestionData = (suggestionData: SetStateAction<SuggestionState>): void => {
// setState allows either the data itself or a callback which returns the data
logger.log(
`## 26037 ## wysiwyg useSuggestion hook setting suggestion data to ${
suggestionData === null || suggestionData instanceof Function
? suggestionData
: suggestionData.mappedSuggestion.keyChar + suggestionData.mappedSuggestion.text
}`,
);
setSuggestionData0(suggestionData);
};
// We create a `selectionchange` handler here because we need to know when the user has moved the cursor,
// we can not depend on input events only
const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData, isAutoReplaceEmojiEnabled);
const handleMention = (href: string, displayName: string, attributes: AllowedMentionAttributes): void =>
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
const handleAtRoomMention = (attributes: AllowedMentionAttributes): void =>
processMention("#", "@room", attributes, suggestionData, setSuggestionData, setText);
const handleCommand = (replacementText: string): void =>
processCommand(replacementText, suggestionData, setSuggestionData, setText);
const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText);
return {
suggestion: suggestionData?.mappedSuggestion ?? null,
handleCommand,
handleMention,
handleAtRoomMention,
handleEmojiReplacement,
onSelect,
};
}
/**
* When the selection changes inside the current editor, check to see if the cursor is inside
* something that could be a command or a mention and update the suggestion state if so
*
* @param editorRef - ref to the composer
* @param setSuggestionData - the setter for the suggestion state
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
*/
export function processSelectionChange(
editorRef: React.RefObject<HTMLDivElement>,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
isAutoReplaceEmojiEnabled?: boolean,
): void {
const selection = document.getSelection();
// return early if we do not have a current editor ref with a cursor selection inside a text node
if (
editorRef.current === null ||
selection === null ||
!selection.isCollapsed ||
selection.anchorNode?.nodeName !== "#text"
) {
setSuggestionData(null);
return;
}
// from here onwards we have a cursor inside a text node
const { anchorNode: currentNode, anchorOffset: currentOffset } = selection;
// if we have no text content, return, clearing the suggestion state
if (currentNode.textContent === null) {
setSuggestionData(null);
return;
}
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
const isFirstTextNode = currentNode === firstTextNode;
const foundSuggestion = findSuggestionInText(
currentNode.textContent,
currentOffset,
isFirstTextNode,
isAutoReplaceEmojiEnabled,
);
// if we have not found a suggestion, return, clearing the suggestion state
if (foundSuggestion === null) {
setSuggestionData(null);
return;
}
setSuggestionData({
mappedSuggestion: foundSuggestion.mappedSuggestion,
node: currentNode,
startOffset: foundSuggestion.startOffset,
endOffset: foundSuggestion.endOffset,
});
}
/**
* Replaces the relevant part of the editor text with a link representing a mention after it
* is selected from the autocomplete.
*
* @param href - the href that the inserted link will use
* @param displayName - the text content of the link
* @param attributes - additional attributes to add to the link, can include data-* attributes
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export function processMention(
href: string,
displayName: string,
attributes: AllowedMentionAttributes, // these will be used when formatting the link as a pill
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion, return early
if (suggestionData === null) {
return;
}
const { node } = suggestionData;
// create an <a> element with the required attributes to allow us to interpret the mention as being a pill
const linkElement = document.createElement("a");
const linkTextNode = document.createTextNode(displayName);
linkElement.setAttribute("href", href);
linkElement.setAttribute("contenteditable", "false");
for (const [attr, value] of attributes.entries()) {
linkElement.setAttribute(attr, value);
}
linkElement.appendChild(linkTextNode);
// create text nodes to go before and after the link
const leadingTextNode = document.createTextNode(node.textContent?.slice(0, suggestionData.startOffset) || "\u200b");
const trailingTextNode = document.createTextNode(` ${node.textContent?.slice(suggestionData.endOffset) ?? ""}`);
// now add the leading text node, link element and trailing text node before removing the node we are replacing
const parentNode = node.parentNode;
if (isNotNull(parentNode)) {
parentNode.insertBefore(leadingTextNode, node);
parentNode.insertBefore(linkElement, node);
parentNode.insertBefore(trailingTextNode, node);
parentNode.removeChild(node);
}
// move the selection to the trailing text node
document.getSelection()?.setBaseAndExtent(trailingTextNode, 1, trailingTextNode, 1);
// set the text content to be the innerHTML of the current editor ref and clear the suggestion state
setText();
setSuggestionData(null);
}
/**
* Replaces the relevant part of the editor text with the replacement text after a command is selected
* from the autocomplete.
*
* @param replacementText - the text that we will insert into the DOM
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export function processCommand(
replacementText: string,
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion, return early
if (suggestionData === null) {
return;
}
const { node } = suggestionData;
// for a command, we know we start at the beginning of the text node, so build the replacement
// string (note trailing space) and manually adjust the node's textcontent
const newContent = `${replacementText} `;
node.textContent = newContent;
// then set the cursor to the end of the node, update the `content` state in the usePlainTextListeners
// hook and clear the suggestion from state
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestionData(null);
}
/**
* Replaces the relevant part of the editor text, replacing the plain text emoitcon with the suggested emoji.
*
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export function processEmojiReplacement(
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion of the correct type, return early
if (suggestionData === null || suggestionData.mappedSuggestion.type !== `custom`) {
return;
}
const { node, mappedSuggestion } = suggestionData;
const existingContent = node.textContent;
if (existingContent == null) {
return;
}
// replace the emoticon with the suggesed emoji
const newContent =
existingContent.slice(0, suggestionData.startOffset) +
mappedSuggestion.text +
existingContent.slice(suggestionData.endOffset);
node.textContent = newContent;
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestionData(null);
}
/**
* Given some text content from a node and the cursor position, find the word that the cursor is currently inside
* and then test that word to see if it is a suggestion. Return the `MappedSuggestion` with start and end offsets if
* the cursor is inside a valid suggestion, null otherwise.
*
* @param text - the text content of a node
* @param offset - the current cursor offset position within the node
* @param isFirstTextNode - whether or not the node is the first text node in the editor. Used to determine
* if a command suggestion is found or not
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
* @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null
*/
export function findSuggestionInText(
text: string,
offset: number,
isFirstTextNode: boolean,
isAutoReplaceEmojiEnabled?: boolean,
): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null {
// Return null early if the offset is outside the content
if (offset < 0 || offset > text.length) {
return null;
}
// Variables to keep track of the indices we will be slicing from and to in order to create
// a substring of the word that the cursor is currently inside
let startSliceIndex = offset;
let endSliceIndex = offset;
// Search backwards from the current cursor position to find the start index of the word
// containing the cursor
while (shouldDecrementStartIndex(text, startSliceIndex)) {
startSliceIndex--;
}
// Search forwards from the current cursor position to find the end index of the word
// containing the cursor
while (shouldIncrementEndIndex(text, endSliceIndex)) {
endSliceIndex++;
}
// Get the word at the cursor then check if it contains a suggestion or not
const wordAtCursor = text.slice(startSliceIndex, endSliceIndex);
const mappedSuggestion = getMappedSuggestion(wordAtCursor, isAutoReplaceEmojiEnabled);
/**
* If we have a word that could be a command, it is not a valid command if:
* - the node we're looking at isn't the first text node in the editor (adding paragraphs can
* result in nested <p> tags inside the editor <div>)
* - the starting index is anything other than 0 (they can only appear at the start of a message)
* - there is more text following the command (eg `/spo asdf|` should not be interpreted as
* something requiring autocomplete)
*/
if (
mappedSuggestion === null ||
(mappedSuggestion.type === "command" &&
(!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length))
) {
return null;
}
return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + wordAtCursor.length };
}
/**
* Associated function for findSuggestionInText. Checks the character at the preceding index
* to determine if the search loop should continue.
*
* @param text - text content to check for mentions or commands
* @param index - the current index to check
* @returns true if check should keep moving backwards, false otherwise
*/
function shouldDecrementStartIndex(text: string, index: number): boolean {
// If the index is at or outside the beginning of the string, return false
if (index <= 0) return false;
// We are inside the string so can guarantee that there is a preceding character
// Keep searching backwards if the preceding character is not a space
return !/\s/.test(text[index - 1]);
}
/**
* Associated function for findSuggestionInText. Checks the character at the current index
* to determine if the search loop should continue.
*
* @param text - text content to check for mentions or commands
* @param index - the current index to check
* @returns true if check should keep moving forwards, false otherwise
*/
function shouldIncrementEndIndex(text: string, index: number): boolean {
// If the index is at or outside the end of the string, return false
if (index >= text.length) return false;
// Keep searching forwards if the current character is not a space
return !/\s/.test(text[index]);
}
/**
* Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null.
*
* @param text - string to check for a suggestion
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
* @returns a `MappedSuggestion` if a suggestion is present, null otherwise
*/
export function getMappedSuggestion(text: string, isAutoReplaceEmojiEnabled?: boolean): MappedSuggestion | null {
if (isAutoReplaceEmojiEnabled) {
// variations of plaintext emoitcons(E.g. :P vs :p vs :-P) are handled upstream by the emojibase-bindings/emojibase libraries.
// See rules for variations here https://github.com/milesj/emojibase/blob/master/packages/core/src/generateEmoticonPermutations.ts#L3-L32
const emoji = EMOTICON_TO_EMOJI.get(text);
if (emoji?.unicode) {
return { keyChar: "", text: emoji.unicode, type: "custom" };
}
}
const firstChar = text.charAt(0);
const restOfString = text.slice(1);
switch (firstChar) {
case "/":
return { keyChar: firstChar, text: restOfString, type: "command" };
case "#":
case "@":
return { keyChar: firstChar, text: restOfString, type: "mention" };
default:
return null;
}
}

View file

@ -0,0 +1,57 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RefObject, useCallback, useRef } from "react";
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { ActionPayload } from "../../../../../dispatcher/payloads";
import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { focusComposer } from "./utils";
import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
import { ComposerFunctions } from "../types";
import { setSelection } from "../utils/selection";
import { useComposerContext } from "../ComposerContext";
export function useWysiwygEditActionHandler(
disabled: boolean,
composerElement: RefObject<HTMLElement>,
composerFunctions: ComposerFunctions,
): void {
const roomContext = useRoomContext();
const composerContext = useComposerContext();
const timeoutId = useRef<number | null>(null);
const handler = useCallback(
(payload: ActionPayload) => {
// 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 (disabled || !composerElement.current) return;
const context = payload.context ?? TimelineRenderingType.Room;
switch (payload.action) {
case Action.FocusEditMessageComposer:
focusComposer(composerElement, context, roomContext, timeoutId);
break;
case Action.ComposerInsert:
if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break;
if (payload.composerType !== ComposerType.Edit) break;
if (payload.text) {
setSelection(composerContext.selection).then(() => composerFunctions.insertText(payload.text));
}
break;
}
},
[disabled, composerElement, composerFunctions, timeoutId, roomContext, composerContext],
);
useDispatcher(defaultDispatcher, handler);
}

View file

@ -0,0 +1,70 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MutableRefObject, useCallback, useRef } from "react";
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { ActionPayload } from "../../../../../dispatcher/payloads";
import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { focusComposer } from "./utils";
import { ComposerFunctions } from "../types";
import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
import { useComposerContext } from "../ComposerContext";
import { setSelection } from "../utils/selection";
export function useWysiwygSendActionHandler(
disabled: boolean,
composerElement: MutableRefObject<HTMLElement>,
composerFunctions: ComposerFunctions,
): void {
const roomContext = useRoomContext();
const composerContext = useComposerContext();
const timeoutId = useRef<number | null>(null);
const handler = useCallback(
(payload: ActionPayload) => {
// 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 (disabled || !composerElement?.current) return;
const context = payload.context ?? TimelineRenderingType.Room;
switch (payload.action) {
case "reply_to_event":
case Action.FocusAComposer:
case Action.FocusSendMessageComposer:
focusComposer(composerElement, context, roomContext, timeoutId);
break;
case Action.ClearAndFocusSendMessageComposer:
// When a thread is opened, prevent the main composer to steal the thread composer focus
if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break;
composerFunctions.clear();
focusComposer(composerElement, context, roomContext, timeoutId);
break;
case Action.ComposerInsert:
if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break;
if (payload.composerType !== ComposerType.Send) break;
if (payload.userId) {
// TODO insert mention - see SendMessageComposer
} else if (payload.event) {
// TODO insert quote message - see SendMessageComposer
} else if (payload.text) {
setSelection(composerContext.selection).then(() => composerFunctions.insertText(payload.text));
}
break;
}
},
[disabled, composerElement, roomContext, composerFunctions, composerContext],
);
useDispatcher(defaultDispatcher, handler);
}

View file

@ -0,0 +1,214 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MutableRefObject, RefObject } from "react";
import { IEventRelation, MatrixClient } from "matrix-js-sdk/src/matrix";
import { WysiwygEvent } from "@vector-im/matrix-wysiwyg";
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
import Autocomplete from "../../Autocomplete";
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
import ContentMessages from "../../../../../ContentMessages";
import { isNotNull } from "../../../../../Typeguards";
export function focusComposer(
composerElement: MutableRefObject<HTMLElement | null>,
renderingType: TimelineRenderingType,
roomContext: IRoomState,
timeoutId: MutableRefObject<number | null>,
): void {
if (renderingType === roomContext.timelineRenderingType) {
// Immediately set the focus, so if you start typing it
// will appear in the composer
composerElement.current?.focus();
// If we call focus immediate, the focus _is_ in the right
// place, but the cursor is invisible, presumably because
// some other event is still processing.
// The following line ensures that the cursor is actually
// visible in composer.
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
timeoutId.current = window.setTimeout(() => composerElement.current?.focus(), 200);
}
}
export function setCursorPositionAtTheEnd(element: HTMLElement): void {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
element.focus();
}
/**
* When the autocomplete modal is open we need to be able to properly
* handle events that are dispatched. This allows the user to move the selection
* in the autocomplete and select using enter.
*
* @param autocompleteRef - a ref to the autocomplete of interest
* @param event - the keyboard event that has been dispatched
* @returns boolean - whether or not the autocomplete has handled the event
*/
export function handleEventWithAutocomplete(
autocompleteRef: RefObject<Autocomplete>,
// we get a React Keyboard event from plain text composer, a Keyboard Event from the rich text composer
event: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>,
): boolean {
const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide;
if (!autocompleteIsOpen) {
return false;
}
let handled = false;
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
const component = autocompleteRef.current;
if (component && component.countCompletions() > 0) {
switch (autocompleteAction) {
case KeyBindingAction.ForceCompleteAutocomplete:
case KeyBindingAction.CompleteAutocomplete:
autocompleteRef.current.onConfirmCompletion();
handled = true;
break;
case KeyBindingAction.PrevSelectionInAutocomplete:
autocompleteRef.current.moveSelection(-1);
handled = true;
break;
case KeyBindingAction.NextSelectionInAutocomplete:
autocompleteRef.current.moveSelection(1);
handled = true;
break;
case KeyBindingAction.CancelAutocomplete:
autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent);
handled = true;
break;
default:
break; // don't return anything, allow event to pass through
}
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
return handled;
}
/**
* Takes an event and handles image pasting. Returns a boolean to indicate if it has handled
* the event or not. Must accept either clipboard or input events in order to prevent issue:
* https://github.com/vector-im/element-web/issues/25327
*
* @param event - event to process
* @param data - data from the event to process
* @param roomContext - room in which the event occurs
* @param mxClient - current matrix client
* @param eventRelation - used to send the event to the correct place eg timeline vs thread
* @returns - boolean to show if the event was handled or not
*/
export function handleClipboardEvent(
event: ClipboardEvent | InputEvent,
data: DataTransfer | null,
roomContext: IRoomState,
mxClient: MatrixClient,
eventRelation?: IEventRelation,
): boolean {
// Logic in this function follows that of `SendMessageComposer.onPaste`
const { room, timelineRenderingType, replyToEvent } = roomContext;
function handleError(error: unknown): void {
if (error instanceof Error) {
console.log(error.message);
} else if (typeof error === "string") {
console.log(error);
}
}
if (event.type !== "paste" || data === null || room === undefined) {
return false;
}
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
// it puts the filename in as text/plain which we want to ignore.
if (data.files.length && !data.types.includes("text/rtf")) {
ContentMessages.sharedInstance()
.sendContentListToRoom(Array.from(data.files), room.roomId, eventRelation, mxClient, timelineRenderingType)
.catch(handleError);
return true;
}
// Safari `Insert from iPhone or iPad`
// data.getData("text/html") returns a string like: <img src="blob:https://...">
if (data.types.includes("text/html")) {
const imgElementStr = data.getData("text/html");
const parser = new DOMParser();
const imgDoc = parser.parseFromString(imgElementStr, "text/html");
if (
imgDoc.getElementsByTagName("img").length !== 1 ||
!imgDoc.querySelector("img")?.src.startsWith("blob:") ||
imgDoc.childNodes.length !== 1
) {
handleError("Failed to handle pasted content as Safari inserted content");
return false;
}
const imgSrc = imgDoc.querySelector("img")!.src;
fetch(imgSrc)
.then((response) => {
response
.blob()
.then((imgBlob) => {
const type = imgBlob.type;
const safetype = getBlobSafeMimeType(type);
const ext = type.split("/")[1];
const parts = response.url.split("/");
const filename = parts[parts.length - 1];
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
ContentMessages.sharedInstance()
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
.catch(handleError);
})
.catch(handleError);
})
.catch(handleError);
return true;
}
return false;
}
/**
* Util to determine if an input event or clipboard event must be handled as a clipboard event.
* Due to https://github.com/vector-im/element-web/issues/25327, certain paste events
* must be listenened for with an onBeforeInput handler and so will be caught as input events.
*
* @param event - the event to test, can be a WysiwygEvent if it comes from the rich text editor, or
* input or clipboard events if from the plain text editor
* @returns - true if event should be handled as a clipboard event
*/
export function isEventToHandleAsClipboardEvent(
event: WysiwygEvent | InputEvent | ClipboardEvent,
): event is InputEvent | ClipboardEvent {
const isInputEventForClipboard =
event instanceof InputEvent && event.inputType === "insertFromPaste" && isNotNull(event.dataTransfer);
const isClipboardEvent = event instanceof ClipboardEvent;
return isClipboardEvent || isInputEventForClipboard;
}

View file

@ -0,0 +1,14 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export {
DynamicImportSendWysiwygComposer as SendWysiwygComposer,
DynamicImportEditWysiwygComposer as EditWysiwygComposer,
dynamicImportSendMessage as sendMessage,
dynamicImportConversionFunctions as getConversionFunctions,
} from "./DynamicImportWysiwygComposer";

View file

@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export type ComposerFunctions = {
clear: () => void;
insertText: (text: string) => void;
};
export type SubSelection = Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset"> & {
isForward: boolean;
};

View file

@ -0,0 +1,184 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { AllowedMentionAttributes, MappedSuggestion } from "@vector-im/matrix-wysiwyg";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { ICompletion } from "../../../../../autocomplete/Autocompleter";
import * as Avatar from "../../../../../Avatar";
/**
* Builds the query for the `<Autocomplete />` component from the rust suggestion. This
* will change as we implement handling / commands.
*
* @param suggestion - represents if the rust model is tracking a potential mention
* @returns an empty string if we can not generate a query, otherwise a query beginning
* with @ for a user query, # for a room or space query
*/
export function buildQuery(suggestion: MappedSuggestion | null): string {
if (!suggestion || !suggestion.keyChar) {
// if we have an empty key character, we do not build a query
return "";
}
return `${suggestion.keyChar}${suggestion.text}`;
}
/**
* Find the room from the completion by looking it up using the client from the context
* we are currently in
*
* @param completion - the completion from the autocomplete
* @param client - the current client we are using
* @returns a Room if one is found, null otherwise
*/
export function getRoomFromCompletion(completion: ICompletion, client: MatrixClient): Room | null {
const roomId = completion.completionId;
const aliasFromCompletion = completion.completion;
let roomToReturn: Room | null | undefined;
// Not quite sure if the logic here makes sense - specifically calling .getRoom with an alias
// that doesn't start with #, but keeping the logic the same as in PartCreator.roomPill for now
if (roomId) {
roomToReturn = client.getRoom(roomId);
} else if (!aliasFromCompletion.startsWith("#")) {
roomToReturn = client.getRoom(aliasFromCompletion);
} else {
roomToReturn = client.getRooms().find((r) => {
return r.getCanonicalAlias() === aliasFromCompletion || r.getAltAliases().includes(aliasFromCompletion);
});
}
return roomToReturn ?? null;
}
/**
* Given an autocomplete suggestion, determine the text to display in the pill
*
* @param completion - the item selected from the autocomplete
* @param client - the MatrixClient is required for us to look up the correct room mention text
* @returns the text to display in the mention
*/
export function getMentionDisplayText(completion: ICompletion, client: MatrixClient): string {
if (completion.type === "user" || completion.type === "at-room") {
return completion.completion;
} else if (completion.type === "room") {
// try and get the room and use it's name, if not available, fall back to
// completion.completion
return getRoomFromCompletion(completion, client)?.name || completion.completion;
}
return "";
}
function getCSSProperties({
url,
initialLetter,
id = "",
}: {
url: string;
initialLetter?: string;
id: string;
}): string {
const cssProperties = [`--avatar-background: url(${url})`, `--avatar-letter: '${initialLetter}'`];
const textColor = Avatar.getAvatarTextColor(id);
if (textColor) {
cssProperties.push(textColor);
}
return cssProperties.join("; ");
}
/**
* For a given completion, the attributes will change depending on the completion type
*
* @param completion - the item selected from the autocomplete
* @param client - the MatrixClient is required for us to look up the correct room mention text
* @param room - the room the composer is currently in
* @returns an object of attributes containing HTMLAnchor attributes or data-* attributes
*/
export function getMentionAttributes(
completion: ICompletion,
client: MatrixClient,
room: Room,
): AllowedMentionAttributes {
// To ensure that we always have something set in the --avatar-letter CSS variable
// as otherwise alignment varies depending on whether the content is empty or not.
// Use a zero width space so that it counts as content, but does not display anything.
const defaultLetterContent = "\u200b";
const attributes: AllowedMentionAttributes = new Map();
if (completion.type === "user") {
// logic as used in UserPillPart.setAvatar in parts.ts
const mentionedMember = room.getMember(completion.completionId || "");
if (!mentionedMember) return attributes;
const name = mentionedMember.name || mentionedMember.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(mentionedMember.userId);
const avatarUrl = Avatar.avatarUrlForMember(mentionedMember, 16, 16, "crop");
let initialLetter = defaultLetterContent;
if (avatarUrl === defaultAvatarUrl) {
initialLetter = Avatar.getInitialLetter(name) ?? defaultLetterContent;
}
attributes.set("data-mention-type", completion.type);
attributes.set(
"style",
getCSSProperties({
url: avatarUrl,
initialLetter,
id: mentionedMember.userId,
}),
);
} else if (completion.type === "room") {
// logic as used in RoomPillPart.setAvatar in parts.ts
const mentionedRoom = getRoomFromCompletion(completion, client);
const aliasFromCompletion = completion.completion;
let initialLetter = defaultLetterContent;
let avatarUrl = Avatar.avatarUrlForRoom(mentionedRoom ?? null, 16, 16, "crop");
if (!avatarUrl) {
initialLetter = Avatar.getInitialLetter(mentionedRoom?.name || aliasFromCompletion) ?? defaultLetterContent;
avatarUrl = Avatar.defaultAvatarUrlForString(mentionedRoom?.roomId ?? aliasFromCompletion);
}
attributes.set("data-mention-type", completion.type);
attributes.set(
"style",
getCSSProperties({
url: avatarUrl,
initialLetter,
id: mentionedRoom?.roomId ?? aliasFromCompletion,
}),
);
} else if (completion.type === "at-room") {
// logic as used in RoomPillPart.setAvatar in parts.ts, but now we know the current room
// from the arguments passed
let initialLetter = defaultLetterContent;
let avatarUrl = Avatar.avatarUrlForRoom(room, 16, 16, "crop");
if (!avatarUrl) {
initialLetter = Avatar.getInitialLetter(room.name) ?? defaultLetterContent;
avatarUrl = Avatar.defaultAvatarUrlForString(room.roomId);
}
attributes.set("data-mention-type", completion.type);
attributes.set(
"style",
getCSSProperties({
url: avatarUrl,
initialLetter,
id: room.roomId,
}),
);
}
return attributes;
}

View file

@ -0,0 +1,185 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { richToPlain, plainToRich } from "@vector-im/matrix-wysiwyg";
import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { ReplacementEvent, RoomMessageEventContent, RoomMessageTextEventContent } from "matrix-js-sdk/src/types";
import SettingsStore from "../../../../../settings/SettingsStore";
import { parsePermalink, RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
import { isNotNull } from "../../../../../Typeguards";
export const EMOTE_PREFIX = "/me ";
// Merges favouring the given relation
function attachRelation(content: IContent, relation?: IEventRelation): void {
if (relation) {
content["m.relates_to"] = {
...(content["m.relates_to"] || {}),
...relation,
};
}
}
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
if (!html) {
return "";
}
const rootNode = new DOMParser().parseFromString(html, "text/html").body;
const mxReply = rootNode.querySelector("mx-reply");
return (mxReply && mxReply.outerHTML) || "";
}
function getTextReplyFallback(mxEvent: MatrixEvent): string {
const body = mxEvent.getContent().body;
if (typeof body !== "string") {
return "";
}
const lines = body.split("\n").map((l) => l.trim());
if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
return `${lines[0]}\n\n`;
}
return "";
}
interface CreateMessageContentParams {
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
includeReplyLegacyFallback?: boolean;
editedEvent?: MatrixEvent;
}
const isMatrixEvent = (e: MatrixEvent | undefined): e is MatrixEvent => e instanceof MatrixEvent;
export async function createMessageContent(
message: string,
isHTML: boolean,
{
relation,
replyToEvent,
permalinkCreator,
includeReplyLegacyFallback = true,
editedEvent,
}: CreateMessageContentParams,
): Promise<RoomMessageEventContent> {
const isEditing = isMatrixEvent(editedEvent);
const isReply = isEditing ? Boolean(editedEvent.replyEventId) : isMatrixEvent(replyToEvent);
const isReplyAndEditing = isEditing && isReply;
const isEmote = message.startsWith(EMOTE_PREFIX);
if (isEmote) {
// if we are dealing with an emote we want to remove the prefix so that `/me` does not
// appear after the `* <userName>` text in the timeline
message = message.slice(EMOTE_PREFIX.length);
}
if (message.startsWith("//")) {
// if user wants to enter a single slash at the start of a message, this
// is how they have to do it (due to it clashing with commands), so here we
// remove the first character to make sure //word displays as /word
message = message.slice(1);
}
// if we're editing rich text, the message content is pure html
// BUT if we're not, the message content will be plain text where we need to convert the mentions
const body = isHTML ? await richToPlain(message, false) : convertPlainTextToBody(message);
const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || "";
const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";
const content = {
msgtype: isEmote ? MsgType.Emote : MsgType.Text,
body: isEditing ? `${bodyPrefix} * ${body}` : body,
} as RoomMessageTextEventContent & ReplacementEvent<RoomMessageTextEventContent>;
// TODO markdown support
const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown");
const formattedBody = isHTML ? message : isMarkdownEnabled ? await plainToRich(message, true) : null;
if (formattedBody) {
content.format = "org.matrix.custom.html";
content.formatted_body = isEditing ? `${formattedBodyPrefix} * ${formattedBody}` : formattedBody;
}
if (isEditing) {
content["m.new_content"] = {
msgtype: content.msgtype,
body: body,
};
if (formattedBody) {
content["m.new_content"].format = "org.matrix.custom.html";
content["m.new_content"]["formatted_body"] = formattedBody;
}
}
const newRelation = isEditing ? { ...relation, rel_type: "m.replace", event_id: editedEvent.getId() } : relation;
// TODO Do we need to attach mentions here?
// TODO Handle editing?
attachRelation(content, newRelation);
if (!isEditing && replyToEvent && permalinkCreator) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
includeLegacyFallback: includeReplyLegacyFallback,
});
}
return content;
}
/**
* Without a model, we need to manually amend mentions in uncontrolled message content
* to make sure that mentions meet the matrix specification.
*
* @param content - the output from the `MessageComposer` state when in plain text mode
* @returns - a string formatted with the mentions replaced as required
*/
function convertPlainTextToBody(content: string): string {
const document = new DOMParser().parseFromString(content, "text/html");
const mentions = Array.from(document.querySelectorAll("a[data-mention-type]"));
mentions.forEach((mention) => {
const mentionType = mention.getAttribute("data-mention-type");
switch (mentionType) {
case "at-room": {
mention.replaceWith("@room");
break;
}
case "user": {
const innerText = mention.innerHTML;
mention.replaceWith(innerText);
break;
}
case "room": {
// for this case we use parsePermalink to try and get the mx id
const href = mention.getAttribute("href");
// if the mention has no href attribute, leave it alone
if (href === null) break;
// otherwise, attempt to parse the room alias or id from the href
const permalinkParts = parsePermalink(href);
// then if we have permalink parts with a valid roomIdOrAlias, replace the
// room mention with that text
if (isNotNull(permalinkParts) && isNotNull(permalinkParts.roomIdOrAlias)) {
mention.replaceWith(permalinkParts.roomIdOrAlias);
}
break;
}
default:
break;
}
});
return document.body.innerHTML;
}

View file

@ -0,0 +1,39 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventStatus, MatrixClient } from "matrix-js-sdk/src/matrix";
import { IRoomState } from "../../../../structures/RoomView";
import dis from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
export function endEditing(roomContext: IRoomState): void {
// todo local storage
// localStorage.removeItem(this.editorRoomKey);
// localStorage.removeItem(this.editorStateKey);
// close the event editing and focus composer
dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: roomContext.timelineRenderingType,
});
dis.dispatch({
action: Action.FocusSendMessageComposer,
context: roomContext.timelineRenderingType,
});
}
export function cancelPreviousPendingEdit(mxClient: MatrixClient, editorStateTransfer: EditorStateTransfer): void {
const originalEvent = editorStateTransfer.getEvent();
const previousEdit = originalEvent.replacingEvent();
if (previousEdit && (previousEdit.status === EventStatus.QUEUED || previousEdit.status === EventStatus.NOT_SENT)) {
mxClient.cancelPendingEvent(previousEdit);
}
}

View file

@ -0,0 +1,50 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { IRoomState } from "../../../../structures/RoomView";
import { ComposerContextState } from "../ComposerContext";
// From EditMessageComposer private get events(): MatrixEvent[]
export function getEventsFromEditorStateTransfer(
editorStateTransfer: EditorStateTransfer,
roomContext: IRoomState,
mxClient: MatrixClient,
): MatrixEvent[] | undefined {
const liveTimelineEvents = roomContext.liveTimeline?.getEvents();
if (!liveTimelineEvents) {
return;
}
const roomId = editorStateTransfer.getEvent().getRoomId();
if (!roomId) {
return;
}
const room = mxClient.getRoom(roomId);
if (!room) {
return;
}
const pendingEvents = room.getPendingEvents();
const isInThread = Boolean(editorStateTransfer.getEvent().getThread());
return liveTimelineEvents.concat(isInThread ? [] : pendingEvents);
}
// From SendMessageComposer private onKeyDown = (event: KeyboardEvent): void
export function getEventsFromRoom(
composerContext: ComposerContextState,
roomContext: IRoomState,
): MatrixEvent[] | undefined {
const isReplyingToThread = composerContext.eventRelation?.key === THREAD_RELATION_TYPE.name;
return roomContext.liveTimeline
?.getEvents()
.concat(isReplyingToThread ? [] : roomContext.room?.getPendingEvents() || []);
}

View file

@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RoomMessageEventContent, RoomMessageTextEventContent } from "matrix-js-sdk/src/types";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
export function isContentModified(
newContent: RoomMessageEventContent,
editorStateTransfer: EditorStateTransfer,
): boolean {
// if nothing has changed then bail
const oldContent = editorStateTransfer.getEvent().getContent<RoomMessageEventContent>();
if (
oldContent["msgtype"] === newContent["msgtype"] &&
oldContent["body"] === newContent["body"] &&
(<RoomMessageTextEventContent>oldContent)["format"] === (<RoomMessageTextEventContent>newContent)["format"] &&
(<RoomMessageTextEventContent>oldContent)["formatted_body"] ===
(<RoomMessageTextEventContent>newContent)["formatted_body"]
) {
return false;
}
return true;
}

View file

@ -0,0 +1,249 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
import {
IEventRelation,
MatrixEvent,
ISendEventResponse,
MatrixClient,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
import SettingsStore from "../../../../../settings/SettingsStore";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics";
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { doMaybeLocalRoomAction } from "../../../../../utils/local-room";
import { CHAT_EFFECTS } from "../../../../../effects";
import { containsEmoji } from "../../../../../effects/utils";
import { IRoomState } from "../../../../structures/RoomView";
import dis from "../../../../../dispatcher/dispatcher";
import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog";
import { endEditing, cancelPreviousPendingEdit } from "./editing";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { createMessageContent, EMOTE_PREFIX } from "./createMessageContent";
import { isContentModified } from "./isContentModified";
import { CommandCategories, getCommand } from "../../../../../SlashCommands";
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
import { Action } from "../../../../../dispatcher/actions";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
import { attachRelation } from "../../SendMessageComposer";
export interface SendMessageParams {
mxClient: MatrixClient;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
roomContext: IRoomState;
permalinkCreator?: RoomPermalinkCreator;
includeReplyLegacyFallback?: boolean;
}
export async function sendMessage(
message: string,
isHTML: boolean,
{ roomContext, mxClient, ...params }: SendMessageParams,
): Promise<ISendEventResponse | undefined> {
const { relation, replyToEvent, permalinkCreator } = params;
const { room } = roomContext;
const roomId = room?.roomId;
if (!roomId) {
return;
}
const posthogEvent: ComposerEvent = {
eventName: "Composer",
isEditing: false,
messageType: "Text",
isReply: Boolean(replyToEvent),
// TODO thread
inThread: relation?.rel_type === THREAD_RELATION_TYPE.name,
};
// TODO thread
/*if (posthogEvent.inThread) {
const threadRoot = room.findEventById(relation?.event_id);
posthogEvent.startsThread = threadRoot?.getThread()?.events.length === 1;
}*/
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
let content: RoomMessageEventContent | null = null;
// Slash command handling here approximates what can be found in SendMessageComposer.sendMessage()
// but note that the /me and // special cases are handled by the call to createMessageContent
if (message.startsWith("/") && !message.startsWith("//") && !message.startsWith(EMOTE_PREFIX)) {
const { cmd, args } = getCommand(message);
if (cmd) {
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation?.event_id : null;
let commandSuccessful: boolean;
[content, commandSuccessful] = await runSlashCommand(mxClient, cmd, args, roomId, threadId ?? null);
if (!commandSuccessful) {
return; // errored
}
if (
content &&
(cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects)
) {
attachRelation(content, relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
// Exclude the legacy fallback for custom event types such as those used by /fireworks
includeLegacyFallback: content.msgtype?.startsWith("m.") ?? true,
});
}
} else {
// instead of setting shouldSend to false as in SendMessageComposer, just return
return;
}
} else {
const sendAnyway = await shouldSendAnyway(message);
// re-focus the composer after QuestionDialog is closed
dis.dispatch({
action: Action.FocusAComposer,
context: roomContext.timelineRenderingType,
});
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
// if content is null, we haven't done any slash command processing, so generate some content
content ??= await createMessageContent(message, isHTML, params);
// TODO replace emotion end of message ?
// TODO quick reaction
// don't bother sending an empty message
if (!content.body.trim()) {
return;
}
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
const threadId = relation?.event_id && relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
const prom = doMaybeLocalRoomAction(
roomId,
(actualRoomId: string) => mxClient.sendMessage(actualRoomId, threadId, content!),
mxClient,
);
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
dis.dispatch({
action: "reply_to_event",
event: null,
context: roomContext.timelineRenderingType,
});
}
dis.dispatch({ action: "message_sent" });
CHAT_EFFECTS.forEach((effect) => {
if (content && containsEmoji(content, effect.emojis)) {
// For initial threads launch, chat effects are disabled
// see #19731
const isNotThread = relation?.rel_type !== THREAD_RELATION_TYPE.name;
if (isNotThread) {
dis.dispatch({ action: `effects.${effect.command}` });
}
}
});
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then((resp) => {
sendRoundTripMetric(mxClient, roomId, resp.event_id);
});
}
// TODO save history
// TODO save local state
//if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({
action: "scroll_to_bottom",
timelineRenderingType: roomContext.timelineRenderingType,
});
}
return prom;
}
interface EditMessageParams {
mxClient: MatrixClient;
roomContext: IRoomState;
editorStateTransfer: EditorStateTransfer;
}
export async function editMessage(
html: string,
{ roomContext, mxClient, editorStateTransfer }: EditMessageParams,
): Promise<ISendEventResponse | undefined> {
const editedEvent = editorStateTransfer.getEvent();
PosthogAnalytics.instance.trackEvent<ComposerEvent>({
eventName: "Composer",
isEditing: true,
messageType: "Text",
inThread: Boolean(editedEvent?.getThread()),
isReply: Boolean(editedEvent.replyEventId),
});
// TODO emoji
// Replace emoticon at the end of the message
/* if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}*/
const editContent = await createMessageContent(html, true, { editedEvent });
const newContent = editContent["m.new_content"]!;
const shouldSend = true;
if (newContent?.body === "") {
cancelPreviousPendingEdit(mxClient, editorStateTransfer);
createRedactEventDialog({
mxEvent: editedEvent,
onCloseDialog: () => {
endEditing(roomContext);
},
});
return;
}
let response: Promise<ISendEventResponse> | undefined;
const roomId = editedEvent.getRoomId();
// If content is modified then send an updated event into the room
if (isContentModified(newContent, editorStateTransfer) && roomId) {
// TODO Slash Commands
if (shouldSend) {
cancelPreviousPendingEdit(mxClient, editorStateTransfer);
const event = editorStateTransfer.getEvent();
const threadId = event.threadRootId || null;
response = mxClient.sendMessage(roomId, threadId, editContent);
dis.dispatch({ action: "message_sent" });
}
}
endEditing(roomContext);
return response;
}

View file

@ -0,0 +1,86 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { SubSelection } from "../types";
export function setSelection(selection: SubSelection): Promise<void> {
if (selection.anchorNode && selection.focusNode) {
const range = new Range();
if (selection.isForward) {
range.setStart(selection.anchorNode, selection.anchorOffset);
range.setEnd(selection.focusNode, selection.focusOffset);
} else {
range.setStart(selection.focusNode, selection.focusOffset);
range.setEnd(selection.anchorNode, selection.anchorOffset);
}
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(range);
}
// Waiting for the next loop to ensure that the selection is effective
return new Promise((resolve) => setTimeout(resolve, 0));
}
export function isSelectionEmpty(): boolean {
const selection = document.getSelection();
return Boolean(selection?.isCollapsed);
}
export function isCaretAtStart(editor: HTMLElement): boolean {
const selection = document.getSelection();
// No selection or the caret is not at the beginning of the selected element
if (!selection) {
return false;
}
// When we are pressing keyboard up in an empty main composer, the selection is on the editor with an anchorOffset at O or 1 (yes, this is strange)
const isOnFirstElement = selection.anchorNode === editor && selection.anchorOffset <= 1;
if (isOnFirstElement) {
return true;
}
// In case of nested html elements (list, code blocks), we are going through all the first child
let child = editor.firstChild;
do {
if (child === selection.anchorNode) {
return selection.anchorOffset === 0;
}
} while ((child = child?.firstChild || null));
return false;
}
export function isCaretAtEnd(editor: HTMLElement): boolean {
const selection = document.getSelection();
if (!selection) {
return false;
}
// When we are cycling across all the timeline message with the keyboard
// The caret is on the last text element but focusNode and anchorNode refers to the editor div
// In this case, the focusOffset & anchorOffset match the index + 1 of the selected text
const isOnLastElement = selection.focusNode === editor && selection.focusOffset === editor.childNodes?.length;
if (isOnLastElement) {
return true;
}
// In case of nested html elements (list, code blocks), we are going through all the last child
// The last child of the editor is always a <br> tag, we skip it
let child: ChildNode | null = editor.childNodes.item(editor.childNodes.length - 2);
do {
if (child === selection.focusNode) {
// Checking that the cursor is at end of the selected text
return selection.focusOffset === child.textContent?.length;
}
} while ((child = child.lastChild));
return false;
}