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 0000000000..b49b072ed7 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/with-reason-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/with-reason-auto.png new file mode 100644 index 0000000000..b43edbea20 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx/with-reason-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index adfb0a9727..4ced2b9a7a 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -237,6 +237,8 @@ }, "message_timestamp_received_at": "Received at: %(dateTime)s", "message_timestamp_sent_at": "Sent at: %(dateTime)s", + "pending_moderation": "Message pending moderation", + "pending_moderation_reason": "Message pending moderation: %(reason)s", "url_preview": { "close": "Close preview", "open_link": "Open link", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 234382b222..95953c6a83 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -18,6 +18,7 @@ export * from "./menus/UserMenu"; export * from "./room/timeline/ReadMarker"; export * from "./room/timeline/EventPresentation"; export * from "./room/timeline/event-tile/body/EventContentBodyView"; +export * from "./room/timeline/event-tile/body/HiddenBodyView"; export * from "./room/timeline/event-tile/body/HiddenMediaPlaceholder"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.module.css b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.module.css new file mode 100644 index 0000000000..d3264a02e8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.module.css @@ -0,0 +1,22 @@ +/* + * 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. + */ + +.content { + white-space: pre-wrap; + color: var(--cpd-color-text-secondary); + vertical-align: middle; + display: inline-flex; + align-items: center; + gap: var(--cpd-space-1-5x); +} + +.icon { + width: 14px; + height: 14px; + color: var(--cpd-color-icon-secondary); + flex: 0 0 14px; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx new file mode 100644 index 0000000000..9231606077 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/HiddenBodyView/HiddenBodyView.stories.tsx @@ -0,0 +1,46 @@ +/* + * 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 React, { type JSX } from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { HiddenBodyView, type HiddenBodyViewSnapshot } from "./HiddenBodyView"; + +type HiddenBodyViewProps = HiddenBodyViewSnapshot; + +const HiddenBodyViewWrapperImpl = ({ + className, + ...rest +}: HiddenBodyViewProps & { className?: string }): JSX.Element => { + 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";