From 3bb9cb2234c5a9a943522122c8b4a46d9155bd4c Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 28 May 2026 10:57:55 +0200 Subject: [PATCH] Refactor ThreadSummary to MVVM (#33603) * Refactor ThreadSummary to MVVM * Stories Snapshot images * Add ThreadSummary ViewModel coverage * Fix ThreadSummary preview avatar rendering * Remove ThreadSummary classnames helper * Fix Prettier * Match ThreadMessagePreview typography * Move folder to correct path and fix storybook path * Catch ThreadSummary preview refresh errors * Update snapshot images + fix prettier * Fix ThreadSummary classNames import * Update Images * Remove wrong path --- .../playwright/e2e/threads/threads.spec.ts | 34 +- apps/web/res/css/_components.pcss | 1 - apps/web/res/css/views/rooms/_EventTile.pcss | 21 +- .../res/css/views/rooms/_ThreadSummary.pcss | 125 --- .../src/components/views/rooms/EventTile.tsx | 111 ++- .../components/views/rooms/ThreadSummary.tsx | 111 --- .../event-tile/ThreadSummaryViewModel.tsx | 682 ++++++++++++++ .../ThreadSummaryViewModel-test.tsx | 888 ++++++++++++++++++ .../decryption-failure-auto.png | Bin 0 -> 8077 bytes .../default-auto.png | Bin 0 -> 7845 bytes .../ThreadSummary.stories.tsx/hidden-auto.png | Bin 0 -> 3523 bytes .../ThreadSummary.stories.tsx/narrow-auto.png | Bin 0 -> 7072 bytes .../with-notification-auto.png | Bin 0 -> 8006 bytes packages/shared-components/src/index.ts | 1 + .../ThreadSummary/ThreadSummary.module.css | 121 +++ .../ThreadSummary/ThreadSummary.stories.tsx | 106 +++ .../ThreadSummary/ThreadSummary.test.tsx | 80 ++ .../ThreadSummary/ThreadSummaryView.tsx | 183 ++++ .../__snapshots__/ThreadSummary.test.tsx.snap | 265 ++++++ .../EventTileView/ThreadSummary/index.tsx | 18 + 20 files changed, 2485 insertions(+), 262 deletions(-) delete mode 100644 apps/web/res/css/views/rooms/_ThreadSummary.pcss delete mode 100644 apps/web/src/components/views/rooms/ThreadSummary.tsx create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx create mode 100644 apps/web/test/viewmodels/event-tiles/ThreadSummaryViewModel-test.tsx create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/decryption-failure-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/hidden-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/narrow-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/with-notification-auto.png create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.module.css create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.test.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummaryView.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/__snapshots__/ThreadSummary.test.tsx.snap create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/index.tsx diff --git a/apps/web/playwright/e2e/threads/threads.spec.ts b/apps/web/playwright/e2e/threads/threads.spec.ts index 5241e654d2..b143c4a83a 100644 --- a/apps/web/playwright/e2e/threads/threads.spec.ts +++ b/apps/web/playwright/e2e/threads/threads.spec.ts @@ -64,8 +64,8 @@ test.describe("Threads", () => { // User asserts timeline thread summary visible & clicks it let locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached(); + await expect(locator.getByText("BotBob")).toBeAttached(); + await expect(locator.getByText(MessageLong)).toBeAttached(); await locator.click(); // Wait until the both messages are read @@ -122,8 +122,8 @@ test.describe("Threads", () => { // User asserts summary was updated correctly locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText("Test")).toBeAttached(); + await expect(locator.getByText("Tom")).toBeAttached(); + await expect(locator.getByText("Test")).toBeAttached(); //////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Check reactions and hidden events @@ -243,8 +243,8 @@ test.describe("Threads", () => { // User asserts summary was updated correctly locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached(); + await expect(locator.getByText("BotBob")).toBeAttached(); + await expect(locator.getByText(MessageLong)).toBeAttached(); // User closes right panel after clicking back to thread list locator = page.locator(".mx_ThreadPanel"); @@ -255,8 +255,8 @@ test.describe("Threads", () => { await bot.sendMessage(roomId, "How are things?", threadId); locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); + await expect(locator.getByText("BotBob")).toBeAttached(); + await expect(locator.getByText("How are things?")).toBeAttached(); locator = page.getByRole("banner").getByRole("button", { name: "Threads" }); await expect(locator).toHaveAttribute("data-indicator", "success"); // User asserts thread list unread indicator @@ -265,7 +265,7 @@ test.describe("Threads", () => { // User asserts thread with correct root & latest events & unread dot locator = page.locator(".mx_ThreadPanel .mx_EventTile_last"); await expect(locator.locator(".mx_EventTile_body").getByText("Hello Mr. Bot")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); + await expect(locator.getByText("How are things?")).toBeAttached(); // Check the number of the replies await expect(locator.locator(".mx_ThreadPanel_replies_amount").getByText("2")).toBeAttached(); // Make sure the notification dot is visible @@ -279,8 +279,8 @@ test.describe("Threads", () => { await locator.press("Enter"); locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText("Great!")).toBeAttached(); + await expect(locator.getByText("Tom")).toBeAttached(); + await expect(locator.getByText("Great!")).toBeAttached(); // User edits & asserts locator = page.locator(".mx_ThreadView .mx_EventTile_last"); @@ -291,8 +291,8 @@ test.describe("Threads", () => { await locator.getByRole("textbox").press("Enter"); locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content")).toHaveText("Great! How about yourself?"); + await expect(locator.getByText("Tom")).toBeAttached(); + await expect(locator).toContainText("Great! How about yourself?"); // User closes right panel await page.locator(".mx_ThreadPanel").getByRole("button", { name: "Close" }).click(); @@ -302,8 +302,8 @@ test.describe("Threads", () => { // User asserts locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks")).toBeAttached(); + await expect(locator.getByText("BotBob")).toBeAttached(); + await expect(locator.getByText("I'm very good thanks")).toBeAttached(); // Bot edits their latest event await bot.sendMessage(roomId, { @@ -321,8 +321,8 @@ test.describe("Threads", () => { // User asserts locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); - await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); - await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks :)")).toBeAttached(); + await expect(locator.getByText("BotBob")).toBeAttached(); + await expect(locator.getByText("I'm very good thanks :)")).toBeAttached(); }); test.describe("with larger viewport", async () => { diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 30fa3fa972..4e52de8a7f 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -293,7 +293,6 @@ @import "./views/rooms/_SendMessageComposer.pcss"; @import "./views/rooms/_Stickers.pcss"; @import "./views/rooms/_ThirdPartyMemberInfo.pcss"; -@import "./views/rooms/_ThreadSummary.pcss"; @import "./views/rooms/_TopUnreadMessagesBar.pcss"; @import "./views/rooms/_UserIdentityWarning.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index 78c17bf1d8..967f282cf4 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -1066,12 +1066,23 @@ $left-gutter: 64px; .mx_ThreadPanel_replies_amount { @mixin ThreadRepliesAmount; } + } +} - .mx_ThreadSummary_content { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } +.mx_ThreadSummary_icon { + display: inline-block; + margin-top: $spacing-8; + margin-bottom: $spacing-8; + + > svg { + @mixin ThreadSummaryIcon; + vertical-align: middle; + margin-inline-end: $spacing-8; + margin-top: -2px; + } + + a& { + color: $secondary-content; } } diff --git a/apps/web/res/css/views/rooms/_ThreadSummary.pcss b/apps/web/res/css/views/rooms/_ThreadSummary.pcss deleted file mode 100644 index a030918f24..0000000000 --- a/apps/web/res/css/views/rooms/_ThreadSummary.pcss +++ /dev/null @@ -1,125 +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. -*/ - -.mx_ThreadSummary, -.mx_ThreadSummary_content { - color: $secondary-content; -} - -.mx_ThreadSummary, -.mx_ThreadSummary_icon { - margin-top: $spacing-8; -} - -.mx_ThreadSummary { - min-width: 267px; - /* leave space on both left & right gutters */ - max-width: min(calc(100% - var(--EventTile_group_line-spacing-inline-start)), 600px); - width: fit-content; - height: 40px; - position: relative; - background-color: $panels; - padding-inline: $spacing-12 $spacing-16; - display: flex; - align-items: center; - justify-content: flex-start; - border-radius: 8px; - box-sizing: border-box; - clear: both; - overflow: hidden; - border: 1px solid $panels; /* always render a border so the hover effect doesn't require a re-layout */ - font: var(--cpd-font-body-sm-regular); - - .mx_ThreadSummary_chevron { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 60px; - box-sizing: border-box; - /* XXX: We use an HEXA `transparent` to work around a Safari <15.4 bug */ - background: linear-gradient(270deg, $system 50%, $system-transparent 100%); - - opacity: 0; - transform: translateX(60px); - transition: all 0.1s ease-in-out; - - svg { - position: absolute; - top: 50%; - right: var(--cpd-space-1x); - transform: translateY(-50%); - width: 24px; - height: 24px; - color: $secondary-content; - } - } - - &:hover, - &:focus { - border-color: $quinary-content; - - .mx_ThreadSummary_chevron { - opacity: 1; - transform: translateX(0); - } - } - - .mx_ThreadSummary_sender, - .mx_ThreadSummary_content, - .mx_ThreadSummary_replies_amount { - line-height: var(--EventTile_ThreadSummary-line-height); - } - - .mx_ThreadSummary_sender, - .mx_ThreadSummary_content { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - .mx_ThreadSummary_sender { - font-weight: var(--cpd-font-weight-semibold); - } - - .mx_ThreadSummary_content { - margin-left: $spacing-4; - flex: 1; - } - - .mx_ThreadSummary_replies_amount { - @mixin ThreadRepliesAmount; - } - - .mx_MessagePanel_narrow & { - min-width: initial; - max-width: 100%; /* prevent overflow */ - width: initial; - } -} - -.mx_ThreadSummary_avatar { - margin-inline-end: $spacing-8; - flex-shrink: 0; -} - -.mx_ThreadSummary_icon { - display: inline-block; - margin-bottom: $spacing-8; - - > svg { - @mixin ThreadSummaryIcon; - vertical-align: middle; - margin-inline-end: $spacing-8; - margin-top: -2px; - } - - a& { - color: $secondary-content; - } -} diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 239509d2ab..dd6e32ee0e 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -20,6 +20,7 @@ import React, { type MouseEvent, type ReactNode, } from "react"; +import classNames from "classnames"; import { type EventStatus, EventType, @@ -46,6 +47,8 @@ import { PinnedMessageBadge, ReactionsRowButtonView, ReactionsRowView, + ThreadMessagePreviewView, + ThreadSummaryView, TileErrorView, useViewModel, } from "@element-hq/web-shared-components"; @@ -80,7 +83,6 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../../PosthogTrackers"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; -import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; @@ -91,6 +93,7 @@ import { EventPreview } from "./EventPreview"; import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; import SettingsStore from "../../../settings/SettingsStore"; import { CardContext } from "../right_panel/context"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel"; import { @@ -119,6 +122,10 @@ import { MessageTimestampViewModel, type MessageTimestampViewModelProps, } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; +import { + ThreadMessagePreviewViewModel, + ThreadSummaryViewModel, +} from "../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; import { MAX_ITEMS_WHEN_LIMITED, @@ -535,7 +542,7 @@ export class UnwrappedEventTile extends React.Component
{threadState.thread.length} - +
); } @@ -543,7 +550,11 @@ export class UnwrappedEventTile extends React.Component private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode { if (threadState.shouldShowThreadSummary && threadState.thread) { return ( - + ); } @@ -1552,6 +1563,100 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele ); } +interface ThreadMessagePreviewWrapperProps { + thread: Thread; + showDisplayName?: boolean; +} + +function ThreadMessagePreviewWrapper({ + 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 ThreadSummaryWrapperProps extends Omit, "aria-label" | "onClick"> { + mxEvent: MatrixEvent; + thread: Thread; +} + +function ThreadSummaryWrapper({ + 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 ReactionsRowButtonItemProps { mxEvent: MatrixEvent; content: string; diff --git a/apps/web/src/components/views/rooms/ThreadSummary.tsx b/apps/web/src/components/views/rooms/ThreadSummary.tsx deleted file mode 100644 index 6ad91d7022..0000000000 --- a/apps/web/src/components/views/rooms/ThreadSummary.tsx +++ /dev/null @@ -1,111 +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, { useContext } from "react"; -import { type Thread, ThreadEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { IndicatorIcon } from "@vector-im/compound-web"; -import { ThreadsSolidIcon, ChevronRightIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; -import { CardContext } from "../right_panel/context"; -import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; -import PosthogTrackers from "../../../PosthogTrackers"; -import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; -import MemberAvatar from "../avatars/MemberAvatar"; -import { Action } from "../../../dispatcher/actions"; -import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; -import { notificationLevelToIndicator } from "../../../utils/notifications"; -import { EventPreviewTile, useEventPreview } from "./EventPreview.tsx"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; - -interface IProps { - mxEvent: MatrixEvent; - thread: Thread; -} - -const ThreadSummary: React.FC = ({ mxEvent, thread, ...props }) => { - const roomContext = useScopedRoomContext("narrow"); - const cardContext = useContext(CardContext); - const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length); - const { level } = useUnreadNotifications(thread.room, thread.id); - - if (!count) return null; // We don't want to show a thread summary if the thread doesn't have replies yet - - let countSection: string | number = count; - if (!roomContext.narrow) { - countSection = _t("threads|count_of_reply", { count }); - } - - return ( - { - defaultDispatcher.dispatch({ - action: Action.ShowThread, - rootEvent: mxEvent, - push: cardContext.isCard, - }); - PosthogTrackers.trackInteraction("WebRoomTimelineThreadSummaryButton", ev); - }} - aria-label={_t("threads|open_thread")} - > - - - - {countSection} - -
- -
-
- ); -}; - -interface IPreviewProps { - thread: Thread; - showDisplayname?: boolean; -} - -export const ThreadMessagePreview: React.FC = ({ thread, showDisplayname = false }) => { - const lastReply = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.replyToEvent) ?? undefined; - const preview = useEventPreview(lastReply); - - if (!preview || !lastReply) { - return null; - } - - return ( - <> - - {showDisplayname && ( -
{lastReply.sender?.name ?? lastReply.getSender()}
- )} - - {lastReply.isDecryptionFailure() ? ( -
- {_t("timeline|decryption_failure|unable_to_decrypt")} -
- ) : ( - - )} - - ); -}; - -export default ThreadSummary; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx new file mode 100644 index 0000000000..23b8ed9686 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx @@ -0,0 +1,682 @@ +/* + * Copyright 2026 Element Creations Ltd. + * 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 MouseEvent, type ReactNode } from "react"; +import { + M_POLL_START, + type MatrixClient, + type MatrixEvent, + MatrixEventEvent, + MsgType, + type NotificationCount, + RoomEvent, + type Room, + type RoomMember, + type RoomState, + RoomStateEvent, + type Thread, + ThreadEvent, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { + BaseViewModel, + type ThreadMessagePreviewAvatar, + type ThreadMessagePreviewViewModel as ThreadMessagePreviewViewModelInterface, + type ThreadMessagePreviewViewSnapshot, + type ThreadSummaryViewModel as ThreadSummaryViewModelInterface, + type ThreadSummaryViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { _t } from "../../../../languageHandler"; +import defaultDispatcher from "../../../../dispatcher/dispatcher"; +import { Action } from "../../../../dispatcher/actions"; +import { type ShowThreadPayload } from "../../../../dispatcher/payloads/ShowThreadPayload"; +import PosthogTrackers from "../../../../PosthogTrackers"; +import { determineUnreadState } from "../../../../RoomNotifs"; +import { notificationLevelToIndicator } from "../../../../utils/notifications"; +import { MessagePreviewStore } from "../../../../stores/message-preview"; +import { mediaFromMxc } from "../../../../customisations/Media"; +import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import { keepIfSame } from "../../../../utils/keepIfSame"; + +const AVATAR_SIZE_PX = 24; +const THREAD_PROFILE_CONTEXTS = new Set([ + TimelineRenderingType.Thread, + TimelineRenderingType.ThreadsList, +]); + +export interface ThreadMessagePreviewViewModelProps { + /** + * Matrix client used for event decryption and media URL resolution. + */ + cli: MatrixClient; + /** + * Thread whose latest reply should be previewed. + */ + thread: Thread; + /** + * Room context used to resolve current member profiles when configured. + */ + room?: Room; + /** + * Timeline context used to match legacy current-profile behaviour in thread timelines. + */ + timelineRenderingType: TimelineRenderingType; + /** + * Whether avatar images should be suppressed for low-bandwidth mode. + */ + lowBandwidth?: boolean; + /** + * Whether to prefer current member profiles over historical event sender profiles. + */ + useOnlyCurrentProfiles: boolean; + /** + * Whether to render the sender display name. + */ + showDisplayName: boolean; + /** + * Optional class name for app-side avatar integration styling. + */ + avatarClassName?: string; +} + +export class ThreadMessagePreviewViewModel + extends BaseViewModel + implements ThreadMessagePreviewViewModelInterface +{ + private threadListenerCleanups: Array<() => void> = []; + private eventListenerCleanups: Array<() => void> = []; + private memberListenerCleanups: Array<() => void> = []; + private watchedEvent?: MatrixEvent; + private watchedMemberRoom?: Room; + private watchedMemberUserId?: string; + private previewRequestId = 0; + private previewContentKey?: string; + private previewContent?: ReactNode; + + public constructor(props: ThreadMessagePreviewViewModelProps) { + super(props, { + isVisible: false, + showDisplayName: props.showDisplayName, + }); + + this.setupThreadListener(); + this.updateFromThreadSafely(); + } + + public dispose(): void { + this.teardownThreadListener(); + this.teardownEventListeners(); + this.teardownMemberListener(); + super.dispose(); + } + + public setClient(cli: MatrixClient): void { + if (this.props.cli === cli) return; + this.props = { ...this.props, cli }; + this.updateFromThreadSafely(); + } + + public setThread(thread: Thread): void { + if (this.props.thread === thread) return; + this.props = { ...this.props, thread }; + this.setupThreadListener(); + this.updateFromThreadSafely(); + } + + public setRoom(room?: Room): void { + if (this.props.room === room) return; + this.props = { ...this.props, room }; + this.updateFromThreadSafely(); + } + + public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void { + if (this.props.timelineRenderingType === timelineRenderingType) return; + this.props = { ...this.props, timelineRenderingType }; + this.updateFromThreadSafely(); + } + + public setLowBandwidth(lowBandwidth?: boolean): void { + if (this.props.lowBandwidth === lowBandwidth) return; + this.props = { ...this.props, lowBandwidth }; + this.updateFromThreadSafely(); + } + + public setUseOnlyCurrentProfiles(useOnlyCurrentProfiles: boolean): void { + if (this.props.useOnlyCurrentProfiles === useOnlyCurrentProfiles) return; + this.props = { ...this.props, useOnlyCurrentProfiles }; + this.updateFromThreadSafely(); + } + + public setShowDisplayName(showDisplayName: boolean): void { + this.props = { ...this.props, showDisplayName }; + this.snapshot.merge({ showDisplayName }); + } + + private setupThreadListener(): void { + this.teardownThreadListener(); + const { thread } = this.props; + thread.on(ThreadEvent.Update, this.onThreadUpdate); + this.threadListenerCleanups.push(() => { + thread.off(ThreadEvent.Update, this.onThreadUpdate); + }); + } + + private teardownThreadListener(): void { + for (const cleanup of this.threadListenerCleanups) { + cleanup(); + } + this.threadListenerCleanups = []; + } + + private setupEventListeners(mxEvent?: MatrixEvent): void { + if (this.watchedEvent === mxEvent) return; + + this.teardownEventListeners(); + this.watchedEvent = mxEvent; + + if (!mxEvent) return; + + mxEvent.on(MatrixEventEvent.Replaced, this.onEventContentChanged); + mxEvent.on(MatrixEventEvent.Decrypted, this.onEventContentChanged); + this.eventListenerCleanups.push(() => { + mxEvent.off(MatrixEventEvent.Replaced, this.onEventContentChanged); + mxEvent.off(MatrixEventEvent.Decrypted, this.onEventContentChanged); + }); + } + + private teardownEventListeners(): void { + for (const cleanup of this.eventListenerCleanups) { + cleanup(); + } + this.eventListenerCleanups = []; + this.watchedEvent = undefined; + } + + private setupMemberListener(mxEvent?: MatrixEvent): void { + const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent); + const room = this.getProfileRoom(); + const shouldUseCurrentProfiles = this.shouldUseCurrentProfiles(); + + if (!mxEvent || !userId || !room || !shouldUseCurrentProfiles) { + this.teardownMemberListener(); + return; + } + + if (this.watchedMemberRoom === room && this.watchedMemberUserId === userId) return; + + this.teardownMemberListener(); + this.watchedMemberRoom = room; + this.watchedMemberUserId = userId; + + room.on(RoomStateEvent.Members, this.onRoomStateMember); + this.memberListenerCleanups.push(() => { + room.off(RoomStateEvent.Members, this.onRoomStateMember); + }); + } + + private teardownMemberListener(): void { + for (const cleanup of this.memberListenerCleanups) { + cleanup(); + } + this.memberListenerCleanups = []; + this.watchedMemberRoom = undefined; + this.watchedMemberUserId = undefined; + } + + private readonly onThreadUpdate = (): void => { + this.updateFromThreadSafely(); + }; + + private readonly onEventContentChanged = (): void => { + this.updateFromThreadSafely(); + }; + + private readonly onRoomStateMember = (_event: MatrixEvent, _state: RoomState, member: RoomMember): void => { + if (member.userId !== this.watchedMemberUserId) return; + this.updateProfileSnapshot(); + }; + + private updateFromThreadSafely(): void { + void this.updateFromThread().catch((error) => { + logger.error("Failed to update thread preview", error); + }); + } + + private async updateFromThread(): Promise { + const requestId = ++this.previewRequestId; + const lastReply = this.props.thread.replyToEvent ?? undefined; + this.setupEventListeners(lastReply); + this.setupMemberListener(lastReply); + + if (!lastReply) { + this.setHidden(); + return; + } + + await this.updateFromEvent(lastReply, requestId); + } + + private async updateFromEvent(mxEvent: MatrixEvent, requestId: number): Promise { + if (mxEvent.isRedacted()) { + this.setHidden(); + return; + } + + const baseSnapshot = this.computeBaseReplySnapshot(mxEvent); + + if (mxEvent.isDecryptionFailure()) { + this.setDecryptionFailure(baseSnapshot); + return; + } + + try { + await this.props.cli.decryptEventIfNeeded(mxEvent); + } catch (error) { + logger.error("Failed to decrypt thread preview event", error); + if (!this.isCurrentPreviewRequest(requestId, mxEvent)) return; + + if (mxEvent.isDecryptionFailure()) { + this.setDecryptionFailure(baseSnapshot); + } else { + this.setHidden(); + } + return; + } + + if (!this.isCurrentPreviewRequest(requestId, mxEvent)) return; + + if (mxEvent.isRedacted()) { + this.setHidden(); + return; + } + + if (mxEvent.isDecryptionFailure()) { + this.setDecryptionFailure(baseSnapshot); + return; + } + + const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent); + if (!preview) { + this.setHidden(); + return; + } + + const prefix = ThreadMessagePreviewViewModel.getPreviewPrefix( + mxEvent.getType(), + mxEvent.getContent().msgtype as MsgType | undefined, + ); + const previewContent = this.getPreviewContent(preview, prefix); + + this.snapshot.merge({ + ...baseSnapshot, + previewContent, + previewTooltip: prefix ? undefined : preview, + isVisible: true, + }); + } + + private isCurrentPreviewRequest(requestId: number, mxEvent: MatrixEvent): boolean { + return !this.isDisposed && requestId === this.previewRequestId && this.props.thread.replyToEvent === mxEvent; + } + + private computeBaseReplySnapshot( + mxEvent: MatrixEvent, + ): Pick { + const member = ThreadMessagePreviewViewModel.getDisplayMember(this.props, mxEvent); + const sender = mxEvent.getSender() ?? ""; + const senderName = member?.name ?? sender; + const avatar = ThreadMessagePreviewViewModel.computeAvatar(this.props, mxEvent, member); + + return { + avatar: keepIfSame(this.snapshot.current.avatar, avatar), + senderName, + showDisplayName: this.props.showDisplayName, + }; + } + + private updateProfileSnapshot(): void { + const lastReply = this.props.thread.replyToEvent; + if (!lastReply || !this.snapshot.current.isVisible) return; + + this.snapshot.merge(this.computeBaseReplySnapshot(lastReply)); + } + + private setDecryptionFailure( + baseSnapshot: Pick, + ): void { + const label = _t("timeline|decryption_failure|unable_to_decrypt"); + this.snapshot.merge({ + ...baseSnapshot, + previewContent: label, + previewTooltip: label, + isVisible: true, + }); + } + + private setHidden(): void { + this.snapshot.merge({ + isVisible: false, + avatar: undefined, + senderName: undefined, + previewContent: undefined, + previewTooltip: undefined, + showDisplayName: this.props.showDisplayName, + }); + } + + private getPreviewContent(preview: string, prefix: string | null): ReactNode { + const key = `${prefix ?? ""}\u0000${preview}`; + if (this.previewContentKey === key) { + return this.previewContent; + } + + this.previewContentKey = key; + this.previewContent = prefix + ? _t( + "event_preview|preview", + { + prefix, + preview, + }, + { + bold: (sub) => {sub}, + }, + ) + : preview; + + return this.previewContent; + } + + private static getPreviewPrefix(type: string, msgType?: MsgType): string | null { + switch (type) { + case M_POLL_START.name: + return _t("event_preview|prefix|poll"); + default: + } + + switch (msgType) { + case MsgType.Audio: + return _t("event_preview|prefix|audio"); + case MsgType.Image: + return _t("event_preview|prefix|image"); + case MsgType.Video: + return _t("event_preview|prefix|video"); + case MsgType.File: + return _t("event_preview|prefix|file"); + default: + return null; + } + } + + private static getDisplayMember( + props: ThreadMessagePreviewViewModelProps, + mxEvent: MatrixEvent, + ): RoomMember | null | undefined { + const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent); + if (userId && ThreadMessagePreviewViewModel.shouldUseCurrentProfilesForProps(props)) { + const currentMember = ThreadMessagePreviewViewModel.getProfileRoomForProps(props)?.getMember(userId); + if (currentMember) return currentMember; + } + + return mxEvent.sender; + } + + private getProfileRoom(): Room | undefined { + return ThreadMessagePreviewViewModel.getProfileRoomForProps(this.props); + } + + private shouldUseCurrentProfiles(): boolean { + return ThreadMessagePreviewViewModel.shouldUseCurrentProfilesForProps(this.props); + } + + private static getProfileRoomForProps(props: ThreadMessagePreviewViewModelProps): Room | undefined { + return props.room ?? props.thread.room; + } + + private static shouldUseCurrentProfilesForProps(props: ThreadMessagePreviewViewModelProps): boolean { + return props.useOnlyCurrentProfiles || THREAD_PROFILE_CONTEXTS.has(props.timelineRenderingType); + } + + private static getProfileUserId(mxEvent?: MatrixEvent): string { + return mxEvent?.sender?.userId ?? mxEvent?.getSender() ?? ""; + } + + private static computeAvatar( + props: ThreadMessagePreviewViewModelProps, + mxEvent: MatrixEvent, + member: RoomMember | null | undefined, + ): ThreadMessagePreviewAvatar { + const fallbackUserId = mxEvent.getSender() ?? ""; + const name = member?.name ?? fallbackUserId; + let src: string | undefined; + let title: string | undefined; + + if (member?.name) { + const mxcAvatarUrl = member.getMxcAvatarUrl(); + if (mxcAvatarUrl && !props.lowBandwidth) { + src = + mediaFromMxc(mxcAvatarUrl, props.cli).getThumbnailOfSourceHttp( + AVATAR_SIZE_PX, + AVATAR_SIZE_PX, + "crop", + ) ?? undefined; + } + + title = + UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { + roomId: member.roomId, + }) ?? fallbackUserId; + } + + return { + className: props.avatarClassName, + id: member?.userId ?? fallbackUserId, + name, + src, + title, + label: _t("common|user_avatar"), + }; + } +} + +export interface ThreadSummaryViewModelProps extends Omit { + /** + * Thread root event opened by the summary. + */ + mxEvent: MatrixEvent; + /** + * Whether the timeline is in narrow mode. + */ + narrow: boolean; + /** + * Whether opening the thread should push a card. + */ + isCard: boolean; +} + +export class ThreadSummaryViewModel + extends BaseViewModel + implements ThreadSummaryViewModelInterface +{ + private listenerCleanups: Array<() => void> = []; + private readonly previewVm: ThreadMessagePreviewViewModel; + + public constructor(props: ThreadSummaryViewModelProps) { + const previewVm = new ThreadMessagePreviewViewModel({ + ...props, + showDisplayName: !props.narrow, + }); + + super(props, ThreadSummaryViewModel.computeSnapshot(props, previewVm)); + this.previewVm = previewVm; + this.disposables.track(previewVm); + this.setupListeners(); + } + + public dispose(): void { + this.teardownListeners(); + super.dispose(); + } + + public setRootEvent(mxEvent: MatrixEvent): void { + this.props = { ...this.props, mxEvent }; + } + + public setClient(cli: MatrixClient): void { + if (this.props.cli === cli) return; + this.props = { ...this.props, cli }; + this.previewVm.setClient(cli); + } + + public setThread(thread: Thread): void { + if (this.props.thread === thread) return; + this.props = { ...this.props, thread }; + this.previewVm.setThread(thread); + this.setupListeners(); + this.updateSnapshotFromProps(); + } + + public setRoom(room?: Room): void { + if (this.props.room === room) return; + this.props = { ...this.props, room }; + this.previewVm.setRoom(room); + } + + public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void { + if (this.props.timelineRenderingType === timelineRenderingType) return; + this.props = { ...this.props, timelineRenderingType }; + this.previewVm.setTimelineRenderingType(timelineRenderingType); + } + + public setLowBandwidth(lowBandwidth?: boolean): void { + if (this.props.lowBandwidth === lowBandwidth) return; + this.props = { ...this.props, lowBandwidth }; + this.previewVm.setLowBandwidth(lowBandwidth); + } + + public setUseOnlyCurrentProfiles(useOnlyCurrentProfiles: boolean): void { + if (this.props.useOnlyCurrentProfiles === useOnlyCurrentProfiles) return; + this.props = { ...this.props, useOnlyCurrentProfiles }; + this.previewVm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); + } + + public setNarrow(narrow: boolean): void { + this.props = { ...this.props, narrow }; + this.previewVm.setShowDisplayName(!narrow); + this.snapshot.merge(ThreadSummaryViewModel.computeLayoutSnapshot(this.props)); + } + + public setIsCard(isCard: boolean): void { + this.props = { ...this.props, isCard }; + } + + public onClick = (event: MouseEvent): void => { + defaultDispatcher.dispatch({ + action: Action.ShowThread, + rootEvent: this.props.mxEvent, + push: this.props.isCard, + }); + PosthogTrackers.trackInteraction("WebRoomTimelineThreadSummaryButton", event); + }; + + private setupListeners(): void { + this.teardownListeners(); + + const { thread } = this.props; + const { room } = thread; + thread.on(ThreadEvent.Update, this.onThreadUpdate); + this.listenerCleanups.push(() => { + thread.off(ThreadEvent.Update, this.onThreadUpdate); + }); + + room.on(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications); + room.on(RoomEvent.Receipt, this.onNotificationChanged); + room.on(RoomEvent.Timeline, this.onNotificationChanged); + room.on(RoomEvent.Redaction, this.onNotificationChanged); + room.on(RoomEvent.LocalEchoUpdated, this.onNotificationChanged); + room.on(RoomEvent.MyMembership, this.onNotificationChanged); + this.listenerCleanups.push(() => { + room.off(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications); + room.off(RoomEvent.Receipt, this.onNotificationChanged); + room.off(RoomEvent.Timeline, this.onNotificationChanged); + room.off(RoomEvent.Redaction, this.onNotificationChanged); + room.off(RoomEvent.LocalEchoUpdated, this.onNotificationChanged); + room.off(RoomEvent.MyMembership, this.onNotificationChanged); + }); + } + + private teardownListeners(): void { + for (const cleanup of this.listenerCleanups) { + cleanup(); + } + this.listenerCleanups = []; + } + + private readonly onThreadUpdate = (): void => { + this.updateSnapshotFromProps(); + }; + + private readonly onRoomUnreadNotifications = ( + _unreadNotifications?: NotificationCount, + eventThreadId?: string, + ): void => { + if (eventThreadId && eventThreadId !== this.props.thread.id) return; + this.updateNotificationSnapshot(); + }; + + private readonly onNotificationChanged = (): void => { + this.updateNotificationSnapshot(); + }; + + private updateSnapshotFromProps(): void { + this.snapshot.merge({ + ...ThreadSummaryViewModel.computeLayoutSnapshot(this.props), + notificationIndicator: ThreadSummaryViewModel.computeNotificationIndicator(this.props), + }); + } + + private updateNotificationSnapshot(): void { + this.snapshot.merge({ + notificationIndicator: ThreadSummaryViewModel.computeNotificationIndicator(this.props), + }); + } + + private static computeSnapshot( + props: ThreadSummaryViewModelProps, + previewVm: ThreadMessagePreviewViewModel, + ): ThreadSummaryViewSnapshot { + return { + openThreadLabel: _t("threads|open_thread"), + previewVm, + notificationIndicator: ThreadSummaryViewModel.computeNotificationIndicator(props), + ...ThreadSummaryViewModel.computeLayoutSnapshot(props), + }; + } + + private static computeLayoutSnapshot( + props: ThreadSummaryViewModelProps, + ): Pick { + const count = props.thread.length; + + return { + isVisible: count > 0, + replyCountLabel: props.narrow ? String(count) : _t("threads|count_of_reply", { count }), + narrow: props.narrow, + }; + } + + private static computeNotificationIndicator( + props: ThreadSummaryViewModelProps, + ): ThreadSummaryViewSnapshot["notificationIndicator"] { + const { level } = determineUnreadState(props.thread.room, props.thread.id, false); + return notificationLevelToIndicator(level); + } +} diff --git a/apps/web/test/viewmodels/event-tiles/ThreadSummaryViewModel-test.tsx b/apps/web/test/viewmodels/event-tiles/ThreadSummaryViewModel-test.tsx new file mode 100644 index 0000000000..e77a9fdc48 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/ThreadSummaryViewModel-test.tsx @@ -0,0 +1,888 @@ +/* + * 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 { EventEmitter } from "events"; +import { type MouseEvent } from "react"; +import { + EventType, + M_POLL_START, + type MatrixClient, + type MatrixEvent, + MatrixEventEvent, + MsgType, + type Room, + RoomEvent, + type RoomMember, + RoomStateEvent, + type Thread, + ThreadEvent, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { waitFor } from "jest-matrix-react"; + +import { mkEvent } from "../../test-utils"; +import { NotificationLevel } from "../../../src/stores/notifications/NotificationLevel"; +import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; +import { MessagePreviewStore } from "../../../src/stores/message-preview"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import PosthogTrackers from "../../../src/PosthogTrackers"; +import { determineUnreadState } from "../../../src/RoomNotifs"; +import { + ThreadMessagePreviewViewModel, + type ThreadMessagePreviewViewModelProps, + ThreadSummaryViewModel, + type ThreadSummaryViewModelProps, +} from "../../../src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel"; + +jest.mock("../../../src/dispatcher/dispatcher", () => ({ + __esModule: true, + default: { + dispatch: jest.fn(), + register: jest.fn(), + unregister: jest.fn(), + }, +})); + +jest.mock("../../../src/PosthogTrackers", () => ({ + __esModule: true, + default: { + trackInteraction: jest.fn(), + }, +})); + +jest.mock("../../../src/RoomNotifs", () => ({ + determineUnreadState: jest.fn(() => ({ + symbol: null, + count: 0, + level: 0, + invited: false, + })), +})); + +const roomId = "!room:example.org"; +const userId = "@alice:example.org"; +const clientMxcUrlToHttpMocks = new WeakMap(); + +class TestRoom extends EventEmitter { + public roomId = roomId; + public getMember = jest.fn(); +} + +class TestThread extends EventEmitter { + public id = "$root"; + public length = 2; + public replyToEvent?: MatrixEvent; + + public constructor(public room: Room) { + super(); + } +} + +function makeClient(decryptEventIfNeeded = jest.fn().mockResolvedValue(undefined)): MatrixClient { + const mxcUrlToHttp = jest.fn( + (mxc: string, width?: number, height?: number, resizeMethod?: string) => + `https://matrix.example.org/_matrix/media/${mxc}/${width}x${height}/${resizeMethod}`, + ); + const cli = { + decryptEventIfNeeded, + mxcUrlToHttp, + } as unknown as MatrixClient; + clientMxcUrlToHttpMocks.set(cli, mxcUrlToHttp); + return cli; +} + +function getMxcUrlToHttpMock(cli: MatrixClient): jest.Mock { + const mock = clientMxcUrlToHttpMocks.get(cli); + if (!mock) throw new Error("Missing mxcUrlToHttp mock for test client"); + return mock; +} + +function makeMember(name: string, senderUserId = userId, mxcAvatarUrl?: string): RoomMember { + return { + userId: senderUserId, + membership: "join", + name, + rawDisplayName: name, + roomId, + getAvatarUrl: jest.fn(), + getMxcAvatarUrl: jest.fn(() => mxcAvatarUrl), + } as unknown as RoomMember; +} + +function makeEvent( + body = "Latest reply", + { + type = EventType.RoomMessage, + msgtype = MsgType.Text, + senderName = "Alice", + senderUserId = userId, + mxcAvatarUrl, + }: Partial<{ + type: string; + msgtype: MsgType; + senderName: string; + senderUserId: string; + mxcAvatarUrl: string; + }> = {}, +): MatrixEvent { + const mxEvent = mkEvent({ + event: true, + id: `$${body}`, + type, + room: roomId, + user: senderUserId, + content: { + msgtype, + body, + }, + }); + mxEvent.sender = makeMember(senderName, senderUserId, mxcAvatarUrl); + return mxEvent; +} + +function makePreviewVm(overrides: Partial = {}): { + vm: ThreadMessagePreviewViewModel; + thread: TestThread & Thread; + room: TestRoom & Room; + cli: MatrixClient; +} { + const room = (overrides.room as (TestRoom & Room) | undefined) ?? (new TestRoom() as TestRoom & Room); + const thread = + (overrides.thread as (TestThread & Thread) | undefined) ?? (new TestThread(room) as TestThread & Thread); + if (!thread.replyToEvent) { + thread.replyToEvent = makeEvent(); + } + + const cli = overrides.cli ?? makeClient(); + const vm = new ThreadMessagePreviewViewModel({ + cli, + thread, + room, + timelineRenderingType: TimelineRenderingType.Room, + lowBandwidth: false, + useOnlyCurrentProfiles: false, + showDisplayName: false, + ...overrides, + }); + + return { vm, thread, room, cli }; +} + +function makeSummaryVm(overrides: Partial = {}): { + vm: ThreadSummaryViewModel; + thread: TestThread & Thread; + rootEvent: MatrixEvent; + room: TestRoom & Room; + cli: MatrixClient; +} { + const room = new TestRoom() as TestRoom & Room; + const thread = new TestThread(room) as TestThread & Thread; + thread.replyToEvent = makeEvent(); + const rootEvent = makeEvent("Root"); + const cli = overrides.cli ?? makeClient(); + const vm = new ThreadSummaryViewModel({ + cli, + mxEvent: rootEvent, + thread, + room, + timelineRenderingType: TimelineRenderingType.Room, + lowBandwidth: false, + useOnlyCurrentProfiles: false, + narrow: true, + isCard: false, + ...overrides, + }); + + return { vm, thread, rootEvent, room, cli }; +} + +describe("ThreadSummaryViewModel", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(determineUnreadState).mockReturnValue({ + symbol: null, + count: 0, + level: NotificationLevel.None, + invited: false, + }); + jest.spyOn(MessagePreviewStore.instance, "generatePreviewForEvent").mockReturnValue("Latest reply"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("computes initial summary state from the thread", () => { + const { vm } = makeSummaryVm(); + + expect(vm.getSnapshot().isVisible).toBe(true); + expect(vm.getSnapshot().replyCountLabel).toBe("2"); + expect(vm.getSnapshot().narrow).toBe(true); + expect(vm.getSnapshot().openThreadLabel).toBeTruthy(); + }); + + it("updates visibility and reply count when the thread mutates", () => { + const { vm, thread } = makeSummaryVm(); + + thread.length = 0; + thread.emit(ThreadEvent.Update, thread); + + expect(vm.getSnapshot().isVisible).toBe(false); + expect(vm.getSnapshot().replyCountLabel).toBe("0"); + }); + + it("uses plural reply labels outside narrow mode and synchronizes the nested preview display name", () => { + const { vm } = makeSummaryVm({ narrow: false }); + + expect(vm.getSnapshot().replyCountLabel).toBe("2 replies"); + expect(vm.getSnapshot().previewVm.getSnapshot().showDisplayName).toBe(true); + + vm.setNarrow(true); + + expect(vm.getSnapshot().replyCountLabel).toBe("2"); + expect(vm.getSnapshot().narrow).toBe(true); + expect(vm.getSnapshot().previewVm.getSnapshot().showDisplayName).toBe(false); + }); + + it("does not emit when setters receive unchanged values", () => { + const { vm, thread, rootEvent, room, cli } = makeSummaryVm(); + const previewVm = vm.getSnapshot().previewVm as ThreadMessagePreviewViewModel; + const setClientSpy = jest.spyOn(previewVm, "setClient"); + const setRoomSpy = jest.spyOn(previewVm, "setRoom"); + const setTimelineRenderingTypeSpy = jest.spyOn(previewVm, "setTimelineRenderingType"); + const setLowBandwidthSpy = jest.spyOn(previewVm, "setLowBandwidth"); + const setUseOnlyCurrentProfilesSpy = jest.spyOn(previewVm, "setUseOnlyCurrentProfiles"); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setRootEvent(rootEvent); + vm.setClient(cli); + vm.setThread(thread); + vm.setRoom(room); + vm.setTimelineRenderingType(TimelineRenderingType.Room); + vm.setLowBandwidth(false); + vm.setUseOnlyCurrentProfiles(false); + vm.setNarrow(true); + vm.setIsCard(false); + + expect(listener).not.toHaveBeenCalled(); + expect(setClientSpy).not.toHaveBeenCalled(); + expect(setRoomSpy).not.toHaveBeenCalled(); + expect(setTimelineRenderingTypeSpy).not.toHaveBeenCalled(); + expect(setLowBandwidthSpy).not.toHaveBeenCalled(); + expect(setUseOnlyCurrentProfilesSpy).not.toHaveBeenCalled(); + }); + + it("forwards changed context inputs to the preview view model", () => { + const { vm } = makeSummaryVm(); + const previewVm = vm.getSnapshot().previewVm as ThreadMessagePreviewViewModel; + const nextClient = makeClient(); + const nextRoom = new TestRoom() as TestRoom & Room; + + const setClientSpy = jest.spyOn(previewVm, "setClient"); + const setRoomSpy = jest.spyOn(previewVm, "setRoom"); + const setTimelineRenderingTypeSpy = jest.spyOn(previewVm, "setTimelineRenderingType"); + const setLowBandwidthSpy = jest.spyOn(previewVm, "setLowBandwidth"); + const setUseOnlyCurrentProfilesSpy = jest.spyOn(previewVm, "setUseOnlyCurrentProfiles"); + + vm.setClient(nextClient); + vm.setRoom(nextRoom); + vm.setTimelineRenderingType(TimelineRenderingType.Thread); + vm.setLowBandwidth(true); + vm.setUseOnlyCurrentProfiles(true); + + expect(setClientSpy).toHaveBeenCalledWith(nextClient); + expect(setRoomSpy).toHaveBeenCalledWith(nextRoom); + expect(setTimelineRenderingTypeSpy).toHaveBeenCalledWith(TimelineRenderingType.Thread); + expect(setLowBandwidthSpy).toHaveBeenCalledWith(true); + expect(setUseOnlyCurrentProfilesSpy).toHaveBeenCalledWith(true); + }); + + it("refreshes notification indicator for thread unread events", () => { + const { vm, room, thread } = makeSummaryVm(); + jest.mocked(determineUnreadState).mockReturnValue({ + symbol: null, + count: 1, + level: NotificationLevel.Highlight, + invited: false, + }); + + room.emit(RoomEvent.UnreadNotifications, {}, thread.id); + + expect(vm.getSnapshot().notificationIndicator).toBe("critical"); + }); + + it("ignores unread notifications for other threads and refreshes on room notification events", () => { + const { vm, room } = makeSummaryVm(); + jest.mocked(determineUnreadState).mockClear(); + jest.mocked(determineUnreadState).mockReturnValue({ + symbol: null, + count: 1, + level: NotificationLevel.Notification, + invited: false, + }); + + room.emit(RoomEvent.UnreadNotifications, {}, "$other-thread"); + + expect(determineUnreadState).not.toHaveBeenCalled(); + expect(vm.getSnapshot().notificationIndicator).toBeUndefined(); + + room.emit(RoomEvent.Receipt); + + expect(determineUnreadState).toHaveBeenCalledWith(room, "$root", false); + expect(vm.getSnapshot().notificationIndicator).toBe("success"); + }); + + it("rebinds thread listeners when the thread changes and removes them on dispose", () => { + const { vm, thread, room } = makeSummaryVm(); + const nextRoom = new TestRoom() as TestRoom & Room; + const nextThread = new TestThread(nextRoom) as TestThread & Thread; + nextThread.length = 4; + nextThread.replyToEvent = makeEvent("Next thread reply"); + + vm.setThread(nextThread); + + expect(thread.listenerCount(ThreadEvent.Update)).toBe(0); + expect(room.listenerCount(RoomEvent.Receipt)).toBe(0); + expect(vm.getSnapshot().replyCountLabel).toBe("4"); + + thread.length = 0; + thread.emit(ThreadEvent.Update, thread); + expect(vm.getSnapshot().replyCountLabel).toBe("4"); + + nextThread.length = 5; + nextThread.emit(ThreadEvent.Update, nextThread); + expect(vm.getSnapshot().replyCountLabel).toBe("5"); + + vm.dispose(); + + expect(nextThread.listenerCount(ThreadEvent.Update)).toBe(0); + expect(nextRoom.listenerCount(RoomEvent.Receipt)).toBe(0); + }); + + it("dispatches ShowThread and tracks interaction on click", () => { + const { vm, rootEvent } = makeSummaryVm({ isCard: true }); + const event = { type: "click" } as MouseEvent; + + vm.onClick(event); + + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "show_thread", + rootEvent, + push: true, + }); + expect(PosthogTrackers.trackInteraction).toHaveBeenCalledWith("WebRoomTimelineThreadSummaryButton", event); + }); + + it("uses the latest root event and card mode from setters when opening the thread", () => { + const { vm } = makeSummaryVm(); + const nextRootEvent = makeEvent("Next root"); + const event = { type: "click" } as MouseEvent; + + vm.setRootEvent(nextRootEvent); + vm.setIsCard(true); + vm.onClick(event); + + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "show_thread", + rootEvent: nextRootEvent, + push: true, + }); + }); +}); + +describe("ThreadMessagePreviewViewModel", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(determineUnreadState).mockReturnValue({ + symbol: null, + count: 0, + level: NotificationLevel.None, + invited: false, + }); + jest.spyOn(MessagePreviewStore.instance, "generatePreviewForEvent").mockImplementation((event) => { + return event.getContent().body; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("hides the preview when the thread has no latest reply", () => { + const room = new TestRoom() as TestRoom & Room; + const thread = new TestThread(room) as TestThread & Thread; + const vm = new ThreadMessagePreviewViewModel({ + cli: makeClient(), + thread, + room, + timelineRenderingType: TimelineRenderingType.Room, + lowBandwidth: false, + useOnlyCurrentProfiles: false, + showDisplayName: true, + }); + + expect(vm.getSnapshot().isVisible).toBe(false); + expect(vm.getSnapshot().avatar).toBeUndefined(); + expect(vm.getSnapshot().senderName).toBeUndefined(); + expect(vm.getSnapshot().previewContent).toBeUndefined(); + expect(vm.getSnapshot().previewTooltip).toBeUndefined(); + expect(vm.getSnapshot().showDisplayName).toBe(true); + }); + + it("renders the latest reply preview", async () => { + const room = new TestRoom() as TestRoom & Room; + const thread = new TestThread(room) as TestThread & Thread; + thread.replyToEvent = makeEvent("First reply"); + const vm = new ThreadMessagePreviewViewModel({ + cli: makeClient(), + thread, + room, + timelineRenderingType: TimelineRenderingType.Room, + lowBandwidth: false, + useOnlyCurrentProfiles: false, + showDisplayName: true, + }); + + await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true)); + + expect(vm.getSnapshot().senderName).toBe("Alice"); + expect(vm.getSnapshot().showDisplayName).toBe(true); + expect(vm.getSnapshot().previewContent).toBe("First reply"); + }); + + it("skips preview recomputation when guarded setters receive unchanged values", async () => { + const { vm, thread, room, cli } = makePreviewVm(); + await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true)); + jest.mocked(cli.decryptEventIfNeeded).mockClear(); + + vm.setClient(cli); + vm.setThread(thread); + vm.setRoom(room); + vm.setTimelineRenderingType(TimelineRenderingType.Room); + vm.setLowBandwidth(false); + vm.setUseOnlyCurrentProfiles(false); + + expect(cli.decryptEventIfNeeded).not.toHaveBeenCalled(); + expect(thread.listenerCount(ThreadEvent.Update)).toBe(1); + }); + + it.each([ + ["audio", EventType.RoomMessage, MsgType.Audio], + ["image", EventType.RoomMessage, MsgType.Image], + ["video", EventType.RoomMessage, MsgType.Video], + ["file", EventType.RoomMessage, MsgType.File], + ["poll", M_POLL_START.name, undefined], + ])("renders a prefixed preview for %s replies", async (_name, type, msgtype) => { + const { vm } = makePreviewVm({ + thread: new TestThread(new TestRoom() as TestRoom & Room) as TestThread & Thread, + }); + vm.setThread( + Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: makeEvent("Attachment preview", msgtype === undefined ? { type } : { type, msgtype }), + }) as TestThread & Thread, + ); + + await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true)); + + expect(vm.getSnapshot().previewContent).not.toBe("Attachment preview"); + expect(vm.getSnapshot().previewTooltip).toBeUndefined(); + }); + + it("reuses prefixed preview content when the generated text has not changed", async () => { + const event = makeEvent("Image preview", { msgtype: MsgType.Image }); + const { vm } = makePreviewVm({ + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: event, + }) as TestThread & Thread, + }); + await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true)); + const previewContent = vm.getSnapshot().previewContent; + + event.emit(MatrixEventEvent.Decrypted, event); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe(previewContent)); + }); + + it("updates when the thread latest reply changes in place", async () => { + const room = new TestRoom() as TestRoom & Room; + const thread = new TestThread(room) as TestThread & Thread; + thread.replyToEvent = makeEvent("First reply"); + const vm = new ThreadMessagePreviewViewModel({ + cli: makeClient(), + thread, + room, + timelineRenderingType: TimelineRenderingType.Room, + lowBandwidth: false, + useOnlyCurrentProfiles: false, + showDisplayName: false, + }); + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("First reply")); + + thread.replyToEvent = makeEvent("Second reply"); + thread.emit(ThreadEvent.Update, thread); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Second reply")); + }); + + it("updates when the watched event is replaced or decrypted", async () => { + const event = makeEvent("First reply"); + const { vm } = makePreviewVm({ + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: event, + }) as TestThread & Thread, + }); + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("First reply")); + + jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockReturnValue("Edited reply"); + event.emit(MatrixEventEvent.Replaced, event); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Edited reply")); + + jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockReturnValue("Decrypted reply"); + event.emit(MatrixEventEvent.Decrypted, event); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Decrypted reply")); + }); + + it("hides redacted replies and replies without generated previews", async () => { + const redactedEvent = makeEvent("Redacted reply"); + jest.spyOn(redactedEvent, "isRedacted").mockReturnValue(true); + const redactedVm = makePreviewVm({ + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: redactedEvent, + }) as TestThread & Thread, + }).vm; + + expect(redactedVm.getSnapshot().isVisible).toBe(false); + + jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockReturnValue(""); + const emptyPreviewVm = makePreviewVm({ + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: makeEvent("Unsupported reply"), + }) as TestThread & Thread, + }).vm; + + await waitFor(() => expect(MessagePreviewStore.instance.generatePreviewForEvent).toHaveBeenCalled()); + expect(emptyPreviewVm.getSnapshot().isVisible).toBe(false); + }); + + it("shows a decryption failure preview without trying to decrypt again", async () => { + const decryptionFailureEvent = makeEvent("Encrypted reply"); + jest.spyOn(decryptionFailureEvent, "isDecryptionFailure").mockReturnValue(true); + const cli = makeClient(); + const { vm } = makePreviewVm({ + cli, + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: decryptionFailureEvent, + }) as TestThread & Thread, + }); + + await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true)); + + expect(cli.decryptEventIfNeeded).not.toHaveBeenCalled(); + expect(vm.getSnapshot().previewContent).toBe("Unable to decrypt message"); + expect(vm.getSnapshot().previewTooltip).toBe("Unable to decrypt message"); + }); + + it("logs and hides the preview when decryption rejects", async () => { + const decryptError = new Error("No key"); + const cli = makeClient(jest.fn().mockRejectedValue(decryptError)); + const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => {}); + const { vm } = makePreviewVm({ cli }); + + await waitFor(() => + expect(loggerSpy).toHaveBeenCalledWith("Failed to decrypt thread preview event", decryptError), + ); + + expect(vm.getSnapshot().isVisible).toBe(false); + }); + + it("catches fire-and-forget preview update errors", async () => { + const previewError = new Error("Preview failed"); + jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockImplementation(() => { + throw previewError; + }); + const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => {}); + + makePreviewVm(); + + await waitFor(() => expect(loggerSpy).toHaveBeenCalledWith("Failed to update thread preview", previewError)); + }); + + it("shows a decryption failure preview when decryption rejects into a failure state", async () => { + const decryptError = new Error("No key"); + const event = makeEvent("Encrypted reply"); + const isDecryptionFailureSpy = jest.spyOn(event, "isDecryptionFailure").mockReturnValue(false); + const cli = makeClient( + jest.fn().mockImplementation(async () => { + isDecryptionFailureSpy.mockReturnValue(true); + throw decryptError; + }), + ); + jest.spyOn(logger, "error").mockImplementation(() => {}); + const { vm } = makePreviewVm({ + cli, + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: event, + }) as TestThread & Thread, + }); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Unable to decrypt message")); + + expect(vm.getSnapshot().isVisible).toBe(true); + expect(vm.getSnapshot().previewTooltip).toBe("Unable to decrypt message"); + }); + + it("hides the preview when an event is redacted during decryption", async () => { + const event = makeEvent("Redacted during decrypt"); + const isRedactedSpy = jest.spyOn(event, "isRedacted").mockReturnValue(false); + const cli = makeClient( + jest.fn().mockImplementation(async () => { + isRedactedSpy.mockReturnValue(true); + }), + ); + const { vm } = makePreviewVm({ + cli, + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: event, + }) as TestThread & Thread, + }); + + await waitFor(() => expect(isRedactedSpy).toHaveBeenCalledTimes(2)); + + expect(vm.getSnapshot().isVisible).toBe(false); + expect(MessagePreviewStore.instance.generatePreviewForEvent).not.toHaveBeenCalled(); + }); + + it("shows a decryption failure preview when an event fails after decryption", async () => { + const event = makeEvent("Failure during decrypt"); + const isDecryptionFailureSpy = jest.spyOn(event, "isDecryptionFailure").mockReturnValue(false); + const cli = makeClient( + jest.fn().mockImplementation(async () => { + isDecryptionFailureSpy.mockReturnValue(true); + }), + ); + const { vm } = makePreviewVm({ + cli, + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: event, + }) as TestThread & Thread, + }); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Unable to decrypt message")); + + expect(vm.getSnapshot().isVisible).toBe(true); + expect(MessagePreviewStore.instance.generatePreviewForEvent).not.toHaveBeenCalled(); + }); + + it("ignores stale decrypt results when the latest reply changes during decryption", async () => { + let resolveFirstDecrypt!: () => void; + const firstDecrypt = new Promise((resolve) => { + resolveFirstDecrypt = resolve; + }); + const cli = makeClient(jest.fn().mockReturnValueOnce(firstDecrypt).mockResolvedValue(undefined)); + const room = new TestRoom() as TestRoom & Room; + const thread = new TestThread(room) as TestThread & Thread; + thread.replyToEvent = makeEvent("First reply"); + const vm = new ThreadMessagePreviewViewModel({ + cli, + thread, + room, + timelineRenderingType: TimelineRenderingType.Room, + lowBandwidth: false, + useOnlyCurrentProfiles: false, + showDisplayName: false, + }); + + thread.replyToEvent = makeEvent("Second reply"); + thread.emit(ThreadEvent.Update, thread); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Second reply")); + + resolveFirstDecrypt(); + + await Promise.resolve(); + + expect(vm.getSnapshot().previewContent).toBe("Second reply"); + }); + + it("ignores stale decrypt errors when the latest reply changes before rejection", async () => { + let rejectFirstDecrypt!: (error: Error) => void; + const firstDecrypt = new Promise((_resolve, reject) => { + rejectFirstDecrypt = reject; + }); + const cli = makeClient(jest.fn().mockReturnValueOnce(firstDecrypt).mockResolvedValue(undefined)); + const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => {}); + const room = new TestRoom() as TestRoom & Room; + const thread = new TestThread(room) as TestThread & Thread; + thread.replyToEvent = makeEvent("First reply"); + const vm = new ThreadMessagePreviewViewModel({ + cli, + thread, + room, + timelineRenderingType: TimelineRenderingType.Room, + lowBandwidth: false, + useOnlyCurrentProfiles: false, + showDisplayName: false, + }); + + thread.replyToEvent = makeEvent("Second reply"); + thread.emit(ThreadEvent.Update, thread); + + await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Second reply")); + + rejectFirstDecrypt(new Error("Stale decrypt")); + await waitFor(() => expect(loggerSpy).toHaveBeenCalled()); + + expect(vm.getSnapshot().previewContent).toBe("Second reply"); + }); + + it("uses current room profiles and avatar thumbnails when requested", async () => { + const room = new TestRoom() as TestRoom & Room; + const currentMember = makeMember("Current Alice", userId, "mxc://example.org/avatar"); + room.getMember.mockReturnValue(currentMember); + const { vm, cli } = makePreviewVm({ + room, + useOnlyCurrentProfiles: true, + thread: Object.assign(new TestThread(room), { + replyToEvent: makeEvent("Profile reply", { senderName: "Historical Alice" }), + }) as TestThread & Thread, + }); + + await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Current Alice")); + + expect(vm.getSnapshot().avatar).toEqual( + expect.objectContaining({ + id: userId, + name: "Current Alice", + src: "https://matrix.example.org/_matrix/media/mxc://example.org/avatar/24x24/crop", + }), + ); + expect(getMxcUrlToHttpMock(cli)).toHaveBeenCalledWith("mxc://example.org/avatar", 24, 24, "crop", false, true); + }); + + it("updates the avatar and sender when the current room member profile changes", async () => { + const room = new TestRoom() as TestRoom & Room; + const historicalEvent = makeEvent("Profile update reply", { senderName: "Historical Alice" }); + const { vm } = makePreviewVm({ + room, + timelineRenderingType: TimelineRenderingType.ThreadsList, + thread: Object.assign(new TestThread(room), { + replyToEvent: historicalEvent, + }) as TestThread & Thread, + }); + await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Historical Alice")); + + const currentMember = makeMember("Current Alice", userId, "mxc://example.org/current-avatar"); + room.getMember.mockReturnValue(currentMember); + room.emit(RoomStateEvent.Members, makeEvent("Member event"), room, currentMember); + + await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Current Alice")); + expect(vm.getSnapshot().avatar).toEqual( + expect.objectContaining({ + id: userId, + name: "Current Alice", + src: "https://matrix.example.org/_matrix/media/mxc://example.org/current-avatar/24x24/crop", + }), + ); + }); + + it("ignores current room member profile updates for other users and unsubscribes on dispose", async () => { + const room = new TestRoom() as TestRoom & Room; + const { vm } = makePreviewVm({ + room, + timelineRenderingType: TimelineRenderingType.ThreadsList, + thread: Object.assign(new TestThread(room), { + replyToEvent: makeEvent("Profile listener reply", { senderName: "Historical Alice" }), + }) as TestThread & Thread, + }); + await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Historical Alice")); + + expect(room.listenerCount(RoomStateEvent.Members)).toBe(1); + + room.getMember.mockReturnValue(makeMember("Current Alice", userId, "mxc://example.org/avatar")); + room.emit( + RoomStateEvent.Members, + makeEvent("Other member event"), + room, + makeMember("Other User", "@other:example.org"), + ); + + expect(vm.getSnapshot().senderName).toBe("Historical Alice"); + + vm.dispose(); + + expect(room.listenerCount(RoomStateEvent.Members)).toBe(0); + }); + + it("uses current room profiles in thread timelines and suppresses avatar URLs in low-bandwidth mode", async () => { + const room = new TestRoom() as TestRoom & Room; + room.getMember.mockReturnValue(makeMember("Thread Alice", userId, "mxc://example.org/avatar")); + const { vm, cli } = makePreviewVm({ + room, + timelineRenderingType: TimelineRenderingType.Thread, + lowBandwidth: true, + useOnlyCurrentProfiles: false, + thread: Object.assign(new TestThread(room), { + replyToEvent: makeEvent("Low bandwidth reply", { senderName: "Historical Alice" }), + }) as TestThread & Thread, + }); + + await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Thread Alice")); + + expect(vm.getSnapshot().avatar).toEqual( + expect.objectContaining({ + name: "Thread Alice", + src: undefined, + }), + ); + expect(getMxcUrlToHttpMock(cli)).not.toHaveBeenCalled(); + }); + + it("falls back to historical sender profiles when no current member exists", async () => { + const room = new TestRoom() as TestRoom & Room; + room.getMember.mockReturnValue(null); + const { vm } = makePreviewVm({ + room, + useOnlyCurrentProfiles: true, + thread: Object.assign(new TestThread(room), { + replyToEvent: makeEvent("Historical profile reply", { senderName: "Historical Alice" }), + }) as TestThread & Thread, + }); + + await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Historical Alice")); + + expect(vm.getSnapshot().avatar).toEqual( + expect.objectContaining({ + id: userId, + name: "Historical Alice", + }), + ); + }); + + it("falls back to the sender id when event sender profile data is missing", async () => { + const mxEvent = makeEvent("Missing sender profile"); + mxEvent.sender = null; + jest.spyOn(mxEvent, "getSender").mockReturnValue("@fallback:example.org"); + const { vm } = makePreviewVm({ + thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), { + replyToEvent: mxEvent, + }) as TestThread & Thread, + }); + + await waitFor(() => expect(vm.getSnapshot().senderName).toBe("@fallback:example.org")); + + expect(vm.getSnapshot().avatar).toEqual( + expect.objectContaining({ + id: "@fallback:example.org", + name: "@fallback:example.org", + title: undefined, + }), + ); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/decryption-failure-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/decryption-failure-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..2c8f9a6e79fa9d98b119c32c147965191c824898 GIT binary patch literal 8077 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 E7sC`-)Bpeg literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..8edfaf8d6abbb1e39b0e9704e26c354ecdcdde72 GIT binary patch literal 7845 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 A-~a#s literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/with-notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/with-notification-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..570595a935acc43755d3c4718dd52799b1a4d56b GIT binary patch literal 8006 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 & + Partial & { + preview: ThreadMessagePreviewViewSnapshot; + }; + +const ThreadSummaryViewWrapperImpl = ({ onClick, preview, ...snapshotProps }: Readonly): JSX.Element => { + const previewVm = useMockedViewModel(preview, {}); + const vm = useMockedViewModel( + { + ...snapshotProps, + previewVm, + }, + { + onClick: onClick ?? fn(), + }, + ); + + return ; +}; + +const ThreadSummaryViewWrapper = withViewDocs(ThreadSummaryViewWrapperImpl, ThreadSummaryView); + +const defaultPreview: ThreadMessagePreviewViewSnapshot = { + isVisible: true, + avatar: { + id: "@alice:example.org", + name: "Alice", + label: "User avatar", + }, + showDisplayName: true, + senderName: "Alice", + previewContent: "Can you review the draft?", + previewTooltip: "Can you review the draft?", +}; + +const meta = { + title: "Timeline/Timeline Event//ThreadSummary", + component: ThreadSummaryViewWrapper, + tags: ["autodocs"], + args: { + isVisible: true, + replyCountLabel: "3 replies", + openThreadLabel: "Open thread", + notificationIndicator: undefined, + narrow: false, + preview: defaultPreview, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Narrow: Story = { + args: { + replyCountLabel: "3", + narrow: true, + preview: { + ...defaultPreview, + showDisplayName: false, + }, + }, +}; + +export const WithNotification: Story = { + args: { + notificationIndicator: "critical", + }, +}; + +export const DecryptionFailure: Story = { + args: { + preview: { + ...defaultPreview, + previewContent: "Unable to decrypt message", + previewTooltip: "Unable to decrypt message", + }, + }, +}; + +export const Hidden: Story = { + args: { + isVisible: false, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.test.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.test.tsx new file mode 100644 index 0000000000..6d4e742f8f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 from "react"; +import { composeStories } from "@storybook/react-vite"; +import { render, screen } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { + ThreadSummaryView, + type ThreadMessagePreviewViewSnapshot, + type ThreadSummaryViewActions, + type ThreadSummaryViewModel, + type ThreadSummaryViewSnapshot, +} from "./ThreadSummaryView"; +import * as stories from "./ThreadSummary.stories"; + +const { Default, Narrow, WithNotification, DecryptionFailure, Hidden } = composeStories(stories); + +describe("ThreadSummaryView", () => { + it("renders the default thread summary", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the narrow thread summary", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders a notification indicator", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders a decryption failure preview", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("does not render when hidden", () => { + render(); + expect(screen.queryByRole("button", { name: "Open thread" })).not.toBeInTheDocument(); + }); + + it("invokes the click action", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const previewVm = new MockViewModel({ + isVisible: true, + showDisplayName: false, + previewContent: "Latest reply", + }); + const vm = new (class extends MockViewModel implements ThreadSummaryViewActions { + public constructor() { + super({ + isVisible: true, + replyCountLabel: "1 reply", + openThreadLabel: "Open thread", + narrow: false, + previewVm, + }); + } + + public onClick = onClick; + })() as ThreadSummaryViewModel; + + render(); + + await user.click(screen.getByRole("button", { name: "Open thread" })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummaryView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummaryView.tsx new file mode 100644 index 0000000000..b60a7e424e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummaryView.tsx @@ -0,0 +1,183 @@ +/* + * Copyright 2026 Element Creations Ltd. + * 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 ComponentPropsWithoutRef, type JSX, type MouseEventHandler, type ReactNode } from "react"; +import { Avatar, IndicatorIcon, Tooltip } from "@vector-im/compound-web"; +import { ChevronRightIcon, ThreadsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import styles from "./ThreadSummary.module.css"; + +export type ThreadSummaryNotificationIndicator = ComponentPropsWithoutRef["indicator"]; + +export interface ThreadMessagePreviewAvatar { + /** + * Optional class name supplied by the app for integration styling. + */ + className?: string; + /** + * Stable id used for avatar colour hashing. + */ + id: string; + /** + * Name used by the avatar fallback. + */ + name: string; + /** + * Optional avatar image URL. + */ + src?: string; + /** + * Accessible label for the avatar. + */ + label: string; + /** + * Optional tooltip/title text for the avatar image. + */ + title?: string; +} + +export interface ThreadMessagePreviewViewSnapshot { + /** + * Controls whether the preview should render. + */ + isVisible: boolean; + /** + * Sender avatar data. + */ + avatar?: ThreadMessagePreviewAvatar; + /** + * Whether to render the sender display name. + */ + showDisplayName: boolean; + /** + * Sender display name. + */ + senderName?: string; + /** + * Rendered preview content. + */ + previewContent?: ReactNode; + /** + * Optional styled tooltip text for the preview content. + */ + previewTooltip?: string; +} + +export type ThreadMessagePreviewViewModel = ViewModel; + +export interface ThreadSummaryViewSnapshot { + /** + * Controls whether the summary should render. + */ + isVisible: boolean; + /** + * Text for the reply count section. + */ + replyCountLabel: string; + /** + * Accessible label for opening the thread. + */ + openThreadLabel: string; + /** + * Notification indicator shown on the thread icon. + */ + notificationIndicator?: ThreadSummaryNotificationIndicator; + /** + * Whether the summary is being rendered in the narrow timeline layout. + */ + narrow: boolean; + /** + * View model for the last-message preview. + */ + previewVm: ThreadMessagePreviewViewModel; +} + +export interface ThreadSummaryViewActions { + /** + * Invoked when the user opens the thread. + */ + onClick?: MouseEventHandler; +} + +export type ThreadSummaryViewModel = ViewModel; + +type ThreadSummaryViewProps = Omit, "aria-label" | "onClick"> & { + /** + * The view model for the thread summary. + */ + vm: ThreadSummaryViewModel; +}; + +interface ThreadMessagePreviewViewProps { + /** + * The view model for the thread message preview. + */ + vm: ThreadMessagePreviewViewModel; +} + +export function ThreadMessagePreviewView({ vm }: Readonly): JSX.Element { + const { isVisible, avatar, showDisplayName, senderName, previewContent, previewTooltip } = useViewModel(vm); + + if (!isVisible || !previewContent) { + return <>; + } + + const content = {previewContent}; + const avatarClassName = avatar?.className ? `${styles.avatar} ${avatar.className}` : styles.avatar; + + return ( + <> + {avatar && ( + + )} + {showDisplayName && senderName &&
{senderName}
} + {previewTooltip ? {content} : content} + + ); +} + +export function ThreadSummaryView({ + vm, + className, + type = "button", + ...props +}: Readonly): JSX.Element { + const { isVisible, replyCountLabel, openThreadLabel, notificationIndicator, narrow, previewVm } = useViewModel(vm); + + if (!isVisible) { + return <>; + } + + const buttonClassName = [styles.threadSummary, className, narrow ? styles.narrow : undefined] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/__snapshots__/ThreadSummary.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/__snapshots__/ThreadSummary.test.tsx.snap new file mode 100644 index 0000000000..4cc93e529e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/__snapshots__/ThreadSummary.test.tsx.snap @@ -0,0 +1,265 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ThreadSummaryView > renders a decryption failure preview 1`] = ` +
+ +
+`; + +exports[`ThreadSummaryView > renders a notification indicator 1`] = ` +
+ +
+`; + +exports[`ThreadSummaryView > renders the default thread summary 1`] = ` +
+ +
+`; + +exports[`ThreadSummaryView > renders the narrow thread summary 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/index.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/index.tsx new file mode 100644 index 0000000000..914bd0a222 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/index.tsx @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { + ThreadMessagePreviewView, + ThreadSummaryView, + type ThreadMessagePreviewAvatar, + type ThreadMessagePreviewViewModel, + type ThreadMessagePreviewViewSnapshot, + type ThreadSummaryNotificationIndicator, + type ThreadSummaryViewActions, + type ThreadSummaryViewModel, + type ThreadSummaryViewSnapshot, +} from "./ThreadSummaryView";