element-web/apps/web/test/viewmodels/message-body/EventContentBodyViewModel-test.tsx
Zack 8d076c897d
Refactor EventContentBody to shared-components (#31914)
* Init of refactoring of eventcontentbody

* update stories css by copying css from element x to shared components

* Replaced old component EventContentBody with newly created mmvm component EventContentBodyViewModel

* Refactor TextualBody and EditHistoryMessage to properly manage EventContentBodyViewModel

* generated snapshot after vitest

* Update import placement for eslint to pass CI

* Fixed lint warnings

* Update css for codeblock to represent js highlight

* test: add EventContentBodyViewModel snapshot coverage

* fix: pass content ref to EventContentBodyView for link previews

* Fix: return to old code that passed tests

* Added storybook snapshots

* Removal of old component that is being unused

* Update snapshot

* Fix missing enableBigEmoji and shouldShowPillAvatar settings in EventContentBodyViewModel

* update snapshot

* narrow setProps to mutable fields and skip no-op snapshot recomputes

* Update Snapshots

* replace EventContentBodyViewModel setProps with explicit setters and update call sites

* render body in view and keep parser/replacer in snapshot

* Eslint Restruct

* Eslint Restructure

* Removed unused function, moved to shared component

* Remove Unused Module (Moved To Shared Component)

* Disable EventContent-body Test to check weather it fixes CI

* Enable EventContentBody Tests

* Remove EventTest

* Update Include in Vitest

* Added EventContentBody test

* Update Package.json

* Update Lockfile

* Update dependencies

* update lockfile

* ptimize EventContentBodyViewModel to recompute/merge only changed snapshot fields

* Update snapshots

* setEventContent and setStripReply run whenever the existing update block runs

* defined arrow functions for undefined runtime issues that might occur.

* Update test cases

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com>

* move big-emoji and pill-avatar setting watchers into EventContentBodyViewModel

* Update packages/shared-components/src/message-body/EventContentBody/index.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBody.test.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBody.stories.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Fix dubblicate variables

* clarify applyReplacerOnString input/replacer params

* Added memo to the view

* Prettier Fix

* Update apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Added compund variables instead of reguler values

* Added boolean default values

* remove redundant setting props from TextualBody and EditHistoryMessage

* Prettier FIx

* replace MatrixClientPeg usage with `client: MatrixClient | null` passed from context

* TextualBody now passes EventContentBodyViewModel `client` from RoomContext.

* Remove redundant as prop from EventContentBody VM usage

* Normalize EventContentBodyViewModel renderer flags to booleans

---------

Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com>
Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
2026-03-09 09:58:05 +00:00

374 lines
13 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 { MsgType, PushRuleKind, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { type JSX } from "react";
import {
EventContentBodyViewModel,
type EventContentBodyViewModelProps,
} from "../../../src/viewmodels/message-body/EventContentBodyViewModel";
import { stubClient, mkStubRoom, mkEvent } from "../../test-utils";
import { bodyToNode } from "../../../src/HtmlUtils";
import {
combineRenderers,
mentionPillRenderer,
keywordPillRenderer,
ambiguousLinkTooltipRenderer,
spoilerRenderer,
codeBlockRenderer,
} from "../../../src/renderer";
import PlatformPeg from "../../../src/PlatformPeg";
import type BasePlatform from "../../../src/BasePlatform";
import SettingsStore from "../../../src/settings/SettingsStore";
jest.mock("../../../src/HtmlUtils", () => ({
...jest.requireActual("../../../src/HtmlUtils"),
bodyToNode: jest.fn(),
}));
jest.mock("../../../src/renderer", () => ({
combineRenderers: jest.fn(),
mentionPillRenderer: jest.fn(),
keywordPillRenderer: jest.fn(),
ambiguousLinkTooltipRenderer: jest.fn(),
codeBlockRenderer: jest.fn(),
spoilerRenderer: jest.fn(),
}));
jest.mock("../../../src/PlatformPeg", () => ({
__esModule: true,
default: {
get: jest.fn(),
},
}));
const mockedBodyToNode = jest.mocked(bodyToNode);
const mockedCombineRenderers = jest.mocked(combineRenderers);
const mockedPlatformPeg = jest.mocked(PlatformPeg);
describe("EventContentBodyViewModel", () => {
const defaultContent = {
body: "Hello world",
msgtype: MsgType.Text,
};
const defaultProps = (overrides: Partial<EventContentBodyViewModelProps> = {}): EventContentBodyViewModelProps => ({
client: null,
content: defaultContent,
linkify: false,
as: "span",
...overrides,
});
beforeEach(() => {
mockedBodyToNode.mockReset();
mockedCombineRenderers.mockReset();
mockedPlatformPeg.get.mockReset();
mockedPlatformPeg.get.mockReturnValue(null);
});
it("passes render options to bodyToNode", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(
defaultProps({
linkify: true,
stripReply: true,
enableBigEmoji: true,
mediaIsVisible: false,
}),
);
const snapshot = vm.getSnapshot();
expect(mockedBodyToNode).toHaveBeenCalledWith(defaultContent, undefined, {
disableBigEmoji: false,
stripReplyFallback: true,
mediaIsVisible: false,
linkify: true,
});
expect(snapshot.body).toBe("Hello world");
expect(snapshot.replacer).toBe(replacer);
expect(snapshot.className).toContain("mx_EventTile_body");
});
it("initializes setting-backed options from SettingsStore when omitted", () => {
const replacer = jest.fn();
const createReplacerFromOptions = jest.fn().mockReturnValue(replacer);
mockedCombineRenderers.mockReturnValue(createReplacerFromOptions);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const getValueSpy = jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "TextualBody.enableBigEmoji") return false;
if (settingName === "Pill.shouldShowPillAvatar") return false;
return true;
});
new EventContentBodyViewModel(defaultProps());
expect(getValueSpy).toHaveBeenCalledWith("TextualBody.enableBigEmoji");
expect(getValueSpy).toHaveBeenCalledWith("Pill.shouldShowPillAvatar");
expect(mockedBodyToNode).toHaveBeenCalledWith(
defaultContent,
undefined,
expect.objectContaining({ disableBigEmoji: true }),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith();
expect(createReplacerFromOptions).toHaveBeenCalledWith(
expect.objectContaining({ shouldShowPillAvatar: false }),
);
getValueSpy.mockRestore();
});
it("uses the injected client to resolve the room for renderer context", () => {
const replacer = jest.fn();
const createReplacerFromOptions = jest.fn().mockReturnValue(replacer);
mockedCombineRenderers.mockReturnValue(createReplacerFromOptions);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const client = stubClient();
const mxEvent = mkEvent({
type: "m.room.message",
room: "!room:example.org",
user: "@user:example.org",
content: defaultContent,
event: true,
});
const room = mkStubRoom("!room:example.org", "Room", client) as Room;
const getRoomSpy = jest.spyOn(client, "getRoom").mockReturnValue(room);
new EventContentBodyViewModel(defaultProps({ mxEvent, client }));
expect(getRoomSpy).toHaveBeenCalledWith("!room:example.org");
expect(createReplacerFromOptions).toHaveBeenCalledWith(expect.objectContaining({ room }));
});
it("forces disableBigEmoji for emote events", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Emote",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
new EventContentBodyViewModel(
defaultProps({
content: {
body: "Emote",
msgtype: MsgType.Emote,
},
enableBigEmoji: true,
}),
);
expect(mockedBodyToNode).toHaveBeenCalledWith(
{ body: "Emote", msgtype: MsgType.Emote },
undefined,
expect.objectContaining({ disableBigEmoji: true }),
);
});
it("uses parse when formattedBody is provided", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: "<b>Hello</b>",
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
const snapshot = vm.getSnapshot();
expect(snapshot.formattedBody).toBe("<b>Hello</b>");
expect(snapshot.body).toBe("Hello world");
expect(snapshot.replacer).toBe(replacer);
});
it("uses emojiBodyElements when provided", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
const emojiElements = ["emoji"] as unknown as JSX.Element[];
mockedBodyToNode.mockReturnValue({
strippedBody: "ignored",
formattedBody: undefined,
emojiBodyElements: emojiElements,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
expect(vm.getSnapshot().body).toBe(emojiElements);
expect(vm.getSnapshot().replacer).toBe(replacer);
});
it("sets dir to auto for div elements even when includeDir is false", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps({ as: "div", includeDir: false }));
expect(vm.getSnapshot().dir).toBe("auto");
});
it("omits dir when includeDir is false on span elements", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps({ as: "span", includeDir: false }));
expect(vm.getSnapshot().dir).toBeUndefined();
});
it("updates snapshot when setEventContent changes content", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Initial",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
expect(vm.getSnapshot().body).toBe("Initial");
mockedBodyToNode.mockReturnValue({
strippedBody: "Updated",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
vm.setEventContent(undefined, { body: "Updated", msgtype: MsgType.Text });
expect(vm.getSnapshot().body).toBe("Updated");
});
it("emits updates when setters are called with unchanged values", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Initial",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
const previousSnapshot = vm.getSnapshot();
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setEventContent(undefined, defaultContent);
vm.setAs("span");
expect(subscriber).toHaveBeenCalledTimes(2);
expect(vm.getSnapshot()).toEqual(previousSnapshot);
});
it("includes renderers based on options and platform capabilities", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
mockedPlatformPeg.get.mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
const client = stubClient();
const mxEvent = mkEvent({
type: "m.room.message",
room: "!room:example.org",
user: "@user:example.org",
content: defaultContent,
event: true,
});
jest.spyOn(mxEvent, "getPushDetails").mockReturnValue({
rule: {
enabled: true,
kind: PushRuleKind.ContentSpecific,
pattern: "Hello",
},
} as unknown as ReturnType<MatrixEvent["getPushDetails"]>);
jest.spyOn(client, "getRoom").mockReturnValue(mkStubRoom("!room:example.org", "Room", client) as Room);
new EventContentBodyViewModel(
defaultProps({
renderMentionPills: true,
renderKeywordPills: true,
renderTooltipsForAmbiguousLinks: true,
renderSpoilers: true,
renderCodeBlocks: true,
mxEvent,
}),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith(
mentionPillRenderer,
keywordPillRenderer,
ambiguousLinkTooltipRenderer,
spoilerRenderer,
codeBlockRenderer,
);
});
it("skips tooltip renderer when platform does not need URL tooltips", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
mockedPlatformPeg.get.mockReturnValue({ needsUrlTooltips: () => false } as unknown as BasePlatform);
new EventContentBodyViewModel(
defaultProps({
renderMentionPills: true,
renderTooltipsForAmbiguousLinks: true,
}),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith(mentionPillRenderer);
});
});