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 (
+
+
+ {text}
+
+ );
+}
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";