Refactor and Move RedactedBodyView To Shared Components (#32772)
* refactoring and creation of shared-components for reductedBodyView * move redacted message rendering to shared MVVM view * Update snapshots + fix lint errors * Remove MatrixClientPeg and use reguler react matrix client context * Stop resyncing redacted body view models with mxEvent * Fix redacted_because test fixtures for stricter event typing * Simplify redacted body client access * Watch timestamp setting in redacted body view model * Refactor redacted and decryption failure body factories into MBodyFactory * Prettier Fix * Refactor FileBody into same pattern for consitancy
This commit is contained in:
@@ -237,7 +237,6 @@
|
||||
@import "./views/messages/_MessageActionBar.pcss";
|
||||
@import "./views/messages/_MjolnirBody.pcss";
|
||||
@import "./views/messages/_ReactionsRow.pcss";
|
||||
@import "./views/messages/_RedactedBody.pcss";
|
||||
@import "./views/messages/_RoomAvatarEvent.pcss";
|
||||
@import "./views/messages/_TextualEvent.pcss";
|
||||
@import "./views/messages/_UnknownBody.pcss";
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_RedactedBody {
|
||||
white-space: pre-wrap;
|
||||
color: $secondary-content;
|
||||
vertical-align: middle;
|
||||
|
||||
svg {
|
||||
margin-right: 6px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
color: $icon-button-color;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
|
||||
import { formatTime } from "../../../DateUtils";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import RedactedBody from "./RedactedBody";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
|
||||
import ViewSource from "../../structures/ViewSource";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { RedactedBodyFactory } from "./MBodyFactory";
|
||||
|
||||
function getReplacedContent(event: MatrixEvent): IContent {
|
||||
const originalContent = event.getOriginalContent();
|
||||
@@ -151,7 +151,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
const content = getReplacedContent(mxEvent);
|
||||
let contentContainer;
|
||||
if (mxEvent.isRedacted()) {
|
||||
contentContainer = <RedactedBody mxEvent={this.props.mxEvent} />;
|
||||
contentContainer = <RedactedBodyFactory mxEvent={this.props.mxEvent} />;
|
||||
} else {
|
||||
let contentElements;
|
||||
if (this.props.previousEdit) {
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface IBodyProps {
|
||||
|
||||
showUrlPreview?: boolean;
|
||||
forExport?: boolean;
|
||||
// Whether file-style rendering should show the info row / placeholder.
|
||||
showFileInfo?: boolean;
|
||||
maxImageHeight?: number;
|
||||
replacingEventId?: string;
|
||||
editState?: EditorStateTransfer;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
|
||||
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
interface IState {
|
||||
error?: boolean;
|
||||
@@ -111,7 +111,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
|
||||
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory)}
|
||||
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,30 +7,28 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { type JSX, type RefObject, useContext, useEffect, useRef } from "react";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import { FileBodyView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
|
||||
import {
|
||||
DecryptionFailureBodyView,
|
||||
FileBodyView,
|
||||
RedactedBodyView,
|
||||
useCreateAutoDisposedViewModel,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
|
||||
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
|
||||
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
|
||||
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
|
||||
|
||||
interface FileBodyViewProps {
|
||||
/*
|
||||
* Whether file-style message bodies should render their info row/placeholder.
|
||||
* Used by file-body rendering paths (for example FileBodyViewModel via MBodyFactory).
|
||||
*/
|
||||
showFileInfo?: boolean;
|
||||
}
|
||||
type MBodyComponent = React.ComponentType<IBodyProps>;
|
||||
|
||||
type MBodyComponent = React.ComponentType<IBodyProps & FileBodyViewProps>;
|
||||
|
||||
// Adapter that binds RoomContext data and lifecycle updates to the
|
||||
// FileBody view model before rendering the shared view component.
|
||||
function FileBodyViewWrapped({
|
||||
export function FileBodyFactory({
|
||||
mxEvent,
|
||||
mediaEventHelper,
|
||||
forExport,
|
||||
showFileInfo,
|
||||
}: IBodyProps & FileBodyViewProps): JSX.Element {
|
||||
}: Pick<IBodyProps, "mxEvent" | "mediaEventHelper" | "forExport" | "showFileInfo">): JSX.Element {
|
||||
const { timelineRenderingType } = useContext(RoomContext);
|
||||
const refIFrame = useRef<HTMLIFrameElement>(null) as RefObject<HTMLIFrameElement>;
|
||||
const refLink = useRef<HTMLAnchorElement>(null) as RefObject<HTMLAnchorElement>;
|
||||
@@ -61,19 +59,41 @@ function FileBodyViewWrapped({
|
||||
return <FileBodyView vm={vm} refIFrame={refIFrame} refLink={refLink} className="mx_MFileBody" />;
|
||||
}
|
||||
|
||||
// Exported for explicit fallback usage where callers want file-body rendering.
|
||||
export const FileBodyViewFactory: MBodyComponent = (props) => <FileBodyViewWrapped {...props} />;
|
||||
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
|
||||
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
|
||||
|
||||
useEffect(() => {
|
||||
vm.setEvent(mxEvent);
|
||||
}, [mxEvent, vm]);
|
||||
|
||||
return <RedactedBodyView vm={vm} ref={ref} className="mx_RedactedBody" />;
|
||||
}
|
||||
|
||||
export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
|
||||
const verificationState = useContext(LocalDeviceVerificationStateContext);
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: mxEvent.decryptionFailureReason,
|
||||
verificationState,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setDecryptionFailureCode(mxEvent.decryptionFailureReason);
|
||||
vm.setVerificationState(verificationState);
|
||||
}, [mxEvent, verificationState, vm]);
|
||||
|
||||
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
|
||||
}
|
||||
|
||||
// Message body factory registry.
|
||||
// Start small: only m.file currently routes to the new FileBodyView path.
|
||||
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([[MsgType.File, FileBodyViewFactory]]);
|
||||
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([[MsgType.File, FileBodyFactory]]);
|
||||
|
||||
// Render a body using the picked factory.
|
||||
// Falls back to the provided factory when msgtype has no specific handler.
|
||||
export function renderMBody(
|
||||
props: IBodyProps & FileBodyViewProps,
|
||||
fallbackFactory?: MBodyComponent,
|
||||
): JSX.Element | null {
|
||||
export function renderMBody(props: IBodyProps, fallbackFactory?: MBodyComponent): JSX.Element | null {
|
||||
const BodyType = MESSAGE_BODY_TYPES.get(props.mxEvent.getContent().msgtype as string) ?? fallbackFactory;
|
||||
if (!BodyType) {
|
||||
return null;
|
||||
|
||||
@@ -36,7 +36,7 @@ import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
|
||||
import { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
|
||||
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
enum Placeholder {
|
||||
NoImage,
|
||||
@@ -651,7 +651,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
|
||||
if (!hasMessageActionBar) {
|
||||
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory);
|
||||
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info
|
||||
) {
|
||||
return renderMBody(this.props, FileBodyViewFactory);
|
||||
return renderMBody(this.props, FileBodyFactory);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContex
|
||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
interface IState {
|
||||
decryptedUrl: string | null;
|
||||
@@ -246,7 +246,7 @@ class MVideoBodyInner extends React.PureComponent<IProps, IState> {
|
||||
|
||||
private getFileBody = (): ReactNode => {
|
||||
if (this.props.forExport) return null;
|
||||
return this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory);
|
||||
return this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
|
||||
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
|
||||
import { type Playback } from "../../../audio/Playback";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
export default class MVoiceMessageBody extends MAudioBody {
|
||||
public static contextType = RoomContext;
|
||||
@@ -54,7 +54,7 @@ export default class MVoiceMessageBody extends MAudioBody {
|
||||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<RecordingPlayback playback={this.state.playback} />
|
||||
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory)}
|
||||
{this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import mime from "mime";
|
||||
import React, { type JSX, createRef, useContext, useEffect } from "react";
|
||||
import React, { createRef } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
EventType,
|
||||
@@ -18,12 +18,9 @@ import {
|
||||
M_POLL_START,
|
||||
type IContent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { useCreateAutoDisposedViewModel, DecryptionFailureBodyView } from "@element-hq/web-shared-components";
|
||||
|
||||
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { Mjolnir } from "../../../mjolnir/Mjolnir";
|
||||
import RedactedBody from "./RedactedBody";
|
||||
import UnknownBody from "./UnknownBody";
|
||||
import { type IMediaBody } from "./IMediaBody";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
@@ -38,8 +35,7 @@ import MLocationBody from "./MLocationBody";
|
||||
import MjolnirBody from "./MjolnirBody";
|
||||
import MBeaconBody from "./MBeaconBody";
|
||||
import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile";
|
||||
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
|
||||
import { FileBodyViewFactory, renderMBody } from "./MBodyFactory";
|
||||
import { DecryptionFailureBodyFactory, FileBodyFactory, RedactedBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
// onMessageAllowed is handled internally
|
||||
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
|
||||
@@ -67,7 +63,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
||||
[MsgType.Notice, TextualBody],
|
||||
[MsgType.Emote, TextualBody],
|
||||
[MsgType.Image, MImageBody],
|
||||
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyViewFactory)!],
|
||||
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
||||
[MsgType.Audio, MVoiceOrAudioBody],
|
||||
[MsgType.Video, MVideoBody],
|
||||
]);
|
||||
@@ -246,11 +242,11 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const type = this.props.mxEvent.getType();
|
||||
const msgtype = content.msgtype;
|
||||
let BodyType: React.ComponentType<IBodyProps> = RedactedBody;
|
||||
let BodyType: React.ComponentType<IBodyProps> = RedactedBodyFactory;
|
||||
if (!this.props.mxEvent.isRedacted()) {
|
||||
// only resolve BodyType if event is not redacted
|
||||
if (this.props.mxEvent.isDecryptionFailure()) {
|
||||
BodyType = DecryptionFailureBodyWrapper;
|
||||
BodyType = DecryptionFailureBodyFactory;
|
||||
} else if (type && this.evTypes.has(type)) {
|
||||
BodyType = this.evTypes.get(type)!;
|
||||
} else if (msgtype && this.bodyTypes.has(msgtype)) {
|
||||
@@ -330,22 +326,3 @@ const CaptionBody: React.FunctionComponent<IBodyProps & { WrappedBodyType: React
|
||||
<TextualBody {...{ ...props, ref: undefined }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Bridge decryption-failure events into the view model using current local verification state.
|
||||
* This wrapper can be removed after MessageEvent has been changed to a function component.
|
||||
*/
|
||||
function DecryptionFailureBodyWrapper({ mxEvent, ref }: IBodyProps): JSX.Element {
|
||||
const verificationState = useContext(LocalDeviceVerificationStateContext);
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: mxEvent.decryptionFailureReason,
|
||||
verificationState,
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
vm.setVerificationState(verificationState);
|
||||
}, [verificationState, vm]);
|
||||
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useContext, type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { formatFullDate } from "../../../DateUtils";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
|
||||
const RedactedBody = ({ mxEvent, ref }: IBodyProps): JSX.Element => {
|
||||
const cli: MatrixClient = useContext(MatrixClientContext);
|
||||
let text = _t("timeline|self_redaction");
|
||||
const unsigned = mxEvent.getUnsigned();
|
||||
const redactedBecauseUserId = unsigned && unsigned.redacted_because && unsigned.redacted_because.sender;
|
||||
if (redactedBecauseUserId && redactedBecauseUserId !== mxEvent.getSender()) {
|
||||
const room = cli.getRoom(mxEvent.getRoomId());
|
||||
const sender = room && room.getMember(redactedBecauseUserId);
|
||||
text = _t("timeline|redaction", { name: sender ? sender.name : redactedBecauseUserId });
|
||||
}
|
||||
|
||||
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
const fullDate = unsigned.redacted_because
|
||||
? formatFullDate(new Date(unsigned.redacted_because.origin_server_ts), showTwelveHour)
|
||||
: undefined;
|
||||
const titleText = fullDate ? _t("timeline|redacted|tooltip", { date: fullDate }) : undefined;
|
||||
|
||||
return (
|
||||
<span className="mx_RedactedBody" ref={ref} title={titleText}>
|
||||
<DeleteIcon />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedactedBody;
|
||||
@@ -50,7 +50,6 @@ import { uniqueId, uniqBy } from "lodash";
|
||||
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
useCreateAutoDisposedViewModel,
|
||||
DecryptionFailureBodyView,
|
||||
MessageTimestampView,
|
||||
PinnedMessageBadge,
|
||||
ReactionsRowButtonView,
|
||||
@@ -58,7 +57,6 @@ import {
|
||||
useViewModel,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
|
||||
import ReplyChain from "../elements/ReplyChain";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
@@ -88,7 +86,6 @@ import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { copyPlaintext } from "../../../utils/strings";
|
||||
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
||||
import RedactedBody from "../messages/RedactedBody";
|
||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { shouldDisplayReply } from "../../../utils/Reply";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
@@ -105,7 +102,6 @@ import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { EventPreview } from "./EventPreview";
|
||||
import { ElementCallEventType } from "../../../call-types";
|
||||
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
|
||||
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
|
||||
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@@ -116,6 +112,7 @@ import {
|
||||
import { ReactionsRowButtonViewModel } from "../../../viewmodels/message-body/ReactionsRowButtonViewModel";
|
||||
import { MAX_ITEMS_WHEN_LIMITED, ReactionsRowViewModel } from "../../../viewmodels/message-body/ReactionsRowViewModel";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
||||
|
||||
export type GetRelationsForEvent = (
|
||||
eventId: string,
|
||||
@@ -1401,9 +1398,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
<div className={lineClasses} key="mx_EventTile_line">
|
||||
<div className="mx_EventTile_body">
|
||||
{this.props.mxEvent.isRedacted() ? (
|
||||
<RedactedBody mxEvent={this.props.mxEvent} />
|
||||
<RedactedBodyFactory mxEvent={this.props.mxEvent} />
|
||||
) : this.props.mxEvent.isDecryptionFailure() ? (
|
||||
<DecryptionFailureBodyWrapper mxEvent={this.props.mxEvent} />
|
||||
<DecryptionFailureBodyFactory mxEvent={this.props.mxEvent} />
|
||||
) : (
|
||||
<EventPreview mxEvent={this.props.mxEvent} />
|
||||
)}
|
||||
@@ -1600,26 +1597,6 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge decryption-failure events into the view model using current local verification state.
|
||||
* This wrapper can be removed after EventTile has been changed to a function component.
|
||||
*/
|
||||
function DecryptionFailureBodyWrapper({ mxEvent }: { mxEvent: MatrixEvent }): JSX.Element {
|
||||
const verificationState = useContext(LocalDeviceVerificationStateContext);
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: mxEvent.decryptionFailureReason,
|
||||
verificationState,
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
vm.setVerificationState(verificationState);
|
||||
}, [verificationState, vm]);
|
||||
|
||||
return <DecryptionFailureBodyView vm={vm} className="mx_DecryptionFailureBody mx_EventTile_content" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps MessageTimestampView with a view model synced to the provided props.
|
||||
* This wrapper can be removed after EventTile has been changed to a function component.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { renderReplyTile } from "../../../events/EventTileFactory";
|
||||
import { type GetRelationsForEvent } from "../rooms/EventTile";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { type IBodyProps } from "../messages/IBodyProps";
|
||||
import { FileBodyViewFactory, renderMBody } from "../messages/MBodyFactory";
|
||||
import { FileBodyFactory, renderMBody } from "../messages/MBodyFactory";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -130,7 +130,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
||||
);
|
||||
}
|
||||
|
||||
const ReplyTileFileBody: React.ComponentType<IBodyProps> = (props) => renderMBody(props, FileBodyViewFactory);
|
||||
const ReplyTileFileBody: React.ComponentType<IBodyProps> = (props) => renderMBody(props, FileBodyFactory);
|
||||
|
||||
const msgtypeOverrides: Record<string, React.ComponentType<IBodyProps>> = {
|
||||
[MsgType.Image]: MImageReplyBody,
|
||||
|
||||
@@ -84,4 +84,13 @@ export class DecryptionFailureBodyViewModel
|
||||
this.props.verificationState = verificationState;
|
||||
this.snapshot.merge({ isLocalDeviceVerified: verificationState });
|
||||
}
|
||||
|
||||
public setDecryptionFailureCode(decryptionFailureCode: DecryptionFailureCode | null): void {
|
||||
if (this.props.decryptionFailureCode === decryptionFailureCode) return;
|
||||
|
||||
this.props.decryptionFailureCode = decryptionFailureCode;
|
||||
this.snapshot.merge({
|
||||
decryptionFailureReason: DecryptionFailureBodyViewModel.getDecryptionReasonFromCode(decryptionFailureCode),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BaseViewModel,
|
||||
type RedactedBodyViewSnapshot,
|
||||
type RedactedBodyViewModel as RedactedBodyViewModelInterface,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { formatFullDate } from "../../DateUtils";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
export interface RedactedBodyViewModelProps {
|
||||
/**
|
||||
* The redacted event being rendered.
|
||||
*/
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
export class RedactedBodyViewModel
|
||||
extends BaseViewModel<RedactedBodyViewSnapshot, RedactedBodyViewModelProps>
|
||||
implements RedactedBodyViewModelInterface
|
||||
{
|
||||
private showTwelveHour: boolean;
|
||||
|
||||
private static readonly computeText = ({ mxEvent }: RedactedBodyViewModelProps): string => {
|
||||
const redactedBecauseUserId = mxEvent.getUnsigned().redacted_because?.sender;
|
||||
if (!redactedBecauseUserId || redactedBecauseUserId === mxEvent.getSender()) {
|
||||
return _t("timeline|self_redaction");
|
||||
}
|
||||
|
||||
const roomId = mxEvent.getRoomId();
|
||||
const room = roomId ? MatrixClientPeg.get()?.getRoom(roomId) : null;
|
||||
const sender = room?.getMember(redactedBecauseUserId);
|
||||
|
||||
return _t("timeline|redaction", { name: sender?.name ?? redactedBecauseUserId });
|
||||
};
|
||||
|
||||
private static readonly computeTooltip = (mxEvent: MatrixEvent, showTwelveHour: boolean): string | undefined => {
|
||||
const redactionTs = mxEvent.getUnsigned().redacted_because?.origin_server_ts;
|
||||
if (!redactionTs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return _t("timeline|redacted|tooltip", {
|
||||
date: formatFullDate(new Date(redactionTs), showTwelveHour),
|
||||
});
|
||||
};
|
||||
|
||||
private static readonly computeSnapshot = (
|
||||
props: RedactedBodyViewModelProps,
|
||||
showTwelveHour: boolean,
|
||||
): RedactedBodyViewSnapshot => ({
|
||||
text: RedactedBodyViewModel.computeText(props),
|
||||
tooltip: RedactedBodyViewModel.computeTooltip(props.mxEvent, showTwelveHour),
|
||||
});
|
||||
|
||||
public constructor(props: RedactedBodyViewModelProps) {
|
||||
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
|
||||
super(props, RedactedBodyViewModel.computeSnapshot(props, showTwelveHour));
|
||||
|
||||
this.showTwelveHour = showTwelveHour;
|
||||
|
||||
const showTwelveHourWatcherRef = SettingsStore.watchSetting(
|
||||
"showTwelveHourTimestamps",
|
||||
null,
|
||||
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
|
||||
if (this.showTwelveHour === newVal) return;
|
||||
|
||||
this.showTwelveHour = newVal;
|
||||
this.updateTooltip();
|
||||
},
|
||||
);
|
||||
this.disposables.track(() => SettingsStore.unwatchSetting(showTwelveHourWatcherRef));
|
||||
}
|
||||
|
||||
public setEvent(mxEvent: MatrixEvent): void {
|
||||
if (this.props.mxEvent === mxEvent) return;
|
||||
|
||||
this.props = { ...this.props, mxEvent };
|
||||
|
||||
const text = RedactedBodyViewModel.computeText(this.props);
|
||||
const tooltip = RedactedBodyViewModel.computeTooltip(this.props.mxEvent, this.showTwelveHour);
|
||||
const updates: Partial<RedactedBodyViewSnapshot> = {};
|
||||
|
||||
if (this.snapshot.current.text !== text) {
|
||||
updates.text = text;
|
||||
}
|
||||
if (this.snapshot.current.tooltip !== tooltip) {
|
||||
updates.tooltip = tooltip;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
this.snapshot.merge(updates);
|
||||
}
|
||||
}
|
||||
|
||||
private updateTooltip(): void {
|
||||
const tooltip = RedactedBodyViewModel.computeTooltip(this.props.mxEvent, this.showTwelveHour);
|
||||
if (this.snapshot.current.tooltip !== tooltip) {
|
||||
this.snapshot.merge({ tooltip });
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventType, MsgType, type MatrixClient, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { formatFullDate } from "../../../../../src/DateUtils";
|
||||
import { _t } from "../../../../../src/languageHandler";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { RedactedBodyViewModel } from "../../../../../src/viewmodels/message-body/RedactedBodyViewModel";
|
||||
import { mkEvent, mkRoom, stubClient } from "../../../../test-utils";
|
||||
|
||||
describe("RedactedBodyViewModel", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
let showTwelveHourSettingWatcher: ((...args: any[]) => void) | undefined;
|
||||
|
||||
const makeRedactedBecauseEvent = ({ sender, originServerTs }: { sender: string; originServerTs: number }) => ({
|
||||
content: {},
|
||||
event_id: "$redaction:example.com",
|
||||
origin_server_ts: originServerTs,
|
||||
redacts: "$message:example.com",
|
||||
room_id: room.roomId,
|
||||
sender,
|
||||
type: EventType.RoomRedaction,
|
||||
unsigned: {},
|
||||
});
|
||||
|
||||
const makeRedactedEvent = ({
|
||||
sender = "@alice:example.com",
|
||||
redactedBecauseSender = sender,
|
||||
originServerTs = Date.UTC(2022, 10, 17, 15, 58, 32),
|
||||
}: {
|
||||
sender?: string;
|
||||
redactedBecauseSender?: string;
|
||||
originServerTs?: number;
|
||||
} = {}): MatrixEvent =>
|
||||
mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: sender,
|
||||
room: room.roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "Message",
|
||||
},
|
||||
unsigned: {
|
||||
redacted_because: makeRedactedBecauseEvent({
|
||||
sender: redactedBecauseSender,
|
||||
originServerTs,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
room = mkRoom(client, "!room:example.com");
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(settingName) => settingName === "showTwelveHourTimestamps",
|
||||
);
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingName, _roomId, callbackFn) => {
|
||||
showTwelveHourSettingWatcher = callbackFn as (...args: any[]) => void;
|
||||
return "mock-show-twelve-hour-watcher";
|
||||
});
|
||||
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
it("builds self-redaction text and tooltip from the event", () => {
|
||||
const event = makeRedactedEvent();
|
||||
const vm = new RedactedBodyViewModel({ mxEvent: event });
|
||||
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
text: "Message deleted",
|
||||
tooltip: _t("timeline|redacted|tooltip", {
|
||||
date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the redacting member name when another user removed the message", () => {
|
||||
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice" } as any);
|
||||
const event = makeRedactedEvent({
|
||||
redactedBecauseSender: "@alice-redactor:example.com",
|
||||
});
|
||||
|
||||
const vm = new RedactedBodyViewModel({ mxEvent: event });
|
||||
|
||||
expect(vm.getSnapshot().text).toBe("Message deleted by Alice");
|
||||
});
|
||||
|
||||
it("updates the tooltip when showTwelveHourTimestamps changes", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||
const event = makeRedactedEvent();
|
||||
const vm = new RedactedBodyViewModel({ mxEvent: event });
|
||||
const listener = jest.fn();
|
||||
|
||||
vm.subscribe(listener);
|
||||
|
||||
showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, false, false);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
showTwelveHourSettingWatcher?.("showTwelveHourTimestamps", null, undefined, true, true);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(vm.getSnapshot().tooltip).toBe(
|
||||
_t("timeline|redacted|tooltip", {
|
||||
date: formatFullDate(new Date(Date.UTC(2022, 10, 17, 15, 58, 32)), true),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("setEvent is a no-op for the same event and updates for a different event", () => {
|
||||
const originalEvent = makeRedactedEvent();
|
||||
const updatedEvent = makeRedactedEvent({
|
||||
redactedBecauseSender: "@moderator:example.com",
|
||||
originServerTs: Date.UTC(2023, 0, 1, 12, 0, 0),
|
||||
});
|
||||
const vm = new RedactedBodyViewModel({ mxEvent: originalEvent });
|
||||
const listener = jest.fn();
|
||||
|
||||
jest.spyOn(room, "getMember").mockReturnValue({ name: "Moderator" } as any);
|
||||
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setEvent(originalEvent);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
vm.setEvent(updatedEvent);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(vm.getSnapshot().text).toBe("Message deleted by Moderator");
|
||||
});
|
||||
|
||||
it("unwatches the timestamp setting when disposed", () => {
|
||||
const vm = new RedactedBodyViewModel({ mxEvent: makeRedactedEvent() });
|
||||
|
||||
vm.dispose();
|
||||
|
||||
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-show-twelve-hour-watcher");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "../../../../test-utils";
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { FileBodyViewFactory, renderMBody } from "../../../../../src/components/views/messages/MBodyFactory";
|
||||
import { FileBodyFactory, renderMBody } from "../../../../../src/components/views/messages/MBodyFactory";
|
||||
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("MBodyFactory", () => {
|
||||
mxEvent: mediaEvent,
|
||||
mediaEventHelper: new MediaEventHelper(mediaEvent),
|
||||
},
|
||||
FileBodyViewFactory,
|
||||
FileBodyFactory,
|
||||
)}
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
@@ -139,7 +139,7 @@ describe("MBodyFactory", () => {
|
||||
mediaEventHelper: new MediaEventHelper(mediaEvent),
|
||||
showFileInfo: true,
|
||||
},
|
||||
FileBodyViewFactory,
|
||||
FileBodyFactory,
|
||||
)}
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { mkEvent, mkRoom, stubClient } from "../../../../test-utils";
|
||||
import MessageEvent from "../../../../../src/components/views/messages/MessageEvent";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/UnknownBody", () => ({
|
||||
__esModule: true,
|
||||
@@ -35,7 +36,9 @@ jest.mock("../../../../../src/components/views/messages/MVideoBody", () => ({
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
|
||||
__esModule: true,
|
||||
FileBodyViewFactory: () => <div data-testid="file-body" />,
|
||||
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
||||
FileBodyFactory: () => <div data-testid="file-body" />,
|
||||
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
||||
renderMBody: () => <div data-testid="file-body" />,
|
||||
}));
|
||||
|
||||
@@ -59,18 +62,61 @@ describe("MessageEvent", () => {
|
||||
let client: MatrixClient;
|
||||
let event: MatrixEvent;
|
||||
|
||||
const makeRedactedBecauseEvent = ({ sender, originServerTs }: { sender: string; originServerTs: number }) => ({
|
||||
content: {},
|
||||
event_id: "$redaction:example.com",
|
||||
origin_server_ts: originServerTs,
|
||||
redacts: "$message:example.com",
|
||||
room_id: room.roomId,
|
||||
sender,
|
||||
type: EventType.RoomRedaction,
|
||||
unsigned: {},
|
||||
});
|
||||
|
||||
const renderMessageEvent = (): RenderResult => {
|
||||
return render(<MessageEvent mxEvent={event} permalinkCreator={new RoomPermalinkCreator(room)} />);
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<MessageEvent mxEvent={event} permalinkCreator={new RoomPermalinkCreator(room)} />
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
room = mkRoom(client, "!room:example.com");
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
jest.spyOn(SettingsStore, "getValue");
|
||||
jest.spyOn(SettingsStore, "watchSetting");
|
||||
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
it("renders the shared redacted body for redacted events", () => {
|
||||
jest.spyOn(room, "getMember").mockReturnValue({ name: "Moderator" } as any);
|
||||
event = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@alice:example.com",
|
||||
room: room.roomId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "Secret",
|
||||
},
|
||||
unsigned: {
|
||||
redacted_because: makeRedactedBecauseEvent({
|
||||
sender: "@moderator:example.com",
|
||||
originServerTs: Date.UTC(2022, 10, 17, 15, 58, 32),
|
||||
}),
|
||||
},
|
||||
});
|
||||
jest.spyOn(event, "isRedacted").mockReturnValue(true);
|
||||
|
||||
const result = renderMessageEvent();
|
||||
|
||||
expect(result.getByText("Message deleted by Moderator")).toBeInTheDocument();
|
||||
expect(result.container.querySelector(".mx_RedactedBody")).not.toBeNull();
|
||||
expect(result.queryByTestId("textual-body")).toBeNull();
|
||||
});
|
||||
|
||||
describe("when an image with a caption is sent", () => {
|
||||
let result: RenderResult;
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import PinningUtils from "../../../../../src/utils/PinningUtils";
|
||||
import { Layout } from "../../../../../src/settings/enums/Layout";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
|
||||
describe("EventTile", () => {
|
||||
const ROOM_ID = "!roomId:example.org";
|
||||
@@ -94,6 +95,7 @@ describe("EventTile", () => {
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue();
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||
|
||||
mxEvent = mkMessage({
|
||||
room: room.roomId,
|
||||
@@ -220,6 +222,22 @@ describe("EventTile", () => {
|
||||
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org");
|
||||
});
|
||||
|
||||
it("renders the shared redacted body for thread previews", () => {
|
||||
jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true);
|
||||
jest.spyOn(mxEvent, "getUnsigned").mockReturnValue({
|
||||
redacted_because: {
|
||||
sender: "@moderator:example.org",
|
||||
origin_server_ts: Date.UTC(2022, 10, 17, 15, 58, 32),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
|
||||
const redactedBody = container.querySelector(".mx_RedactedBody");
|
||||
|
||||
expect(redactedBody).not.toBeNull();
|
||||
expect(redactedBody).toHaveTextContent("Message deleted by @moderator:example.org");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[TimelineRenderingType.Notification, Action.ViewRoom],
|
||||
[TimelineRenderingType.ThreadsList, Action.ShowThread],
|
||||
|
||||
@@ -85,4 +85,16 @@ describe("DecryptionFailureBodyViewModel", () => {
|
||||
vm.setVerificationState(true);
|
||||
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(true);
|
||||
});
|
||||
|
||||
it("should update snapshot when decryption failure code changes", () => {
|
||||
const vm = new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().decryptionFailureReason).toBe(DecryptionFailureReason.UNABLE_TO_DECRYPT);
|
||||
|
||||
vm.setDecryptionFailureCode(DecryptionFailureCode.UNSIGNED_SENDER_DEVICE);
|
||||
|
||||
expect(vm.getSnapshot().decryptionFailureReason).toBe(DecryptionFailureReason.UNSIGNED_SENDER_DEVICE);
|
||||
});
|
||||
});
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -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";
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.content {
|
||||
white-space: pre-wrap;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
vertical-align: middle;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
flex: 0 0 14px;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
import { RedactedBodyView, type RedactedBodyViewSnapshot } from "./RedactedBodyView";
|
||||
|
||||
type RedactedBodyProps = RedactedBodyViewSnapshot;
|
||||
|
||||
const RedactedBodyViewWrapperImpl = ({
|
||||
className,
|
||||
...rest
|
||||
}: RedactedBodyProps & { className?: string }): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {});
|
||||
|
||||
return <RedactedBodyView vm={vm} className={className} />;
|
||||
};
|
||||
|
||||
const RedactedBodyViewWrapper = withViewDocs(RedactedBodyViewWrapperImpl, RedactedBodyView);
|
||||
|
||||
const meta = {
|
||||
title: "MessageBody/RedactedBodyView",
|
||||
component: RedactedBodyViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
text: "Message deleted",
|
||||
tooltip: "This message was deleted on Thu, 17 Nov 2022, 4:58:32 pm",
|
||||
className: "",
|
||||
},
|
||||
} satisfies Meta<typeof RedactedBodyViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.hover(canvas.getByText("Message deleted"));
|
||||
await expect(within(canvasElement.ownerDocument.body).findByRole("tooltip")).resolves.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SelfRedaction: Story = {
|
||||
args: {
|
||||
text: "Message deleted",
|
||||
tooltip: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const RedactedByAnotherUser: Story = {
|
||||
args: {
|
||||
text: "Message deleted by Alice",
|
||||
},
|
||||
};
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MockViewModel } from "../../viewmodel";
|
||||
import { RedactedBodyView, type RedactedBodyViewModel, type RedactedBodyViewSnapshot } from "./RedactedBodyView";
|
||||
import * as stories from "./RedactedBodyView.stories";
|
||||
|
||||
const { Default, SelfRedaction } = composeStories(stories);
|
||||
|
||||
describe("RedactedBodyView", () => {
|
||||
it("renders the default redacted body", () => {
|
||||
const { container } = render(<Default />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders without a tooltip when none is provided", () => {
|
||||
const { container } = render(<SelfRedaction />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the tooltip on hover when tooltip text exists", async () => {
|
||||
const user = userEvent.setup();
|
||||
const vm = new MockViewModel<RedactedBodyViewSnapshot>({
|
||||
text: "Message deleted by Alice",
|
||||
tooltip: "This message was deleted on Thu, 17 Nov 2022, 4:58:32 pm",
|
||||
}) as RedactedBodyViewModel;
|
||||
|
||||
render(<RedactedBodyView vm={vm} />);
|
||||
|
||||
await user.hover(screen.getByText("Message deleted by Alice"));
|
||||
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"This message was deleted on Thu, 17 Nov 2022, 4:58:32 pm",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a custom className to the root span", () => {
|
||||
const vm = new MockViewModel<RedactedBodyViewSnapshot>({
|
||||
text: "Message deleted",
|
||||
}) as RedactedBodyViewModel;
|
||||
|
||||
const { container } = render(<RedactedBodyView vm={vm} className="custom-redacted another-class" />);
|
||||
|
||||
expect(container.firstChild).toHaveClass("custom-redacted", "another-class");
|
||||
});
|
||||
|
||||
it("forwards the provided ref to the root span", () => {
|
||||
const ref = React.createRef<HTMLSpanElement>();
|
||||
const vm = new MockViewModel<RedactedBodyViewSnapshot>({
|
||||
text: "Message deleted",
|
||||
}) as RedactedBodyViewModel;
|
||||
|
||||
render(<RedactedBodyView vm={vm} ref={ref} />);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
|
||||
expect(ref.current).toHaveTextContent("Message deleted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type Ref } from "react";
|
||||
import classNames from "classnames";
|
||||
import { DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import { type ViewModel } from "../../viewmodel";
|
||||
import { useViewModel } from "../../viewmodel/useViewModel";
|
||||
import styles from "./RedactedBodyView.module.css";
|
||||
|
||||
export interface RedactedBodyViewSnapshot {
|
||||
/**
|
||||
* Localized redaction message content.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Optional localized tooltip shown with the redaction timestamp.
|
||||
*/
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export type RedactedBodyViewModel = ViewModel<RedactedBodyViewSnapshot>;
|
||||
|
||||
interface RedactedBodyViewProps {
|
||||
/**
|
||||
* ViewModel providing the rendered text and tooltip.
|
||||
*/
|
||||
vm: RedactedBodyViewModel;
|
||||
/**
|
||||
* Optional CSS class name applied to the root span.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional ref forwarded to the root span.
|
||||
*/
|
||||
ref?: Ref<HTMLSpanElement>;
|
||||
}
|
||||
|
||||
export function RedactedBodyView({ vm, className, ref }: Readonly<RedactedBodyViewProps>): JSX.Element {
|
||||
const { text, tooltip } = useViewModel(vm);
|
||||
|
||||
const content = (
|
||||
<span className={classNames(styles.content, className)} ref={ref}>
|
||||
<DeleteIcon className={styles.icon} aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <Tooltip description={tooltip}>{content}</Tooltip>;
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`RedactedBodyView > renders the default redacted body 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="content"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Message deleted
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`RedactedBodyView > renders without a tooltip when none is provided 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="content"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Message deleted
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RedactedBodyView, type RedactedBodyViewSnapshot, type RedactedBodyViewModel } from "./RedactedBodyView";
|
||||
Reference in New Issue
Block a user