Refactor and Move RedactedBodyView To Shared Components (#32772)

* refactoring and creation of shared-components for reductedBodyView

* move redacted message rendering to shared MVVM view

* Update snapshots + fix lint errors

* Remove MatrixClientPeg and use reguler react matrix client context

* Stop resyncing redacted body view models with mxEvent

* Fix redacted_because test fixtures for stricter event typing

* Simplify redacted body client access

* Watch timestamp setting in redacted body view model

* Refactor redacted and decryption failure body factories into MBodyFactory

* Prettier Fix

* Refactor FileBody into same pattern for consitancy
This commit is contained in:
Zack
2026-03-24 11:02:07 +01:00
committed by GitHub
parent d7843bb9b8
commit 4f3a1a2cc6
30 changed files with 688 additions and 159 deletions
+1
View File
@@ -21,6 +21,7 @@ export * from "./message-body/EventContentBody";
export * from "./message-body/MediaBody";
export * from "./message-body/MessageTimestampView";
export * from "./message-body/DecryptionFailureBodyView";
export * from "./message-body/RedactedBodyView";
export * from "./message-body/FileBodyView";
export * from "./message-body/PinnedMessageBadge";
export * from "./message-body/ReactionsRowButtonTooltip";
@@ -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: 6px;
}
.icon {
width: 14px;
height: 14px;
color: var(--cpd-color-icon-secondary);
flex: 0 0 14px;
}
@@ -0,0 +1,62 @@
/*
* 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 { expect, userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { RedactedBodyView, type RedactedBodyViewSnapshot } from "./RedactedBodyView";
type RedactedBodyProps = RedactedBodyViewSnapshot;
const RedactedBodyViewWrapperImpl = ({
className,
...rest
}: RedactedBodyProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, {});
return <RedactedBodyView vm={vm} className={className} />;
};
const RedactedBodyViewWrapper = withViewDocs(RedactedBodyViewWrapperImpl, RedactedBodyView);
const meta = {
title: "MessageBody/RedactedBodyView",
component: RedactedBodyViewWrapper,
tags: ["autodocs"],
args: {
text: "Message deleted",
tooltip: "This message was deleted on Thu, 17 Nov 2022, 4:58:32 pm",
className: "",
},
} satisfies Meta<typeof RedactedBodyViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByText("Message deleted"));
await expect(within(canvasElement.ownerDocument.body).findByRole("tooltip")).resolves.toBeInTheDocument();
},
};
export const SelfRedaction: Story = {
args: {
text: "Message deleted",
tooltip: undefined,
},
};
export const RedactedByAnotherUser: Story = {
args: {
text: "Message deleted by Alice",
},
};
@@ -0,0 +1,71 @@
/*
* 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 userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { MockViewModel } from "../../viewmodel";
import { RedactedBodyView, type RedactedBodyViewModel, type RedactedBodyViewSnapshot } from "./RedactedBodyView";
import * as stories from "./RedactedBodyView.stories";
const { Default, SelfRedaction } = composeStories(stories);
describe("RedactedBodyView", () => {
it("renders the default redacted body", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders without a tooltip when none is provided", () => {
const { container } = render(<SelfRedaction />);
expect(container).toMatchSnapshot();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
it("shows the tooltip on hover when tooltip text exists", async () => {
const user = userEvent.setup();
const vm = new MockViewModel<RedactedBodyViewSnapshot>({
text: "Message deleted by Alice",
tooltip: "This message was deleted on Thu, 17 Nov 2022, 4:58:32 pm",
}) as RedactedBodyViewModel;
render(<RedactedBodyView vm={vm} />);
await user.hover(screen.getByText("Message deleted by Alice"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"This message was deleted on Thu, 17 Nov 2022, 4:58:32 pm",
);
});
it("applies a custom className to the root span", () => {
const vm = new MockViewModel<RedactedBodyViewSnapshot>({
text: "Message deleted",
}) as RedactedBodyViewModel;
const { container } = render(<RedactedBodyView vm={vm} className="custom-redacted another-class" />);
expect(container.firstChild).toHaveClass("custom-redacted", "another-class");
});
it("forwards the provided ref to the root span", () => {
const ref = React.createRef<HTMLSpanElement>();
const vm = new MockViewModel<RedactedBodyViewSnapshot>({
text: "Message deleted",
}) as RedactedBodyViewModel;
render(<RedactedBodyView vm={vm} ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
expect(ref.current).toHaveTextContent("Message deleted");
});
});
@@ -0,0 +1,60 @@
/*
* 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, type Ref } from "react";
import classNames from "classnames";
import { DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web";
import { type ViewModel } from "../../viewmodel";
import { useViewModel } from "../../viewmodel/useViewModel";
import styles from "./RedactedBodyView.module.css";
export interface RedactedBodyViewSnapshot {
/**
* Localized redaction message content.
*/
text: string;
/**
* Optional localized tooltip shown with the redaction timestamp.
*/
tooltip?: string;
}
export type RedactedBodyViewModel = ViewModel<RedactedBodyViewSnapshot>;
interface RedactedBodyViewProps {
/**
* ViewModel providing the rendered text and tooltip.
*/
vm: RedactedBodyViewModel;
/**
* Optional CSS class name applied to the root span.
*/
className?: string;
/**
* Optional ref forwarded to the root span.
*/
ref?: Ref<HTMLSpanElement>;
}
export function RedactedBodyView({ vm, className, ref }: Readonly<RedactedBodyViewProps>): JSX.Element {
const { text, tooltip } = useViewModel(vm);
const content = (
<span className={classNames(styles.content, className)} ref={ref}>
<DeleteIcon className={styles.icon} aria-hidden="true" />
<span>{text}</span>
</span>
);
if (!tooltip) {
return content;
}
return <Tooltip description={tooltip}>{content}</Tooltip>;
}
@@ -0,0 +1,51 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RedactedBodyView > renders the default redacted body 1`] = `
<div>
<span
class="content"
>
<svg
aria-hidden="true"
class="icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
<span>
Message deleted
</span>
</span>
</div>
`;
exports[`RedactedBodyView > renders without a tooltip when none is provided 1`] = `
<div>
<span
class="content"
>
<svg
aria-hidden="true"
class="icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
<span>
Message deleted
</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 { RedactedBodyView, type RedactedBodyViewSnapshot, type RedactedBodyViewModel } from "./RedactedBodyView";