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
@@ -237,7 +237,6 @@
@import "./views/messages/_MessageActionBar.pcss";
@import "./views/messages/_MjolnirBody.pcss";
@import "./views/messages/_ReactionsRow.pcss";
@import "./views/messages/_RedactedBody.pcss";
@import "./views/messages/_RoomAvatarEvent.pcss";
@import "./views/messages/_TextualEvent.pcss";
@import "./views/messages/_UnknownBody.pcss";
@@ -1,21 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 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_RedactedBody {
white-space: pre-wrap;
color: $secondary-content;
vertical-align: middle;
svg {
margin-right: 6px;
height: 14px;
width: 14px;
color: $icon-button-color;
vertical-align: -2px;
}
}
@@ -16,12 +16,12 @@ import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
import { formatTime } from "../../../DateUtils";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import RedactedBody from "./RedactedBody";
import AccessibleButton from "../elements/AccessibleButton";
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../structures/ViewSource";
import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { RedactedBodyFactory } from "./MBodyFactory";
function getReplacedContent(event: MatrixEvent): IContent {
const originalContent = event.getOriginalContent();
@@ -151,7 +151,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
const content = getReplacedContent(mxEvent);
let contentContainer;
if (mxEvent.isRedacted()) {
contentContainer = <RedactedBody mxEvent={this.props.mxEvent} />;
contentContainer = <RedactedBodyFactory mxEvent={this.props.mxEvent} />;
} else {
let contentElements;
if (this.props.previousEdit) {
@@ -25,6 +25,8 @@ export interface IBodyProps {
showUrlPreview?: boolean;
forExport?: boolean;
// Whether file-style rendering should show the info row / placeholder.
showFileInfo?: boolean;
maxImageHeight?: number;
replacingEventId?: string;
editState?: EditorStateTransfer;
@@ -20,7 +20,7 @@ import { PlaybackManager } from "../../../audio/PlaybackManager";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import MediaProcessingError from "./shared/MediaProcessingError";
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
interface IState {
error?: boolean;
@@ -111,7 +111,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
return (
<span className="mx_MAudioBody">
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory)}
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory)}
</span>
);
}
@@ -7,30 +7,28 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, type RefObject, useContext, useEffect, useRef } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix";
import { FileBodyView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import {
DecryptionFailureBodyView,
FileBodyView,
RedactedBodyView,
useCreateAutoDisposedViewModel,
} from "@element-hq/web-shared-components";
import { type IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext";
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
interface FileBodyViewProps {
/*
* Whether file-style message bodies should render their info row/placeholder.
* Used by file-body rendering paths (for example FileBodyViewModel via MBodyFactory).
*/
showFileInfo?: boolean;
}
type MBodyComponent = React.ComponentType<IBodyProps>;
type MBodyComponent = React.ComponentType<IBodyProps & FileBodyViewProps>;
// Adapter that binds RoomContext data and lifecycle updates to the
// FileBody view model before rendering the shared view component.
function FileBodyViewWrapped({
export function FileBodyFactory({
mxEvent,
mediaEventHelper,
forExport,
showFileInfo,
}: IBodyProps & FileBodyViewProps): JSX.Element {
}: Pick<IBodyProps, "mxEvent" | "mediaEventHelper" | "forExport" | "showFileInfo">): JSX.Element {
const { timelineRenderingType } = useContext(RoomContext);
const refIFrame = useRef<HTMLIFrameElement>(null) as RefObject<HTMLIFrameElement>;
const refLink = useRef<HTMLAnchorElement>(null) as RefObject<HTMLAnchorElement>;
@@ -61,19 +59,41 @@ function FileBodyViewWrapped({
return <FileBodyView vm={vm} refIFrame={refIFrame} refLink={refLink} className="mx_MFileBody" />;
}
// Exported for explicit fallback usage where callers want file-body rendering.
export const FileBodyViewFactory: MBodyComponent = (props) => <FileBodyViewWrapped {...props} />;
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
useEffect(() => {
vm.setEvent(mxEvent);
}, [mxEvent, vm]);
return <RedactedBodyView vm={vm} ref={ref} className="mx_RedactedBody" />;
}
export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
const verificationState = useContext(LocalDeviceVerificationStateContext);
const vm = useCreateAutoDisposedViewModel(
() =>
new DecryptionFailureBodyViewModel({
decryptionFailureCode: mxEvent.decryptionFailureReason,
verificationState,
}),
);
useEffect(() => {
vm.setDecryptionFailureCode(mxEvent.decryptionFailureReason);
vm.setVerificationState(verificationState);
}, [mxEvent, verificationState, vm]);
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
}
// Message body factory registry.
// Start small: only m.file currently routes to the new FileBodyView path.
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([[MsgType.File, FileBodyViewFactory]]);
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([[MsgType.File, FileBodyFactory]]);
// Render a body using the picked factory.
// Falls back to the provided factory when msgtype has no specific handler.
export function renderMBody(
props: IBodyProps & FileBodyViewProps,
fallbackFactory?: MBodyComponent,
): JSX.Element | null {
export function renderMBody(props: IBodyProps, fallbackFactory?: MBodyComponent): JSX.Element | null {
const BodyType = MESSAGE_BODY_TYPES.get(props.mxEvent.getContent().msgtype as string) ?? fallbackFactory;
if (!BodyType) {
return null;
@@ -36,7 +36,7 @@ import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
import { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
enum Placeholder {
NoImage,
@@ -651,7 +651,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
if (!hasMessageActionBar) {
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory);
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
}
}
@@ -664,7 +664,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
!content.info?.thumbnail_info
) {
return renderMBody(this.props, FileBodyViewFactory);
return renderMBody(this.props, FileBodyFactory);
}
if (this.state.error) {
@@ -22,7 +22,7 @@ import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContex
import MediaProcessingError from "./shared/MediaProcessingError";
import { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
interface IState {
decryptedUrl: string | null;
@@ -246,7 +246,7 @@ class MVideoBodyInner extends React.PureComponent<IProps, IState> {
private getFileBody = (): ReactNode => {
if (this.props.forExport) return null;
return this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory);
return this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
};
public render(): React.ReactNode {
@@ -17,7 +17,7 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
import { type Playback } from "../../../audio/Playback";
import RoomContext from "../../../contexts/RoomContext";
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
export default class MVoiceMessageBody extends MAudioBody {
public static contextType = RoomContext;
@@ -54,7 +54,7 @@ export default class MVoiceMessageBody extends MAudioBody {
return (
<span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} />
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory)}
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory)}
</span>
);
}
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import mime from "mime";
import React, { type JSX, createRef, useContext, useEffect } from "react";
import React, { createRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import {
EventType,
@@ -18,12 +18,9 @@ import {
M_POLL_START,
type IContent,
} from "matrix-js-sdk/src/matrix";
import { useCreateAutoDisposedViewModel, DecryptionFailureBodyView } from "@element-hq/web-shared-components";
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
import SettingsStore from "../../../settings/SettingsStore";
import { Mjolnir } from "../../../mjolnir/Mjolnir";
import RedactedBody from "./RedactedBody";
import UnknownBody from "./UnknownBody";
import { type IMediaBody } from "./IMediaBody";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
@@ -38,8 +35,7 @@ import MLocationBody from "./MLocationBody";
import MjolnirBody from "./MjolnirBody";
import MBeaconBody from "./MBeaconBody";
import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile";
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
import { DecryptionFailureBodyFactory, FileBodyFactory, RedactedBodyFactory, renderMBody } from "./MBodyFactory";
// onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
@@ -67,7 +63,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
[MsgType.Notice, TextualBody],
[MsgType.Emote, TextualBody],
[MsgType.Image, MImageBody],
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyViewFactory)!],
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
[MsgType.Audio, MVoiceOrAudioBody],
[MsgType.Video, MVideoBody],
]);
@@ -246,11 +242,11 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType();
const msgtype = content.msgtype;
let BodyType: React.ComponentType<IBodyProps> = RedactedBody;
let BodyType: React.ComponentType<IBodyProps> = RedactedBodyFactory;
if (!this.props.mxEvent.isRedacted()) {
// only resolve BodyType if event is not redacted
if (this.props.mxEvent.isDecryptionFailure()) {
BodyType = DecryptionFailureBodyWrapper;
BodyType = DecryptionFailureBodyFactory;
} else if (type && this.evTypes.has(type)) {
BodyType = this.evTypes.get(type)!;
} else if (msgtype && this.bodyTypes.has(msgtype)) {
@@ -330,22 +326,3 @@ const CaptionBody: React.FunctionComponent<IBodyProps & { WrappedBodyType: React
<TextualBody {...{ ...props, ref: undefined }} />
</div>
);
/**
* Bridge decryption-failure events into the view model using current local verification state.
* This wrapper can be removed after MessageEvent has been changed to a function component.
*/
function DecryptionFailureBodyWrapper({ mxEvent, ref }: IBodyProps): JSX.Element {
const verificationState = useContext(LocalDeviceVerificationStateContext);
const vm = useCreateAutoDisposedViewModel(
() =>
new DecryptionFailureBodyViewModel({
decryptionFailureCode: mxEvent.decryptionFailureReason,
verificationState,
}),
);
useEffect(() => {
vm.setVerificationState(verificationState);
}, [verificationState, vm]);
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
}
@@ -1,44 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 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, { useContext, type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { formatFullDate } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore";
import { type IBodyProps } from "./IBodyProps";
const RedactedBody = ({ mxEvent, ref }: IBodyProps): JSX.Element => {
const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("timeline|self_redaction");
const unsigned = mxEvent.getUnsigned();
const redactedBecauseUserId = unsigned && unsigned.redacted_because && unsigned.redacted_because.sender;
if (redactedBecauseUserId && redactedBecauseUserId !== mxEvent.getSender()) {
const room = cli.getRoom(mxEvent.getRoomId());
const sender = room && room.getMember(redactedBecauseUserId);
text = _t("timeline|redaction", { name: sender ? sender.name : redactedBecauseUserId });
}
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const fullDate = unsigned.redacted_because
? formatFullDate(new Date(unsigned.redacted_because.origin_server_ts), showTwelveHour)
: undefined;
const titleText = fullDate ? _t("timeline|redacted|tooltip", { date: fullDate }) : undefined;
return (
<span className="mx_RedactedBody" ref={ref} title={titleText}>
<DeleteIcon />
{text}
</span>
);
};
export default RedactedBody;
@@ -50,7 +50,6 @@ import { uniqueId, uniqBy } from "lodash";
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
useCreateAutoDisposedViewModel,
DecryptionFailureBodyView,
MessageTimestampView,
PinnedMessageBadge,
ReactionsRowButtonView,
@@ -58,7 +57,6 @@ import {
useViewModel,
} from "@element-hq/web-shared-components";
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
import ReplyChain from "../elements/ReplyChain";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
@@ -88,7 +86,6 @@ import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type ButtonEvent } from "../elements/AccessibleButton";
import { copyPlaintext } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import RedactedBody from "../messages/RedactedBody";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { shouldDisplayReply } from "../../../utils/Reply";
import PosthogTrackers from "../../../PosthogTrackers";
@@ -105,7 +102,6 @@ import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
import PinningUtils from "../../../utils/PinningUtils";
import { EventPreview } from "./EventPreview";
import { ElementCallEventType } from "../../../call-types";
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
import SettingsStore from "../../../settings/SettingsStore";
@@ -116,6 +112,7 @@ import {
import { ReactionsRowButtonViewModel } from "../../../viewmodels/message-body/ReactionsRowButtonViewModel";
import { MAX_ITEMS_WHEN_LIMITED, ReactionsRowViewModel } from "../../../viewmodels/message-body/ReactionsRowViewModel";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
export type GetRelationsForEvent = (
eventId: string,
@@ -1401,9 +1398,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
<div className={lineClasses} key="mx_EventTile_line">
<div className="mx_EventTile_body">
{this.props.mxEvent.isRedacted() ? (
<RedactedBody mxEvent={this.props.mxEvent} />
<RedactedBodyFactory mxEvent={this.props.mxEvent} />
) : this.props.mxEvent.isDecryptionFailure() ? (
<DecryptionFailureBodyWrapper mxEvent={this.props.mxEvent} />
<DecryptionFailureBodyFactory mxEvent={this.props.mxEvent} />
) : (
<EventPreview mxEvent={this.props.mxEvent} />
)}
@@ -1600,26 +1597,6 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element {
);
}
/**
* Bridge decryption-failure events into the view model using current local verification state.
* This wrapper can be removed after EventTile has been changed to a function component.
*/
function DecryptionFailureBodyWrapper({ mxEvent }: { mxEvent: MatrixEvent }): JSX.Element {
const verificationState = useContext(LocalDeviceVerificationStateContext);
const vm = useCreateAutoDisposedViewModel(
() =>
new DecryptionFailureBodyViewModel({
decryptionFailureCode: mxEvent.decryptionFailureReason,
verificationState,
}),
);
useEffect(() => {
vm.setVerificationState(verificationState);
}, [verificationState, vm]);
return <DecryptionFailureBodyView vm={vm} className="mx_DecryptionFailureBody mx_EventTile_content" />;
}
/**
* Wraps MessageTimestampView with a view model synced to the provided props.
* This wrapper can be removed after EventTile has been changed to a function component.
@@ -26,7 +26,7 @@ import { renderReplyTile } from "../../../events/EventTileFactory";
import { type GetRelationsForEvent } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { type IBodyProps } from "../messages/IBodyProps";
import { FileBodyViewFactory, renderMBody } from "../messages/MBodyFactory";
import { FileBodyFactory, renderMBody } from "../messages/MBodyFactory";
interface IProps {
mxEvent: MatrixEvent;
@@ -130,7 +130,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
);
}
const ReplyTileFileBody: React.ComponentType<IBodyProps> = (props) => renderMBody(props, FileBodyViewFactory);
const ReplyTileFileBody: React.ComponentType<IBodyProps> = (props) => renderMBody(props, FileBodyFactory);
const msgtypeOverrides: Record<string, React.ComponentType<IBodyProps>> = {
[MsgType.Image]: MImageReplyBody,
@@ -84,4 +84,13 @@ export class DecryptionFailureBodyViewModel
this.props.verificationState = verificationState;
this.snapshot.merge({ isLocalDeviceVerified: verificationState });
}
public setDecryptionFailureCode(decryptionFailureCode: DecryptionFailureCode | null): void {
if (this.props.decryptionFailureCode === decryptionFailureCode) return;
this.props.decryptionFailureCode = decryptionFailureCode;
this.snapshot.merge({
decryptionFailureReason: DecryptionFailureBodyViewModel.getDecryptionReasonFromCode(decryptionFailureCode),
});
}
}
@@ -0,0 +1,112 @@
/*
* 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 RedactedBodyViewSnapshot,
type RedactedBodyViewModel as RedactedBodyViewModelInterface,
} from "@element-hq/web-shared-components";
import { formatFullDate } from "../../DateUtils";
import { _t } from "../../languageHandler";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import SettingsStore from "../../settings/SettingsStore";
export interface RedactedBodyViewModelProps {
/**
* The redacted event being rendered.
*/
mxEvent: MatrixEvent;
}
export class RedactedBodyViewModel
extends BaseViewModel<RedactedBodyViewSnapshot, RedactedBodyViewModelProps>
implements RedactedBodyViewModelInterface
{
private showTwelveHour: boolean;
private static readonly computeText = ({ mxEvent }: RedactedBodyViewModelProps): string => {
const redactedBecauseUserId = mxEvent.getUnsigned().redacted_because?.sender;
if (!redactedBecauseUserId || redactedBecauseUserId === mxEvent.getSender()) {
return _t("timeline|self_redaction");
}
const roomId = mxEvent.getRoomId();
const room = roomId ? MatrixClientPeg.get()?.getRoom(roomId) : null;
const sender = room?.getMember(redactedBecauseUserId);
return _t("timeline|redaction", { name: sender?.name ?? redactedBecauseUserId });
};
private static readonly computeTooltip = (mxEvent: MatrixEvent, showTwelveHour: boolean): string | undefined => {
const redactionTs = mxEvent.getUnsigned().redacted_because?.origin_server_ts;
if (!redactionTs) {
return undefined;
}
return _t("timeline|redacted|tooltip", {
date: formatFullDate(new Date(redactionTs), showTwelveHour),
});
};
private static readonly computeSnapshot = (
props: RedactedBodyViewModelProps,
showTwelveHour: boolean,
): RedactedBodyViewSnapshot => ({
text: RedactedBodyViewModel.computeText(props),
tooltip: RedactedBodyViewModel.computeTooltip(props.mxEvent, showTwelveHour),
});
public constructor(props: RedactedBodyViewModelProps) {
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
super(props, RedactedBodyViewModel.computeSnapshot(props, showTwelveHour));
this.showTwelveHour = showTwelveHour;
const showTwelveHourWatcherRef = SettingsStore.watchSetting(
"showTwelveHourTimestamps",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
if (this.showTwelveHour === newVal) return;
this.showTwelveHour = newVal;
this.updateTooltip();
},
);
this.disposables.track(() => SettingsStore.unwatchSetting(showTwelveHourWatcherRef));
}
public setEvent(mxEvent: MatrixEvent): void {
if (this.props.mxEvent === mxEvent) return;
this.props = { ...this.props, mxEvent };
const text = RedactedBodyViewModel.computeText(this.props);
const tooltip = RedactedBodyViewModel.computeTooltip(this.props.mxEvent, this.showTwelveHour);
const updates: Partial<RedactedBodyViewSnapshot> = {};
if (this.snapshot.current.text !== text) {
updates.text = text;
}
if (this.snapshot.current.tooltip !== tooltip) {
updates.tooltip = tooltip;
}
if (Object.keys(updates).length > 0) {
this.snapshot.merge(updates);
}
}
private updateTooltip(): void {
const tooltip = RedactedBodyViewModel.computeTooltip(this.props.mxEvent, this.showTwelveHour);
if (this.snapshot.current.tooltip !== tooltip) {
this.snapshot.merge({ tooltip });
}
}
}
@@ -0,0 +1,147 @@
/*
* 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 { EventType, MsgType, type MatrixClient, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { formatFullDate } from "../../../../../src/DateUtils";
import { _t } from "../../../../../src/languageHandler";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { RedactedBodyViewModel } from "../../../../../src/viewmodels/message-body/RedactedBodyViewModel";
import { mkEvent, mkRoom, stubClient } from "../../../../test-utils";
describe("RedactedBodyViewModel", () => {
let client: MatrixClient;
let room: Room;
let showTwelveHourSettingWatcher: ((...args: any[]) => void) | undefined;
const makeRedactedBecauseEvent = ({ sender, originServerTs }: { sender: string; originServerTs: number }) => ({
content: {},
event_id: "$redaction:example.com",
origin_server_ts: originServerTs,
redacts: "$message:example.com",
room_id: room.roomId,
sender,
type: EventType.RoomRedaction,
unsigned: {},
});
const makeRedactedEvent = ({
sender = "@alice:example.com",
redactedBecauseSender = sender,
originServerTs = Date.UTC(2022, 10, 17, 15, 58, 32),
}: {
sender?: string;
redactedBecauseSender?: string;
originServerTs?: number;
} = {}): MatrixEvent =>
mkEvent({
event: true,
type: EventType.RoomMessage,
user: sender,
room: room.roomId,
content: {
msgtype: MsgType.Text,
body: "Message",
},
unsigned: {
redacted_because: makeRedactedBecauseEvent({
sender: redactedBecauseSender,
originServerTs,
}),
},
});
beforeEach(() => {
client = stubClient();
room = mkRoom(client, "!room:example.com");
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "showTwelveHourTimestamps",
);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingName, _roomId, callbackFn) => {
showTwelveHourSettingWatcher = callbackFn as (...args: any[]) => void;
return "mock-show-twelve-hour-watcher";
});
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
});
it("builds self-redaction text and tooltip from the event", () => {
const event = makeRedactedEvent();
const vm = new RedactedBodyViewModel({ mxEvent: event });
expect(vm.getSnapshot()).toEqual({
text: "Message deleted",
tooltip: _t("timeline|redacted|tooltip", {
date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true),
}),
});
});
it("uses the redacting member name when another user removed the message", () => {
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice" } as any);
const event = makeRedactedEvent({
redactedBecauseSender: "@alice-redactor:example.com",
});
const vm = new RedactedBodyViewModel({ mxEvent: event });
expect(vm.getSnapshot().text).toBe("Message deleted by Alice");
});
it("updates the tooltip when showTwelveHourTimestamps changes", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
const event = makeRedactedEvent();
const vm = new RedactedBodyViewModel({ mxEvent: event });
const listener = jest.fn();
vm.subscribe(listener);
showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, false, false);
expect(listener).not.toHaveBeenCalled();
showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, true, true);
expect(listener).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().tooltip).toBe(
_t("timeline|redacted|tooltip", {
date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true),
}),
);
});
it("setEvent is a no-op for the same event and updates for a different event", () => {
const originalEvent = makeRedactedEvent();
const updatedEvent = makeRedactedEvent({
redactedBecauseSender: "@moderator:example.com",
originServerTs: Date.UTC(2023, 0, 1, 12, 0, 0),
});
const vm = new RedactedBodyViewModel({ mxEvent: originalEvent });
const listener = jest.fn();
jest.spyOn(room, "getMember").mockReturnValue({ name: "Moderator" } as any);
vm.subscribe(listener);
vm.setEvent(originalEvent);
expect(listener).not.toHaveBeenCalled();
vm.setEvent(updatedEvent);
expect(listener).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().text).toBe("Message deleted by Moderator");
});
it("unwatches the timestamp setting when disposed", () => {
const vm = new RedactedBodyViewModel({ mxEvent: makeRedactedEvent() });
vm.dispose();
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-show-twelve-hour-watcher");
});
});
@@ -19,7 +19,7 @@ import {
} from "../../../../test-utils";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { FileBodyViewFactory, renderMBody } from "../../../../../src/components/views/messages/MBodyFactory";
import { FileBodyFactory, renderMBody } from "../../../../../src/components/views/messages/MBodyFactory";
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
@@ -108,7 +108,7 @@ describe("MBodyFactory", () => {
mxEvent: mediaEvent,
mediaEventHelper: new MediaEventHelper(mediaEvent),
},
FileBodyViewFactory,
FileBodyFactory,
)}
</ScopedRoomContextProvider>,
);
@@ -139,7 +139,7 @@ describe("MBodyFactory", () => {
mediaEventHelper: new MediaEventHelper(mediaEvent),
showFileInfo: true,
},
FileBodyViewFactory,
FileBodyFactory,
)}
</ScopedRoomContextProvider>,
);
@@ -17,6 +17,7 @@ import SettingsStore from "../../../../../src/settings/SettingsStore";
import { mkEvent, mkRoom, stubClient } from "../../../../test-utils";
import MessageEvent from "../../../../../src/components/views/messages/MessageEvent";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
jest.mock("../../../../../src/components/views/messages/UnknownBody", () => ({
__esModule: true,
@@ -35,7 +36,9 @@ jest.mock("../../../../../src/components/views/messages/MVideoBody", () => ({
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
__esModule: true,
FileBodyViewFactory: () => <div data-testid="file-body" />,
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
FileBodyFactory: () => <div data-testid="file-body" />,
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
renderMBody: () => <div data-testid="file-body" />,
}));
@@ -59,18 +62,61 @@ describe("MessageEvent", () => {
let client: MatrixClient;
let event: MatrixEvent;
const makeRedactedBecauseEvent = ({ sender, originServerTs }: { sender: string; originServerTs: number }) => ({
content: {},
event_id: "$redaction:example.com",
origin_server_ts: originServerTs,
redacts: "$message:example.com",
room_id: room.roomId,
sender,
type: EventType.RoomRedaction,
unsigned: {},
});
const renderMessageEvent = (): RenderResult => {
return render(<MessageEvent mxEvent={event} permalinkCreator={new RoomPermalinkCreator(room)} />);
return render(
<MatrixClientContext.Provider value={client}>
<MessageEvent mxEvent={event} permalinkCreator={new RoomPermalinkCreator(room)} />
</MatrixClientContext.Provider>,
);
};
beforeEach(() => {
client = stubClient();
room = mkRoom(client, "!room:example.com");
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(SettingsStore, "getValue");
jest.spyOn(SettingsStore, "watchSetting");
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
});
it("renders the shared redacted body for redacted events", () => {
jest.spyOn(room, "getMember").mockReturnValue({ name: "Moderator" } as any);
event = mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@alice:example.com",
room: room.roomId,
content: {
msgtype: MsgType.Text,
body: "Secret",
},
unsigned: {
redacted_because: makeRedactedBecauseEvent({
sender: "@moderator:example.com",
originServerTs: Date.UTC(2022, 10, 17, 15, 58, 32),
}),
},
});
jest.spyOn(event, "isRedacted").mockReturnValue(true);
const result = renderMessageEvent();
expect(result.getByText("Message deleted by Moderator")).toBeInTheDocument();
expect(result.container.querySelector(".mx_RedactedBody")).not.toBeNull();
expect(result.queryByTestId("textual-body")).toBeNull();
});
describe("when an image with a caption is sent", () => {
let result: RenderResult;
@@ -42,6 +42,7 @@ import { Action } from "../../../../../src/dispatcher/actions";
import PinningUtils from "../../../../../src/utils/PinningUtils";
import { Layout } from "../../../../../src/settings/enums/Layout";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import SettingsStore from "../../../../../src/settings/SettingsStore";
describe("EventTile", () => {
const ROOM_ID = "!roomId:example.org";
@@ -94,6 +95,7 @@ describe("EventTile", () => {
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue();
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
mxEvent = mkMessage({
room: room.roomId,
@@ -220,6 +222,22 @@ describe("EventTile", () => {
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org");
});
it("renders the shared redacted body for thread previews", () => {
jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true);
jest.spyOn(mxEvent, "getUnsigned").mockReturnValue({
redacted_because: {
sender: "@moderator:example.org",
origin_server_ts: Date.UTC(2022, 10, 17, 15, 58, 32),
},
} as any);
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
const redactedBody = container.querySelector(".mx_RedactedBody");
expect(redactedBody).not.toBeNull();
expect(redactedBody).toHaveTextContent("Message deleted by @moderator:example.org");
});
it.each([
[TimelineRenderingType.Notification, Action.ViewRoom],
[TimelineRenderingType.ThreadsList, Action.ShowThread],
@@ -85,4 +85,16 @@ describe("DecryptionFailureBodyViewModel", () => {
vm.setVerificationState(true);
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(true);
});
it("should update snapshot when decryption failure code changes", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: DecryptionFailureCode.UNKNOWN_ERROR,
});
expect(vm.getSnapshot().decryptionFailureReason).toBe(DecryptionFailureReason.UNABLE_TO_DECRYPT);
vm.setDecryptionFailureCode(DecryptionFailureCode.UNSIGNED_SENDER_DEVICE);
expect(vm.getSnapshot().decryptionFailureReason).toBe(DecryptionFailureReason.UNSIGNED_SENDER_DEVICE);
});
});
+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";