Files
blap/apps/web/test/viewmodels/event-tiles/ThreadSummaryViewModel-test.tsx
T
Zack 3bb9cb2234 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
2026-05-28 08:57:55 +00:00

889 lines
34 KiB
TypeScript

/*
* 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,
}),
);
});
});