Refactor and Move RedactedBodyView To Shared Components (#32772)

* refactoring and creation of shared-components for reductedBodyView

* move redacted message rendering to shared MVVM view

* Update snapshots + fix lint errors

* Remove MatrixClientPeg and use reguler react matrix client context

* Stop resyncing redacted body view models with mxEvent

* Fix redacted_because test fixtures for stricter event typing

* Simplify redacted body client access

* Watch timestamp setting in redacted body view model

* Refactor redacted and decryption failure body factories into MBodyFactory

* Prettier Fix

* Refactor FileBody into same pattern for consitancy
This commit is contained in:
Zack
2026-03-24 11:02:07 +01:00
committed by GitHub
parent d7843bb9b8
commit 4f3a1a2cc6
30 changed files with 688 additions and 159 deletions
@@ -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,