element-web/apps/web/test/viewmodels/message-body/TextualBodyViewModel-test.tsx
Zack 4e9655dc6b
Phase 2 : Refactor TextualBody to MVVM and remove legacy component (#33165)
* Refactor TextualBody to MVVM and remove legacy component

* Update snapshot + fix eslint warning

* update css to fix playwright tests failure

* return i18n into the MVVM

* Update snapshots

* Update tests to reflect the css changes

* Update snapshot

* Update css to correct letter-spacing

* Update css to fix playwright issues.

* Preserve inline emote sender rendering in TextualBodyView

* Update snapshot to reflect html change

* Update back to span instead of button, the default button css fails tests

* Extract TextualBodyFactory from MBodyFactory

* Update snapshot

* Update HTML snapshot to pass tests

* Update Snapshots

* Added several tests for coverage

* Remove double checks, merge function already checks.

* Remove unessecery comment

* revert to button

* Update snapshots because of the revert

* added Math.min() to simplify ternary expressions.

* Update playwright screenshots for accessibility

* Update playwright screenshots

* Update css to fix playwright fail

* Update screenshot + snapshots

* Add comments to props
2026-04-28 07:07:19 +00:00

437 lines
16 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 {
LINKIFIED_DATA_ATTRIBUTE,
TextualBodyViewBodyWrapperKind,
TextualBodyViewKind,
} from "@element-hq/web-shared-components";
import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { flushPromises, mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { TextualBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel";
import Modal from "../../../src/Modal";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { IntegrationManagers } from "../../../src/integrations/IntegrationManagers";
import * as permalinkUtils from "../../../src/utils/permalinks/Permalinks";
import QuestionDialog from "../../../src/components/views/dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../../../src/components/views/dialogs/MessageEditHistoryDialog";
describe("TextualBodyViewModel", () => {
const createEvent = (
content: Record<string, unknown>,
overrides?: Partial<{ room: string; user: string }>,
): MatrixEvent =>
mkEvent({
event: true,
type: "m.room.message",
room: overrides?.room ?? "!room:example.com",
user: overrides?.user ?? "@alice:example.com",
content,
});
const createVm = (
overrides?: Partial<ConstructorParameters<typeof TextualBodyViewModel>[0]>,
): TextualBodyViewModel =>
new TextualBodyViewModel({
mxEvent: createEvent({
body: "Hello world",
msgtype: MsgType.Text,
}),
timelineRenderingType: TimelineRenderingType.Room,
...overrides,
});
afterEach(() => {
jest.restoreAllMocks();
window.location.hash = "";
});
it("computes the initial snapshot from props", () => {
const event = createEvent({
body: "Caption",
msgtype: MsgType.Image,
});
jest.spyOn(event, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
jest.spyOn(event, "messageVisibility").mockReturnValue({
visible: false,
reason: "copyright",
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({
id: "event-id",
mxEvent: event,
highlightLink: "https://example.com",
replacingEventId: "$replacement",
isSeeingThroughMessageHiddenForModeration: true,
});
const snapshot = vm.getSnapshot();
expect(snapshot.id).toBe("event-id");
expect(snapshot.kind).toBe(TextualBodyViewKind.CAPTION);
expect(snapshot.bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.LINK);
expect(snapshot.bodyLinkHref).toBe("https://example.com");
expect(snapshot.showEditedMarker).toBe(true);
expect(snapshot.editedMarkerText).toContain("edited");
expect(snapshot.showPendingModerationMarker).toBe(true);
expect(snapshot.pendingModerationText).toContain("copyright");
});
it("updates message-derived fields when the event changes", () => {
const vm = createVm();
const emoteEvent = createEvent(
{
body: "waves",
msgtype: MsgType.Emote,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
},
{ user: "@bob:example.com" },
);
emoteEvent.sender = { name: "Bob" } as MatrixEvent["sender"];
vm.setEvent(emoteEvent);
expect(vm.getSnapshot().kind).toBe(TextualBodyViewKind.EMOTE);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
expect(vm.getSnapshot().emoteSenderName).toBe("Bob");
});
it("updates wrapper state when the highlight link changes", () => {
const starterLinkEvent = createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
});
const vm = createVm({ mxEvent: starterLinkEvent });
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
vm.setHighlightLink("https://element.io");
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.LINK);
expect(vm.getSnapshot().bodyLinkHref).toBe("https://element.io");
vm.setHighlightLink(undefined);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
});
it("uses the notice kind and no action wrapper for non-string starter links", () => {
const noticeEvent = createEvent({
body: "Notice",
msgtype: MsgType.Notice,
data: {
"org.matrix.neb.starter_link": 42,
},
});
const vm = createVm({ mxEvent: noticeEvent });
expect(vm.getSnapshot().kind).toBe(TextualBodyViewKind.NOTICE);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.NONE);
expect(vm.getSnapshot().bodyActionAriaLabel).toBeUndefined();
});
it("updates the moderation marker from the dedicated setter", () => {
const hiddenEvent = createEvent({
body: "hidden",
msgtype: MsgType.Text,
});
jest.spyOn(hiddenEvent, "messageVisibility").mockReturnValue({
visible: false,
reason: "spam",
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({ mxEvent: hiddenEvent });
vm.setIsSeeingThroughMessageHiddenForModeration(true);
expect(vm.getSnapshot().showPendingModerationMarker).toBe(true);
expect(vm.getSnapshot().pendingModerationText).toContain("spam");
});
it("updates id and edited marker from dedicated setters", () => {
const event = createEvent({
body: "edited",
msgtype: MsgType.Text,
});
jest.spyOn(event, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
const vm = createVm({ mxEvent: event });
vm.setId("updated-id");
vm.setReplacingEventId("$edit");
expect(vm.getSnapshot().id).toBe("updated-id");
expect(vm.getSnapshot().showEditedMarker).toBe(true);
expect(vm.getSnapshot().editedMarkerText).toContain("edited");
vm.setReplacingEventId(undefined);
expect(vm.getSnapshot().showEditedMarker).toBe(false);
expect(vm.getSnapshot().editedMarkerTooltip).toBeUndefined();
});
it("renders the generic pending moderation text when there is no reason", () => {
const hiddenEvent = createEvent({
body: "hidden",
msgtype: MsgType.Text,
});
jest.spyOn(hiddenEvent, "messageVisibility").mockReturnValue({
visible: false,
reason: null,
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({ mxEvent: hiddenEvent });
vm.setIsSeeingThroughMessageHiddenForModeration(true);
expect(vm.getSnapshot().showPendingModerationMarker).toBe(true);
expect(vm.getSnapshot().pendingModerationText).toMatch(/^\(.+\)$/);
expect(vm.getSnapshot().pendingModerationText).not.toContain("undefined");
});
it("throws when pending moderation is requested for a visible message", () => {
const visibleEvent = createEvent({
body: "visible",
msgtype: MsgType.Text,
});
jest.spyOn(visibleEvent, "messageVisibility").mockReturnValue({
visible: true,
} as ReturnType<MatrixEvent["messageVisibility"]>);
expect(() =>
createVm({
mxEvent: visibleEvent,
isSeeingThroughMessageHiddenForModeration: true,
}),
).toThrow("TextualBodyViewModel should only render pending moderation for hidden messages");
});
it("ignores linkified root clicks", () => {
const vm = createVm();
const preventDefault = jest.fn();
const transformSpy = jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref");
vm.onRootClick({
preventDefault,
target: {
dataset: {
[LINKIFIED_DATA_ATTRIBUTE]: "true",
},
href: "https://example.org",
nodeName: "A",
},
} as any);
expect(transformSpy).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("rewrites permalink clicks to local hashes", () => {
const vm = createVm();
const preventDefault = jest.fn();
const anchor = { href: "https://element.example/#/room/!room:example.org", nodeName: "A" };
jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref").mockReturnValue("#/room/!room:example.org");
vm.onRootClick({
preventDefault,
target: {
nodeName: "SPAN",
closest: jest.fn().mockReturnValue(anchor),
},
} as any);
expect(preventDefault).toHaveBeenCalled();
expect(window.location.hash).toBe("#/room/!room:example.org");
});
it("leaves external root clicks alone when no local permalink is found", () => {
const vm = createVm();
const preventDefault = jest.fn();
const href = "https://example.org";
jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref").mockReturnValue(href);
vm.onRootClick({
preventDefault,
target: {
href,
nodeName: "A",
},
} as any);
expect(preventDefault).not.toHaveBeenCalled();
expect(window.location.hash).toBe("");
});
it("does nothing for body actions without a starter link", () => {
const vm = createVm();
const preventDefault = jest.fn();
const hasManagerSpy = jest.spyOn(IntegrationManagers.sharedInstance(), "hasManager");
vm.onBodyActionClick({ preventDefault } as any);
expect(preventDefault).toHaveBeenCalled();
expect(hasManagerSpy).not.toHaveBeenCalled();
});
it("opens the no-manager dialog for starter links when integrations are unavailable", () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const preventDefault = jest.fn();
const managers = IntegrationManagers.sharedInstance();
jest.spyOn(managers, "hasManager").mockReturnValue(false);
const openNoManagerDialogSpy = jest.spyOn(managers, "openNoManagerDialog").mockImplementation(() => {});
vm.onBodyActionClick({ preventDefault } as any);
expect(preventDefault).toHaveBeenCalled();
expect(openNoManagerDialogSpy).toHaveBeenCalled();
});
it("opens the scalar starter link after confirmation", async () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const preventDefault = jest.fn();
const managers = IntegrationManagers.sharedInstance();
const connect = jest.fn().mockResolvedValue(undefined);
const getStarterLink = jest.fn().mockReturnValue("https://scalar.example/complete");
const scalarClient = { connect, getStarterLink };
const integrationManager = {
getScalarClient: jest.fn().mockReturnValue(scalarClient),
uiUrl: "https://scalar.example/ui",
};
const popup = { opener: "initial" };
jest.spyOn(managers, "hasManager").mockReturnValue(true);
jest.spyOn(managers, "getPrimaryManager").mockReturnValue(integrationManager as any);
jest.spyOn(Modal, "createDialog").mockReturnValue({
close: jest.fn(),
finished: Promise.resolve([true]),
} as any);
const openSpy = jest.spyOn(window, "open").mockImplementation(() => popup as any);
vm.onBodyActionClick({ preventDefault } as any);
await flushPromises();
expect(connect).toHaveBeenCalled();
expect(Modal.createDialog).toHaveBeenCalledWith(
QuestionDialog,
expect.objectContaining({
button: expect.any(String),
title: expect.any(String),
}),
);
expect(openSpy).toHaveBeenCalledWith(
"https://scalar.example/complete",
"_blank",
expect.stringContaining("width="),
);
expect(popup.opener).toBeNull();
});
it("does not open the scalar starter link when the dialog is cancelled", async () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const managers = IntegrationManagers.sharedInstance();
const scalarClient = {
connect: jest.fn().mockResolvedValue(undefined),
getStarterLink: jest.fn().mockReturnValue("https://scalar.example/complete"),
};
jest.spyOn(managers, "hasManager").mockReturnValue(true);
jest.spyOn(managers, "getPrimaryManager").mockReturnValue({
getScalarClient: jest.fn().mockReturnValue(scalarClient),
uiUrl: "https://scalar.example/ui",
} as any);
jest.spyOn(Modal, "createDialog").mockReturnValue({
close: jest.fn(),
finished: Promise.resolve([false]),
} as any);
const openSpy = jest.spyOn(window, "open").mockImplementation(() => ({ opener: "initial" }) as any);
vm.onBodyActionClick({ preventDefault: jest.fn() } as any);
await flushPromises();
expect(openSpy).not.toHaveBeenCalled();
});
it("opens the edit history dialog from the edited marker", () => {
const event = createEvent({
body: "edited",
msgtype: MsgType.Text,
});
const vm = createVm({ mxEvent: event });
const createDialogSpy = jest
.spyOn(Modal, "createDialog")
.mockImplementation(() => ({ close: jest.fn() }) as any);
vm.onEditedMarkerClick();
expect(createDialogSpy).toHaveBeenCalledWith(MessageEditHistoryDialog, { mxEvent: event });
});
it("dispatches composer insert for the emote sender using the current rendering type", () => {
const event = createEvent({
body: "waves",
msgtype: MsgType.Emote,
});
const vm = createVm({ mxEvent: event });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(() => {});
vm.setTimelineRenderingType(TimelineRenderingType.Thread);
vm.onEmoteSenderClick();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ComposerInsert,
timelineRenderingType: TimelineRenderingType.Thread,
userId: event.getSender(),
});
});
it("does not emit for unchanged setter values", () => {
const mxEvent = createEvent({
body: "Hello world",
msgtype: MsgType.Text,
});
const vm = createVm({ mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setId(undefined);
vm.setEvent(mxEvent);
vm.setHighlightLink(undefined);
vm.setReplacingEventId(undefined);
vm.setIsSeeingThroughMessageHiddenForModeration(undefined);
vm.setTimelineRenderingType(TimelineRenderingType.Room);
expect(listener).not.toHaveBeenCalled();
});
});