From 4f3a1a2cc6fe62d7a7536675254fcf7e2b5e993f Mon Sep 17 00:00:00 2001 From: Zack Date: Tue, 24 Mar 2026 11:02:07 +0100 Subject: [PATCH] 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 --- apps/web/res/css/_components.pcss | 1 - .../res/css/views/messages/_RedactedBody.pcss | 21 --- .../views/messages/EditHistoryMessage.tsx | 4 +- .../components/views/messages/IBodyProps.ts | 2 + .../components/views/messages/MAudioBody.tsx | 4 +- .../views/messages/MBodyFactory.tsx | 62 +++++--- .../components/views/messages/MImageBody.tsx | 6 +- .../components/views/messages/MVideoBody.tsx | 4 +- .../views/messages/MVoiceMessageBody.tsx | 4 +- .../views/messages/MessageEvent.tsx | 33 +--- .../views/messages/RedactedBody.tsx | 44 ------ .../src/components/views/rooms/EventTile.tsx | 29 +--- .../src/components/views/rooms/ReplyTile.tsx | 4 +- .../DecryptionFailureBodyViewModel.ts | 9 ++ .../message-body/RedactedBodyViewModel.ts | 112 +++++++++++++ .../RedactedBodyViewModel-test.ts | 147 ++++++++++++++++++ .../views/messages/MBodyFactory-test.tsx | 6 +- .../views/messages/MessageEvent-test.tsx | 50 +++++- .../components/views/rooms/EventTile-test.tsx | 18 +++ .../DecryptionFailureBodyViewModel-test.tsx | 12 ++ .../default-auto.png | Bin 0 -> 9199 bytes .../redacted-by-another-user-auto.png | Bin 0 -> 6577 bytes .../self-redaction-auto.png | Bin 0 -> 5710 bytes packages/shared-components/src/index.ts | 1 + .../RedactedBodyView.module.css | 22 +++ .../RedactedBodyView.stories.tsx | 62 ++++++++ .../RedactedBodyView.test.tsx | 71 +++++++++ .../RedactedBodyView/RedactedBodyView.tsx | 60 +++++++ .../RedactedBodyView.test.tsx.snap | 51 ++++++ .../message-body/RedactedBodyView/index.tsx | 8 + 30 files changed, 688 insertions(+), 159 deletions(-) delete mode 100644 apps/web/res/css/views/messages/_RedactedBody.pcss delete mode 100644 apps/web/src/components/views/messages/RedactedBody.tsx create mode 100644 apps/web/src/viewmodels/message-body/RedactedBodyViewModel.ts create mode 100644 apps/web/test/unit-tests/components/viewmodels/message-body/RedactedBodyViewModel-test.ts create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/redacted-by-another-user-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/RedactedBodyView/RedactedBodyView.stories.tsx/self-redaction-auto.png create mode 100644 packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.module.css create mode 100644 packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.stories.tsx create mode 100644 packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.test.tsx create mode 100644 packages/shared-components/src/message-body/RedactedBodyView/RedactedBodyView.tsx create mode 100644 packages/shared-components/src/message-body/RedactedBodyView/__snapshots__/RedactedBodyView.test.tsx.snap create mode 100644 packages/shared-components/src/message-body/RedactedBodyView/index.tsx 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 0000000000000000000000000000000000000000..116c906791855abdfd8f465a343e1c6a593eb218 GIT binary patch literal 9199 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 literal 0 HcmV?d00001 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 = ( + + + ); + + 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";