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 <florian.duros@ormaz.fr>

* Update apps/web/src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Use compound Text in HiddenBodyView

* Update snapshots

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Zack
2026-05-07 12:11:35 +02:00
committed by GitHub
parent f721dfb139
commit fa5caa76d9
16 changed files with 370 additions and 70 deletions
@@ -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",
+1
View File
@@ -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";
@@ -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;
}
@@ -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 <HiddenBodyView vm={vm} className={className} />;
};
const HiddenBodyViewWrapper = withViewDocs(HiddenBodyViewWrapperImpl, HiddenBodyView);
const meta = {
title: "Timeline/Timeline Body/HiddenBodyView",
component: HiddenBodyViewWrapper,
tags: ["autodocs"],
args: {
reason: undefined,
},
} satisfies Meta<typeof HiddenBodyViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithReason: Story = {
args: {
reason: "spam",
},
};
@@ -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(<Default />);
expect(container).toMatchSnapshot();
expect(screen.getByText("Message pending moderation")).toBeInTheDocument();
});
it("renders a hidden body with a reason", () => {
const { container } = render(<WithReason />);
expect(container).toMatchSnapshot();
expect(screen.getByText("Message pending moderation: spam")).toBeInTheDocument();
});
it("applies a custom className to the root span", () => {
const vm = new MockViewModel<HiddenBodyViewSnapshot>({
reason: undefined,
}) as HiddenBodyViewModel;
const { container } = render(<HiddenBodyView vm={vm} className="custom-hidden another-class" />);
expect(container.firstChild).toHaveClass("custom-hidden", "another-class");
});
it("forwards the provided ref to the root span", () => {
const ref = React.createRef<HTMLSpanElement>();
const vm = new MockViewModel<HiddenBodyViewSnapshot>({
reason: undefined,
}) as HiddenBodyViewModel;
render(<HiddenBodyView vm={vm} ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
expect(ref.current).toHaveTextContent("Message pending moderation");
});
});
@@ -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<HiddenBodyViewSnapshot>;
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<HTMLSpanElement>;
}
/**
* Renders a message-body placeholder for messages hidden pending moderation.
*/
export function HiddenBodyView({ vm, className, ref }: Readonly<HiddenBodyViewProps>): JSX.Element {
const { reason } = useViewModel(vm);
const _t = useI18n().translate;
const text = reason ? _t("timeline|pending_moderation_reason", { reason }) : _t("timeline|pending_moderation");
return (
<span className={classNames(styles.content, className)} ref={ref}>
<VisibilityOffIcon className={styles.icon} aria-hidden="true" />
<Text as="span">{text}</Text>
</span>
);
}
@@ -0,0 +1,55 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HiddenBodyView > renders a hidden body with a reason 1`] = `
<div>
<span
class="HiddenBodyView-module_content"
>
<svg
aria-hidden="true"
class="HiddenBodyView-module_icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m16.1 13.3-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.424-.2.863-.3A4.2 4.2 0 0 1 12 7q1.875 0 3.188 1.312Q16.5 9.625 16.5 11.5q0 .5-.1.938t-.3.862m3.2 3.15-1.45-1.4a11 11 0 0 0 1.688-1.588A9 9 0 0 0 20.8 11.5q-1.25-2.524-3.588-4.013Q14.875 6 12 6q-.724 0-1.425.1a10 10 0 0 0-1.375.3L7.65 4.85A11.1 11.1 0 0 1 12 4q3.575 0 6.425 1.887T22.7 10.8a.8.8 0 0 1 .1.313q.025.188.025.387a2 2 0 0 1-.125.7 10.9 10.9 0 0 1-3.4 4.25m-.2 5.45-3.5-3.45q-.874.274-1.762.413Q12.95 19 12 19q-3.575 0-6.425-1.887T1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.763.8.8 0 0 1 .1-.3Q1.825 9.7 2.55 8.75A13.3 13.3 0 0 1 4.15 7L2.075 4.9a.93.93 0 0 1-.275-.688q0-.412.3-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275l17 17q.275.275.288.688a.93.93 0 0 1-.288.712.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275M5.55 8.4q-.725.65-1.325 1.425A9 9 0 0 0 3.2 11.5q1.25 2.524 3.588 4.012T12 17q.5 0 .975-.062.475-.063.975-.138l-.9-.95q-.274.075-.525.113A3.5 3.5 0 0 1 12 16q-1.875 0-3.187-1.312Q7.5 13.375 7.5 11.5q0-.274.038-.525.037-.25.112-.525z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Message pending moderation: spam
</span>
</span>
</div>
`;
exports[`HiddenBodyView > renders the default hidden body 1`] = `
<div>
<span
class="HiddenBodyView-module_content"
>
<svg
aria-hidden="true"
class="HiddenBodyView-module_icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m16.1 13.3-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.424-.2.863-.3A4.2 4.2 0 0 1 12 7q1.875 0 3.188 1.312Q16.5 9.625 16.5 11.5q0 .5-.1.938t-.3.862m3.2 3.15-1.45-1.4a11 11 0 0 0 1.688-1.588A9 9 0 0 0 20.8 11.5q-1.25-2.524-3.588-4.013Q14.875 6 12 6q-.724 0-1.425.1a10 10 0 0 0-1.375.3L7.65 4.85A11.1 11.1 0 0 1 12 4q3.575 0 6.425 1.887T22.7 10.8a.8.8 0 0 1 .1.313q.025.188.025.387a2 2 0 0 1-.125.7 10.9 10.9 0 0 1-3.4 4.25m-.2 5.45-3.5-3.45q-.874.274-1.762.413Q12.95 19 12 19q-3.575 0-6.425-1.887T1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.763.8.8 0 0 1 .1-.3Q1.825 9.7 2.55 8.75A13.3 13.3 0 0 1 4.15 7L2.075 4.9a.93.93 0 0 1-.275-.688q0-.412.3-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275l17 17q.275.275.288.688a.93.93 0 0 1-.288.712.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275M5.55 8.4q-.725.65-1.325 1.425A9 9 0 0 0 3.2 11.5q1.25 2.524 3.588 4.012T12 17q.5 0 .975-.062.475-.063.975-.138l-.9-.95q-.274.075-.525.113A3.5 3.5 0 0 1 12 16q-1.875 0-3.187-1.312Q7.5 13.375 7.5 11.5q0-.274.038-.525.037-.25.112-.525z"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Message pending moderation
</span>
</span>
</div>
`;
@@ -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";