Refactor MessageActionBar using MVVM and move to shared-components (#32784)

* Refactor MessageActionBar into MVVM ActionBarView

* Adding tooltips for menu items and correct i18n strings

* Layout changes

* Renaming some properties

* Rename property

* Create a first version of the view model and refactor media visibility logic

* Refactor view to take options and rections menu as optional properties

* Cleaner interface between view and view model

* Refactor view properties and replace Menu and MenuItem

* Bugfixes and switching to ActionBarView instead of MessageActionBar in element-web

* Avoid creating view models and render toolbar until it is actually shown

* Added unit and playwright tests and documented the view

* Added view model unit tests and updated snapshots of dependant tests

* Remove unused components and unnecessary css

* Remove unused language tags

* Fix for handling join-rules correctly

* Prettier

* Add handling of stale view model in async calls

* Prettier

* Split the element-web css into two different. One for legacy components and one for the ActionBarView

* Missing variables used for linting

* Fix for showing ActionBarView when using keyboard for navigation

* Handle visibility on context menu closing

* ThreadPanel uses the ActionBarView so restore css rule

* Fix for visibility of the ActionBarView in Thread panel

* Fix for ActionBarVuew visibility when closing right-click context menu and not still hovering

* Add roving index to function as a toolbar

* Adjust the RoomView test to send hover to the EventTile instead of the message text

* Fix SonarCloud issues

* Fix for SonarCloud issue

* Merge fix

* Rename mx_LegacyActionBar to mx_ThreadActionBar

* Added documentation and simplified join rules

* Generalize the ActionBarView and move logic to view model

* Add the four new buttons to the ActionBarView

* Update view model and tests to use the updated ActionBarView

* Refactor element-web to use ActionBarView

* Clean up styling in element-web

* Clean up and updating snaps and screenshots

* Added unit-tests for better coverage

* Moving ActionBarView to the correct folder in shared components

* Update snaps in element-web

* Better documentation in stories

* Merge fixes

* Updates after review comments

* Review comment fixes

* Added documentation to view models and updated snaps

* Hide button had the wrong label

* Replace createRef with useRef
This commit is contained in:
rbondesson
2026-04-01 12:27:03 +00:00
committed by GitHub
parent 0391543bbc
commit 4315038346
46 changed files with 4071 additions and 2155 deletions
@@ -1,65 +0,0 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import React, { type ReactElement, useMemo } from "react";
import classNames from "classnames";
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner";
import { _t } from "../../../languageHandler";
import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
interface IProps {
mxEvent: MatrixEvent;
// XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup
// required to get us a MediaEventHelper, so we use a getter function instead to prod for
// one.
mediaEventHelperGet: () => MediaEventHelper | undefined;
}
function useButtonTitle(loading: boolean, isEncrypted: boolean): string {
if (!loading) return _t("action|download");
return isEncrypted ? _t("timeline|download_action_decrypting") : _t("timeline|download_action_downloading");
}
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
const fileName = mediaEventHelper?.fileName;
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
const buttonTitle = useButtonTitle(loading, mediaEventHelper?.media.isEncrypted ?? false);
if (!canDownload) return null;
const spinner = loading ? <Spinner size={18} /> : undefined;
const classes = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
});
return (
<RovingAccessibleButton
className={classes}
title={buttonTitle}
onClick={download}
disabled={loading}
placement="left"
>
<DownloadIcon />
{spinner}
</RovingAccessibleButton>
);
}
@@ -6,17 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, createRef } from "react";
import React, { createRef } from "react";
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { EventContentBodyView } from "@element-hq/web-shared-components";
import { ActionBarView, EventContentBodyView } from "@element-hq/web-shared-components";
import { EditHistoryActionBarViewModel } from "../../../viewmodels/message-body/EditHistoryActionBarViewModel";
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
import { formatTime } from "../../../DateUtils";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import AccessibleButton from "../elements/AccessibleButton";
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../structures/ViewSource";
import SettingsStore from "../../../settings/SettingsStore";
@@ -47,6 +46,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
private content = createRef<HTMLDivElement>();
private EventContentBodyViewModel: EventContentBodyViewModel;
private editHistoryActionBarViewModel: EditHistoryActionBarViewModel;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
@@ -72,6 +72,13 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
linkify: true,
client: cli,
});
this.editHistoryActionBarViewModel = new EditHistoryActionBarViewModel({
canRemove: !props.mxEvent.isRedacted() && !props.isBaseEvent && canRedact,
showViewSource: SettingsStore.getValue("developerMode"),
onRemoveClick: this.onRedactClick,
onViewSourceClick: this.onViewSourceClick,
});
}
public componentDidUpdate(prevProps: IProps): void {
@@ -79,6 +86,13 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
const mxEventContent = getReplacedContent(this.props.mxEvent);
this.EventContentBodyViewModel.setEventContent(this.props.mxEvent, mxEventContent);
}
this.editHistoryActionBarViewModel.setProps({
canRemove: !this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact,
showViewSource: SettingsStore.getValue("developerMode"),
onRemoveClick: this.onRedactClick,
onViewSourceClick: this.onViewSourceClick,
});
}
private onAssociatedStatusChanged = (): void => {
@@ -116,34 +130,20 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
const event = this.props.mxEvent;
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
this.EventContentBodyViewModel.dispose();
this.editHistoryActionBarViewModel.dispose();
}
private renderActionBar(): React.ReactNode {
// hide the button when already redacted
let redactButton: JSX.Element | undefined;
if (!this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact) {
redactButton = <AccessibleButton onClick={this.onRedactClick}>{_t("action|remove")}</AccessibleButton>;
}
this.editHistoryActionBarViewModel.setProps({
canRemove: !this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact,
showViewSource: SettingsStore.getValue("developerMode"),
onRemoveClick: this.onRedactClick,
onViewSourceClick: this.onViewSourceClick,
});
let viewSourceButton: JSX.Element | undefined;
if (SettingsStore.getValue("developerMode")) {
viewSourceButton = (
<AccessibleButton onClick={this.onViewSourceClick}>{_t("action|view_source")}</AccessibleButton>
);
}
if (!redactButton && !viewSourceButton) {
// Hide the empty MessageActionBar
return null;
} else {
// disabled remove button when not allowed
return (
<div className="mx_MessageActionBar">
{redactButton}
{viewSourceButton}
</div>
);
}
return (
<ActionBarView vm={this.editHistoryActionBarViewModel} className="mx_ThreadActionBar mx_HistoryActionBar" />
);
}
public render(): React.ReactNode {
@@ -1,44 +0,0 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import React from "react";
import { VisibilityOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
interface IProps {
/**
* Matrix event that this action applies to.
*/
mxEvent: MatrixEvent;
}
/**
* Quick action button for marking a media event as hidden.
*/
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent);
if (!mediaIsVisible) {
return;
}
return (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton "
title={_t("action|hide")}
onClick={() => setVisible(false)}
placement="left"
>
<VisibilityOffIcon />
</RovingAccessibleButton>
);
};
@@ -1,601 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactElement, useCallback, useContext, useEffect } from "react";
import {
EventStatus,
type MatrixEvent,
MatrixEventEvent,
MsgType,
RelationType,
M_BEACON_INFO,
EventTimeline,
RoomStateEvent,
EventType,
type Relations,
} from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import {
PinIcon,
UnpinIcon,
OverflowHorizontalIcon,
ReplyIcon,
DeleteIcon,
RestartIcon,
ThreadsIcon,
EditIcon,
ReactionAddIcon,
ExpandIcon,
CollapseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import { isContentActionable, canEditContent, editEvent, canCancel } from "../../../utils/EventUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton";
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import type ReplyChain from "../elements/ReplyChain";
import ReactionPicker from "../emojipicker/ReactionPicker";
import { CardContext } from "../right_panel/context";
import { shouldDisplayReply } from "../../../utils/Reply";
import { Key } from "../../../Keyboard";
import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts";
import { Action } from "../../../dispatcher/actions";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { type GetRelationsForEvent, type IEventTileType } from "../rooms/EventTile";
import { type ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils";
import PosthogTrackers from "../../../PosthogTrackers.ts";
import { HideActionButton } from "./HideActionButton.tsx";
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
getTile: () => IEventTileType | null;
getReplyChain: () => ReplyChain | null;
permalinkCreator?: RoomPermalinkCreator;
onFocusChange: (menuDisplayed: boolean) => void;
getRelationsForEvent?: GetRelationsForEvent;
}
const OptionsButton: React.FC<IOptionsButtonProps> = ({
mxEvent,
getTile,
getReplyChain,
permalinkCreator,
onFocusChange,
getRelationsForEvent,
}) => {
const [onFocus, isActive, buttonRefCallback, buttonRef] = useRovingTabIndex();
const [menuDisplayed, , openMenu, closeMenu] = useContextMenu(buttonRef);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
const onOptionsClick = useCallback(
(e: ButtonEvent): void => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
openMenu();
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
// position in the page even when someone is clicking around.
onFocus();
},
[openMenu, onFocus],
);
let contextMenu: ReactElement | undefined;
if (menuDisplayed && buttonRef.current) {
const tile = getTile?.();
const replyChain = getReplyChain();
const buttonRect = buttonRef.current.getBoundingClientRect();
contextMenu = (
<MessageContextMenu
{...aboveLeftOf(buttonRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyChain={replyChain?.canCollapse() ? replyChain.collapse : undefined}
onFinished={closeMenu}
getRelationsForEvent={getRelationsForEvent}
/>
);
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
title={_t("common|options")}
onClick={onOptionsClick}
onContextMenu={onOptionsClick}
isExpanded={menuDisplayed}
ref={buttonRefCallback}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
placement="top"
>
<OverflowHorizontalIcon />
</ContextMenuTooltipButton>
{contextMenu}
</React.Fragment>
);
};
interface IReactButtonProps {
mxEvent: MatrixEvent;
reactions?: Relations | null | undefined;
onFocusChange: (menuDisplayed: boolean) => void;
}
const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusChange }) => {
const [onFocus, isActive, buttonRefCallback, buttonRef] = useRovingTabIndex();
const [menuDisplayed, , openMenu, closeMenu] = useContextMenu(buttonRef);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && buttonRef.current) {
const buttonRect = buttonRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false} focusLock>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
</ContextMenu>
);
}
const onClick = useCallback(
(e: ButtonEvent) => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
openMenu();
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
// position in the page even when someone is clicking around.
onFocus();
},
[openMenu, onFocus],
);
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_iconButton"
title={_t("action|react")}
onClick={onClick}
onContextMenu={onClick}
isExpanded={menuDisplayed}
ref={buttonRefCallback}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
placement="top"
>
<ReactionAddIcon />
</ContextMenuTooltipButton>
{contextMenu}
</React.Fragment>
);
};
interface IReplyInThreadButton {
mxEvent: MatrixEvent;
}
const ReplyInThreadButton: React.FC<IReplyInThreadButton> = ({ mxEvent }) => {
const context = useContext(CardContext);
const relationType = mxEvent?.getRelation()?.rel_type;
const hasARelation = !!relationType && relationType !== RelationType.Thread;
const onClick = (e: ButtonEvent): void => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
const thread = mxEvent.getThread();
if (thread?.rootEvent && !mxEvent.isThreadRoot) {
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: thread.rootEvent,
initialEvent: mxEvent,
scroll_into_view: true,
highlighted: true,
push: context.isCard,
});
} else {
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: mxEvent,
push: context.isCard,
});
}
};
const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation");
return (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_threadButton"
disabled={hasARelation}
title={title}
onClick={onClick}
onContextMenu={onClick}
placement="top"
>
<ThreadsIcon />
</RovingAccessibleButton>
);
};
interface IMessageActionBarProps {
mxEvent: MatrixEvent;
reactions?: Relations | null | undefined;
getTile: () => IEventTileType | null;
getReplyChain: () => ReplyChain | null;
permalinkCreator?: RoomPermalinkCreator;
onFocusChange?: (menuDisplayed: boolean) => void;
toggleThreadExpanded: () => void;
isQuoteExpanded?: boolean;
getRelationsForEvent?: GetRelationsForEvent;
}
export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
public componentDidMount(): void {
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on(MatrixEventEvent.Status, this.onSent);
}
const client = MatrixClientPeg.safeGet();
client.decryptEventIfNeeded(this.props.mxEvent);
if (this.props.mxEvent.isBeingDecrypted()) {
this.props.mxEvent.once(MatrixEventEvent.Decrypted, this.onDecrypted);
}
this.props.mxEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
this.context.room
?.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.on(RoomStateEvent.Events, this.onRoomEvent);
}
public componentWillUnmount(): void {
this.props.mxEvent.off(MatrixEventEvent.Status, this.onSent);
this.props.mxEvent.off(MatrixEventEvent.Decrypted, this.onDecrypted);
this.props.mxEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
this.context.room
?.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.off(RoomStateEvent.Events, this.onRoomEvent);
}
private onDecrypted = (): void => {
// When an event decrypts, it is likely to change the set of available
// actions, so we force an update to check again.
this.forceUpdate();
};
private onBeforeRedaction = (): void => {
// When an event is redacted, we can't edit it so update the available actions.
this.forceUpdate();
};
private onRoomEvent = (event?: MatrixEvent): void => {
// If the event is pinned or unpinned, rerender the component.
if (!event || event.getType() !== EventType.RoomPinnedEvents) return;
this.forceUpdate();
};
private onSent = (): void => {
// When an event is sent and echoed the possible actions change.
this.forceUpdate();
};
private onFocusChange = (focused: boolean): void => {
this.props.onFocusChange?.(focused);
};
private onReplyClick = (e: ButtonEvent): void => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({
action: "reply_to_event",
event: this.props.mxEvent,
context: this.context.timelineRenderingType,
});
};
private onEditClick = (e: ButtonEvent): void => {
// Don't open the regular browser or our context menu on right-click
e.preventDefault();
e.stopPropagation();
editEvent(
MatrixClientPeg.safeGet(),
this.props.mxEvent,
this.context.timelineRenderingType,
this.props.getRelationsForEvent,
);
};
private readonly forbiddenThreadHeadMsgType = [MsgType.KeyVerificationRequest];
private get showReplyInThreadAction(): boolean {
const inNotThreadTimeline = this.context.timelineRenderingType !== TimelineRenderingType.Thread;
const isAllowedMessageType =
!this.forbiddenThreadHeadMsgType.includes(this.props.mxEvent.getContent().msgtype as MsgType) &&
/** forbid threads from live location shares
* until cross-platform support
* (PSF-1041)
*/
!M_BEACON_INFO.matches(this.props.mxEvent.getType());
return inNotThreadTimeline && isAllowedMessageType;
}
/**
* Runs a given fn on the set of possible events to test. The first event
* that passes the checkFn will have fn executed on it. Both functions take
* a MatrixEvent object. If no particular conditions are needed, checkFn can
* be null/undefined. If no functions pass the checkFn, no action will be
* taken.
* @param {Function} fn The execution function.
* @param {Function} checkFn The test function.
*/
private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void {
if (!checkFn) checkFn = () => true;
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const tryOrder = [redactEvent, editEvent, mxEvent];
for (const ev of tryOrder) {
if (ev && checkFn(ev)) {
fn(ev);
break;
}
}
}
private onResendClick = (ev: ButtonEvent): void => {
// Don't open the regular browser or our context menu on right-click
ev.preventDefault();
ev.stopPropagation();
this.runActionOnFailedEv((tarEv) => Resend.resend(MatrixClientPeg.safeGet(), tarEv));
};
private onCancelClick = (ev: ButtonEvent): void => {
this.runActionOnFailedEv(
(tarEv) => Resend.removeFromQueue(MatrixClientPeg.safeGet(), tarEv),
(testEv) => canCancel(testEv.status),
);
};
/**
* Pin or unpin the event.
*/
private onPinClick = async (event: ButtonEvent, isPinned: boolean): Promise<void> => {
// Don't open the regular browser or our context menu on right-click
event.preventDefault();
event.stopPropagation();
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline");
};
public render(): React.ReactNode {
const toolbarOpts: JSX.Element[] = [];
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
toolbarOpts.push(
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|edit")}
onClick={this.onEditClick}
onContextMenu={this.onEditClick}
key="edit"
placement="top"
>
<EditIcon />
</RovingAccessibleButton>,
);
}
if (
PinningUtils.canPin(MatrixClientPeg.safeGet(), this.props.mxEvent) ||
PinningUtils.canUnpin(MatrixClientPeg.safeGet(), this.props.mxEvent)
) {
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
toolbarOpts.push(
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={isPinned ? _t("action|unpin") : _t("action|pin")}
onClick={(e: ButtonEvent) => this.onPinClick(e, isPinned)}
onContextMenu={(e: ButtonEvent) => this.onPinClick(e, isPinned)}
key="pin"
placement="top"
>
{isPinned ? <UnpinIcon /> : <PinIcon />}
</RovingAccessibleButton>,
);
}
const cancelSendingButton = (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|delete")}
onClick={this.onCancelClick}
onContextMenu={this.onCancelClick}
key="cancel"
placement="top"
>
<DeleteIcon />
</RovingAccessibleButton>
);
const threadTooltipButton = <ReplyInThreadButton mxEvent={this.props.mxEvent} key="reply_thread" />;
// We show a different toolbar for failed events, so detect that first.
const mxEvent = this.props.mxEvent;
const editStatus = mxEvent.replacingEvent()?.status;
const redactStatus = mxEvent.localRedactionEvent()?.status;
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
const isFailed = [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT);
if (allowCancel && isFailed) {
// The resend button needs to appear ahead of the edit button, so insert to the
// start of the opts
toolbarOpts.splice(
0,
0,
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_retryButton"
title={_t("action|retry")}
onClick={this.onResendClick}
onContextMenu={this.onResendClick}
key="resend"
placement="top"
>
<RestartIcon />
</RovingAccessibleButton>,
);
// The delete button should appear last, so we can just drop it at the end
toolbarOpts.push(cancelSendingButton);
} else {
if (isContentActionable(this.props.mxEvent)) {
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canSendMessages) {
if (this.showReplyInThreadAction) {
toolbarOpts.splice(0, 0, threadTooltipButton);
}
toolbarOpts.splice(
0,
0,
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={_t("action|reply")}
onClick={this.onReplyClick}
onContextMenu={this.onReplyClick}
key="reply"
placement="top"
>
<ReplyIcon />
</RovingAccessibleButton>,
);
}
// We hide the react button in search results as we don't show reactions in results
if (this.context.canReact && !this.context.search) {
toolbarOpts.splice(
0,
0,
<ReactButton
mxEvent={this.props.mxEvent}
reactions={this.props.reactions}
onFocusChange={this.onFocusChange}
key="react"
/>,
);
}
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
toolbarOpts.splice(
0,
0,
<DownloadActionButton
mxEvent={this.props.mxEvent}
mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()}
key="download"
/>,
);
}
if (MediaEventHelper.canHide(this.props.mxEvent)) {
toolbarOpts.splice(0, 0, <HideActionButton mxEvent={this.props.mxEvent} key="hide" />);
}
} else if (
// Show thread icon even for deleted messages, but only within main timeline
this.context.timelineRenderingType === TimelineRenderingType.Room &&
this.props.mxEvent.getThread()
) {
toolbarOpts.unshift(threadTooltipButton);
}
if (allowCancel) {
toolbarOpts.push(cancelSendingButton);
}
if (this.props.isQuoteExpanded !== undefined && shouldDisplayReply(this.props.mxEvent)) {
const expandClassName = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_expandCollapseMessageButton: true,
});
toolbarOpts.push(
<RovingAccessibleButton
className={expandClassName}
title={
this.props.isQuoteExpanded
? _t("timeline|mab|collapse_reply_chain")
: _t("timeline|mab|expand_reply_chain")
}
caption={_t(ALTERNATE_KEY_NAME[Key.SHIFT]) + " + " + _t("action|click")}
onClick={this.props.toggleThreadExpanded}
key="expand"
placement="top"
>
{this.props.isQuoteExpanded ? <CollapseIcon /> : <ExpandIcon />}
</RovingAccessibleButton>,
);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(
<OptionsButton
mxEvent={this.props.mxEvent}
getReplyChain={this.props.getReplyChain}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
key="menu"
getRelationsForEvent={this.props.getRelationsForEvent}
/>,
);
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return (
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
{toolbarOpts}
</Toolbar>
);
}
}
+222 -39
View File
@@ -16,6 +16,7 @@ import React, {
useState,
type JSX,
type Ref,
type FocusEvent,
type MouseEvent,
type ReactNode,
} from "react";
@@ -50,6 +51,7 @@ import { uniqueId, uniqBy } from "lodash";
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
useCreateAutoDisposedViewModel,
ActionBarView,
MessageTimestampView,
PinnedMessageBadge,
ReactionsRowButtonView,
@@ -77,13 +79,11 @@ import PlatformPeg from "../../../PlatformPeg";
import MemberAvatar from "../avatars/MemberAvatar";
import SenderProfile from "../messages/SenderProfile";
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionPicker from "../emojipicker/ReactionPicker";
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import { isContentActionable } from "../../../utils/EventUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type ButtonEvent } from "../elements/AccessibleButton";
import { copyPlaintext } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -96,7 +96,6 @@ import { ReadReceiptGroup } from "./ReadReceiptGroup";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
import PinningUtils from "../../../utils/PinningUtils";
@@ -105,6 +104,7 @@ import { ElementCallEventType } from "../../../call-types";
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
import SettingsStore from "../../../settings/SettingsStore";
import { CardContext } from "../right_panel/context";
import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
@@ -114,6 +114,8 @@ import {
MAX_ITEMS_WHEN_LIMITED,
ReactionsRowViewModel,
} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel";
import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel";
import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
@@ -268,6 +270,7 @@ export interface EventTileProps {
interface IState {
// Whether the action bar is focused.
actionBarFocused: boolean;
showActionBarFromFocus: boolean;
/**
* E2EE shield we should show for decryption problems.
@@ -342,6 +345,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.state = {
// Whether the action bar is focused.
actionBarFocused: false,
showActionBarFromFocus: false,
shieldColour: EventShieldColour.NONE,
shieldReason: null,
@@ -453,7 +457,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.verifyEvent();
}
private updateThread = (thread: Thread): void => {
private readonly updateThread = (thread: Thread): void => {
this.setState({ thread });
};
@@ -498,7 +502,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current);
}
private onNewThread = (thread: Thread): void => {
private readonly onNewThread = (thread: Thread): void => {
if (thread.id === this.props.mxEvent.getId()) {
this.updateThread(thread);
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
@@ -561,9 +565,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
}
private viewInRoom = (evt: ButtonEvent): void => {
evt.preventDefault();
evt.stopPropagation();
private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
@@ -573,16 +575,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
});
};
private copyLinkToThread = async (evt: ButtonEvent): Promise<void> => {
evt.preventDefault();
evt.stopPropagation();
private readonly onCopyLinkToThreadClick = async (_anchor: HTMLElement | null): Promise<void> => {
const { permalinkCreator, mxEvent } = this.props;
if (!permalinkCreator) return;
const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()!);
await copyPlaintext(matrixToUrl);
};
private onRoomReceipt = (ev: MatrixEvent, room: Room): void => {
private readonly onRoomReceipt = (ev: MatrixEvent, room: Room): void => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
if (room !== tileRoom) return;
@@ -604,20 +604,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
/** called when the event is decrypted after we show it.
*/
private onDecrypted = (): void => {
private readonly onDecrypted = (): void => {
// we need to re-verify the sending device.
this.verifyEvent();
this.forceUpdate();
};
private onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => {
private readonly onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => {
if (userId === this.props.mxEvent.getSender()) {
this.verifyEvent();
}
};
/** called when the event is edited after we show it. */
private onReplaced = (): void => {
private readonly onReplaced = (): void => {
// re-verify the event if it is replaced (the edit may not be verified)
this.verifyEvent();
};
@@ -732,7 +732,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight);
}
private onSenderProfileClick = (): void => {
private readonly onSenderProfileClick = (): void => {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: this.props.mxEvent.getSender()!,
@@ -740,7 +740,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
});
};
private onPermalinkClicked = (e: MouseEvent): void => {
private readonly onPermalinkClicked = (e: MouseEvent): void => {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Element when clicked.
e.preventDefault();
@@ -855,15 +855,34 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return null;
}
private onActionBarFocusChange = (actionBarFocused: boolean): void => {
this.setState({ actionBarFocused });
private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => {
this.setState((prevState) => ({
actionBarFocused,
hover: actionBarFocused ? prevState.hover : (this.ref.current?.matches(":hover") ?? false),
}));
};
private getTile: () => IEventTileType | null = () => this.tile.current;
private readonly onFocusWithin = (event: FocusEvent<HTMLElement>): void => {
// Show the action toolbar for keyboard-visible focus, with what-input as a fallback signal.
const target = event.target as HTMLElement;
const showActionBarFromFocus =
target.matches(":focus-visible") || document.body.dataset["data-whatinput"] === "keyboard";
this.setState({ focusWithin: true, showActionBarFromFocus });
};
private getReplyChain = (): ReplyChain | null => this.replyChain.current;
private readonly onBlurWithin = (event: FocusEvent<HTMLElement>): void => {
if (event.currentTarget.contains(event.relatedTarget)) {
return;
}
private getReactions = (): Relations | null => {
this.setState({ focusWithin: false, showActionBarFromFocus: false });
};
private readonly getTile: () => IEventTileType | null = () => this.tile.current;
private readonly getReplyChain = (): ReplyChain | null => this.replyChain.current;
private readonly getReactions = (): Relations | null => {
if (!this.props.showReactions || !this.props.getRelationsForEvent) {
return null;
}
@@ -871,7 +890,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction") ?? null;
};
private onReactionsCreated = (relationType: string, eventType: string): void => {
private readonly onReactionsCreated = (relationType: string, eventType: string): void => {
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
return;
}
@@ -880,11 +899,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
});
};
private onContextMenu = (ev: React.MouseEvent): void => {
private readonly onContextMenu = (ev: React.MouseEvent): void => {
this.showContextMenu(ev);
};
private onTimestampContextMenu = (ev: React.MouseEvent): void => {
private readonly onTimestampContextMenu = (ev: React.MouseEvent): void => {
this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()!));
};
@@ -917,17 +936,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
link: anchorElement?.href || permalink,
},
actionBarFocused: true,
hover: false,
});
}
private onCloseMenu = (): void => {
private readonly onCloseMenu = (): void => {
this.setState({
contextMenu: undefined,
actionBarFocused: false,
hover: false,
});
};
private setQuoteExpanded = (expanded: boolean): void => {
private readonly setQuoteExpanded = (expanded: boolean): void => {
this.setState({
isQuoteExpanded: expanded,
});
@@ -1150,9 +1171,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
}
const showMessageActionBar = !isEditing && !this.props.forExport;
const showMessageActionBar =
!isEditing &&
!this.props.forExport &&
(this.state.hover ||
this.state.showActionBarFromFocus ||
(this.state.actionBarFocused && !this.state.contextMenu));
const actionBar = showMessageActionBar ? (
<MessageActionBar
<ActionBarWrapper
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
@@ -1286,8 +1312,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-event-id": this.props.mxEvent.getId(),
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onFocus": () => this.setState({ focusWithin: true }),
"onBlur": () => this.setState({ focusWithin: false }),
"onFocus": this.onFocusWithin,
"onBlur": this.onBlurWithin,
},
[
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
@@ -1348,15 +1374,15 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-has-reply": !!replyChain,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onFocus": () => this.setState({ focusWithin: true }),
"onBlur": () => this.setState({ focusWithin: false }),
"onFocus": this.onFocusWithin,
"onBlur": this.onBlurWithin,
"onClick": (ev: MouseEvent) => {
const target = ev.currentTarget as HTMLElement;
let index = -1;
if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target);
switch (this.context.timelineRenderingType) {
case TimelineRenderingType.Notification:
this.viewInRoom(ev);
this.onViewInRoomClick(null);
break;
case TimelineRenderingType.ThreadsList:
dis.dispatch<ShowThreadPayload>({
@@ -1411,9 +1437,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{this.renderThreadPanelSummary()}
</div>
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
<EventTileThreadToolbar
viewInRoom={this.viewInRoom}
copyLinkToThread={this.copyLinkToThread}
<ThreadListActionBarWrapper
onViewInRoomClick={this.onViewInRoomClick}
onCopyLinkClick={this.onCopyLinkToThreadClick}
/>
)}
@@ -1481,8 +1507,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-has-reply": !!replyChain,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onFocus": () => this.setState({ focusWithin: true }),
"onBlur": () => this.setState({ focusWithin: false }),
"onFocus": this.onFocusWithin,
"onBlur": this.onBlurWithin,
},
<>
{ircTimestamp}
@@ -1861,3 +1887,160 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrappe
</>
);
}
interface ActionBarWrapperProps {
mxEvent: MatrixEvent;
reactions?: Relations | null;
permalinkCreator?: RoomPermalinkCreator;
getTile: () => IEventTileType | null;
getReplyChain: () => ReplyChain | null;
onFocusChange?: (focused: boolean) => void;
isQuoteExpanded?: boolean;
toggleThreadExpanded: () => void;
getRelationsForEvent?: GetRelationsForEvent;
}
interface ThreadListActionBarWrapperProps {
onViewInRoomClick: (anchor: HTMLElement | null) => void;
onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise<void>;
}
function ThreadListActionBarWrapper({
onViewInRoomClick,
onCopyLinkClick,
}: Readonly<ThreadListActionBarWrapperProps>): JSX.Element {
const vm = useCreateAutoDisposedViewModel(
() =>
new ThreadListActionBarViewModel({
onViewInRoomClick,
onCopyLinkClick,
}),
);
useEffect(() => {
vm.setProps({
onViewInRoomClick,
onCopyLinkClick,
});
}, [vm, onViewInRoomClick, onCopyLinkClick]);
return <ActionBarView vm={vm} className="mx_ThreadActionBar" />;
}
function ActionBarWrapper({
mxEvent,
reactions,
permalinkCreator,
getTile,
getReplyChain,
onFocusChange,
isQuoteExpanded,
toggleThreadExpanded,
getRelationsForEvent,
}: Readonly<ActionBarWrapperProps>): JSX.Element {
const roomContext = useContext(RoomContext);
const { isCard } = useContext(CardContext);
const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState<DOMRect | null>(null);
const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState<DOMRect | null>(null);
const isSearch = Boolean(roomContext.search);
const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => {
setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
}, []);
const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => {
setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
}, []);
const vm = useCreateAutoDisposedViewModel(
() =>
new EventTileActionBarViewModel({
mxEvent,
timelineRenderingType: roomContext.timelineRenderingType,
canSendMessages: roomContext.canSendMessages,
canReact: roomContext.canReact,
isSearch,
isCard,
isQuoteExpanded,
onToggleThreadExpanded: toggleThreadExpanded,
onOptionsClick: handleOptionsClick,
onReactionsClick: handleReactionsClick,
getRelationsForEvent,
}),
);
useEffect(() => {
vm.setProps({
mxEvent,
timelineRenderingType: roomContext.timelineRenderingType,
canSendMessages: roomContext.canSendMessages,
canReact: roomContext.canReact,
isSearch,
isCard,
isQuoteExpanded,
getRelationsForEvent,
onToggleThreadExpanded: toggleThreadExpanded,
onOptionsClick: handleOptionsClick,
onReactionsClick: handleReactionsClick,
});
}, [
vm,
mxEvent,
roomContext.timelineRenderingType,
roomContext.canSendMessages,
roomContext.canReact,
isSearch,
isCard,
isQuoteExpanded,
getRelationsForEvent,
handleOptionsClick,
handleReactionsClick,
toggleThreadExpanded,
]);
useEffect(() => {
onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect));
}, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]);
useEffect(() => {
setOptionsMenuAnchorRect(null);
setReactionsMenuAnchorRect(null);
}, [mxEvent]);
const closeOptionsMenu = useCallback((): void => {
setOptionsMenuAnchorRect(null);
}, []);
const closeReactionsMenu = useCallback((): void => {
setReactionsMenuAnchorRect(null);
}, []);
const tile = getTile();
const replyChain = getReplyChain();
const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined;
const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined;
return (
<>
<ActionBarView vm={vm} className="mx_MessageActionBar" />
{optionsMenuAnchorRect ? (
<MessageContextMenu
{...aboveLeftOf(optionsMenuAnchorRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={eventTileOps}
collapseReplyChain={collapseReplyChain}
onFinished={closeOptionsMenu}
getRelationsForEvent={getRelationsForEvent}
/>
) : null}
{reactionsMenuAnchorRect ? (
<ContextMenu
{...aboveLeftOf(reactionsMenuAnchorRect)}
onFinished={closeReactionsMenu}
managed={false}
focusLock
>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionsMenu} />
</ContextMenu>
) : null}
</>
);
}
@@ -1,44 +0,0 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { LinkIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex";
import Toolbar from "../../../../accessibility/Toolbar";
import { _t } from "../../../../languageHandler";
import { type 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"
>
<VisibilityOnIcon />
</RovingAccessibleButton>
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
onClick={copyLinkToThread}
title={_t("timeline|mab|copy_link_thread")}
key="copy_link_to_thread"
>
<LinkIcon />
</RovingAccessibleButton>
</Toolbar>
);
}
+28 -43
View File
@@ -8,64 +8,49 @@ Please see LICENSE files in the repository root for full details.
import { useCallback } from "react";
import { JoinRule, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { SettingLevel } from "../settings/SettingLevel";
import { useSettingValue } from "./useSettings";
import SettingsStore from "../settings/SettingsStore";
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { MediaPreviewValue } from "../@types/media_preview";
import { useRoomState } from "./useRoomState";
const PRIVATE_JOIN_RULES: JoinRule[] = [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted];
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { computeMediaVisibility, setMediaVisibility } from "../utils/media/mediaVisibility";
/**
* Should the media event be visible in the client, or hidden.
* Determine whether media for an event should be visible in the client and expose a setter for
* a per-event override.
*
* This function uses the `mediaPreviewConfig` setting to determine the rules for the room
* along with the `showMediaEventIds` setting for specific events.
* Visibility is resolved from the effective `mediaPreviewConfig` setting together with any
* event-specific overrides stored in `showMediaEventIds`.
*
* A function may be provided to alter the visible state.
* @param mxEvent - The event that contains the media. If omitted, visibility is derived from the
* current setting defaults and the returned setter is a no-op.
*
* @param The event that contains the media. If not provided, the global rule is used.
*
* @returns Returns a tuple of:
* A boolean describing the hidden status.
* A function to show or hide the event.
* @returns A tuple containing the effective visibility for the event and a function that stores a
* device-local visibility override for that event.
*/
export function useMediaVisible(mxEvent?: MatrixEvent): [boolean, (visible: boolean) => void] {
const eventId = mxEvent?.getId();
const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", mxEvent?.getRoomId());
const client = useMatrixClientContext();
const roomId = mxEvent?.getRoomId();
const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId);
const eventVisibility = useSettingValue("showMediaEventIds");
const room = client.getRoom(mxEvent?.getRoomId()) ?? undefined;
const room = roomId ? (client.getRoom(roomId) ?? undefined) : undefined;
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const setMediaVisible = useCallback(
(visible: boolean) => {
SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
...eventVisibility,
[eventId!]: visible,
});
if (!mxEvent) return;
void setMediaVisibility(mxEvent, visible);
},
[eventId, eventVisibility],
[mxEvent],
);
const roomIsPrivate = joinRule ? PRIVATE_JOIN_RULES.includes(joinRule) : false;
const explicitEventVisiblity = eventId ? eventVisibility[eventId] : undefined;
// Always prefer the explicit per-event user preference here.
if (explicitEventVisiblity !== undefined) {
return [explicitEventVisiblity, setMediaVisible];
} else if (mxEvent?.getSender() === client.getUserId()) {
// If this event is ours and we've not set an explicit visibility, default to on.
return [true, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Off) {
return [false, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.On) {
return [true, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Private) {
return [roomIsPrivate, setMediaVisible];
} else {
// Invalid setting.
console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews);
return [false, setMediaVisible];
}
return [
computeMediaVisibility(
mediaPreviewSetting,
eventVisibility,
client.getUserId() ?? undefined,
mxEvent?.getId(),
mxEvent?.getSender(),
joinRule ? [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted].includes(joinRule) : false,
),
setMediaVisible,
];
}
-7
View File
@@ -32,7 +32,6 @@
"cancel": "Cancel",
"change": "Change",
"clear": "Clear",
"click": "Click",
"click_to_copy": "Click to copy",
"close": "Close",
"collapse": "Collapse",
@@ -66,7 +65,6 @@
"go": "Go",
"go_back": "Go back",
"got_it": "Got it",
"hide": "Hide",
"hide_advanced": "Hide advanced",
"hold": "Hold",
"ignore": "Ignore",
@@ -3316,7 +3314,6 @@
},
"empty_description": "Use “%(replyInThread)s” when hovering over a message.",
"empty_title": "Threads help keep your conversations on-topic and easy to track.",
"error_start_thread_existing_relation": "Can't create a thread from an event with an existing relation",
"mark_all_read": "Mark all as read",
"my_threads": "My threads",
"my_threads_description": "Shows all threads you've participated in",
@@ -3360,7 +3357,6 @@
"unable_to_decrypt": "Unable to decrypt message"
},
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
"download_action_decrypting": "Decrypting",
"download_action_downloading": "Downloading",
"download_failed": "Download failed",
"download_failed_description": "An error occurred while downloading this file",
@@ -3560,10 +3556,7 @@
"removed": "%(widgetName)s widget removed by %(senderName)s"
},
"mab": {
"collapse_reply_chain": "Collapse quotes",
"copy_link_thread": "Copy link to thread",
"expand_reply_chain": "Expand quotes",
"label": "Message Actions",
"view_in_room": "View in room"
},
"mjolnir": {
+122
View File
@@ -0,0 +1,122 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { JoinRule, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type MediaPreviewConfig, MediaPreviewValue } from "../../@types/media_preview";
import { SettingLevel } from "../../settings/SettingLevel";
import SettingsStore from "../../settings/SettingsStore";
/**
* Determine whether a room should be treated as private when applying media preview defaults.
*
* @param client - Matrix client used to resolve the room and its current join rule.
* @param roomId - Room to inspect. If omitted or unknown, the room is treated as non-private.
* @returns `true` when the room's join rule restricts membership, otherwise `false`.
*/
function isRoomPrivate(client: MatrixClient, roomId?: string): boolean {
const room = roomId ? client.getRoom(roomId) : undefined;
const joinRule = room?.currentState.getJoinRule();
switch (joinRule) {
case JoinRule.Invite:
case JoinRule.Knock:
case JoinRule.Restricted:
return true;
default:
return false;
}
}
/**
* Resolve whether media for a single event should be shown.
*
* Precedence is:
* 1. An explicit per-event override stored in `showMediaEventIds`
* 2. Always show media in events sent by the current user
* 3. Fall back to the room-level `mediaPreviewConfig` policy
*
* @param mediaPreviewSetting - Effective room-level media preview configuration.
* @param eventVisibility - Per-event visibility overrides keyed by event ID.
* @param userId - Current user ID, used to always show media sent by the local user.
* @param eventId - Event being evaluated. Used to look up any explicit override.
* @param sender - Sender of the event being evaluated.
* @param roomIsPrivate - Whether the event's room should use the private-room preview behavior.
* @returns `true` when media should be displayed for the event, otherwise `false`.
*/
export function computeMediaVisibility(
mediaPreviewSetting: MediaPreviewConfig,
eventVisibility: Record<string, boolean>,
userId: string | undefined,
eventId: string | undefined,
sender: string | undefined,
roomIsPrivate: boolean,
): boolean {
const explicitEventVisibility = eventId ? eventVisibility[eventId] : undefined;
if (explicitEventVisibility !== undefined) {
return explicitEventVisibility;
}
if (sender === userId) {
return true;
}
switch (mediaPreviewSetting.media_previews) {
case MediaPreviewValue.Off:
return false;
case MediaPreviewValue.On:
return true;
case MediaPreviewValue.Private:
return roomIsPrivate;
default:
console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews);
return false;
}
}
/**
* Compute the effective media visibility for a Matrix event using the current settings state.
*
* @param mxEvent - Event whose media visibility should be evaluated.
* @param client - Matrix client used to resolve the current user and room metadata.
* @returns `true` when media should be shown for the event, otherwise `false`.
*/
export function getMediaVisibility(mxEvent: MatrixEvent, client: MatrixClient): boolean {
const eventId = mxEvent.getId();
const roomId = mxEvent.getRoomId();
const mediaPreviewSetting = SettingsStore.getValue("mediaPreviewConfig", roomId);
const eventVisibility = SettingsStore.getValue("showMediaEventIds");
return computeMediaVisibility(
mediaPreviewSetting,
eventVisibility,
client.getUserId() ?? undefined,
eventId,
mxEvent.getSender(),
isRoomPrivate(client, roomId),
);
}
/**
* Persist a per-event override for whether media should be displayed on this device.
*
* @param mxEvent - Event whose media visibility override should be updated.
* @param visible - Whether media for the event should be shown.
* @returns A promise that resolves once the device-scoped setting has been updated.
*/
export async function setMediaVisibility(mxEvent: MatrixEvent, visible: boolean): Promise<void> {
const eventId = mxEvent.getId();
if (!eventId) return;
const eventVisibility = SettingsStore.getValue("showMediaEventIds");
await SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
...eventVisibility,
[eventId]: visible,
});
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import {
ActionBarAction,
BaseViewModel,
type ActionBarViewActions,
type ActionBarViewSnapshot,
} from "@element-hq/web-shared-components";
/** Props for the edit-history action bar view model. */
export interface EditHistoryActionBarViewModelProps {
/** Whether to include the remove action. */
canRemove: boolean;
/** Whether to include the view source action. */
showViewSource: boolean;
/** Called when the remove action is activated. */
onRemoveClick?: (anchor: HTMLElement | null) => void;
/** Called when the view source action is activated. */
onViewSourceClick?: (anchor: HTMLElement | null) => void;
}
/** View model for the label-style action bar shown in the edit-history panel. */
export class EditHistoryActionBarViewModel
extends BaseViewModel<ActionBarViewSnapshot, EditHistoryActionBarViewModelProps>
implements ActionBarViewActions
{
public constructor(props: EditHistoryActionBarViewModelProps) {
super(props, EditHistoryActionBarViewModel.buildSnapshot(props));
}
private static buildSnapshot(props: EditHistoryActionBarViewModelProps): ActionBarViewSnapshot {
const actions: ActionBarAction[] = [];
if (props.canRemove) {
actions.push(ActionBarAction.Remove);
}
if (props.showViewSource) {
actions.push(ActionBarAction.ViewSource);
}
return {
actions,
presentation: "label",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
};
}
/** Updates props and rebuilds the derived action-bar snapshot. */
public setProps(newProps: Partial<EditHistoryActionBarViewModelProps>): void {
this.props = {
...this.props,
...newProps,
};
this.snapshot.merge(EditHistoryActionBarViewModel.buildSnapshot(this.props));
}
/** Forwards the remove action using the triggering button as the anchor. */
public onRemoveClick = (anchor: HTMLElement | null): void => {
this.props.onRemoveClick?.(anchor);
};
/** Forwards the view source action using the triggering button as the anchor. */
public onViewSourceClick = (anchor: HTMLElement | null): void => {
this.props.onViewSourceClick?.(anchor);
};
}
@@ -0,0 +1,504 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import {
EventStatus,
EventTimeline,
EventType,
MatrixEventEvent,
M_BEACON_INFO,
MsgType,
RelationType,
RoomStateEvent,
type MatrixEvent,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import {
ActionBarAction,
BaseViewModel,
type ActionBarViewActions,
type ActionBarViewSnapshot,
} from "@element-hq/web-shared-components";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
import { type GetRelationsForEvent } from "../../components/views/rooms/EventTile";
import { canCancel, canEditContent, editEvent, isContentActionable } from "../../utils/EventUtils";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import Resend from "../../Resend";
import PinningUtils from "../../utils/PinningUtils";
import PosthogTrackers from "../../PosthogTrackers";
import { shouldDisplayReply } from "../../utils/Reply";
import { MediaEventHelper } from "../../utils/MediaEventHelper";
import SettingsStore from "../../settings/SettingsStore";
import { type SettingKey } from "../../settings/Settings";
import { getMediaVisibility, setMediaVisibility } from "../../utils/media/mediaVisibility";
import { FileDownloader } from "../../utils/FileDownloader";
import { _t } from "../../languageHandler";
import Modal from "../../Modal";
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
import { ModuleApi } from "../../modules/Api";
/** Props for the event-tile action bar view model. */
export interface EventTileActionBarViewModelProps {
/** The event whose available actions are being resolved. */
mxEvent: MatrixEvent;
/** The timeline context the event is rendered within. */
timelineRenderingType: TimelineRenderingType;
/** Whether the current user can send message-based actions such as reply. */
canSendMessages: boolean;
/** Whether the current user can react to the event. */
canReact: boolean;
/** Whether the tile is being rendered in search results. */
isSearch?: boolean;
/** Whether the tile is being rendered inside a card-style surface. */
isCard?: boolean;
/** Whether the quoted reply chain is currently expanded. */
isQuoteExpanded?: boolean;
/** Called when the overflow options action is activated. */
onOptionsClick?: (anchor: HTMLElement | null) => void;
/** Called when the reactions action is activated. */
onReactionsClick?: (anchor: HTMLElement | null) => void;
/** Provides relations needed for editing when available. */
getRelationsForEvent?: GetRelationsForEvent;
/** Called when the expand or collapse thread action is activated. */
onToggleThreadExpanded?: (anchor: HTMLElement | null) => void;
}
interface LocalActionBarState {
canDownload: boolean;
isDownloadLoading: boolean;
}
interface DerivedEventState {
showCancel: boolean;
showEdit: boolean;
showPinOrUnpin: boolean;
showReact: boolean;
showReply: boolean;
showExpandCollapse: boolean;
showReplyInThread: boolean;
showThreadForDeletedMessage: boolean;
isFailed: boolean;
isPinned: boolean;
isQuoteExpanded: boolean;
isThreadReplyAllowed: boolean;
}
interface DerivedMediaState {
showHide: boolean;
showDownload: boolean;
isDownloadEncrypted: boolean;
isDownloadLoading: boolean;
}
/** View model for the timeline event action bar shown on event tiles. */
export class EventTileActionBarViewModel
extends BaseViewModel<ActionBarViewSnapshot, EventTileActionBarViewModelProps>
implements ActionBarViewActions
{
private listenerCleanups: Array<() => void> = [];
private downloadPermissionRequestId = 0;
private downloadRequestId = 0;
private canDownload = true;
private isDownloadLoading = false;
private readonly downloader = new FileDownloader();
private downloadedBlob?: Blob;
public constructor(props: EventTileActionBarViewModelProps) {
super(
props,
EventTileActionBarViewModel.buildSnapshot(props, {
canDownload: true,
isDownloadLoading: false,
}),
);
this.setupListeners();
}
private static buildSnapshot(
props: EventTileActionBarViewModelProps,
localState: LocalActionBarState,
): ActionBarViewSnapshot {
const client = MatrixClientPeg.safeGet();
const eventState = EventTileActionBarViewModel.getDerivedEventState(props, client);
const mediaState = EventTileActionBarViewModel.getDerivedMediaState(props.mxEvent, client, localState);
return {
actions: EventTileActionBarViewModel.resolveActions(eventState, mediaState),
presentation: "icon",
isDownloadEncrypted: mediaState.isDownloadEncrypted,
isDownloadLoading: mediaState.isDownloadLoading,
isPinned: eventState.isPinned,
isQuoteExpanded: eventState.isQuoteExpanded,
isThreadReplyAllowed: eventState.isThreadReplyAllowed,
};
}
private static resolveActions(eventState: DerivedEventState, mediaState: DerivedMediaState): ActionBarAction[] {
const actions: ActionBarAction[] = [];
if (eventState.showCancel && eventState.isFailed) {
return [ActionBarAction.Resend, ActionBarAction.Cancel];
}
if (mediaState.showHide) {
actions.push(ActionBarAction.Hide);
}
if (mediaState.showDownload) {
actions.push(ActionBarAction.Download);
}
if (eventState.showReact) {
actions.push(ActionBarAction.React);
}
if (!eventState.showReply && eventState.showThreadForDeletedMessage) {
actions.push(ActionBarAction.ReplyInThread);
}
if (eventState.showReply) {
actions.push(ActionBarAction.Reply);
}
if (eventState.showReply && eventState.showReplyInThread) {
actions.push(ActionBarAction.ReplyInThread);
}
if (eventState.showEdit) {
actions.push(ActionBarAction.Edit);
}
if (eventState.showPinOrUnpin) {
actions.push(ActionBarAction.Pin);
}
if (eventState.showCancel) {
actions.push(ActionBarAction.Cancel);
}
if (eventState.showExpandCollapse) {
actions.push(ActionBarAction.Expand);
}
actions.push(ActionBarAction.Options);
return actions;
}
private static getDerivedEventState(
props: EventTileActionBarViewModelProps,
client: ReturnType<typeof MatrixClientPeg.safeGet>,
): DerivedEventState {
const { mxEvent } = props;
const contentActionable = isContentActionable(mxEvent);
const editStatus = mxEvent.replacingEvent()?.status;
const redactStatus = mxEvent.localRedactionEvent()?.status;
const relationType = mxEvent.getRelation()?.rel_type;
return {
showCancel: canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus),
showEdit: canEditContent(client, mxEvent),
showPinOrUnpin: PinningUtils.canPin(client, mxEvent) || PinningUtils.canUnpin(client, mxEvent),
showReact: contentActionable && props.canReact && !props.isSearch,
showReply: contentActionable && props.canSendMessages,
isThreadReplyAllowed: !(!!relationType && relationType !== RelationType.Thread),
showExpandCollapse: props.isQuoteExpanded !== undefined && shouldDisplayReply(mxEvent),
showReplyInThread: contentActionable && EventTileActionBarViewModel.canShowReplyInThreadAction(props),
showThreadForDeletedMessage:
!contentActionable &&
props.timelineRenderingType === TimelineRenderingType.Room &&
Boolean(mxEvent.getThread()),
isFailed: [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT),
isPinned: PinningUtils.isPinned(client, mxEvent),
isQuoteExpanded: props.isQuoteExpanded ?? false,
};
}
private static getDerivedMediaState(
mxEvent: MatrixEvent,
client: ReturnType<typeof MatrixClientPeg.safeGet>,
localState: LocalActionBarState,
): DerivedMediaState {
const contentActionable = isContentActionable(mxEvent);
const mediaHelper = MediaEventHelper.isEligible(mxEvent) ? new MediaEventHelper(mxEvent) : undefined;
return {
showDownload: contentActionable && Boolean(mediaHelper) && localState.canDownload,
showHide: contentActionable && MediaEventHelper.canHide(mxEvent) && getMediaVisibility(mxEvent, client),
isDownloadEncrypted: mediaHelper?.media.isEncrypted ?? false,
isDownloadLoading: localState.isDownloadLoading,
};
}
private computeSnapshot(): ActionBarViewSnapshot {
return EventTileActionBarViewModel.buildSnapshot(this.props, {
canDownload: this.canDownload,
isDownloadLoading: this.isDownloadLoading,
});
}
private static canShowReplyInThreadAction(props: EventTileActionBarViewModelProps): boolean {
const inNotThreadTimeline = props.timelineRenderingType !== TimelineRenderingType.Thread;
const content = props.mxEvent.getContent();
const isAllowedMessageType =
![MsgType.KeyVerificationRequest].includes(content.msgtype as MsgType) &&
!M_BEACON_INFO.matches(props.mxEvent.getType());
return inNotThreadTimeline && isAllowedMessageType;
}
private setupListeners(): void {
this.teardownListeners();
const { mxEvent } = this.props;
const roomId = mxEvent.getRoomId();
this.trackEvent(mxEvent, MatrixEventEvent.Status, this.refreshSnapshot);
this.trackEvent(mxEvent, MatrixEventEvent.Decrypted, this.refreshSnapshot);
this.trackEvent(mxEvent, MatrixEventEvent.BeforeRedaction, this.refreshSnapshot);
this.watchSetting("mediaPreviewConfig", roomId ?? null);
this.watchSetting("showMediaEventIds", null);
const roomState = roomId
? MatrixClientPeg.safeGet().getRoom(roomId)?.getLiveTimeline().getState(EventTimeline.FORWARDS)
: undefined;
if (roomState) {
roomState.on(RoomStateEvent.Events, this.onRoomEvent);
this.addListenerCleanup(() => roomState.off(RoomStateEvent.Events, this.onRoomEvent));
}
MatrixClientPeg.safeGet().decryptEventIfNeeded(mxEvent);
void this.updateDownloadPermission(++this.downloadPermissionRequestId);
}
private teardownListeners(): void {
for (const cleanup of this.listenerCleanups) {
cleanup();
}
this.listenerCleanups = [];
}
private addListenerCleanup(cleanup: () => void): void {
this.listenerCleanups.push(cleanup);
}
private trackEvent(event: MatrixEvent, eventName: MatrixEventEvent, callback: (...args: unknown[]) => void): void {
event.on(eventName, callback);
this.addListenerCleanup(() => event.off(eventName, callback));
}
private watchSetting(settingName: SettingKey, roomId: string | null): void {
const watcherRef = SettingsStore.watchSetting(settingName, roomId, this.refreshSnapshot);
this.addListenerCleanup(() => SettingsStore.unwatchSetting(watcherRef));
}
private readonly refreshSnapshot = (): void => {
this.snapshot.merge(this.computeSnapshot());
};
private resetEventState(): void {
this.downloadedBlob = undefined;
this.canDownload = true;
this.isDownloadLoading = false;
}
private isCurrentDownloadPermissionRequest(requestId: number, mxEvent: MatrixEvent): boolean {
return !this.isDisposed && requestId === this.downloadPermissionRequestId && this.props.mxEvent === mxEvent;
}
private updateDownloadPermissionState(requestId: number, mxEvent: MatrixEvent, canDownload: boolean): boolean {
if (!this.isCurrentDownloadPermissionRequest(requestId, mxEvent)) return false;
this.canDownload = canDownload;
this.refreshSnapshot();
return true;
}
private async updateDownloadPermission(requestId: number): Promise<void> {
const { mxEvent } = this.props;
const hints = ModuleApi.instance.customComponents.getHintsForMessage(mxEvent);
if (!hints?.allowDownloadingMedia) {
this.updateDownloadPermissionState(requestId, mxEvent, true);
return;
}
if (!this.updateDownloadPermissionState(requestId, mxEvent, false)) return;
try {
const canDownload = await hints.allowDownloadingMedia();
this.updateDownloadPermissionState(requestId, mxEvent, canDownload);
} catch (err) {
logger.error(`Failed to check media download permission for ${mxEvent.getId()}`, err);
this.updateDownloadPermissionState(requestId, mxEvent, false);
}
}
private isCurrentDownloadRequest(requestId: number, mxEvent: MatrixEvent): boolean {
return !this.isDisposed && requestId === this.downloadRequestId && this.props.mxEvent === mxEvent;
}
private setDownloadLoading(requestId: number, mxEvent: MatrixEvent, isDownloadLoading: boolean): boolean {
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return false;
this.isDownloadLoading = isDownloadLoading;
this.refreshSnapshot();
return true;
}
private readonly onRoomEvent = (event?: MatrixEvent): void => {
if (!event) return;
if (event.getType() !== EventType.RoomPinnedEvents && event.getType() !== EventType.RoomJoinRules) return;
this.refreshSnapshot();
};
/**
* Runs an action against the failed event variant that is still actionable.
*/
private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void {
const shouldUseEvent = checkFn ?? (() => true);
const { mxEvent } = this.props;
const tryOrder = [mxEvent.localRedactionEvent(), mxEvent.replacingEvent(), mxEvent];
for (const event of tryOrder) {
if (event && shouldUseEvent(event)) {
fn(event);
break;
}
}
}
/** Updates props, refreshes listeners when the event changes, and rebuilds the snapshot. */
public setProps(newProps: Partial<EventTileActionBarViewModelProps>): void {
const prevEvent = this.props.mxEvent;
const prevRoomId = prevEvent.getRoomId();
this.props = {
...this.props,
...newProps,
};
if (this.props.mxEvent !== prevEvent || this.props.mxEvent.getRoomId() !== prevRoomId) {
this.resetEventState();
this.setupListeners();
}
this.refreshSnapshot();
}
/** Removes listeners and releases resources owned by the view model. */
public override dispose(): void {
this.teardownListeners();
super.dispose();
}
/** Starts a reply to the current event. */
public onReplyClick = (_anchor: HTMLElement | null): void => {
defaultDispatcher.dispatch({
action: "reply_to_event",
event: this.props.mxEvent,
context: this.props.timelineRenderingType,
});
};
/** Opens the edit composer for the current event. */
public onEditClick = (_anchor: HTMLElement | null): void => {
editEvent(
MatrixClientPeg.safeGet(),
this.props.mxEvent,
this.props.timelineRenderingType,
this.props.getRelationsForEvent,
);
};
/** Retries sending the failed event variant that is still actionable. */
public onResendClick = (_anchor: HTMLElement | null): void => {
this.runActionOnFailedEv((event) => Resend.resend(MatrixClientPeg.safeGet(), event));
};
/** Cancels the failed event variant that is still cancellable. */
public onCancelClick = (_anchor: HTMLElement | null): void => {
this.runActionOnFailedEv(
(event) => Resend.removeFromQueue(MatrixClientPeg.safeGet(), event),
(event) => canCancel(event.status),
);
};
/** Pins or unpins the current event. */
public onPinClick = async (_anchor: HTMLElement | null): Promise<void> => {
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline");
};
/** Downloads the media content for the current event when available. */
public onDownloadClick = async (_anchor: HTMLElement | null): Promise<void> => {
if (this.isDownloadLoading || !this.canDownload) return;
const requestId = ++this.downloadRequestId;
const { mxEvent } = this.props;
try {
if (!this.setDownloadLoading(requestId, mxEvent, true)) return;
const mediaEventHelper = new MediaEventHelper(mxEvent);
if (!this.downloadedBlob) {
const downloadedBlob = await mediaEventHelper.sourceBlob.value;
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return;
this.downloadedBlob = downloadedBlob;
}
await this.downloader.download({
blob: this.downloadedBlob,
name: mediaEventHelper.fileName ?? _t("common|image"),
});
} catch (e) {
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return;
Modal.createDialog(ErrorDialog, {
title: _t("timeline|download_failed"),
description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`,
});
} finally {
this.setDownloadLoading(requestId, mxEvent, false);
}
};
/** Hides the media preview for the current event. */
public onHideClick = (_anchor: HTMLElement | null): void => {
void setMediaVisibility(this.props.mxEvent, false);
};
/** Forwards the expand or collapse thread action using the triggering button as the anchor. */
public onToggleThreadExpanded = (anchor: HTMLElement | null): void => {
this.props.onToggleThreadExpanded?.(anchor);
};
/** Forwards the overflow options action using the triggering button as the anchor. */
public onOptionsClick = (anchor: HTMLElement | null): void => {
this.props.onOptionsClick?.(anchor);
};
/** Forwards the reactions action using the triggering button as the anchor. */
public onReactionsClick = (anchor: HTMLElement | null): void => {
this.props.onReactionsClick?.(anchor);
};
/** Opens or starts the thread associated with the current event. */
public onReplyInThreadClick = (_anchor: HTMLElement | null): void => {
const { mxEvent, isCard } = this.props;
const thread = mxEvent.getThread();
if (thread?.rootEvent && !mxEvent.isThreadRoot) {
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: thread.rootEvent,
initialEvent: mxEvent,
scroll_into_view: true,
highlighted: true,
push: isCard,
});
return;
}
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: mxEvent,
push: isCard,
});
};
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import {
BaseViewModel,
ActionBarAction,
type ActionBarViewActions,
type ActionBarViewSnapshot,
} from "@element-hq/web-shared-components";
/** Props for the thread-list action bar view model. */
export interface ThreadListActionBarViewModelProps {
/** Called when the view in room action is activated. */
onViewInRoomClick?: (anchor: HTMLElement | null) => void;
/** Called when the copy link action is activated. */
onCopyLinkClick?: (anchor: HTMLElement | null) => void;
}
/** View model for the icon-only action bar shown in the thread list. */
export class ThreadListActionBarViewModel
extends BaseViewModel<ActionBarViewSnapshot, ThreadListActionBarViewModelProps>
implements ActionBarViewActions
{
public constructor(props: ThreadListActionBarViewModelProps) {
super(props, {
actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
}
/** Updates the action handlers exposed by the view model. */
public setProps(newProps: Partial<ThreadListActionBarViewModelProps>): void {
this.props = {
...this.props,
...newProps,
};
}
/** Forwards the view in room action using the triggering button as the anchor. */
public onViewInRoomClick = (anchor: HTMLElement | null): void => {
this.props.onViewInRoomClick?.(anchor);
};
/** Forwards the copy link action using the triggering button as the anchor. */
public onCopyLinkClick = (anchor: HTMLElement | null): void => {
this.props.onCopyLinkClick?.(anchor);
};
}