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
This commit is contained in:
Zack
2026-05-28 10:57:55 +02:00
committed by GitHub
parent 34c388f760
commit 3bb9cb2234
20 changed files with 2485 additions and 262 deletions
+17 -17
View File
@@ -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 () => {
-1
View File
@@ -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";
+16 -5
View File
@@ -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;
}
}
@@ -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;
}
}
@@ -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<EventTileProps, IState>
<div className="mx_ThreadPanel_replies">
<ThreadsIcon />
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
<ThreadMessagePreview thread={threadState.thread} />
<ThreadMessagePreviewWrapper thread={threadState.thread} />
</div>
);
}
@@ -543,7 +550,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
if (threadState.shouldShowThreadSummary && threadState.thread) {
return (
<ThreadSummary mxEvent={this.props.mxEvent} thread={threadState.thread} data-testid="thread-summary" />
<ThreadSummaryWrapper
mxEvent={this.props.mxEvent}
thread={threadState.thread}
data-testid="thread-summary"
/>
);
}
@@ -1552,6 +1563,100 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele
);
}
interface ThreadMessagePreviewWrapperProps {
thread: Thread;
showDisplayName?: boolean;
}
function ThreadMessagePreviewWrapper({
thread,
showDisplayName = false,
}: Readonly<ThreadMessagePreviewWrapperProps>): 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 <ThreadMessagePreviewView vm={vm} />;
}
interface ThreadSummaryWrapperProps extends Omit<React.ComponentPropsWithoutRef<"button">, "aria-label" | "onClick"> {
mxEvent: MatrixEvent;
thread: Thread;
}
function ThreadSummaryWrapper({
mxEvent,
thread,
className,
...props
}: Readonly<ThreadSummaryWrapperProps>): 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 <ThreadSummaryView {...props} vm={vm} className={classNames("mx_ThreadSummary", className)} />;
}
interface ReactionsRowButtonItemProps {
mxEvent: MatrixEvent;
content: string;
@@ -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<IProps> = ({ 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 (
<AccessibleButton
{...props}
className="mx_ThreadSummary"
onClick={(ev: ButtonEvent) => {
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: mxEvent,
push: cardContext.isCard,
});
PosthogTrackers.trackInteraction("WebRoomTimelineThreadSummaryButton", ev);
}}
aria-label={_t("threads|open_thread")}
>
<IndicatorIcon size="24px" indicator={notificationLevelToIndicator(level)}>
<ThreadsSolidIcon />
</IndicatorIcon>
<span className="mx_ThreadSummary_replies_amount">{countSection}</span>
<ThreadMessagePreview thread={thread} showDisplayname={!roomContext.narrow} />
<div className="mx_ThreadSummary_chevron">
<ChevronRightIcon />
</div>
</AccessibleButton>
);
};
interface IPreviewProps {
thread: Thread;
showDisplayname?: boolean;
}
export const ThreadMessagePreview: React.FC<IPreviewProps> = ({ thread, showDisplayname = false }) => {
const lastReply = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.replyToEvent) ?? undefined;
const preview = useEventPreview(lastReply);
if (!preview || !lastReply) {
return null;
}
return (
<>
<MemberAvatar
member={lastReply.sender}
fallbackUserId={lastReply.getSender()}
size="24px"
className="mx_ThreadSummary_avatar"
/>
{showDisplayname && (
<div className="mx_ThreadSummary_sender">{lastReply.sender?.name ?? lastReply.getSender()}</div>
)}
{lastReply.isDecryptionFailure() ? (
<div
className="mx_ThreadSummary_content mx_DecryptionFailureBody"
title={_t("timeline|decryption_failure|unable_to_decrypt")}
>
{_t("timeline|decryption_failure|unable_to_decrypt")}
</div>
) : (
<EventPreviewTile preview={preview} className="mx_ThreadSummary_content" />
)}
</>
);
};
export default ThreadSummary;
@@ -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>([
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<ThreadMessagePreviewViewSnapshot, ThreadMessagePreviewViewModelProps>
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<void> {
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<void> {
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<ThreadMessagePreviewViewSnapshot, "avatar" | "senderName" | "showDisplayName"> {
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<ThreadMessagePreviewViewSnapshot, "avatar" | "senderName" | "showDisplayName">,
): 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) => <strong>{sub}</strong>,
},
)
: 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<ThreadMessagePreviewViewModelProps, "showDisplayName"> {
/**
* 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<ThreadSummaryViewSnapshot, ThreadSummaryViewModelProps>
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<HTMLButtonElement>): void => {
defaultDispatcher.dispatch<ShowThreadPayload>({
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<ThreadSummaryViewSnapshot, "isVisible" | "replyCountLabel" | "narrow"> {
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);
}
}
@@ -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<MatrixClient, jest.Mock>();
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<ThreadMessagePreviewViewModelProps> = {}): {
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<ThreadSummaryViewModelProps> = {}): {
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<HTMLButtonElement>;
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<HTMLButtonElement>;
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<void>((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<void>((_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,
}),
);
});
});