mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-31 19:21:31 +02:00
* 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>
374 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|