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 = ( + + + ); + + 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";