From fa5caa76d9023e2a14d4e19820baeedac29a9a3c Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 7 May 2026 12:11:35 +0200 Subject: [PATCH] Refactor hidden body into shared MVVM (#33403) * Refactor hidden body into shared MVVM * Snapshots * Update packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx Co-authored-by: Florian Duros * Update apps/web/src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel.ts Co-authored-by: Florian Duros * Use compound Text in HiddenBodyView * Update snapshots --------- Co-authored-by: Florian Duros --- apps/web/res/css/_components.pcss | 1 - .../res/css/views/messages/_HiddenBody.pcss | 22 ------ .../components/views/messages/HiddenBody.tsx | 44 ------------ apps/web/src/events/EventTileFactory.tsx | 16 ++++- .../event-tile/body/HiddenBodyViewModel.ts | 49 ++++++++++++++ .../message-body/HiddenBodyViewModel-test.ts | 63 ++++++++++++++++++ .../default-auto.png | Bin 0 -> 20632 bytes .../with-reason-auto.png | Bin 0 -> 21220 bytes .../src/i18n/strings/en_EN.json | 2 + packages/shared-components/src/index.ts | 1 + .../HiddenBodyView/HiddenBodyView.module.css | 22 ++++++ .../HiddenBodyView/HiddenBodyView.stories.tsx | 46 +++++++++++++ .../HiddenBodyView/HiddenBodyView.test.tsx | 55 +++++++++++++++ .../body/HiddenBodyView/HiddenBodyView.tsx | 56 ++++++++++++++++ .../HiddenBodyView.test.tsx.snap | 55 +++++++++++++++ .../event-tile/body/HiddenBodyView/index.tsx | 8 +++ 16 files changed, 370 insertions(+), 70 deletions(-) delete mode 100644 apps/web/res/css/views/messages/_HiddenBody.pcss delete mode 100644 apps/web/src/components/views/messages/HiddenBody.tsx create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel.ts create mode 100644 apps/web/test/viewmodels/message-body/HiddenBodyViewModel-test.ts create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/with-reason-auto.png create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.module.css create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.test.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/__snapshots__/HiddenBodyView.test.tsx.snap create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/index.tsx diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 914555497e..4347d9a340 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -222,7 +222,6 @@ @import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss"; -@import "./views/messages/_HiddenBody.pcss"; @import "./views/messages/_LegacyCallEvent.pcss"; @import "./views/messages/_MFileBody.pcss"; @import "./views/messages/_MImageBody.pcss"; diff --git a/apps/web/res/css/views/messages/_HiddenBody.pcss b/apps/web/res/css/views/messages/_HiddenBody.pcss deleted file mode 100644 index 3644db16e6..0000000000 --- a/apps/web/res/css/views/messages/_HiddenBody.pcss +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_HiddenBody { - white-space: pre-wrap; - color: $muted-fg-color; - vertical-align: middle; - - svg { - height: 14px; - width: 14px; - display: inline-block; - margin-right: var(--cpd-space-1-5x); - color: $muted-fg-color; - vertical-align: -2px; - } -} diff --git a/apps/web/src/components/views/messages/HiddenBody.tsx b/apps/web/src/components/views/messages/HiddenBody.tsx deleted file mode 100644 index aeca8e7ea7..0000000000 --- a/apps/web/src/components/views/messages/HiddenBody.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 React, { type JSX } from "react"; -import { VisibilityOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; -import { type IBodyProps } from "./IBodyProps"; - -/** - * A message hidden from the user pending moderation. - * - * Note: This component must not be used when the user is the author of the message - * or has a sufficient powerlevel to see the message. - */ -const HiddenBody = ({ mxEvent, ref }: IBodyProps): JSX.Element => { - let text; - const visibility = mxEvent.messageVisibility(); - switch (visibility.visible) { - case true: - throw new Error("HiddenBody should only be applied to hidden messages"); - case false: - if (visibility.reason) { - text = _t("timeline|pending_moderation_reason", { reason: visibility.reason }); - } else { - text = _t("timeline|pending_moderation"); - } - break; - } - - return ( - - - {text} - - ); -}; - -export default HiddenBody; diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index 56faffc26c..0ba3cf7932 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useEffect } from "react"; import { type MatrixEvent, EventType, @@ -19,6 +19,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { EncryptionEventView, + HiddenBodyView, TextualEventView, useCreateAutoDisposedViewModel, } from "@element-hq/web-shared-components"; @@ -41,13 +42,13 @@ import { WidgetType } from "../widgets/WidgetType"; import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent"; import { hasText } from "../TextForEvent"; import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils"; -import HiddenBody from "../components/views/messages/HiddenBody"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { type IBodyProps } from "../components/views/messages/IBodyProps"; import { ModuleApi } from "../modules/Api"; import { EncryptionEventViewModel } from "../viewmodels/room/timeline/event-tile/EncryptionEventViewModel"; import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel"; +import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel"; import { ElementCallEventType } from "../call-types"; // Subset of EventTile's IProps plus some mixins @@ -95,7 +96,16 @@ const EncryptionEventFactory: Factory = (ref, props) => { return ; }; const VerificationReqFactory: Factory = (_ref, props) => ; -const HiddenEventFactory: Factory = (ref, props) => ; +function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { + const vm = useCreateAutoDisposedViewModel(() => new HiddenBodyViewModel({ mxEvent })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + return ; +} +const HiddenEventFactory: Factory = (ref, props) => ; // These factories are exported for reference comparison against pickFactory() export const JitsiEventFactory: Factory = (ref, props) => ; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel.ts new file mode 100644 index 0000000000..3df2d31cda --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel.ts @@ -0,0 +1,49 @@ +/* + * 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 { + BaseViewModel, + type HiddenBodyViewModel as HiddenBodyViewModelInterface, + type HiddenBodyViewSnapshot, +} from "@element-hq/web-shared-components"; + +export interface HiddenBodyViewModelProps { + /** + * The hidden event being rendered. + */ + mxEvent: MatrixEvent; +} + +/** + * ViewModel for messages hidden pending moderation. + */ +export class HiddenBodyViewModel + extends BaseViewModel + implements HiddenBodyViewModelInterface +{ + private static readonly computeSnapshot = ({ mxEvent }: HiddenBodyViewModelProps): HiddenBodyViewSnapshot => { + const visibility = mxEvent.messageVisibility(); + + if (visibility.visible) { + throw new Error("HiddenBodyViewModel should only be applied to hidden messages"); + } + + return { + reason: visibility.reason || undefined, + }; + }; + + public constructor(props: HiddenBodyViewModelProps) { + super(props, HiddenBodyViewModel.computeSnapshot(props)); + } + + public setEvent(mxEvent: MatrixEvent): void { + this.props.mxEvent = mxEvent; + this.snapshot.merge(HiddenBodyViewModel.computeSnapshot(this.props)); + } +} diff --git a/apps/web/test/viewmodels/message-body/HiddenBodyViewModel-test.ts b/apps/web/test/viewmodels/message-body/HiddenBodyViewModel-test.ts new file mode 100644 index 0000000000..e8d961f6ec --- /dev/null +++ b/apps/web/test/viewmodels/message-body/HiddenBodyViewModel-test.ts @@ -0,0 +1,63 @@ +/* + * 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 { HiddenBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel"; + +describe("HiddenBodyViewModel", () => { + const createEvent = (visible: boolean, reason?: string | null): MatrixEvent => + ({ + messageVisibility: jest.fn().mockReturnValue({ + visible, + reason, + }), + }) as unknown as MatrixEvent; + + it("extracts a moderation reason from a hidden event", () => { + const vm = new HiddenBodyViewModel({ mxEvent: createEvent(false, "spam") }); + + expect(vm.getSnapshot()).toEqual({ + reason: "spam", + }); + }); + + it("omits the reason when the hidden event has no reason", () => { + const vm = new HiddenBodyViewModel({ mxEvent: createEvent(false, null) }); + + expect(vm.getSnapshot()).toEqual({ + reason: undefined, + }); + }); + + it("throws when created for a visible event", () => { + expect(() => new HiddenBodyViewModel({ mxEvent: createEvent(true) })).toThrow( + "HiddenBodyViewModel should only be applied to hidden messages", + ); + }); + + it("updates the snapshot when the event changes", () => { + const vm = new HiddenBodyViewModel({ mxEvent: createEvent(false) }); + + vm.setEvent(createEvent(false, "abuse")); + + expect(vm.getSnapshot()).toEqual({ + reason: "abuse", + }); + }); + + it("does not emit when setEvent receives the current event", () => { + const event = createEvent(false, "spam"); + const vm = new HiddenBodyViewModel({ mxEvent: event }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setEvent(event); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..b49b072ed7d50cb20996cc1c56f5217b9fa2851b GIT binary patch literal 20632 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 { + const vm = useMockedViewModel(rest, {}); + + return ; +}; + +const HiddenBodyViewWrapper = withViewDocs(HiddenBodyViewWrapperImpl, HiddenBodyView); + +const meta = { + title: "Timeline/Timeline Body/HiddenBodyView", + component: HiddenBodyViewWrapper, + tags: ["autodocs"], + args: { + reason: undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithReason: Story = { + args: { + reason: "spam", + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.test.tsx new file mode 100644 index 0000000000..34bb4c8fd9 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 { render, screen } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import React from "react"; +import { describe, expect, it } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { HiddenBodyView, type HiddenBodyViewModel, type HiddenBodyViewSnapshot } from "./HiddenBodyView"; +import * as stories from "./HiddenBodyView.stories"; + +const { Default, WithReason } = composeStories(stories); + +describe("HiddenBodyView", () => { + it("renders the default hidden body", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("Message pending moderation")).toBeInTheDocument(); + }); + + it("renders a hidden body with a reason", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("Message pending moderation: spam")).toBeInTheDocument(); + }); + + it("applies a custom className to the root span", () => { + const vm = new MockViewModel({ + reason: undefined, + }) as HiddenBodyViewModel; + + const { container } = render(); + + expect(container.firstChild).toHaveClass("custom-hidden", "another-class"); + }); + + it("forwards the provided ref to the root span", () => { + const ref = React.createRef(); + const vm = new MockViewModel({ + reason: undefined, + }) as HiddenBodyViewModel; + + render(); + + expect(ref.current).toBeInstanceOf(HTMLSpanElement); + expect(ref.current).toHaveTextContent("Message pending moderation"); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.tsx new file mode 100644 index 0000000000..a1b9ced393 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.tsx @@ -0,0 +1,56 @@ +/* + * 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 classNames from "classnames"; +import React, { type JSX, type Ref } from "react"; +import { VisibilityOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Text } from "@vector-im/compound-web"; + +import { type ViewModel } from "../../../../../core/viewmodel"; +import { useViewModel } from "../../../../../core/viewmodel/useViewModel"; +import { useI18n } from "../../../../../core/i18n/i18nContext"; +import styles from "./HiddenBodyView.module.css"; + +export interface HiddenBodyViewSnapshot { + /** + * Optional moderation reason supplied by the homeserver. + */ + reason?: string; +} + +export type HiddenBodyViewModel = ViewModel; + +interface HiddenBodyViewProps { + /** + * ViewModel providing the hidden message details. + */ + vm: HiddenBodyViewModel; + /** + * Optional CSS class name applied to the root span. + */ + className?: string; + /** + * Optional ref forwarded to the root span. + */ + ref?: Ref; +} + +/** + * Renders a message-body placeholder for messages hidden pending moderation. + */ +export function HiddenBodyView({ vm, className, ref }: Readonly): JSX.Element { + const { reason } = useViewModel(vm); + const _t = useI18n().translate; + const text = reason ? _t("timeline|pending_moderation_reason", { reason }) : _t("timeline|pending_moderation"); + + return ( + + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/__snapshots__/HiddenBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/__snapshots__/HiddenBodyView.test.tsx.snap new file mode 100644 index 0000000000..fb8d93fd80 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/__snapshots__/HiddenBodyView.test.tsx.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`HiddenBodyView > renders a hidden body with a reason 1`] = ` +
+ + + + Message pending moderation: spam + + +
+`; + +exports[`HiddenBodyView > renders the default hidden body 1`] = ` +
+ + + + Message pending moderation + + +
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/index.tsx new file mode 100644 index 0000000000..dc9e28d12f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/index.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { HiddenBodyView, type HiddenBodyViewSnapshot, type HiddenBodyViewModel } from "./HiddenBodyView";