diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss
index 78cb4b9b4a..9435e53b6f 100644
--- a/apps/web/res/css/_components.pcss
+++ b/apps/web/res/css/_components.pcss
@@ -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";
diff --git a/apps/web/res/css/views/messages/_RedactedBody.pcss b/apps/web/res/css/views/messages/_RedactedBody.pcss
deleted file mode 100644
index 417048c7f5..0000000000
--- a/apps/web/res/css/views/messages/_RedactedBody.pcss
+++ /dev/null
@@ -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;
- }
-}
diff --git a/apps/web/src/components/views/messages/EditHistoryMessage.tsx b/apps/web/src/components/views/messages/EditHistoryMessage.tsx
index 4629d4786e..e6af74f2b1 100644
--- a/apps/web/src/components/views/messages/EditHistoryMessage.tsx
+++ b/apps/web/src/components/views/messages/EditHistoryMessage.tsx
@@ -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;
+ contentContainer = ;
} else {
let contentElements;
if (this.props.previousEdit) {
diff --git a/apps/web/src/components/views/messages/IBodyProps.ts b/apps/web/src/components/views/messages/IBodyProps.ts
index a85f86644d..f7ab066b75 100644
--- a/apps/web/src/components/views/messages/IBodyProps.ts
+++ b/apps/web/src/components/views/messages/IBodyProps.ts
@@ -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;
diff --git a/apps/web/src/components/views/messages/MAudioBody.tsx b/apps/web/src/components/views/messages/MAudioBody.tsx
index 52630959b0..ad1a1d2f5f 100644
--- a/apps/web/src/components/views/messages/MAudioBody.tsx
+++ b/apps/web/src/components/views/messages/MAudioBody.tsx
@@ -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
return (
- {this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory)}
+ {this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory)}
);
}
diff --git a/apps/web/src/components/views/messages/MBodyFactory.tsx b/apps/web/src/components/views/messages/MBodyFactory.tsx
index 7be9181fda..b033098529 100644
--- a/apps/web/src/components/views/messages/MBodyFactory.tsx
+++ b/apps/web/src/components/views/messages/MBodyFactory.tsx
@@ -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;
-type MBodyComponent = React.ComponentType;
-
-// 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): JSX.Element {
const { timelineRenderingType } = useContext(RoomContext);
const refIFrame = useRef(null) as RefObject;
const refLink = useRef(null) as RefObject;
@@ -61,19 +59,41 @@ function FileBodyViewWrapped({
return ;
}
-// Exported for explicit fallback usage where callers want file-body rendering.
-export const FileBodyViewFactory: MBodyComponent = (props) => ;
+export function RedactedBodyFactory({ mxEvent, ref }: Pick): JSX.Element {
+ const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
+
+ useEffect(() => {
+ vm.setEvent(mxEvent);
+ }, [mxEvent, vm]);
+
+ return ;
+}
+
+export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick): 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 ;
+}
// Message body factory registry.
// Start small: only m.file currently routes to the new FileBodyView path.
-const MESSAGE_BODY_TYPES = new Map([[MsgType.File, FileBodyViewFactory]]);
+const MESSAGE_BODY_TYPES = new Map([[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;
diff --git a/apps/web/src/components/views/messages/MImageBody.tsx b/apps/web/src/components/views/messages/MImageBody.tsx
index 8686469c34..2d1b1402e5 100644
--- a/apps/web/src/components/views/messages/MImageBody.tsx
+++ b/apps/web/src/components/views/messages/MImageBody.tsx
@@ -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 {
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 {
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
!content.info?.thumbnail_info
) {
- return renderMBody(this.props, FileBodyViewFactory);
+ return renderMBody(this.props, FileBodyFactory);
}
if (this.state.error) {
diff --git a/apps/web/src/components/views/messages/MVideoBody.tsx b/apps/web/src/components/views/messages/MVideoBody.tsx
index 830d57be6d..848dc7a32e 100644
--- a/apps/web/src/components/views/messages/MVideoBody.tsx
+++ b/apps/web/src/components/views/messages/MVideoBody.tsx
@@ -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 {
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 {
diff --git a/apps/web/src/components/views/messages/MVoiceMessageBody.tsx b/apps/web/src/components/views/messages/MVoiceMessageBody.tsx
index e94973d1ea..3d5d465ffc 100644
--- a/apps/web/src/components/views/messages/MVoiceMessageBody.tsx
+++ b/apps/web/src/components/views/messages/MVoiceMessageBody.tsx
@@ -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 (
- {this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory)}
+ {this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory)}
);
}
diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx
index c5774d7e0a..d8e0b656bb 100644
--- a/apps/web/src/components/views/messages/MessageEvent.tsx
+++ b/apps/web/src/components/views/messages/MessageEvent.tsx
@@ -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 {
@@ -67,7 +63,7 @@ const baseBodyTypes = new Map>([
[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 implements IMe
const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType();
const msgtype = content.msgtype;
- let BodyType: React.ComponentType = RedactedBody;
+ let BodyType: React.ComponentType = 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
);
-
-/**
- * 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 ;
-}
diff --git a/apps/web/src/components/views/messages/RedactedBody.tsx b/apps/web/src/components/views/messages/RedactedBody.tsx
deleted file mode 100644
index a3a2c475dd..0000000000
--- a/apps/web/src/components/views/messages/RedactedBody.tsx
+++ /dev/null
@@ -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 (
-
-
- {text}
-
- );
-};
-
-export default RedactedBody;
diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx
index 72ba6dcf95..aca72046bd 100644
--- a/apps/web/src/components/views/rooms/EventTile.tsx
+++ b/apps/web/src/components/views/rooms/EventTile.tsx
@@ -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
{this.props.mxEvent.isRedacted() ? (
-
+
) : this.props.mxEvent.isDecryptionFailure() ? (
-
+
) : (
)}
@@ -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 ;
-}
-
/**
* 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.
diff --git a/apps/web/src/components/views/rooms/ReplyTile.tsx b/apps/web/src/components/views/rooms/ReplyTile.tsx
index 5184599b07..48121acff7 100644
--- a/apps/web/src/components/views/rooms/ReplyTile.tsx
+++ b/apps/web/src/components/views/rooms/ReplyTile.tsx
@@ -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 {
);
}
- const ReplyTileFileBody: React.ComponentType = (props) => renderMBody(props, FileBodyViewFactory);
+ const ReplyTileFileBody: React.ComponentType = (props) => renderMBody(props, FileBodyFactory);
const msgtypeOverrides: Record> = {
[MsgType.Image]: MImageReplyBody,
diff --git a/apps/web/src/viewmodels/message-body/DecryptionFailureBodyViewModel.ts b/apps/web/src/viewmodels/message-body/DecryptionFailureBodyViewModel.ts
index a51ea71117..1cf41425d6 100644
--- a/apps/web/src/viewmodels/message-body/DecryptionFailureBodyViewModel.ts
+++ b/apps/web/src/viewmodels/message-body/DecryptionFailureBodyViewModel.ts
@@ -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),
+ });
+ }
}
diff --git a/apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts b/apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts
new file mode 100644
index 0000000000..22553fdffa
--- /dev/null
+++ b/apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts
@@ -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
+ 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 = {};
+
+ 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 });
+ }
+ }
+}
diff --git a/apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts b/apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts
new file mode 100644
index 0000000000..15911318b7
--- /dev/null
+++ b/apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts
@@ -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");
+ });
+});
diff --git a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx
index 4671b60843..e20c8791e8 100644
--- a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx
+++ b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx
@@ -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,
)}
,
);
@@ -139,7 +139,7 @@ describe("MBodyFactory", () => {
mediaEventHelper: new MediaEventHelper(mediaEvent),
showFileInfo: true,
},
- FileBodyViewFactory,
+ FileBodyFactory,
)}
,
);
diff --git a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx
index cbd22fece0..ce79f18efe 100644
--- a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx
+++ b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx
@@ -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: () => ,
+ DecryptionFailureBodyFactory: () => ,
+ FileBodyFactory: () => ,
+ RedactedBodyFactory: () =>
Message deleted by Moderator
,
renderMBody: () => ,
}));
@@ -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();
+ return render(
+
+
+ ,
+ );
};
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;
diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx
index 7f57914b9e..3b4f6b3291 100644
--- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx
+++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx
@@ -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],
diff --git a/apps/web/test/viewmodels/message-body/DecryptionFailureBodyViewModel-test.tsx b/apps/web/test/viewmodels/message-body/DecryptionFailureBodyViewModel-test.tsx
index 580dea3a31..fe5f93f9ac 100644
--- a/apps/web/test/viewmodels/message-body/DecryptionFailureBodyViewModel-test.tsx
+++ b/apps/web/test/viewmodels/message-body/DecryptionFailureBodyViewModel-test.tsx
@@ -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);
+ });
});
diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/default-auto.png
new file mode 100644
index 0000000000..116c906791
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/default-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/redacted-by-another-user-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/redacted-by-another-user-auto.png
new file mode 100644
index 0000000000..308865a0cf
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/redacted-by-another-user-auto.png differ
diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/self-redaction-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/self-redaction-auto.png
new file mode 100644
index 0000000000..44372a5108
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/self-redaction-auto.png differ
diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts
index 3bcfdac0ae..a1e299e6d2 100644
--- a/packages/shared-components/src/index.ts
+++ b/packages/shared-components/src/index.ts
@@ -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";
diff --git a/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.module.css b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.module.css
new file mode 100644
index 0000000000..d39ee1e3d6
--- /dev/null
+++ b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.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: 6px;
+}
+
+.icon {
+ width: 14px;
+ height: 14px;
+ color: var(--cpd-color-icon-secondary);
+ flex: 0 0 14px;
+}
diff --git a/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.stories.tsx b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.stories.tsx
new file mode 100644
index 0000000000..c5d1590b93
--- /dev/null
+++ b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.stories.tsx
@@ -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 ;
+};
+
+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;
+
+export default meta;
+type Story = StoryObj;
+
+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",
+ },
+};
diff --git a/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.test.tsx b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.test.tsx
new file mode 100644
index 0000000000..990e8be638
--- /dev/null
+++ b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.test.tsx
@@ -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();
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders without a tooltip when none is provided", () => {
+ const { container } = render();
+
+ 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({
+ text: "Message deleted by Alice",
+ tooltip: "This message was deleted on Thu, 17 Nov 2022, 4:58:32 pm",
+ }) as RedactedBodyViewModel;
+
+ render();
+
+ 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({
+ text: "Message deleted",
+ }) as RedactedBodyViewModel;
+
+ const { container } = render();
+
+ expect(container.firstChild).toHaveClass("custom-redacted", "another-class");
+ });
+
+ it("forwards the provided ref to the root span", () => {
+ const ref = React.createRef();
+ const vm = new MockViewModel({
+ text: "Message deleted",
+ }) as RedactedBodyViewModel;
+
+ render();
+
+ expect(ref.current).toBeInstanceOf(HTMLSpanElement);
+ expect(ref.current).toHaveTextContent("Message deleted");
+ });
+});
diff --git a/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.tsx b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.tsx
new file mode 100644
index 0000000000..304e325513
--- /dev/null
+++ b/packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.tsx
@@ -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;
+
+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;
+}
+
+export function RedactedBodyView({ vm, className, ref }: Readonly): JSX.Element {
+ const { text, tooltip } = useViewModel(vm);
+
+ const content = (
+
+
+ {text}
+
+ );
+
+ if (!tooltip) {
+ return content;
+ }
+
+ return {content};
+}
diff --git a/packages/shared-components/src/message-body/RedactedBodyView/__snapshots__/RedactedBodyView.test.tsx.snap b/packages/shared-components/src/message-body/RedactedBodyView/__snapshots__/RedactedBodyView.test.tsx.snap
new file mode 100644
index 0000000000..2178dff192
--- /dev/null
+++ b/packages/shared-components/src/message-body/RedactedBodyView/__snapshots__/RedactedBodyView.test.tsx.snap
@@ -0,0 +1,51 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`RedactedBodyView > renders the default redacted body 1`] = `
+
+
+
+
+ Message deleted
+
+
+
+`;
+
+exports[`RedactedBodyView > renders without a tooltip when none is provided 1`] = `
+
+
+
+
+ Message deleted
+
+
+
+`;
diff --git a/packages/shared-components/src/message-body/RedactedBodyView/index.tsx b/packages/shared-components/src/message-body/RedactedBodyView/index.tsx
new file mode 100644
index 0000000000..9688b8b281
--- /dev/null
+++ b/packages/shared-components/src/message-body/RedactedBodyView/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 { RedactedBodyView, type RedactedBodyViewSnapshot, type RedactedBodyViewModel } from "./RedactedBodyView";