"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,
},
[
@@ -1348,15 +1374,15 @@ export class UnwrappedEventTile extends React.Component
"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({
@@ -1411,9 +1437,9 @@ export class UnwrappedEventTile extends React.Component
{this.renderThreadPanelSummary()}
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
-
)}
@@ -1481,8 +1507,8 @@ export class UnwrappedEventTile extends React.Component
"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
);
}
+
+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;
+}
+
+function ThreadListActionBarWrapper({
+ onViewInRoomClick,
+ onCopyLinkClick,
+}: Readonly): JSX.Element {
+ const vm = useCreateAutoDisposedViewModel(
+ () =>
+ new ThreadListActionBarViewModel({
+ onViewInRoomClick,
+ onCopyLinkClick,
+ }),
+ );
+
+ useEffect(() => {
+ vm.setProps({
+ onViewInRoomClick,
+ onCopyLinkClick,
+ });
+ }, [vm, onViewInRoomClick, onCopyLinkClick]);
+
+ return ;
+}
+
+function ActionBarWrapper({
+ mxEvent,
+ reactions,
+ permalinkCreator,
+ getTile,
+ getReplyChain,
+ onFocusChange,
+ isQuoteExpanded,
+ toggleThreadExpanded,
+ getRelationsForEvent,
+}: Readonly): JSX.Element {
+ const roomContext = useContext(RoomContext);
+ const { isCard } = useContext(CardContext);
+ const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null);
+ const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(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 (
+ <>
+
+ {optionsMenuAnchorRect ? (
+
+ ) : null}
+ {reactionsMenuAnchorRect ? (
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx
deleted file mode 100644
index bd29b53fb7..0000000000
--- a/apps/web/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx
+++ /dev/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 (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/hooks/useMediaVisible.ts b/apps/web/src/hooks/useMediaVisible.ts
index de0b0fbf6d..58540be8fb 100644
--- a/apps/web/src/hooks/useMediaVisible.ts
+++ b/apps/web/src/hooks/useMediaVisible.ts
@@ -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,
+ ];
}
diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json
index 283e58fcb6..187908a642 100644
--- a/apps/web/src/i18n/strings/en_EN.json
+++ b/apps/web/src/i18n/strings/en_EN.json
@@ -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": {
diff --git a/apps/web/src/utils/media/mediaVisibility.ts b/apps/web/src/utils/media/mediaVisibility.ts
new file mode 100644
index 0000000000..daebd52715
--- /dev/null
+++ b/apps/web/src/utils/media/mediaVisibility.ts
@@ -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,
+ 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 {
+ const eventId = mxEvent.getId();
+ if (!eventId) return;
+
+ const eventVisibility = SettingsStore.getValue("showMediaEventIds");
+
+ await SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
+ ...eventVisibility,
+ [eventId]: visible,
+ });
+}
diff --git a/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts b/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts
new file mode 100644
index 0000000000..b0e2848c80
--- /dev/null
+++ b/apps/web/src/viewmodels/message-body/EditHistoryActionBarViewModel.ts
@@ -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
+ 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): 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);
+ };
+}
diff --git a/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts b/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts
new file mode 100644
index 0000000000..ab6dacde49
--- /dev/null
+++ b/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts
@@ -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
+ 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,
+ ): 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,
+ 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 {
+ 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): 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 => {
+ 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 => {
+ 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({
+ action: Action.ShowThread,
+ rootEvent: thread.rootEvent,
+ initialEvent: mxEvent,
+ scroll_into_view: true,
+ highlighted: true,
+ push: isCard,
+ });
+ return;
+ }
+
+ defaultDispatcher.dispatch({
+ action: Action.ShowThread,
+ rootEvent: mxEvent,
+ push: isCard,
+ });
+ };
+}
diff --git a/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts b/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts
new file mode 100644
index 0000000000..60cbaf3c27
--- /dev/null
+++ b/apps/web/src/viewmodels/room/ThreadListActionBarViewModel.ts
@@ -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
+ 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): 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);
+ };
+}
diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx
index a1b43be232..77bbdd8d47 100644
--- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx
+++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx
@@ -948,7 +948,10 @@ describe("RoomView", () => {
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible();
});
- await userEvent.hover(getByText("search term"));
+ const searchResultTile = getByText("search term").closest(".mx_EventTile");
+ expect(searchResultTile).not.toBeNull();
+
+ await userEvent.hover(searchResultTile!);
await userEvent.click(await findByLabelText("Edit"));
await waitFor(() => {
@@ -1014,7 +1017,10 @@ describe("RoomView", () => {
});
const prom = untilDispatch(Action.ViewRoom, defaultDispatcher);
- await userEvent.hover(getByText("search term"));
+ const searchResultTile = getByText("search term").closest(".mx_EventTile");
+ expect(searchResultTile).not.toBeNull();
+
+ await userEvent.hover(searchResultTile!);
await userEvent.click(await findByLabelText("Edit"));
await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap
index 048caf3d52..1ced7286ed 100644
--- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap
+++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap
@@ -86,15 +86,23 @@ exports[` should match the snapshot 1`] = `
@@ -224,15 +232,23 @@ exports[` should support events with 1`] = `
@@ -278,15 +294,23 @@ exports[` should support events with 1`] = `
@@ -314,15 +338,23 @@ exports[` should support events with 1`] = `
@@ -332,7 +364,7 @@ exports[` should support events with 1`] = `
-
@@ -169,7 +122,7 @@ exports[` should render 1`] = `