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:
@@ -222,7 +222,6 @@
|
|||||||
@import "./views/messages/_CallEvent.pcss";
|
@import "./views/messages/_CallEvent.pcss";
|
||||||
@import "./views/messages/_CreateEvent.pcss";
|
@import "./views/messages/_CreateEvent.pcss";
|
||||||
@import "./views/messages/_DisambiguatedProfile.pcss";
|
@import "./views/messages/_DisambiguatedProfile.pcss";
|
||||||
@import "./views/messages/_HiddenBody.pcss";
|
|
||||||
@import "./views/messages/_LegacyCallEvent.pcss";
|
@import "./views/messages/_LegacyCallEvent.pcss";
|
||||||
@import "./views/messages/_MFileBody.pcss";
|
@import "./views/messages/_MFileBody.pcss";
|
||||||
@import "./views/messages/_MImageBody.pcss";
|
@import "./views/messages/_MImageBody.pcss";
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<span className="mx_HiddenBody" ref={ref}>
|
|
||||||
<VisibilityOffIcon />
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HiddenBody;
|
|
||||||
@@ -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.
|
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 {
|
import {
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
EventType,
|
EventType,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import {
|
import {
|
||||||
EncryptionEventView,
|
EncryptionEventView,
|
||||||
|
HiddenBodyView,
|
||||||
TextualEventView,
|
TextualEventView,
|
||||||
useCreateAutoDisposedViewModel,
|
useCreateAutoDisposedViewModel,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
@@ -41,13 +42,13 @@ import { WidgetType } from "../widgets/WidgetType";
|
|||||||
import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent";
|
import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent";
|
||||||
import { hasText } from "../TextForEvent";
|
import { hasText } from "../TextForEvent";
|
||||||
import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils";
|
import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils";
|
||||||
import HiddenBody from "../components/views/messages/HiddenBody";
|
|
||||||
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
|
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
|
||||||
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
||||||
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
||||||
import { ModuleApi } from "../modules/Api";
|
import { ModuleApi } from "../modules/Api";
|
||||||
import { EncryptionEventViewModel } from "../viewmodels/room/timeline/event-tile/EncryptionEventViewModel";
|
import { EncryptionEventViewModel } from "../viewmodels/room/timeline/event-tile/EncryptionEventViewModel";
|
||||||
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
|
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
|
||||||
|
import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel";
|
||||||
import { ElementCallEventType } from "../call-types";
|
import { ElementCallEventType } from "../call-types";
|
||||||
|
|
||||||
// Subset of EventTile's IProps plus some mixins
|
// Subset of EventTile's IProps plus some mixins
|
||||||
@@ -95,7 +96,16 @@ const EncryptionEventFactory: Factory = (ref, props) => {
|
|||||||
return <EncryptionEventWrappedView ref={ref} {...props} />;
|
return <EncryptionEventWrappedView ref={ref} {...props} />;
|
||||||
};
|
};
|
||||||
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
||||||
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
|
function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
|
||||||
|
const vm = useCreateAutoDisposedViewModel(() => new HiddenBodyViewModel({ mxEvent }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setEvent(mxEvent);
|
||||||
|
}, [mxEvent, vm]);
|
||||||
|
|
||||||
|
return <HiddenBodyView vm={vm} ref={ref} className="mx_HiddenBody" />;
|
||||||
|
}
|
||||||
|
const HiddenEventFactory: Factory = (ref, props) => <HiddenBodyWrappedView ref={ref} {...props} />;
|
||||||
|
|
||||||
// These factories are exported for reference comparison against pickFactory()
|
// These factories are exported for reference comparison against pickFactory()
|
||||||
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEvent ref={ref} {...props} />;
|
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEvent ref={ref} {...props} />;
|
||||||
|
|||||||
@@ -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<HiddenBodyViewSnapshot, HiddenBodyViewModelProps>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -237,6 +237,8 @@
|
|||||||
},
|
},
|
||||||
"message_timestamp_received_at": "Received at: %(dateTime)s",
|
"message_timestamp_received_at": "Received at: %(dateTime)s",
|
||||||
"message_timestamp_sent_at": "Sent 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": {
|
"url_preview": {
|
||||||
"close": "Close preview",
|
"close": "Close preview",
|
||||||
"open_link": "Open link",
|
"open_link": "Open link",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export * from "./menus/UserMenu";
|
|||||||
export * from "./room/timeline/ReadMarker";
|
export * from "./room/timeline/ReadMarker";
|
||||||
export * from "./room/timeline/EventPresentation";
|
export * from "./room/timeline/EventPresentation";
|
||||||
export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
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/HiddenMediaPlaceholder";
|
||||||
export * from "./room/timeline/event-tile/body/RedactedBodyView";
|
export * from "./room/timeline/event-tile/body/RedactedBodyView";
|
||||||
export * from "./room/timeline/event-tile/body/MFileBodyView";
|
export * from "./room/timeline/event-tile/body/MFileBodyView";
|
||||||
|
|||||||
+22
@@ -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;
|
||||||
|
}
|
||||||
+46
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
+55
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+56
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+55
@@ -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";
|
||||||
Reference in New Issue
Block a user