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 0000000000..2c8f9a6e79 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/decryption-failure-auto.png differ 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 0000000000..8edfaf8d6a Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/hidden-auto.png new file mode 100644 index 0000000000..6f86be0c06 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/narrow-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/narrow-auto.png new file mode 100644 index 0000000000..a2ae0f4dfb Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/narrow-auto.png differ 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 0000000000..570595a935 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx/with-notification-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 36c124e522..ef09ab7225 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -53,6 +53,7 @@ export * from "./room/timeline/event-tile/EventTileView/TextualEventView"; export * from "./room/timeline/event-tile/body/AudioPlayerView"; export * from "./room/timeline/event-tile/body/DecryptionFailureBodyView"; export * from "./room/timeline/event-tile/body/MediaBody"; +export * from "./room/timeline/event-tile/EventTileView/ThreadSummary"; export * from "./room/timeline/event-tile/reactions/ReactionsRow"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButton"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.module.css b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.module.css new file mode 100644 index 0000000000..f04a3167b8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.module.css @@ -0,0 +1,121 @@ +/* + * 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. + */ + +.threadSummary, +.content { + color: var(--cpd-color-text-secondary); +} + +.threadSummary { + margin-top: var(--cpd-space-2x); + min-width: 267px; + max-width: min(calc(100% - var(--EventTile_group_line-spacing-inline-start, 0px)), 600px); + width: fit-content; + height: 40px; + position: relative; + background-color: var(--cpd-color-bg-subtle-secondary); + padding-inline: var(--cpd-space-3x) var(--cpd-space-4x); + display: flex; + align-items: center; + justify-content: flex-start; + border-radius: 8px; + box-sizing: border-box; + clear: both; + overflow: hidden; + border: 1px solid var(--cpd-color-bg-subtle-secondary); + font: var(--cpd-font-body-sm-regular); + appearance: none; + cursor: pointer; + text-align: start; +} + +.threadSummary:hover, +.threadSummary:focus { + border-color: var(--cpd-color-border-interactive-secondary); +} + +.threadSummary:hover .chevron, +.threadSummary:focus .chevron { + opacity: 1; + transform: translateX(0); +} + +.sender, +.content { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.content { + font: var(--cpd-font-body-sm-regular); +} + +.sender { + font-weight: var(--cpd-font-weight-semibold); +} + +.content strong { + font: var(--cpd-font-body-sm-semibold); +} + +.threadSummary .repliesAmount, +.threadSummary .sender, +.threadSummary .content { + line-height: var(--EventTile_ThreadSummary-line-height, 24px); +} + +.threadSummary .content { + margin-left: var(--cpd-space-1x); + flex: 1; +} + +.repliesAmount { + color: var(--cpd-color-text-secondary); + font-weight: var(--cpd-font-weight-semibold); + white-space: nowrap; + position: relative; + padding: 0 var(--cpd-space-3x) 0 var(--cpd-space-2x); +} + +.avatar { + margin-inline-end: var(--cpd-space-2x); + flex-shrink: 0; +} + +.threadIcon { + flex-shrink: 0; +} + +.chevron { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 60px; + box-sizing: border-box; + background: linear-gradient(270deg, var(--cpd-color-bg-canvas-default) 50%, transparent 100%); + opacity: 0; + transform: translateX(60px); + transition: all 0.1s ease-in-out; +} + +.chevron svg { + position: absolute; + top: 50%; + right: var(--cpd-space-1x); + transform: translateY(-50%); + width: 24px; + height: 24px; + color: var(--cpd-color-text-secondary); +} + +.narrow { + min-width: initial; + max-width: 100%; + width: initial; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx new file mode 100644 index 0000000000..3e277aba7e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/ThreadSummary/ThreadSummary.stories.tsx @@ -0,0 +1,106 @@ +/* + * 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 { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { + ThreadSummaryView, + type ThreadMessagePreviewViewSnapshot, + type ThreadSummaryViewActions, + type ThreadSummaryViewSnapshot, +} from "./ThreadSummaryView"; + +type WrapperProps = Omit & + 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";