mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-07 15:08:24 +00:00
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>
This commit is contained in:
@@ -19,13 +19,14 @@ import {
|
||||
mkStubRoom,
|
||||
mockClientPushProcessor,
|
||||
} from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import * as languageHandler from "../../../../../src/languageHandler";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import TextualBody from "../../../../../src/components/views/messages/TextualBody";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../src/contexts/RoomContext";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import { getRoomContext } from "../../../../test-utils/room";
|
||||
|
||||
const room1Id = "!room1:example.com";
|
||||
const room2Id = "!room2:example.com";
|
||||
@@ -57,7 +58,6 @@ const mkFormattedMessage = (body: string, formattedBody: string): MatrixEvent =>
|
||||
|
||||
describe("<TextualBody />", () => {
|
||||
afterEach(() => {
|
||||
jest.spyOn(MatrixClientPeg, "get").mockRestore();
|
||||
jest.spyOn(global.Math, "random").mockRestore();
|
||||
});
|
||||
|
||||
@@ -114,12 +114,17 @@ describe("<TextualBody />", () => {
|
||||
jest.spyOn(global.Math, "random").mockReturnValue(0.123456);
|
||||
});
|
||||
|
||||
const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) =>
|
||||
(renderingFn ?? render)(
|
||||
const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) => {
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
const room = matrixClient.getRoom(mergedProps.mxEvent.getRoomId()) ?? defaultRoom;
|
||||
return (renderingFn ?? render)(
|
||||
<MatrixClientContext.Provider value={matrixClient}>
|
||||
<TextualBody {...defaultProps} {...props} />
|
||||
<RoomContext.Provider value={getRoomContext(room, {})}>
|
||||
<TextualBody {...mergedProps} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("renders m.emote correctly", () => {
|
||||
DMRoomMap.makeShared(defaultMatrixClient);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user