From c2d24295ab988f2a636432015c1c72bebcbedae7 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 29 May 2026 10:12:06 +0200 Subject: [PATCH 1/9] Normalize Leaf Adapter Pattern --- .../src/components/views/rooms/EventTile.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 907612b0ce4..19b1e47faab 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -513,7 +513,7 @@ export class UnwrappedEventTile extends React.Component
{threadState.thread.length} - +
); } @@ -521,7 +521,7 @@ export class UnwrappedEventTile extends React.Component private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode { if (threadState.shouldShowThreadSummary && threadState.thread) { return ( - return null; case "messageShared": return ( - @@ -1068,7 +1068,7 @@ export class UnwrappedEventTile extends React.Component } const actionBar = eventTileSnapshot.actionBar.show ? ( - let reactionsRow: JSX.Element | undefined; if (hasReactionsRow) { reactionsRow = ( - ): JSX.Element { +}: Readonly): JSX.Element { const cli = useMatrixClientContext(); const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( "room", @@ -1614,17 +1614,17 @@ function ThreadMessagePreviewWrapper({ return ; } -interface ThreadSummaryWrapperProps extends Omit, "aria-label" | "onClick"> { +interface ThreadSummaryAdapterProps extends Omit, "aria-label" | "onClick"> { mxEvent: MatrixEvent; thread: Thread; } -function ThreadSummaryWrapper({ +function ThreadSummaryAdapter({ mxEvent, thread, className, ...props -}: Readonly): JSX.Element { +}: Readonly): JSX.Element { const cli = useMatrixClientContext(); const { isCard } = useContext(CardContext); const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( @@ -1688,7 +1688,7 @@ function ThreadListActionBarAdapter({ return ; } -interface ReactionsRowButtonItemProps { +interface ReactionsRowButtonAdapterProps { mxEvent: MatrixEvent; content: string; count: number; @@ -1698,7 +1698,7 @@ interface ReactionsRowButtonItemProps { customReactionImagesEnabled?: boolean; } -function ReactionsRowButtonItem(props: Readonly): JSX.Element { +function ReactionsRowButtonAdapter(props: Readonly): JSX.Element { const client = useMatrixClientContext(); const vm = useCreateAutoDisposedViewModel( @@ -1761,12 +1761,12 @@ const getMyReactions = (reactions: Relations | null | undefined, userId?: string return [...myReactions.values()]; }; -interface ReactionsRowWrapperProps { +interface ReactionsRowAdapterProps { mxEvent: MatrixEvent; reactions?: Relations | null; } -function ReactionsRowWrapper({ mxEvent, reactions }: Readonly): JSX.Element | null { +function ReactionsRowAdapter({ mxEvent, reactions }: Readonly): JSX.Element | null { const roomContext = useContext(RoomContext); const userId = roomContext.room?.client.getUserId() ?? undefined; const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); @@ -1868,7 +1868,7 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly): JSX.Element { +}: Readonly): JSX.Element { const roomContext = useContext(RoomContext); const { isCard } = useContext(CardContext); const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); @@ -2052,7 +2052,7 @@ function ActionBarWrapper({ ); } -interface E2eMessageSharedIconWrapperProps { +interface E2eMessageSharedIconAdapterProps { /** * The ID of the room containing the event whose keys were shared. */ @@ -2063,10 +2063,10 @@ interface E2eMessageSharedIconWrapperProps { keyForwardingUserId: string; } -function E2eMessageSharedIconWrapper({ +function E2eMessageSharedIconAdapter({ roomId, keyForwardingUserId, -}: Readonly): JSX.Element { +}: Readonly): JSX.Element { const client = useMatrixClientContext(); const vm = useCreateAutoDisposedViewModel( () => From e64c1fbb9ee512cfdeb2d1a71cad4e9555d73201 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 29 May 2026 10:28:22 +0200 Subject: [PATCH 2/9] Extract simple EventTile adapters --- .../src/components/views/rooms/EventTile.tsx | 204 +----------------- .../EventTile/E2eMessageSharedIconAdapter.tsx | 56 +++++ .../EventTile/MessageTimestampAdapter.tsx | 35 +++ .../EventTile/ThreadListActionBarAdapter.tsx | 34 +++ .../EventTile/ThreadMessagePreviewAdapter.tsx | 58 +++++ .../rooms/EventTile/ThreadSummaryAdapter.tsx | 68 ++++++ 6 files changed, 257 insertions(+), 198 deletions(-) create mode 100644 apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 19b1e47faab..c7f54420db8 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -20,7 +20,6 @@ import React, { type MouseEvent, type ReactNode, } from "react"; -import classNames from "classnames"; import { type EventStatus, EventType, @@ -42,13 +41,9 @@ import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-de import { useCreateAutoDisposedViewModel, ActionBarView, - E2eMessageSharedIconView, - MessageTimestampView, PinnedMessageBadge, ReactionsRowButtonView, ReactionsRowView, - ThreadMessagePreviewView, - ThreadSummaryView, TileErrorView, useViewModel, } from "@element-hq/web-shared-components"; @@ -87,18 +82,20 @@ import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; -import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; import PinningUtils from "../../../utils/PinningUtils"; import { EventPreview } from "./EventPreview"; import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; +import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter"; +import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter"; +import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter"; +import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter"; +import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter"; import SettingsStore from "../../../settings/SettingsStore"; import { CardContext } from "../right_panel/context"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; import { EventTileViewModel, type EventTileViewModelProps, } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; -import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel"; import { getEventTileReceiptState, type EventTileReceiptState, @@ -121,14 +118,7 @@ import { initialEventTileInteractionState, type EventTileInteractionState, } from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState"; -import { - type MessageTimestampViewModel, - type MessageTimestampViewModelProps, -} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; -import { - ThreadMessagePreviewViewModel, - ThreadSummaryViewModel, -} from "../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; +import { type MessageTimestampViewModelProps } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; import { MAX_ITEMS_WHEN_LIMITED, @@ -141,7 +131,6 @@ import { } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; -import { type ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; @@ -1551,143 +1540,6 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { ); } -interface MessageTimestampAdapterProps { - vm: MessageTimestampViewModel; - timestampProps: MessageTimestampViewModelProps; -} - -function MessageTimestampAdapter({ vm, timestampProps }: Readonly): JSX.Element { - useEffect(() => { - vm.setProps(timestampProps); - }, [vm, timestampProps]); - - return ( - <> - {timestampProps.receivedTs ? ( - - ) : undefined} - - - ); -} - -interface ThreadMessagePreviewAdapterProps { - thread: Thread; - showDisplayName?: boolean; -} - -function ThreadMessagePreviewAdapter({ - thread, - showDisplayName = false, -}: Readonly): JSX.Element { - const cli = useMatrixClientContext(); - const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( - "room", - "timelineRenderingType", - "lowBandwidth", - ); - const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadMessagePreviewViewModel({ - cli, - thread, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - showDisplayName, - avatarClassName: "mx_BaseAvatar", - }), - ); - - useEffect(() => { - vm.setClient(cli); - vm.setThread(thread); - vm.setRoom(room); - vm.setTimelineRenderingType(timelineRenderingType); - vm.setLowBandwidth(lowBandwidth); - vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); - vm.setShowDisplayName(showDisplayName); - }, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]); - - return ; -} - -interface ThreadSummaryAdapterProps extends Omit, "aria-label" | "onClick"> { - mxEvent: MatrixEvent; - thread: Thread; -} - -function ThreadSummaryAdapter({ - mxEvent, - thread, - className, - ...props -}: Readonly): JSX.Element { - const cli = useMatrixClientContext(); - const { isCard } = useContext(CardContext); - const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( - "narrow", - "room", - "timelineRenderingType", - "lowBandwidth", - ); - const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadSummaryViewModel({ - cli, - mxEvent, - thread, - narrow, - isCard, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - avatarClassName: "mx_BaseAvatar", - }), - ); - - useEffect(() => { - vm.setClient(cli); - vm.setRootEvent(mxEvent); - vm.setThread(thread); - vm.setNarrow(narrow); - vm.setIsCard(isCard); - vm.setRoom(room); - vm.setTimelineRenderingType(timelineRenderingType); - vm.setLowBandwidth(lowBandwidth); - vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); - }, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]); - - return ; -} - -interface ThreadListActionBarAdapterProps { - vm: ThreadListActionBarViewModel; - onViewInRoomClick: (anchor: HTMLElement | null) => void; - onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; - className?: string; -} - -function ThreadListActionBarAdapter({ - vm, - onViewInRoomClick, - onCopyLinkClick, - className, -}: Readonly): JSX.Element { - useEffect(() => { - vm.setProps({ - onViewInRoomClick, - onCopyLinkClick, - }); - }, [vm, onViewInRoomClick, onCopyLinkClick]); - - return ; -} - interface ReactionsRowButtonAdapterProps { mxEvent: MatrixEvent; content: string; @@ -2051,47 +1903,3 @@ function ActionBarAdapter({ ); } - -interface E2eMessageSharedIconAdapterProps { - /** - * The ID of the room containing the event whose keys were shared. - */ - roomId: string; - /** - * The ID of the user who shared the keys. - */ - keyForwardingUserId: string; -} - -function E2eMessageSharedIconAdapter({ - roomId, - keyForwardingUserId, -}: Readonly): JSX.Element { - const client = useMatrixClientContext(); - const vm = useCreateAutoDisposedViewModel( - () => - new E2eMessageSharedIconViewModel({ - client, - roomId, - keyForwardingUserId, - }), - ); - - useEffect(() => { - vm.setRoomId(roomId); - }, [roomId, vm]); - - useEffect(() => { - vm.setKeyForwardingUserId(keyForwardingUserId); - }, [keyForwardingUserId, vm]); - - return ( - - ); -} diff --git a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx new file mode 100644 index 00000000000..f55f3d9aa9e --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx @@ -0,0 +1,56 @@ +/* +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 React, { useEffect, type JSX } from "react"; +import { E2eMessageSharedIconView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { E2eMessageSharedIconViewModel } from "../../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel"; + +interface E2eMessageSharedIconAdapterProps { + /** + * The ID of the room containing the event whose keys were shared. + */ + roomId: string; + /** + * The ID of the user who shared the keys. + */ + keyForwardingUserId: string; +} + +export function E2eMessageSharedIconAdapter({ + roomId, + keyForwardingUserId, +}: Readonly): JSX.Element { + const client = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel( + () => + new E2eMessageSharedIconViewModel({ + client, + roomId, + keyForwardingUserId, + }), + ); + + useEffect(() => { + vm.setRoomId(roomId); + }, [roomId, vm]); + + useEffect(() => { + vm.setKeyForwardingUserId(keyForwardingUserId); + }, [keyForwardingUserId, vm]); + + return ( + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx new file mode 100644 index 00000000000..1e19a9b5fcc --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx @@ -0,0 +1,35 @@ +/* +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 React, { useEffect, type JSX } from "react"; +import { MessageTimestampView } from "@element-hq/web-shared-components"; + +import { + type MessageTimestampViewModel, + type MessageTimestampViewModelProps, +} from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; +import { Icon as LateIcon } from "../../../../../res/img/sensor.svg"; + +interface MessageTimestampAdapterProps { + vm: MessageTimestampViewModel; + timestampProps: MessageTimestampViewModelProps; +} + +export function MessageTimestampAdapter({ vm, timestampProps }: Readonly): JSX.Element { + useEffect(() => { + vm.setProps(timestampProps); + }, [vm, timestampProps]); + + return ( + <> + {timestampProps.receivedTs ? ( + + ) : undefined} + + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx new file mode 100644 index 00000000000..dda68099233 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx @@ -0,0 +1,34 @@ +/* +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 React, { useEffect, type JSX } from "react"; +import { ActionBarView } from "@element-hq/web-shared-components"; + +import { type ThreadListActionBarViewModel } from "../../../../viewmodels/room/ThreadListActionBarViewModel"; + +interface ThreadListActionBarAdapterProps { + vm: ThreadListActionBarViewModel; + onViewInRoomClick: (anchor: HTMLElement | null) => void; + onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; + className?: string; +} + +export function ThreadListActionBarAdapter({ + vm, + onViewInRoomClick, + onCopyLinkClick, + className, +}: Readonly): JSX.Element { + useEffect(() => { + vm.setProps({ + onViewInRoomClick, + onCopyLinkClick, + }); + }, [vm, onViewInRoomClick, onCopyLinkClick]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx new file mode 100644 index 00000000000..94179b66057 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx @@ -0,0 +1,58 @@ +/* +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 React, { useEffect, type JSX } from "react"; +import { type Thread } from "matrix-js-sdk/src/matrix"; +import { ThreadMessagePreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { ThreadMessagePreviewViewModel } from "../../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; + +interface ThreadMessagePreviewAdapterProps { + thread: Thread; + showDisplayName?: boolean; +} + +export function ThreadMessagePreviewAdapter({ + thread, + showDisplayName = false, +}: Readonly): JSX.Element { + const cli = useMatrixClientContext(); + const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( + "room", + "timelineRenderingType", + "lowBandwidth", + ); + const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); + const vm = useCreateAutoDisposedViewModel( + () => + new ThreadMessagePreviewViewModel({ + cli, + thread, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + showDisplayName, + avatarClassName: "mx_BaseAvatar", + }), + ); + + useEffect(() => { + vm.setClient(cli); + vm.setThread(thread); + vm.setRoom(room); + vm.setTimelineRenderingType(timelineRenderingType); + vm.setLowBandwidth(lowBandwidth); + vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); + vm.setShowDisplayName(showDisplayName); + }, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx new file mode 100644 index 00000000000..8845604830d --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx @@ -0,0 +1,68 @@ +/* +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 React, { useContext, useEffect, type JSX } from "react"; +import classNames from "classnames"; +import { type MatrixEvent, type Thread } from "matrix-js-sdk/src/matrix"; +import { ThreadSummaryView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import { CardContext } from "../../right_panel/context"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { ThreadSummaryViewModel } from "../../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; + +interface ThreadSummaryAdapterProps extends Omit, "aria-label" | "onClick"> { + mxEvent: MatrixEvent; + thread: Thread; +} + +export function ThreadSummaryAdapter({ + mxEvent, + thread, + className, + ...props +}: Readonly): JSX.Element { + const cli = useMatrixClientContext(); + const { isCard } = useContext(CardContext); + const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( + "narrow", + "room", + "timelineRenderingType", + "lowBandwidth", + ); + const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); + const vm = useCreateAutoDisposedViewModel( + () => + new ThreadSummaryViewModel({ + cli, + mxEvent, + thread, + narrow, + isCard, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + avatarClassName: "mx_BaseAvatar", + }), + ); + + useEffect(() => { + vm.setClient(cli); + vm.setRootEvent(mxEvent); + vm.setThread(thread); + vm.setNarrow(narrow); + vm.setIsCard(isCard); + vm.setRoom(room); + vm.setTimelineRenderingType(timelineRenderingType); + vm.setLowBandwidth(lowBandwidth); + vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); + }, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]); + + return ; +} From 8a82fc76a65f162649f88808e309a5e42064d8e2 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 29 May 2026 10:44:16 +0200 Subject: [PATCH 3/9] Move simple EventTile adapter VM ownership to EventTileViewModel --- .../src/components/views/rooms/EventTile.tsx | 15 ++++---- .../EventTile/E2eMessageSharedIconAdapter.tsx | 23 +++++++------ .../EventTile/MessageTimestampAdapter.tsx | 20 +++++++---- .../EventTile/ThreadListActionBarAdapter.tsx | 11 ++++-- .../EventTile/ThreadMessagePreviewAdapter.tsx | 29 ++++++++-------- .../rooms/EventTile/ThreadSummaryAdapter.tsx | 33 +++++++++--------- .../E2eMessageSharedIconViewModel.ts | 8 +++++ .../timeline/event-tile/EventTileViewModel.ts | 34 +++++++++++++++++++ 8 files changed, 115 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index c7f54420db8..da65ab68a48 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -502,7 +502,7 @@ export class UnwrappedEventTile extends React.Component
{threadState.thread.length} - +
); } @@ -511,6 +511,7 @@ export class UnwrappedEventTile extends React.Component if (threadState.shouldShowThreadSummary && threadState.thread) { return ( case "messageShared": return ( @@ -1082,7 +1084,8 @@ export class UnwrappedEventTile extends React.Component ) : null; const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? ( ) : ( @@ -1090,7 +1093,8 @@ export class UnwrappedEventTile extends React.Component ); const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? ( ) : ( @@ -1304,10 +1308,7 @@ export class UnwrappedEventTile extends React.Component {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( ): JSX.Element { const client = useMatrixClientContext(); - const vm = useCreateAutoDisposedViewModel( - () => - new E2eMessageSharedIconViewModel({ - client, - roomId, - keyForwardingUserId, - }), - ); + const vm = eventTileViewModel.getE2eMessageSharedIconViewModel({ + client, + roomId, + keyForwardingUserId, + }); + + useEffect(() => { + vm.setClient(client); + }, [client, vm]); useEffect(() => { vm.setRoomId(roomId); diff --git a/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx index 1e19a9b5fcc..255ac6ccd98 100644 --- a/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx @@ -8,18 +8,26 @@ Please see LICENSE files in the repository root for full details. import React, { useEffect, type JSX } from "react"; import { MessageTimestampView } from "@element-hq/web-shared-components"; -import { - type MessageTimestampViewModel, - type MessageTimestampViewModelProps, -} from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { type MessageTimestampViewModelProps } from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; import { Icon as LateIcon } from "../../../../../res/img/sensor.svg"; interface MessageTimestampAdapterProps { - vm: MessageTimestampViewModel; + eventTileViewModel: EventTileViewModel; + kind: "plain" | "linked"; timestampProps: MessageTimestampViewModelProps; } -export function MessageTimestampAdapter({ vm, timestampProps }: Readonly): JSX.Element { +export function MessageTimestampAdapter({ + eventTileViewModel, + kind, + timestampProps, +}: Readonly): JSX.Element { + const vm = + kind === "linked" + ? eventTileViewModel.getLinkedMessageTimestampViewModel(timestampProps) + : eventTileViewModel.getMessageTimestampViewModel(timestampProps); + useEffect(() => { vm.setProps(timestampProps); }, [vm, timestampProps]); diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx index dda68099233..d33cc83b312 100644 --- a/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx @@ -8,21 +8,26 @@ Please see LICENSE files in the repository root for full details. import React, { useEffect, type JSX } from "react"; import { ActionBarView } from "@element-hq/web-shared-components"; -import { type ThreadListActionBarViewModel } from "../../../../viewmodels/room/ThreadListActionBarViewModel"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; interface ThreadListActionBarAdapterProps { - vm: ThreadListActionBarViewModel; + eventTileViewModel: EventTileViewModel; onViewInRoomClick: (anchor: HTMLElement | null) => void; onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; className?: string; } export function ThreadListActionBarAdapter({ - vm, + eventTileViewModel, onViewInRoomClick, onCopyLinkClick, className, }: Readonly): JSX.Element { + const vm = eventTileViewModel.getThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }); + useEffect(() => { vm.setProps({ onViewInRoomClick, diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx index 94179b66057..a7f7b3c8ca5 100644 --- a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx @@ -7,19 +7,21 @@ Please see LICENSE files in the repository root for full details. import React, { useEffect, type JSX } from "react"; import { type Thread } from "matrix-js-sdk/src/matrix"; -import { ThreadMessagePreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import { ThreadMessagePreviewView } from "@element-hq/web-shared-components"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; import { useSettingValue } from "../../../../hooks/useSettings"; -import { ThreadMessagePreviewViewModel } from "../../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; interface ThreadMessagePreviewAdapterProps { + eventTileViewModel: EventTileViewModel; thread: Thread; showDisplayName?: boolean; } export function ThreadMessagePreviewAdapter({ + eventTileViewModel, thread, showDisplayName = false, }: Readonly): JSX.Element { @@ -30,19 +32,16 @@ export function ThreadMessagePreviewAdapter({ "lowBandwidth", ); const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadMessagePreviewViewModel({ - cli, - thread, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - showDisplayName, - avatarClassName: "mx_BaseAvatar", - }), - ); + const vm = eventTileViewModel.getThreadMessagePreviewViewModel({ + cli, + thread, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + showDisplayName, + avatarClassName: "mx_BaseAvatar", + }); useEffect(() => { vm.setClient(cli); diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx index 8845604830d..a223b21d4da 100644 --- a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx @@ -8,20 +8,22 @@ Please see LICENSE files in the repository root for full details. import React, { useContext, useEffect, type JSX } from "react"; import classNames from "classnames"; import { type MatrixEvent, type Thread } from "matrix-js-sdk/src/matrix"; -import { ThreadSummaryView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import { ThreadSummaryView } from "@element-hq/web-shared-components"; import { CardContext } from "../../right_panel/context"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; import { useSettingValue } from "../../../../hooks/useSettings"; -import { ThreadSummaryViewModel } from "../../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; interface ThreadSummaryAdapterProps extends Omit, "aria-label" | "onClick"> { + eventTileViewModel: EventTileViewModel; mxEvent: MatrixEvent; thread: Thread; } export function ThreadSummaryAdapter({ + eventTileViewModel, mxEvent, thread, className, @@ -36,21 +38,18 @@ export function ThreadSummaryAdapter({ "lowBandwidth", ); const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadSummaryViewModel({ - cli, - mxEvent, - thread, - narrow, - isCard, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - avatarClassName: "mx_BaseAvatar", - }), - ); + const vm = eventTileViewModel.getThreadSummaryViewModel({ + cli, + mxEvent, + thread, + narrow, + isCard, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + avatarClassName: "mx_BaseAvatar", + }); useEffect(() => { vm.setClient(cli); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts index c992b4f54be..70e5464c0d4 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts @@ -33,6 +33,14 @@ export class E2eMessageSharedIconViewModel this.disposables.track(() => this.teardownRoomStateListener()); } + public setClient(client: MatrixClient): void { + if (this.props.client === client) return; + + this.props = { ...this.props, client }; + this.setupRoomStateListener(); + this.updateSnapshotFromProps(); + } + public setRoomId(roomId: string): void { if (this.props.roomId === roomId) return; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index ac6800bee97..9ed5539492a 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -34,6 +34,16 @@ import { import { TimelineRenderingType } from "../../../../contexts/RoomContext"; import { type Layout } from "../../../../settings/enums/Layout"; import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel"; +import { + ThreadMessagePreviewViewModel, + type ThreadMessagePreviewViewModelProps, + ThreadSummaryViewModel, + type ThreadSummaryViewModelProps, +} from "./ThreadSummaryViewModel.tsx"; +import { + E2eMessageSharedIconViewModel, + type E2eMessageSharedIconViewModelProps, +} from "./E2eMessageSharedIconViewModel"; import { ThreadListActionBarViewModel, type ThreadListActionBarViewModelProps, @@ -285,7 +295,10 @@ export interface EventTileRenderState { export class EventTileViewModel extends BaseViewModel { private messageTimestampViewModel?: MessageTimestampViewModel; private linkedMessageTimestampViewModel?: MessageTimestampViewModel; + private threadMessagePreviewViewModel?: ThreadMessagePreviewViewModel; + private threadSummaryViewModel?: ThreadSummaryViewModel; private threadListActionBarViewModel?: ThreadListActionBarViewModel; + private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel; public constructor(props: EventTileViewModelProps) { const initialRenderState = EventTileViewModel.createRenderState(props); @@ -302,7 +315,10 @@ export class EventTileViewModel extends BaseViewModel Date: Fri, 29 May 2026 10:56:19 +0200 Subject: [PATCH 4/9] Release listener-owning EventTile child VMs on adapter unmount --- .../EventTile/E2eMessageSharedIconAdapter.tsx | 5 +++++ .../EventTile/ThreadMessagePreviewAdapter.tsx | 5 +++++ .../rooms/EventTile/ThreadSummaryAdapter.tsx | 5 +++++ .../timeline/event-tile/EventTileViewModel.ts | 18 ++++++++++++++++++ 4 files changed, 33 insertions(+) diff --git a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx index 7ec7f074410..a1e38e68d75 100644 --- a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx @@ -35,6 +35,11 @@ export function E2eMessageSharedIconAdapter({ keyForwardingUserId, }); + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseE2eMessageSharedIconViewModel(); + }, [eventTileViewModel]); + useEffect(() => { vm.setClient(client); }, [client, vm]); diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx index a7f7b3c8ca5..83d61a30493 100644 --- a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx @@ -43,6 +43,11 @@ export function ThreadMessagePreviewAdapter({ avatarClassName: "mx_BaseAvatar", }); + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseThreadMessagePreviewViewModel(); + }, [eventTileViewModel]); + useEffect(() => { vm.setClient(cli); vm.setThread(thread); diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx index a223b21d4da..7aba814a244 100644 --- a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx @@ -51,6 +51,11 @@ export function ThreadSummaryAdapter({ avatarClassName: "mx_BaseAvatar", }); + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseThreadSummaryViewModel(); + }, [eventTileViewModel]); + useEffect(() => { vm.setClient(cli); vm.setRootEvent(mxEvent); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index 9ed5539492a..0324677aa6d 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -340,12 +340,24 @@ export class EventTileViewModel extends BaseViewModel Date: Fri, 29 May 2026 11:08:28 +0200 Subject: [PATCH 5/9] Extract stateful EventTile adapters --- .../src/components/views/rooms/EventTile.tsx | 396 +----------------- .../rooms/EventTile/ActionBarAdapter.tsx | 159 +++++++ .../rooms/EventTile/ReactionsRowAdapter.tsx | 262 ++++++++++++ 3 files changed, 426 insertions(+), 391 deletions(-) create mode 100644 apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx create mode 100644 apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index da65ab68a48..f66c9bc0557 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -9,11 +9,7 @@ Please see LICENSE files in the repository root for full details. import React, { createRef, - useCallback, - useContext, useEffect, - useMemo, - useState, type JSX, type Ref, type FocusEvent, @@ -27,7 +23,6 @@ import { MatrixEventEvent, type Relations, type Room, - RelationsEvent, RoomEvent, type RoomMember, type Thread, @@ -36,17 +31,9 @@ import { import { logger } from "matrix-js-sdk/src/logger"; import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { Tooltip } from "@vector-im/compound-web"; -import { uniqueId, uniqBy } from "lodash"; +import { uniqueId } from "lodash"; import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { - useCreateAutoDisposedViewModel, - ActionBarView, - PinnedMessageBadge, - ReactionsRowButtonView, - ReactionsRowView, - TileErrorView, - useViewModel, -} from "@element-hq/web-shared-components"; +import { useCreateAutoDisposedViewModel, PinnedMessageBadge, TileErrorView } from "@element-hq/web-shared-components"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -55,7 +42,7 @@ import { Layout } from "../../../settings/enums/Layout"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; -import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu"; +import { aboveRightOf } from "../../structures/ContextMenu"; import { objectHasDiff } from "../../../utils/objects"; import type EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; @@ -68,9 +55,7 @@ import PlatformPeg from "../../../PlatformPeg"; import MemberAvatar from "../avatars/MemberAvatar"; import SenderProfile from "../messages/SenderProfile"; import { type IReadReceiptPosition } from "./ReadReceiptMarker"; -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 { copyPlaintext } from "../../../utils/strings"; @@ -84,14 +69,14 @@ import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationB import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import PinningUtils from "../../../utils/PinningUtils"; import { EventPreview } from "./EventPreview"; +import { ActionBarAdapter } from "./EventTile/ActionBarAdapter"; import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter"; import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter"; +import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter"; import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter"; import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter"; import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter"; -import SettingsStore from "../../../settings/SettingsStore"; -import { CardContext } from "../right_panel/context"; import { EventTileViewModel, type EventTileViewModelProps, @@ -119,19 +104,12 @@ import { type EventTileInteractionState, } from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState"; import { type MessageTimestampViewModelProps } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; -import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; -import { - MAX_ITEMS_WHEN_LIMITED, - ReactionsRowViewModel, -} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; import { getEventTileReactionRelations, isEventTileReactionRelation, type GetRelationsForEvent, } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; -import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel"; @@ -1540,367 +1518,3 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { ); } - -interface ReactionsRowButtonAdapterProps { - mxEvent: MatrixEvent; - content: string; - count: number; - reactionEvents: MatrixEvent[]; - myReactionEvent?: MatrixEvent; - disabled?: boolean; - customReactionImagesEnabled?: boolean; -} - -function ReactionsRowButtonAdapter(props: Readonly): JSX.Element { - const client = useMatrixClientContext(); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowButtonViewModel({ - client, - mxEvent: props.mxEvent, - content: props.content, - count: props.count, - reactionEvents: props.reactionEvents, - myReactionEvent: props.myReactionEvent, - disabled: props.disabled, - customReactionImagesEnabled: props.customReactionImagesEnabled, - }), - ); - - useEffect(() => { - vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); - }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); - - useEffect(() => { - vm.setCount(props.count); - }, [props.count, vm]); - - useEffect(() => { - vm.setMyReactionEvent(props.myReactionEvent); - }, [props.myReactionEvent, vm]); - - useEffect(() => { - vm.setDisabled(props.disabled); - }, [props.disabled, vm]); - - return ; -} - -interface ReactionGroup { - content: string; - events: MatrixEvent[]; -} - -const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => - reactions - ?.getSortedAnnotationsByKey() - ?.map(([content, events]) => ({ - content, - events: [...events], - })) - .filter(({ events }) => events.length > 0) ?? []; - -const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { - if (!reactions || !userId) { - return null; - } - - const myReactions = reactions.getAnnotationsBySender()?.[userId]; - if (!myReactions) { - return null; - } - - return [...myReactions.values()]; -}; - -interface ReactionsRowAdapterProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; -} - -function ReactionsRowAdapter({ mxEvent, reactions }: Readonly): JSX.Element | null { - const roomContext = useContext(RoomContext); - const userId = roomContext.room?.client.getUserId() ?? undefined; - const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); - const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); - const [menuDisplayed, setMenuDisplayed] = useState(false); - const [menuAnchorRect, setMenuAnchorRect] = useState(null); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowViewModel({ - isActionable: isContentActionable(mxEvent), - reactionGroupCount: reactionGroups.length, - canReact: roomContext.canReact, - addReactionButtonActive: false, - }), - ); - - const openReactionMenu = useCallback((event: React.MouseEvent): void => { - setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); - setMenuDisplayed(true); - }, []); - - const closeReactionMenu = useCallback((): void => { - setMenuDisplayed(false); - }, []); - - const updateReactionsState = useCallback((): void => { - const nextReactionGroups = getReactionGroups(reactions); - setReactionGroups(nextReactionGroups); - setMyReactions(getMyReactions(reactions, userId)); - vm.setReactionGroupCount(nextReactionGroups.length); - }, [reactions, userId, vm]); - - useEffect(() => { - vm.setActionable(isContentActionable(mxEvent)); - }, [mxEvent, vm]); - - useEffect(() => { - vm.setCanReact(roomContext.canReact); - if (!roomContext.canReact && menuDisplayed) { - setMenuDisplayed(false); - } - }, [roomContext.canReact, menuDisplayed, vm]); - - useEffect(() => { - vm.setAddReactionHandlers({ - onAddReactionClick: openReactionMenu, - onAddReactionContextMenu: openReactionMenu, - }); - }, [openReactionMenu, vm]); - - useEffect(() => { - vm.setAddReactionButtonActive(menuDisplayed); - }, [menuDisplayed, vm]); - - useEffect(() => { - updateReactionsState(); - }, [updateReactionsState]); - - useEffect(() => { - if (!reactions) return; - - reactions.on(RelationsEvent.Add, updateReactionsState); - reactions.on(RelationsEvent.Remove, updateReactionsState); - reactions.on(RelationsEvent.Redaction, updateReactionsState); - - return () => { - reactions.off(RelationsEvent.Add, updateReactionsState); - reactions.off(RelationsEvent.Remove, updateReactionsState); - reactions.off(RelationsEvent.Redaction, updateReactionsState); - }; - }, [reactions, updateReactionsState]); - - useEffect(() => { - const onDecrypted = (): void => { - vm.setActionable(isContentActionable(mxEvent)); - }; - - if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { - mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); - } - - return () => { - mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); - }; - }, [mxEvent, vm]); - - const snapshot = useViewModel(vm); - const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); - const items = useMemo((): JSX.Element[] | undefined => { - const mappedItems = reactionGroups.map(({ content, events }) => { - // Deduplicate reaction events by sender per Matrix spec. - const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); - const myReactionEvent = myReactions?.find((reactionEvent) => { - if (reactionEvent.isRedacted()) { - return false; - } - return reactionEvent.getRelation()?.key === content; - }); - - return ( - - ); - }); - - if (!mappedItems.length) { - return undefined; - } - - return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; - }, [ - reactionGroups, - myReactions, - mxEvent, - customReactionImagesEnabled, - roomContext.canReact, - roomContext.canSelfRedact, - snapshot.showAllButtonVisible, - ]); - - if (!snapshot.isVisible || !items?.length) { - return null; - } - - let contextMenu: JSX.Element | undefined; - if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { - contextMenu = ( - - - - ); - } - - return ( - <> - - {items} - - {contextMenu} - - ); -} - -interface ActionBarAdapterProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; - permalinkCreator?: RoomPermalinkCreator; - getTile: () => IEventTileType | null; - getReplyChain: () => ReplyChain | null; - onFocusChange?: (focused: boolean) => void; - isQuoteExpanded?: boolean; - toggleThreadExpanded: () => void; - getRelationsForEvent?: GetRelationsForEvent; -} - -function ActionBarAdapter({ - 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/ActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx new file mode 100644 index 00000000000..670bc8a10c2 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx @@ -0,0 +1,159 @@ +/* +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 React, { useCallback, useContext, useEffect, useState, type JSX } from "react"; +import { type MatrixEvent, type Relations } from "matrix-js-sdk/src/matrix"; +import { ActionBarView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import type ReplyChain from "../../elements/ReplyChain"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import MessageContextMenu from "../../context_menus/MessageContextMenu"; +import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu"; +import RoomContext from "../../../../contexts/RoomContext"; +import { CardContext } from "../../right_panel/context"; +import { type RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import { EventTileActionBarViewModel } from "../../../../viewmodels/room/EventTileActionBarViewModel"; +import { type GetRelationsForEvent } from "../../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; + +interface ActionBarEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +interface ActionBarEventTile { + getEventTileOps?(): ActionBarEventTileOps; +} + +interface ActionBarAdapterProps { + mxEvent: MatrixEvent; + reactions?: Relations | null; + permalinkCreator?: RoomPermalinkCreator; + getTile: () => ActionBarEventTile | null; + getReplyChain: () => ReplyChain | null; + onFocusChange?: (focused: boolean) => void; + isQuoteExpanded?: boolean; + toggleThreadExpanded: () => void; + getRelationsForEvent?: GetRelationsForEvent; +} + +export function ActionBarAdapter({ + 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/ReactionsRowAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx new file mode 100644 index 00000000000..76413cef9fc --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx @@ -0,0 +1,262 @@ +/* +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 React, { useCallback, useContext, useEffect, useMemo, useState, type JSX } from "react"; +import { MatrixEventEvent, type MatrixEvent, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix"; +import { uniqBy } from "lodash"; +import { + ReactionsRowButtonView, + ReactionsRowView, + useCreateAutoDisposedViewModel, + useViewModel, +} from "@element-hq/web-shared-components"; + +import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import RoomContext from "../../../../contexts/RoomContext"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import SettingsStore from "../../../../settings/SettingsStore"; +import { isContentActionable } from "../../../../utils/EventUtils"; +import { ReactionsRowButtonViewModel } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; +import { + MAX_ITEMS_WHEN_LIMITED, + ReactionsRowViewModel, +} from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; + +interface ReactionsRowButtonAdapterProps { + mxEvent: MatrixEvent; + content: string; + count: number; + reactionEvents: MatrixEvent[]; + myReactionEvent?: MatrixEvent; + disabled?: boolean; + customReactionImagesEnabled?: boolean; +} + +function ReactionsRowButtonAdapter(props: Readonly): JSX.Element { + const client = useMatrixClientContext(); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowButtonViewModel({ + client, + mxEvent: props.mxEvent, + content: props.content, + count: props.count, + reactionEvents: props.reactionEvents, + myReactionEvent: props.myReactionEvent, + disabled: props.disabled, + customReactionImagesEnabled: props.customReactionImagesEnabled, + }), + ); + + useEffect(() => { + vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); + }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); + + useEffect(() => { + vm.setCount(props.count); + }, [props.count, vm]); + + useEffect(() => { + vm.setMyReactionEvent(props.myReactionEvent); + }, [props.myReactionEvent, vm]); + + useEffect(() => { + vm.setDisabled(props.disabled); + }, [props.disabled, vm]); + + return ; +} + +interface ReactionGroup { + content: string; + events: MatrixEvent[]; +} + +const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => + reactions + ?.getSortedAnnotationsByKey() + ?.map(([content, events]) => ({ + content, + events: [...events], + })) + .filter(({ events }) => events.length > 0) ?? []; + +const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { + if (!reactions || !userId) { + return null; + } + + const myReactions = reactions.getAnnotationsBySender()?.[userId]; + if (!myReactions) { + return null; + } + + return [...myReactions.values()]; +}; + +interface ReactionsRowAdapterProps { + mxEvent: MatrixEvent; + reactions?: Relations | null; +} + +export function ReactionsRowAdapter({ mxEvent, reactions }: Readonly): JSX.Element | null { + const roomContext = useContext(RoomContext); + const userId = roomContext.room?.client.getUserId() ?? undefined; + const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); + const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); + const [menuDisplayed, setMenuDisplayed] = useState(false); + const [menuAnchorRect, setMenuAnchorRect] = useState(null); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowViewModel({ + isActionable: isContentActionable(mxEvent), + reactionGroupCount: reactionGroups.length, + canReact: roomContext.canReact, + addReactionButtonActive: false, + }), + ); + + const openReactionMenu = useCallback((event: React.MouseEvent): void => { + setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); + setMenuDisplayed(true); + }, []); + + const closeReactionMenu = useCallback((): void => { + setMenuDisplayed(false); + }, []); + + const updateReactionsState = useCallback((): void => { + const nextReactionGroups = getReactionGroups(reactions); + setReactionGroups(nextReactionGroups); + setMyReactions(getMyReactions(reactions, userId)); + vm.setReactionGroupCount(nextReactionGroups.length); + }, [reactions, userId, vm]); + + useEffect(() => { + vm.setActionable(isContentActionable(mxEvent)); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setCanReact(roomContext.canReact); + if (!roomContext.canReact && menuDisplayed) { + setMenuDisplayed(false); + } + }, [roomContext.canReact, menuDisplayed, vm]); + + useEffect(() => { + vm.setAddReactionHandlers({ + onAddReactionClick: openReactionMenu, + onAddReactionContextMenu: openReactionMenu, + }); + }, [openReactionMenu, vm]); + + useEffect(() => { + vm.setAddReactionButtonActive(menuDisplayed); + }, [menuDisplayed, vm]); + + useEffect(() => { + updateReactionsState(); + }, [updateReactionsState]); + + useEffect(() => { + if (!reactions) return; + + reactions.on(RelationsEvent.Add, updateReactionsState); + reactions.on(RelationsEvent.Remove, updateReactionsState); + reactions.on(RelationsEvent.Redaction, updateReactionsState); + + return () => { + reactions.off(RelationsEvent.Add, updateReactionsState); + reactions.off(RelationsEvent.Remove, updateReactionsState); + reactions.off(RelationsEvent.Redaction, updateReactionsState); + }; + }, [reactions, updateReactionsState]); + + useEffect(() => { + const onDecrypted = (): void => { + vm.setActionable(isContentActionable(mxEvent)); + }; + + if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { + mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); + } + + return () => { + mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mxEvent, vm]); + + const snapshot = useViewModel(vm); + const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); + const items = useMemo((): JSX.Element[] | undefined => { + const mappedItems = reactionGroups.map(({ content, events }) => { + // Deduplicate reaction events by sender per Matrix spec. + const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); + const myReactionEvent = myReactions?.find((reactionEvent) => { + if (reactionEvent.isRedacted()) { + return false; + } + return reactionEvent.getRelation()?.key === content; + }); + + return ( + + ); + }); + + if (!mappedItems.length) { + return undefined; + } + + return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; + }, [ + reactionGroups, + myReactions, + mxEvent, + customReactionImagesEnabled, + roomContext.canReact, + roomContext.canSelfRedact, + snapshot.showAllButtonVisible, + ]); + + if (!snapshot.isVisible || !items?.length) { + return null; + } + + let contextMenu: JSX.Element | undefined; + if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { + contextMenu = ( + + + + ); + } + + return ( + <> + + {items} + + {contextMenu} + + ); +} From e39667b56c83661032cb9823ad6c98cf6d9410e4 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 29 May 2026 11:14:31 +0200 Subject: [PATCH 6/9] Move action bar VM ownership to EventTileViewModel --- .../src/components/views/rooms/EventTile.tsx | 1 + .../rooms/EventTile/ActionBarAdapter.tsx | 40 ++++++++++--------- .../timeline/event-tile/EventTileViewModel.ts | 15 +++++++ 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index f66c9bc0557..26fc2976333 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -1038,6 +1038,7 @@ export class UnwrappedEventTile extends React.Component const actionBar = eventTileSnapshot.actionBar.show ? ( { 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, - }), - ); + const vm = eventTileViewModel.getActionBarViewModel({ + mxEvent, + timelineRenderingType: roomContext.timelineRenderingType, + canSendMessages: roomContext.canSendMessages, + canReact: roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded, + onToggleThreadExpanded: toggleThreadExpanded, + onOptionsClick: handleOptionsClick, + onReactionsClick: handleReactionsClick, + getRelationsForEvent, + }); + + useEffect(() => { + // This child VM owns Matrix and settings listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseActionBarViewModel(); + }, [eventTileViewModel]); useEffect(() => { vm.setProps({ diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index 0324677aa6d..1ac851a6a7e 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -48,6 +48,7 @@ import { ThreadListActionBarViewModel, type ThreadListActionBarViewModelProps, } from "../../ThreadListActionBarViewModel"; +import { EventTileActionBarViewModel, type EventTileActionBarViewModelProps } from "../../EventTileActionBarViewModel"; /** Event-level inputs for deriving the EventTile snapshot. */ export interface EventTileEventInput { @@ -299,6 +300,7 @@ export class EventTileViewModel extends BaseViewModel Date: Fri, 29 May 2026 11:21:24 +0200 Subject: [PATCH 7/9] Move reactions row VM ownership into EventTileViewModel --- .../src/components/views/rooms/EventTile.tsx | 1 + .../rooms/EventTile/ReactionsRowAdapter.tsx | 33 +++++++++++-------- .../timeline/event-tile/EventTileViewModel.ts | 15 +++++++++ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 26fc2976333..e6d02f4e3ca 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -1089,6 +1089,7 @@ export class UnwrappedEventTile extends React.Component if (hasReactionsRow) { reactionsRow = ( ): JSX.Element | null { +export function ReactionsRowAdapter({ + eventTileViewModel, + mxEvent, + reactions, +}: Readonly): JSX.Element | null { const roomContext = useContext(RoomContext); const userId = roomContext.room?.client.getUserId() ?? undefined; const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); @@ -113,15 +116,17 @@ export function ReactionsRowAdapter({ mxEvent, reactions }: Readonly(null); - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowViewModel({ - isActionable: isContentActionable(mxEvent), - reactionGroupCount: reactionGroups.length, - canReact: roomContext.canReact, - addReactionButtonActive: false, - }), - ); + const vm = eventTileViewModel.getReactionsRowViewModel({ + isActionable: isContentActionable(mxEvent), + reactionGroupCount: reactionGroups.length, + canReact: roomContext.canReact, + addReactionButtonActive: false, + }); + + useEffect(() => { + // This child VM is owned by EventTileViewModel, but scoped to this rendered adapter surface. + return () => eventTileViewModel.releaseReactionsRowViewModel(); + }, [eventTileViewModel]); const openReactionMenu = useCallback((event: React.MouseEvent): void => { setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index 1ac851a6a7e..f707582036b 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -49,6 +49,7 @@ import { type ThreadListActionBarViewModelProps, } from "../../ThreadListActionBarViewModel"; import { EventTileActionBarViewModel, type EventTileActionBarViewModelProps } from "../../EventTileActionBarViewModel"; +import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reactions/ReactionsRowViewModel"; /** Event-level inputs for deriving the EventTile snapshot. */ export interface EventTileEventInput { @@ -301,6 +302,7 @@ export class EventTileViewModel extends BaseViewModel Date: Fri, 29 May 2026 11:38:04 +0200 Subject: [PATCH 8/9] Extract EventTile receipt rendering --- .../src/components/views/rooms/EventTile.tsx | 71 +++--------- .../views/rooms/EventTile/ReceiptAdapter.tsx | 101 ++++++++++++++++++ 2 files changed, 115 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index e6d02f4e3ca..72d70ee64fe 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -30,9 +30,8 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; -import { Tooltip } from "@vector-im/compound-web"; import { uniqueId } from "lodash"; -import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useCreateAutoDisposedViewModel, PinnedMessageBadge, TileErrorView } from "@element-hq/web-shared-components"; import ReplyChain from "../elements/ReplyChain"; @@ -46,8 +45,6 @@ import { aboveRightOf } from "../../structures/ContextMenu"; import { objectHasDiff } from "../../../utils/objects"; import type EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import NotificationBadge from "./NotificationBadge"; import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper"; import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; @@ -63,7 +60,6 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../../PosthogTrackers"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; -import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; @@ -74,6 +70,7 @@ import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter"; import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter"; import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter"; +import { ReceiptAdapter } from "./EventTile/ReceiptAdapter"; import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter"; import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter"; import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter"; @@ -1102,21 +1099,18 @@ export class UnwrappedEventTile extends React.Component const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock(); const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock(); - const receiptState = this.receiptState; - let msgOption: JSX.Element | undefined; - if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { - msgOption = ; - } else if (this.props.showReadReceipts) { - msgOption = ( - - ); - } + const msgOption = ( + + ); const replyChainState = getEventTileReplyChainState({ mxEvent: this.props.mxEvent, @@ -1483,40 +1477,3 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => { ); }; export default SafeEventTile; - -interface ISentReceiptProps { - messageState: EventStatus | undefined; -} - -function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { - const isSent = !messageState || messageState === "sent"; - const isFailed = messageState === "not_sent"; - - let icon: JSX.Element | undefined; - let label: string | undefined; - if (messageState === "encrypting") { - icon = ; - label = _t("timeline|send_state_encrypting"); - } else if (isSent) { - icon = ; - label = _t("timeline|send_state_sent"); - } else if (isFailed) { - icon = ; - label = _t("timeline|send_state_failed"); - } else { - icon = ; - label = _t("timeline|send_state_sending"); - } - - return ( -
-
- -
- {icon} -
-
-
-
- ); -} diff --git a/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx new file mode 100644 index 00000000000..1803583a93d --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx @@ -0,0 +1,101 @@ +/* +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 React, { type JSX } from "react"; +import { type EventStatus, type RoomMember } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; +import { CheckCircleIcon, CircleIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { StaticNotificationState } from "../../../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../NotificationBadge"; +import { ReadReceiptGroup } from "../ReadReceiptGroup"; +import { type IReadReceiptPosition } from "../ReadReceiptMarker"; +import { type EventTileReceiptState } from "../../../../viewmodels/room/timeline/event-tile/EventTileReceiptState"; + +interface ReadReceiptProps { + userId: string; + roomMember: RoomMember | null; + ts: number; +} + +interface ReceiptAdapterProps { + receiptState: EventTileReceiptState; + eventSendStatus?: EventStatus; + showReadReceipts?: boolean; + readReceipts?: ReadReceiptProps[]; + readReceiptMap?: { [userId: string]: IReadReceiptPosition }; + checkUnmounting?: () => boolean; + suppressAnimation: boolean; + isTwelveHour?: boolean; +} + +export function ReceiptAdapter({ + receiptState, + eventSendStatus, + showReadReceipts, + readReceipts, + readReceiptMap, + checkUnmounting, + suppressAnimation, + isTwelveHour, +}: Readonly): JSX.Element | null { + if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { + return ; + } + + if (!showReadReceipts) { + return null; + } + + return ( + + ); +} + +interface SentReceiptProps { + messageState: EventStatus | undefined; +} + +function SentReceipt({ messageState }: Readonly): JSX.Element { + const isSent = !messageState || messageState === "sent"; + const isFailed = messageState === "not_sent"; + + let icon: JSX.Element | undefined; + let label: string | undefined; + if (messageState === "encrypting") { + icon = ; + label = _t("timeline|send_state_encrypting"); + } else if (isSent) { + icon = ; + label = _t("timeline|send_state_sent"); + } else if (isFailed) { + icon = ; + label = _t("timeline|send_state_failed"); + } else { + icon = ; + label = _t("timeline|send_state_sending"); + } + + return ( +
+
+ +
+ {icon} +
+
+
+
+ ); +} From 3ce4fdbff5aff399c5919da5efe9eaf18a00fc09 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 29 May 2026 11:56:41 +0200 Subject: [PATCH 9/9] Extract EventTile sender identity rendering --- .../src/components/views/rooms/EventTile.tsx | 38 ++++------- .../rooms/EventTile/SenderIdentityAdapter.tsx | 63 +++++++++++++++++++ 2 files changed, 74 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 72d70ee64fe..2644edc7103 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -49,8 +49,6 @@ import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; import PlatformPeg from "../../../PlatformPeg"; -import MemberAvatar from "../avatars/MemberAvatar"; -import SenderProfile from "../messages/SenderProfile"; import { type IReadReceiptPosition } from "./ReadReceiptMarker"; import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; @@ -71,6 +69,7 @@ import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAda import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter"; import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter"; import { ReceiptAdapter } from "./EventTile/ReceiptAdapter"; +import { EventTileAvatarAdapter, EventTileSenderAdapter } from "./EventTile/SenderIdentityAdapter"; import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter"; import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter"; import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter"; @@ -1007,31 +1006,16 @@ export class UnwrappedEventTile extends React.Component // Local echos have a send "status". const scrollToken = eventTileRenderState.root.scrollToken; - let avatar: JSX.Element | null = null; - let sender: JSX.Element | null = null; - const { avatarSize } = eventTileSnapshot.sender.profileState; - - if (this.props.mxEvent.sender && avatarSize !== null) { - avatar = ( -
- -
- ); - } - - const senderProfileMode = eventTileSnapshot.sender.profileMode; - if (senderProfileMode === "clickable") { - sender = ; - } else if (senderProfileMode === "tooltip") { - sender = ; - } else if (senderProfileMode === "default") { - sender = ; - } + const avatar = ( + + ); + const sender = ( + + ); const actionBar = eventTileSnapshot.actionBar.show ? ( ): JSX.Element | null { + const { avatarSize } = senderSnapshot.profileState; + + if (!mxEvent.sender || avatarSize === null) { + return null; + } + + return ( +
+ +
+ ); +} + +interface EventTileSenderAdapterProps { + mxEvent: MatrixEvent; + senderSnapshot: EventTileSenderSnapshot; + onSenderProfileClick: () => void; +} + +export function EventTileSenderAdapter({ + mxEvent, + senderSnapshot, + onSenderProfileClick, +}: Readonly): JSX.Element | null { + switch (senderSnapshot.profileMode) { + case "clickable": + return ; + case "tooltip": + return ; + case "default": + return ; + default: + return null; + } +}