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:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
362
src/components/views/rooms/AppsDrawer.tsx
Normal file
362
src/components/views/rooms/AppsDrawer.tsx
Normal 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>
|
||||
);
|
||||
};
|
314
src/components/views/rooms/Autocomplete.tsx
Normal file
314
src/components/views/rooms/Autocomplete.tsx
Normal 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;
|
||||
}
|
||||
}
|
67
src/components/views/rooms/AuxPanel.tsx
Normal file
67
src/components/views/rooms/AuxPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
915
src/components/views/rooms/BasicMessageComposer.tsx
Normal file
915
src/components/views/rooms/BasicMessageComposer.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
41
src/components/views/rooms/CollapsibleButton.tsx
Normal file
41
src/components/views/rooms/CollapsibleButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
110
src/components/views/rooms/E2EIcon.tsx
Normal file
110
src/components/views/rooms/E2EIcon.tsx
Normal 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;
|
500
src/components/views/rooms/EditMessageComposer.tsx
Normal file
500
src/components/views/rooms/EditMessageComposer.tsx
Normal 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;
|
62
src/components/views/rooms/EmojiButton.tsx
Normal file
62
src/components/views/rooms/EmojiButton.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
170
src/components/views/rooms/EntityTile.tsx
Normal file
170
src/components/views/rooms/EntityTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
1571
src/components/views/rooms/EventTile.tsx
Normal file
1571
src/components/views/rooms/EventTile.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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>
|
||||
);
|
||||
}
|
87
src/components/views/rooms/ExtraTile.tsx
Normal file
87
src/components/views/rooms/ExtraTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
src/components/views/rooms/HistoryTile.tsx
Normal file
38
src/components/views/rooms/HistoryTile.tsx
Normal 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;
|
41
src/components/views/rooms/JumpToBottomButton.tsx
Normal file
41
src/components/views/rooms/JumpToBottomButton.tsx
Normal 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;
|
112
src/components/views/rooms/LinkPreviewGroup.tsx
Normal file
112
src/components/views/rooms/LinkPreviewGroup.tsx
Normal 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;
|
139
src/components/views/rooms/LinkPreviewWidget.tsx
Normal file
139
src/components/views/rooms/LinkPreviewWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
66
src/components/views/rooms/LiveContentSummary.tsx
Normal file
66
src/components/views/rooms/LiveContentSummary.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
450
src/components/views/rooms/MemberList.tsx
Normal file
450
src/components/views/rooms/MemberList.tsx
Normal 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);
|
||||
};
|
||||
}
|
221
src/components/views/rooms/MemberTile.tsx
Normal file
221
src/components/views/rooms/MemberTile.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
734
src/components/views/rooms/MessageComposer.tsx
Normal file
734
src/components/views/rooms/MessageComposer.tsx
Normal 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;
|
373
src/components/views/rooms/MessageComposerButtons.tsx
Normal file
373
src/components/views/rooms/MessageComposerButtons.tsx
Normal 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;
|
137
src/components/views/rooms/MessageComposerFormatBar.tsx
Normal file
137
src/components/views/rooms/MessageComposerFormatBar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
298
src/components/views/rooms/NewRoomIntro.tsx
Normal file
298
src/components/views/rooms/NewRoomIntro.tsx
Normal 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;
|
124
src/components/views/rooms/NotificationBadge.tsx
Normal file
124
src/components/views/rooms/NotificationBadge.tsx
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -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} />;
|
||||
}
|
237
src/components/views/rooms/PinnedEventTile.tsx
Normal file
237
src/components/views/rooms/PinnedEventTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
318
src/components/views/rooms/PinnedMessageBanner.tsx
Normal file
318
src/components/views/rooms/PinnedMessageBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
70
src/components/views/rooms/PresenceLabel.tsx
Normal file
70
src/components/views/rooms/PresenceLabel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
262
src/components/views/rooms/ReadReceiptGroup.tsx
Normal file
262
src/components/views/rooms/ReadReceiptGroup.tsx
Normal 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 we’ve got just 4, don’t 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, we’ll 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 we’re 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>
|
||||
);
|
||||
}
|
193
src/components/views/rooms/ReadReceiptMarker.tsx
Normal file
193
src/components/views/rooms/ReadReceiptMarker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
54
src/components/views/rooms/ReplyPreview.tsx
Normal file
54
src/components/views/rooms/ReplyPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
181
src/components/views/rooms/ReplyTile.tsx
Normal file
181
src/components/views/rooms/ReplyTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
130
src/components/views/rooms/RoomBreadcrumbs.tsx
Normal file
130
src/components/views/rooms/RoomBreadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
33
src/components/views/rooms/RoomContextDetails.tsx
Normal file
33
src/components/views/rooms/RoomContextDetails.tsx
Normal 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 <></>;
|
||||
}
|
403
src/components/views/rooms/RoomHeader.tsx
Normal file
403
src/components/views/rooms/RoomHeader.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
158
src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx
Normal file
158
src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
85
src/components/views/rooms/RoomInfoLine.tsx
Normal file
85
src/components/views/rooms/RoomInfoLine.tsx
Normal 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;
|
141
src/components/views/rooms/RoomKnocksBar.tsx
Normal file
141
src/components/views/rooms/RoomKnocksBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
677
src/components/views/rooms/RoomList.tsx
Normal file
677
src/components/views/rooms/RoomList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
426
src/components/views/rooms/RoomListHeader.tsx
Normal file
426
src/components/views/rooms/RoomListHeader.tsx
Normal 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;
|
731
src/components/views/rooms/RoomPreviewBar.tsx
Normal file
731
src/components/views/rooms/RoomPreviewBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
185
src/components/views/rooms/RoomPreviewCard.tsx
Normal file
185
src/components/views/rooms/RoomPreviewCard.tsx
Normal 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;
|
74
src/components/views/rooms/RoomSearchAuxPanel.tsx
Normal file
74
src/components/views/rooms/RoomSearchAuxPanel.tsx
Normal 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;
|
883
src/components/views/rooms/RoomSublist.tsx
Normal file
883
src/components/views/rooms/RoomSublist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
498
src/components/views/rooms/RoomTile.tsx
Normal file
498
src/components/views/rooms/RoomTile.tsx
Normal 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;
|
57
src/components/views/rooms/RoomTileCallSummary.tsx
Normal file
57
src/components/views/rooms/RoomTileCallSummary.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
};
|
63
src/components/views/rooms/RoomTileSubtitle.tsx
Normal file
63
src/components/views/rooms/RoomTileSubtitle.tsx
Normal 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;
|
||||
};
|
113
src/components/views/rooms/RoomUpgradeWarningBar.tsx
Normal file
113
src/components/views/rooms/RoomUpgradeWarningBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
134
src/components/views/rooms/SearchResultTile.tsx
Normal file
134
src/components/views/rooms/SearchResultTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
786
src/components/views/rooms/SendMessageComposer.tsx
Normal file
786
src/components/views/rooms/SendMessageComposer.tsx
Normal 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;
|
357
src/components/views/rooms/Stickerpicker.tsx
Normal file
357
src/components/views/rooms/Stickerpicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
142
src/components/views/rooms/ThirdPartyMemberInfo.tsx
Normal file
142
src/components/views/rooms/ThirdPartyMemberInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
130
src/components/views/rooms/ThreadSummary.tsx
Normal file
130
src/components/views/rooms/ThreadSummary.tsx
Normal 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;
|
36
src/components/views/rooms/TopUnreadMessagesBar.tsx
Normal file
36
src/components/views/rooms/TopUnreadMessagesBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
317
src/components/views/rooms/VoiceRecordComposerTile.tsx
Normal file
317
src/components/views/rooms/VoiceRecordComposerTile.tsx
Normal 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()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
218
src/components/views/rooms/WhoIsTypingTile.tsx
Normal file
218
src/components/views/rooms/WhoIsTypingTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}),
|
||||
);
|
|
@ -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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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],
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
214
src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
Normal file
214
src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
Normal 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;
|
||||
}
|
14
src/components/views/rooms/wysiwyg_composer/index.ts
Normal file
14
src/components/views/rooms/wysiwyg_composer/index.ts
Normal 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";
|
16
src/components/views/rooms/wysiwyg_composer/types.ts
Normal file
16
src/components/views/rooms/wysiwyg_composer/types.ts
Normal 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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
39
src/components/views/rooms/wysiwyg_composer/utils/editing.ts
Normal file
39
src/components/views/rooms/wysiwyg_composer/utils/editing.ts
Normal 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);
|
||||
}
|
||||
}
|
50
src/components/views/rooms/wysiwyg_composer/utils/event.ts
Normal file
50
src/components/views/rooms/wysiwyg_composer/utils/event.ts
Normal 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() || []);
|
||||
}
|
|
@ -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;
|
||||
}
|
249
src/components/views/rooms/wysiwyg_composer/utils/message.ts
Normal file
249
src/components/views/rooms/wysiwyg_composer/utils/message.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue