From 4be2863fa81fb90f53560ee5ce54fd70131ba95f Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 31 Jul 2026 07:18:50 +0200 Subject: [PATCH] Move EventTile to shared components - #3b (#34459) * Refactor redacted body to use render-only view model props * Fix oxfmt issues * Missing mock function for MBodyFacytory-test * Convert to vitest --- .../views/messages/MBodyFactory.tsx | 10 +- .../message-body/RedactedBodyViewModel.ts | 84 ++--------- .../EventTileRedactedBodyState.test.ts | 90 ++++++++++++ .../event-tile/EventTileRedactedBodyState.ts | 37 +++++ .../RedactedBodyViewModel-test.ts | 137 ++---------------- .../views/messages/MBodyFactory-test.tsx | 1 + 6 files changed, 161 insertions(+), 198 deletions(-) create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.test.ts create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.ts diff --git a/apps/web/src/components/views/messages/MBodyFactory.tsx b/apps/web/src/components/views/messages/MBodyFactory.tsx index 6da7a95e4e..d6d4baff14 100644 --- a/apps/web/src/components/views/messages/MBodyFactory.tsx +++ b/apps/web/src/components/views/messages/MBodyFactory.tsx @@ -21,10 +21,12 @@ import { type IBodyProps } from "./IBodyProps"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; import { useMediaVisible } from "../../../hooks/useMediaVisible"; +import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel"; import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel"; import { ImageBodyViewModel } from "../../../viewmodels/message-body/ImageBodyViewModel"; import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel"; +import { getRedactedBodyViewModelProps } from "../../../viewmodels/room/timeline/event-tile/EventTileRedactedBodyState"; import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel"; import { isMimeTypeAllowed } from "../../../utils/blobs"; @@ -255,11 +257,13 @@ export function ImageBodyFactory({ } export function RedactedBodyFactory({ mxEvent, ref }: Pick): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent })); + const showTwelveHour = useSettingValue("showTwelveHourTimestamps"); + const props = getRedactedBodyViewModelProps(mxEvent, showTwelveHour); + const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel(props)); useEffect(() => { - vm.setEvent(mxEvent); - }, [mxEvent, vm]); + vm.setProps(getRedactedBodyViewModelProps(mxEvent, showTwelveHour)); + }, [mxEvent, showTwelveHour, vm]); return ; } diff --git a/apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts b/apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts index 490f7be43f..9e412590e1 100644 --- a/apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts +++ b/apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts @@ -5,93 +5,29 @@ * Please see LICENSE files in the repository root for full details. */ -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, type RedactedBodyViewSnapshot, type RedactedBodyViewModel as RedactedBodyViewModelInterface, } from "@element-hq/web-shared-components"; -import { formatFullDate } from "../../DateUtils"; -import { _t } from "../../languageHandler"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import SettingsStore from "../../settings/SettingsStore"; - -export interface RedactedBodyViewModelProps { - /** - * The redacted event being rendered. - */ - mxEvent: MatrixEvent; -} +/** + * View model for a redacted event body. + * + * All event inspection, localization, and settings access happens in the application adapter. + */ +export type RedactedBodyViewModelProps = RedactedBodyViewSnapshot; export class RedactedBodyViewModel extends BaseViewModel implements RedactedBodyViewModelInterface { - private showTwelveHour: boolean; - - private static readonly computeText = ({ mxEvent }: RedactedBodyViewModelProps): string => { - const redactedBecauseUserId = mxEvent.getUnsigned().redacted_because?.sender; - if (!redactedBecauseUserId || redactedBecauseUserId === mxEvent.getSender()) { - return _t("timeline|self_redaction"); - } - - const roomId = mxEvent.getRoomId(); - const room = roomId ? MatrixClientPeg.get()?.getRoom(roomId) : null; - const sender = room?.getMember(redactedBecauseUserId); - - return _t("timeline|redaction", { name: sender?.name ?? redactedBecauseUserId }); - }; - - private static readonly computeTooltip = (mxEvent: MatrixEvent, showTwelveHour: boolean): string | undefined => { - const redactionTs = mxEvent.getUnsigned().redacted_because?.origin_server_ts; - if (!redactionTs) { - return undefined; - } - - return _t("timeline|redacted|tooltip", { - date: formatFullDate(new Date(redactionTs), showTwelveHour), - }); - }; - - private static readonly computeSnapshot = ( - props: RedactedBodyViewModelProps, - showTwelveHour: boolean, - ): RedactedBodyViewSnapshot => ({ - text: RedactedBodyViewModel.computeText(props), - tooltip: RedactedBodyViewModel.computeTooltip(props.mxEvent, showTwelveHour), - }); - public constructor(props: RedactedBodyViewModelProps) { - const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); - - super(props, RedactedBodyViewModel.computeSnapshot(props, showTwelveHour)); - - this.showTwelveHour = showTwelveHour; - - const showTwelveHourWatcherRef = SettingsStore.watchSetting( - "showTwelveHourTimestamps", - null, - (_settingName, _roomId, _level, _newValAtLevel, newVal) => { - if (this.showTwelveHour === newVal) return; - - this.showTwelveHour = !!newVal; - this.updateTooltip(); - }, - ); - this.disposables.track(() => SettingsStore.unwatchSetting(showTwelveHourWatcherRef)); + super(props, props); } - public setEvent(mxEvent: MatrixEvent): void { - this.props = { ...this.props, mxEvent }; - - const text = RedactedBodyViewModel.computeText(this.props); - const tooltip = RedactedBodyViewModel.computeTooltip(this.props.mxEvent, this.showTwelveHour); - this.snapshot.merge({ text, tooltip }); - } - - private updateTooltip(): void { - const tooltip = RedactedBodyViewModel.computeTooltip(this.props.mxEvent, this.showTwelveHour); - this.snapshot.merge({ tooltip }); + public setProps(props: RedactedBodyViewModelProps): void { + this.props = props; + this.snapshot.merge(props); } } diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.test.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.test.ts new file mode 100644 index 0000000000..8799bc3a83 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.test.ts @@ -0,0 +1,90 @@ +/* +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. +*/ + +// @vitest-environment happy-dom + +import { EventType, MatrixEvent, MsgType, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +import { formatFullDate } from "../../../../DateUtils"; +import { _t } from "../../../../languageHandler"; +import { MatrixClientPeg } from "../../../../MatrixClientPeg"; +import { getRedactedBodyViewModelProps } from "./EventTileRedactedBodyState"; + +describe("EventTileRedactedBodyState", () => { + let client: MatrixClient; + let room: Room; + + const makeRedactedEvent = ({ + sender = "@alice:example.com", + redactedBecauseSender = sender, + originServerTs = Date.UTC(2022, 10, 17, 15, 58, 32), + }: { + sender?: string; + redactedBecauseSender?: string; + originServerTs?: number; + } = {}): MatrixEvent => + new MatrixEvent({ + event_id: "$message:example.com", + type: EventType.RoomMessage, + sender, + room_id: room.roomId, + content: { + msgtype: MsgType.Text, + body: "Message", + }, + unsigned: { + redacted_because: { + content: {}, + event_id: "$redaction:example.com", + origin_server_ts: originServerTs, + redacts: "$message:example.com", + room_id: room.roomId, + sender: redactedBecauseSender, + type: EventType.RoomRedaction, + unsigned: {}, + }, + }, + }); + + beforeEach(() => { + client = { getRoom: vi.fn() } as unknown as MatrixClient; + room = { roomId: "!room:example.com", getMember: vi.fn() } as unknown as Room; + vi.spyOn(MatrixClientPeg, "get").mockReturnValue(client); + vi.spyOn(client, "getRoom").mockReturnValue(room); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("builds self-redaction text and tooltip from the event", () => { + const event = makeRedactedEvent(); + + expect(getRedactedBodyViewModelProps(event, true)).toEqual({ + text: "Message deleted", + tooltip: _t("timeline|redacted|tooltip", { + date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true), + }), + }); + }); + + it("uses the redacting member name when another user removed the message", () => { + vi.spyOn(room, "getMember").mockReturnValue({ name: "Alice" } as any); + + expect( + getRedactedBodyViewModelProps( + makeRedactedEvent({ redactedBecauseSender: "@alice-redactor:example.com" }), + false, + ).text, + ).toBe("Message deleted by Alice"); + }); + + it("omits the tooltip when the event has no redaction timestamp", () => { + const event = makeRedactedEvent({ originServerTs: 0 }); + + expect(getRedactedBodyViewModelProps(event, false).tooltip).toBeUndefined(); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.ts new file mode 100644 index 0000000000..578b78cdb4 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileRedactedBodyState.ts @@ -0,0 +1,37 @@ +/* +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 { type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { formatFullDate } from "../../../../DateUtils"; +import { _t } from "../../../../languageHandler"; +import { MatrixClientPeg } from "../../../../MatrixClientPeg"; +import { type RedactedBodyViewSnapshot } from "@element-hq/web-shared-components"; + +/** Converts a redacted event into the data required by the redacted body view. */ +export function getRedactedBodyViewModelProps(mxEvent: MatrixEvent, showTwelveHour: boolean): RedactedBodyViewSnapshot { + const redactedBecause = mxEvent.getUnsigned().redacted_because; + + const redactingUserId = redactedBecause?.sender; + const redactingRoomId = mxEvent.getRoomId(); + const redactingRoom = redactingRoomId ? MatrixClientPeg.get()?.getRoom(redactingRoomId) : null; + const redactingMember = redactingUserId ? redactingRoom?.getMember(redactingUserId) : undefined; + + const text = + !redactingUserId || redactingUserId === mxEvent.getSender() + ? _t("timeline|self_redaction") + : _t("timeline|redaction", { name: redactingMember?.name ?? redactingUserId }); + + const redactionTs = redactedBecause?.origin_server_ts; + const tooltip = redactionTs + ? _t("timeline|redacted|tooltip", { + date: formatFullDate(new Date(redactionTs), showTwelveHour), + }) + : undefined; + + return { text, tooltip }; +} diff --git a/apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts b/apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts index 15911318b7..c6d517e3b3 100644 --- a/apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts +++ b/apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts @@ -5,143 +5,38 @@ * Please see LICENSE files in the repository root for full details. */ -import { EventType, MsgType, type MatrixClient, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; - -import { formatFullDate } from "../../../../../src/DateUtils"; -import { _t } from "../../../../../src/languageHandler"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; import { RedactedBodyViewModel } from "../../../../../src/viewmodels/message-body/RedactedBodyViewModel"; -import { mkEvent, mkRoom, stubClient } from "../../../../test-utils"; describe("RedactedBodyViewModel", () => { - let client: MatrixClient; - let room: Room; - let showTwelveHourSettingWatcher: ((...args: any[]) => void) | undefined; + it("exposes the supplied render snapshot", () => { + const props = { + text: "Message deleted", + tooltip: "Deleted yesterday", + }; + const vm = new RedactedBodyViewModel(props); - const makeRedactedBecauseEvent = ({ sender, originServerTs }: { sender: string; originServerTs: number }) => ({ - content: {}, - event_id: "$redaction:example.com", - origin_server_ts: originServerTs, - redacts: "$message:example.com", - room_id: room.roomId, - sender, - type: EventType.RoomRedaction, - unsigned: {}, + expect(vm.getSnapshot()).toEqual(props); }); - const makeRedactedEvent = ({ - sender = "@alice:example.com", - redactedBecauseSender = sender, - originServerTs = Date.UTC(2022, 10, 17, 15, 58, 32), - }: { - sender?: string; - redactedBecauseSender?: string; - originServerTs?: number; - } = {}): MatrixEvent => - mkEvent({ - event: true, - type: EventType.RoomMessage, - user: sender, - room: room.roomId, - content: { - msgtype: MsgType.Text, - body: "Message", - }, - unsigned: { - redacted_because: makeRedactedBecauseEvent({ - sender: redactedBecauseSender, - originServerTs, - }), - }, - }); + it("updates the render snapshot when props change", () => { + const vm = new RedactedBodyViewModel({ text: "Message deleted" }); - beforeEach(() => { - client = stubClient(); - room = mkRoom(client, "!room:example.com"); - jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client); - jest.spyOn(client, "getRoom").mockReturnValue(room); - jest.spyOn(SettingsStore, "getValue").mockImplementation( - (settingName) => settingName === "showTwelveHourTimestamps", - ); - jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingName, _roomId, callbackFn) => { - showTwelveHourSettingWatcher = callbackFn as (...args: any[]) => void; - return "mock-show-twelve-hour-watcher"; - }); - jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn()); - }); - - it("builds self-redaction text and tooltip from the event", () => { - const event = makeRedactedEvent(); - const vm = new RedactedBodyViewModel({ mxEvent: event }); + vm.setProps({ text: "Message deleted by Alice", tooltip: "Deleted yesterday" }); expect(vm.getSnapshot()).toEqual({ - text: "Message deleted", - tooltip: _t("timeline|redacted|tooltip", { - date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true), - }), + text: "Message deleted by Alice", + tooltip: "Deleted yesterday", }); }); - it("uses the redacting member name when another user removed the message", () => { - jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice" } as any); - const event = makeRedactedEvent({ - redactedBecauseSender: "@alice-redactor:example.com", - }); - - const vm = new RedactedBodyViewModel({ mxEvent: event }); - - expect(vm.getSnapshot().text).toBe("Message deleted by Alice"); - }); - - it("updates the tooltip when showTwelveHourTimestamps changes", () => { - jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); - const event = makeRedactedEvent(); - const vm = new RedactedBodyViewModel({ mxEvent: event }); + it("does not notify for unchanged props", () => { + const props = { text: "Message deleted" }; + const vm = new RedactedBodyViewModel(props); const listener = jest.fn(); vm.subscribe(listener); + vm.setProps(props); - showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, false, false); expect(listener).not.toHaveBeenCalled(); - - showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, true, true); - - expect(listener).toHaveBeenCalledTimes(1); - expect(vm.getSnapshot().tooltip).toBe( - _t("timeline|redacted|tooltip", { - date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true), - }), - ); - }); - - it("setEvent is a no-op for the same event and updates for a different event", () => { - const originalEvent = makeRedactedEvent(); - const updatedEvent = makeRedactedEvent({ - redactedBecauseSender: "@moderator:example.com", - originServerTs: Date.UTC(2023, 0, 1, 12, 0, 0), - }); - const vm = new RedactedBodyViewModel({ mxEvent: originalEvent }); - const listener = jest.fn(); - - jest.spyOn(room, "getMember").mockReturnValue({ name: "Moderator" } as any); - - vm.subscribe(listener); - - vm.setEvent(originalEvent); - expect(listener).not.toHaveBeenCalled(); - - vm.setEvent(updatedEvent); - - expect(listener).toHaveBeenCalledTimes(1); - expect(vm.getSnapshot().text).toBe("Message deleted by Moderator"); - }); - - it("unwatches the timestamp setting when disposed", () => { - const vm = new RedactedBodyViewModel({ mxEvent: makeRedactedEvent() }); - - vm.dispose(); - - expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-show-twelve-hour-watcher"); }); }); diff --git a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx index aab11a84aa..3f87e1c774 100644 --- a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx @@ -49,6 +49,7 @@ describe("MBodyFactory", () => { ...mockClientMethodsDevice(deviceId), ...mockClientMethodsCrypto(), getRooms: jest.fn().mockReturnValue([]), + getRoom: jest.fn(), getIgnoredUsers: jest.fn(), getVersions: jest.fn().mockResolvedValue({ unstable_features: {