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
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
/*
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user