Phase 2 : Refactor TextualBody to MVVM and remove legacy component (#33165)
* Refactor TextualBody to MVVM and remove legacy component * Update snapshot + fix eslint warning * update css to fix playwright tests failure * return i18n into the MVVM * Update snapshots * Update tests to reflect the css changes * Update snapshot * Update css to correct letter-spacing * Update css to fix playwright issues. * Preserve inline emote sender rendering in TextualBodyView * Update snapshot to reflect html change * Update back to span instead of button, the default button css fails tests * Extract TextualBodyFactory from MBodyFactory * Update snapshot * Update HTML snapshot to pass tests * Update Snapshots * Added several tests for coverage * Remove double checks, merge function already checks. * Remove unessecery comment * revert to button * Update snapshots because of the revert * added Math.min() to simplify ternary expressions. * Update playwright screenshots for accessibility * Update playwright screenshots * Update css to fix playwright fail * Update screenshot + snapshots * Add comments to props
This commit is contained in:
@@ -164,7 +164,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
contentContainer = (
|
||||
<div className="mx_EventTile_content" ref={this.content}>
|
||||
*
|
||||
<span className="mx_MEmoteBody_sender">{name}</span>
|
||||
<span className="mx_EditHistoryMessage_emoteSender">{name}</span>
|
||||
{contentElements}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ import UnknownBody from "./UnknownBody";
|
||||
import { type IMediaBody } from "./IMediaBody";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import TextualBody from "./TextualBody";
|
||||
import MImageBody from "./MImageBody";
|
||||
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
||||
import MStickerBody from "./MStickerBody";
|
||||
@@ -41,6 +40,7 @@ import {
|
||||
VideoBodyFactory,
|
||||
renderMBody,
|
||||
} from "./MBodyFactory";
|
||||
import { TextualBodyFactory } from "./TextualBodyFactory";
|
||||
|
||||
// onMessageAllowed is handled internally
|
||||
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
|
||||
@@ -64,9 +64,9 @@ export interface IOperableEventTile {
|
||||
}
|
||||
|
||||
const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
||||
[MsgType.Text, TextualBody],
|
||||
[MsgType.Notice, TextualBody],
|
||||
[MsgType.Emote, TextualBody],
|
||||
[MsgType.Text, TextualBodyFactory],
|
||||
[MsgType.Notice, TextualBodyFactory],
|
||||
[MsgType.Emote, TextualBodyFactory],
|
||||
[MsgType.Image, MImageBody],
|
||||
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
||||
[MsgType.Audio, MVoiceOrAudioBody],
|
||||
@@ -329,6 +329,6 @@ const CaptionBody: React.FunctionComponent<IBodyProps & { WrappedBodyType: React
|
||||
}) => (
|
||||
<div className="mx_EventTile_content">
|
||||
<WrappedBodyType {...props} />
|
||||
<TextualBody {...{ ...props, ref: undefined }} />
|
||||
<TextualBodyFactory {...{ ...props, ref: undefined }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-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, { type JSX, createRef, type SyntheticEvent, type MouseEvent, useCallback, useEffect } from "react";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
UrlPreviewGroupView,
|
||||
type UrlPreview,
|
||||
useCreateAutoDisposedViewModel,
|
||||
EventContentBodyView,
|
||||
LINKIFIED_DATA_ATTRIBUTE,
|
||||
useViewModel,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
|
||||
import { formatDate } from "../../../DateUtils";
|
||||
import Modal from "../../../Modal";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
||||
import EditMessageComposer from "../rooms/EditMessageComposer";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { getParentEventId } from "../../../utils/Reply";
|
||||
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||
import { type IEventTileOps } from "../rooms/EventTile";
|
||||
import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel.ts";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
|
||||
import ImageView from "../elements/ImageView.tsx";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
||||
import PosthogTrackers from "../../../PosthogTrackers.ts";
|
||||
|
||||
const logger = rootLogger.getChild("TextualBody");
|
||||
|
||||
type Props = IBodyProps & { urlPreviewViewModel: UrlPreviewGroupViewModel };
|
||||
|
||||
class InnerTextualBody extends React.Component<Props> {
|
||||
private readonly contentRef = createRef<HTMLDivElement>();
|
||||
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private EventContentBodyViewModel: EventContentBodyViewModel;
|
||||
|
||||
public constructor(props: Props, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
const mxEvent = props.mxEvent;
|
||||
const content = mxEvent.getContent();
|
||||
const isEmote = content.msgtype === MsgType.Emote;
|
||||
const willHaveWrapper =
|
||||
!!props.replacingEventId || !!props.isSeeingThroughMessageHiddenForModeration || isEmote;
|
||||
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
|
||||
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
|
||||
|
||||
this.EventContentBodyViewModel = new EventContentBodyViewModel({
|
||||
as: willHaveWrapper ? "span" : "div",
|
||||
includeDir: false,
|
||||
mxEvent,
|
||||
content,
|
||||
stripReply,
|
||||
linkify: true,
|
||||
highlights: props.highlights,
|
||||
renderTooltipsForAmbiguousLinks: true,
|
||||
renderKeywordPills: true,
|
||||
renderMentionPills: true,
|
||||
renderCodeBlocks: true,
|
||||
renderSpoilers: true,
|
||||
client: context.room?.client ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
public updateURLPreviewViewModel(): void {
|
||||
const content = this.contentRef.current;
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
void this.props.urlPreviewViewModel.updateEventElement(content);
|
||||
} catch (ex) {
|
||||
logger.warn("UrlPreviewViewModel failed to updateEventElement", ex);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
|
||||
// Update the ViewModel when relevant props change
|
||||
const mxEventChanged = prevProps.mxEvent !== this.props.mxEvent;
|
||||
const highlightsChanged = prevProps.highlights !== this.props.highlights;
|
||||
const wrapperChanged =
|
||||
prevProps.replacingEventId !== this.props.replacingEventId ||
|
||||
prevProps.isSeeingThroughMessageHiddenForModeration !==
|
||||
this.props.isSeeingThroughMessageHiddenForModeration;
|
||||
|
||||
if (mxEventChanged || highlightsChanged || wrapperChanged) {
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const content = mxEvent.getContent();
|
||||
const isEmote = content.msgtype === MsgType.Emote;
|
||||
const willHaveWrapper =
|
||||
!!this.props.replacingEventId || !!this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
|
||||
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
|
||||
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
|
||||
|
||||
this.EventContentBodyViewModel.setEventContent(mxEvent, content);
|
||||
this.EventContentBodyViewModel.setStripReply(stripReply);
|
||||
|
||||
if (mxEventChanged || wrapperChanged) {
|
||||
this.EventContentBodyViewModel.setAs(willHaveWrapper ? "span" : "div");
|
||||
}
|
||||
|
||||
if (highlightsChanged) {
|
||||
this.EventContentBodyViewModel.setHighlights(this.props.highlights);
|
||||
}
|
||||
}
|
||||
this.updateURLPreviewViewModel();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.EventContentBodyViewModel.dispose();
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>): boolean {
|
||||
// exploit that events are immutable :)
|
||||
return (
|
||||
nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
|
||||
nextProps.highlights !== this.props.highlights ||
|
||||
nextProps.replacingEventId !== this.props.replacingEventId ||
|
||||
nextProps.highlightLink !== this.props.highlightLink ||
|
||||
nextProps.editState !== this.props.editState ||
|
||||
nextProps.isSeeingThroughMessageHiddenForModeration !== this.props.isSeeingThroughMessageHiddenForModeration
|
||||
);
|
||||
}
|
||||
|
||||
private onEmoteSenderClick = (): void => {
|
||||
const mxEvent = this.props.mxEvent;
|
||||
dis.dispatch({
|
||||
action: Action.ComposerInsert,
|
||||
userId: mxEvent.getSender(),
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This acts as a fallback in-app navigation handler for any body links that
|
||||
* were ignored as part of linkification because they were already links
|
||||
* to start with (e.g. pills, links in the content).
|
||||
*/
|
||||
private onBodyLinkClick = (e: MouseEvent): void => {
|
||||
let target: HTMLLinkElement | null = e.target as HTMLLinkElement;
|
||||
// links processed by linkifyjs have their own handler so don't handle those here
|
||||
if (target.dataset[LINKIFIED_DATA_ATTRIBUTE]) return;
|
||||
if (target.nodeName !== "A") {
|
||||
// Jump to parent as the `<a>` may contain children, e.g. an anchor wrapping an inline code section
|
||||
target = target.closest<HTMLLinkElement>("a");
|
||||
}
|
||||
if (!target) return;
|
||||
|
||||
const localHref = tryTransformPermalinkToLocalHref(target.href);
|
||||
if (localHref !== target.href) {
|
||||
// it could be converted to a localHref -> therefore handle locally
|
||||
e.preventDefault();
|
||||
window.location.hash = localHref;
|
||||
}
|
||||
};
|
||||
|
||||
public getEventTileOps = (): IEventTileOps => ({
|
||||
isWidgetHidden: () => {
|
||||
// This controls whether the Show preview button is visibile.
|
||||
return this.props.urlPreviewViewModel.isPreviewHiddenByUser;
|
||||
},
|
||||
|
||||
unhideWidget: () => {
|
||||
(async () => {
|
||||
try {
|
||||
await this.props.urlPreviewViewModel.onShowClick();
|
||||
} catch (ex) {
|
||||
logger.warn("UrlPreviewViewModel failed to onShowClick", ex);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
private onStarterLinkClick = (starterLink: string, ev: SyntheticEvent): void => {
|
||||
ev.preventDefault();
|
||||
// We need to add on our scalar token to the starter link, but we may not have one!
|
||||
// In addition, we can't fetch one on click and then go to it immediately as that
|
||||
// is then treated as a popup!
|
||||
// We can get around this by fetching one now and showing a "confirmation dialog" (hurr hurr)
|
||||
// which requires the user to click through and THEN we can open the link in a new tab because
|
||||
// the window.open command occurs in the same stack frame as the onClick callback.
|
||||
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
if (!managers.hasManager()) {
|
||||
managers.openNoManagerDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Go fetch a scalar token
|
||||
const integrationManager = managers.getPrimaryManager();
|
||||
const scalarClient = integrationManager?.getScalarClient();
|
||||
scalarClient?.connect().then(() => {
|
||||
const completeUrl = scalarClient.getStarterLink(starterLink);
|
||||
const integrationsUrl = integrationManager!.uiUrl;
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
title: _t("timeline|scalar_starter_link|dialog_title"),
|
||||
description: (
|
||||
<div>
|
||||
{_t("timeline|scalar_starter_link|dialog_description", { integrationsUrl: integrationsUrl })}
|
||||
</div>
|
||||
),
|
||||
button: _t("action|continue"),
|
||||
});
|
||||
|
||||
finished.then(([confirmed]) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const width = window.screen.width > 1024 ? 1024 : window.screen.width;
|
||||
const height = window.screen.height > 800 ? 800 : window.screen.height;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
const features = `height=${height}, width=${width}, top=${top}, left=${left},`;
|
||||
const wnd = window.open(completeUrl, "_blank", features)!;
|
||||
wnd.opener = null;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private openHistoryDialog = async (): Promise<void> => {
|
||||
Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
|
||||
};
|
||||
|
||||
private renderEditedMarker(): JSX.Element {
|
||||
const date = this.props.mxEvent.replacingEventDate();
|
||||
const dateString = date && formatDate(date);
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_EventTile_edited"
|
||||
onClick={this.openHistoryDialog}
|
||||
aria-label={_t("timeline|edits|tooltip_label", { date: dateString })}
|
||||
title={_t("timeline|edits|tooltip_title", { date: dateString })}
|
||||
caption={_t("timeline|edits|tooltip_sub")}
|
||||
>
|
||||
<span>{`(${_t("common|edited")})`}</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a marker informing the user that, while they can see the message,
|
||||
* it is hidden for other users.
|
||||
*/
|
||||
private renderPendingModerationMarker(): JSX.Element {
|
||||
let text;
|
||||
const visibility = this.props.mxEvent.messageVisibility();
|
||||
switch (visibility.visible) {
|
||||
case true:
|
||||
throw new Error("renderPendingModerationMarker should only be applied to hidden messages");
|
||||
case false:
|
||||
if (visibility.reason) {
|
||||
text = _t("timeline|pending_moderation_reason", { reason: visibility.reason });
|
||||
} else {
|
||||
text = _t("timeline|pending_moderation");
|
||||
}
|
||||
break;
|
||||
}
|
||||
return <span className="mx_EventTile_pendingModeration">{`(${text})`}</span>;
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.updateURLPreviewViewModel();
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (this.props.editState) {
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
return isWysiwygComposerEnabled ? (
|
||||
<EditWysiwygComposer editorStateTransfer={this.props.editState} className="mx_EventTile_content" />
|
||||
) : (
|
||||
<EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />
|
||||
);
|
||||
}
|
||||
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const content = mxEvent.getContent();
|
||||
const isNotice = content.msgtype === MsgType.Notice;
|
||||
const isEmote = content.msgtype === MsgType.Emote;
|
||||
const isCaption = [MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes(
|
||||
content.msgtype as MsgType,
|
||||
);
|
||||
const annotatedClassName = isEmote
|
||||
? "mx_EventTile_annotated mx_EventTile_annotatedInline"
|
||||
: "mx_EventTile_annotated";
|
||||
|
||||
const willHaveWrapper =
|
||||
this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
|
||||
|
||||
let body = (
|
||||
<EventContentBodyView
|
||||
vm={this.EventContentBodyViewModel}
|
||||
as={willHaveWrapper ? "span" : "div"}
|
||||
ref={this.contentRef}
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.props.replacingEventId) {
|
||||
body = (
|
||||
<div dir="auto" className={annotatedClassName}>
|
||||
{body}
|
||||
{this.renderEditedMarker()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (this.props.isSeeingThroughMessageHiddenForModeration) {
|
||||
body = (
|
||||
<div dir="auto" className={annotatedClassName}>
|
||||
{body}
|
||||
{this.renderPendingModerationMarker()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.highlightLink) {
|
||||
body = <a href={this.props.highlightLink}>{body}</a>;
|
||||
} else if (content.data && typeof content.data["org.matrix.neb.starter_link"] === "string") {
|
||||
body = (
|
||||
<AccessibleButton
|
||||
kind="link_inline"
|
||||
onClick={this.onStarterLinkClick.bind(this, content.data["org.matrix.neb.starter_link"])}
|
||||
>
|
||||
{body}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const urlPreviewWidget = <UrlPreviewGroupView vm={this.props.urlPreviewViewModel} />;
|
||||
|
||||
if (isEmote) {
|
||||
return (
|
||||
<div
|
||||
id={this.props.id}
|
||||
className="mx_MEmoteBody mx_EventTile_content"
|
||||
onClick={this.onBodyLinkClick}
|
||||
dir="auto"
|
||||
>
|
||||
*
|
||||
<span className="mx_MEmoteBody_sender" onClick={this.onEmoteSenderClick}>
|
||||
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()}
|
||||
</span>
|
||||
|
||||
{body}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isNotice) {
|
||||
return (
|
||||
<div id={this.props.id} className="mx_MNoticeBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
|
||||
{body}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isCaption) {
|
||||
return (
|
||||
<div id={this.props.id} className="mx_MTextBody mx_EventTile_caption" onClick={this.onBodyLinkClick}>
|
||||
{body}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div id={this.props.id} className="mx_MTextBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
|
||||
{body}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function TextualBody(props: IBodyProps): React.ReactElement {
|
||||
const [mediaVisible] = useMediaVisible(props.mxEvent);
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
const onUrlPreviewImageClicked = useCallback((preview: UrlPreview): void => {
|
||||
if (!preview.image?.imageFull) {
|
||||
// Should never get this far, but doesn't hurt to check.
|
||||
return;
|
||||
}
|
||||
const params = {
|
||||
src: preview.image.imageFull,
|
||||
width: preview.image.width,
|
||||
height: preview.image.height,
|
||||
name: preview.title,
|
||||
fileSize: preview.image.fileSize,
|
||||
link: preview.link,
|
||||
};
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}, []);
|
||||
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new UrlPreviewGroupViewModel({
|
||||
client,
|
||||
mxEvent: props.mxEvent,
|
||||
mediaVisible: mediaVisible,
|
||||
onImageClicked: onUrlPreviewImageClicked,
|
||||
visible: props.showUrlPreview ?? false,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await vm.updateHidden(props.showUrlPreview ?? false, mediaVisible);
|
||||
} catch (ex) {
|
||||
logger.warn("UrlPreviewViewModel failed to updateHidden", ex);
|
||||
}
|
||||
})();
|
||||
}, [vm, props.showUrlPreview, mediaVisible]);
|
||||
|
||||
const { previews } = useViewModel(vm);
|
||||
|
||||
useEffect(() => {
|
||||
if (previews.length === 0) {
|
||||
return;
|
||||
}
|
||||
PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews);
|
||||
}, [props.mxEvent, previews]);
|
||||
|
||||
return <InnerTextualBody urlPreviewViewModel={vm} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
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, useContext, useEffect, useRef } from "react";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
EventContentBodyView,
|
||||
TextualBodyView,
|
||||
type TextualBodyContentElement,
|
||||
type UrlPreview,
|
||||
UrlPreviewGroupView,
|
||||
useCreateAutoDisposedViewModel,
|
||||
useViewModel,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { TextualBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/TextualBodyViewModel";
|
||||
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
|
||||
import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel";
|
||||
import { getParentEventId } from "../../../utils/Reply";
|
||||
import Modal from "../../../Modal";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import EditMessageComposer from "../rooms/EditMessageComposer";
|
||||
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||
|
||||
const logger = rootLogger.getChild("TextualBodyFactory");
|
||||
|
||||
function getTextualBodyClassName(msgtype: MsgType | undefined): string {
|
||||
if (msgtype === MsgType.Notice) {
|
||||
return "mx_MNoticeBody mx_EventTile_content";
|
||||
}
|
||||
|
||||
if (msgtype === MsgType.Emote) {
|
||||
return "mx_MEmoteBody mx_EventTile_content";
|
||||
}
|
||||
|
||||
if ([MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes(msgtype as MsgType)) {
|
||||
return "mx_MTextBody mx_EventTile_caption";
|
||||
}
|
||||
|
||||
return "mx_MTextBody mx_EventTile_content";
|
||||
}
|
||||
|
||||
export function TextualBodyFactory(props: Readonly<IBodyProps>): JSX.Element {
|
||||
const roomContext = useContext(RoomContext);
|
||||
const client = useMatrixClientContext();
|
||||
const [mediaVisible] = useMediaVisible(props.mxEvent);
|
||||
const content = props.mxEvent.getContent();
|
||||
const isEmote = content.msgtype === MsgType.Emote;
|
||||
const willHaveWrapper = !!props.replacingEventId || !!props.isSeeingThroughMessageHiddenForModeration || isEmote;
|
||||
const stripReply = !props.mxEvent.replacingEvent() && !!getParentEventId(props.mxEvent);
|
||||
const contentRef = useRef<TextualBodyContentElement>(null);
|
||||
|
||||
const textualBodyVm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new TextualBodyViewModel({
|
||||
id: props.id,
|
||||
mxEvent: props.mxEvent,
|
||||
highlightLink: props.highlightLink,
|
||||
replacingEventId: props.replacingEventId,
|
||||
isSeeingThroughMessageHiddenForModeration: props.isSeeingThroughMessageHiddenForModeration,
|
||||
timelineRenderingType: roomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
|
||||
const eventContentBodyVm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new EventContentBodyViewModel({
|
||||
as: willHaveWrapper ? "span" : "div",
|
||||
includeDir: false,
|
||||
mxEvent: props.mxEvent,
|
||||
content,
|
||||
stripReply,
|
||||
linkify: true,
|
||||
highlights: props.highlights,
|
||||
renderTooltipsForAmbiguousLinks: true,
|
||||
renderKeywordPills: true,
|
||||
renderMentionPills: true,
|
||||
renderCodeBlocks: true,
|
||||
renderSpoilers: true,
|
||||
client: roomContext.room?.client ?? client ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
const urlPreviewVm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new UrlPreviewGroupViewModel({
|
||||
client,
|
||||
mxEvent: props.mxEvent,
|
||||
mediaVisible,
|
||||
onImageClicked: (preview: UrlPreview): void => {
|
||||
if (!preview.image?.imageFull) {
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.createDialog(
|
||||
ImageView,
|
||||
{
|
||||
src: preview.image.imageFull,
|
||||
width: preview.image.width,
|
||||
height: preview.image.height,
|
||||
name: preview.title,
|
||||
fileSize: preview.image.fileSize,
|
||||
link: preview.link,
|
||||
},
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
},
|
||||
visible: props.showUrlPreview ?? false,
|
||||
}),
|
||||
);
|
||||
|
||||
const { previews } = useViewModel(urlPreviewVm);
|
||||
|
||||
useEffect(() => {
|
||||
textualBodyVm.setId(props.id);
|
||||
}, [props.id, textualBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
textualBodyVm.setEvent(props.mxEvent);
|
||||
}, [props.mxEvent, textualBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
textualBodyVm.setHighlightLink(props.highlightLink);
|
||||
}, [props.highlightLink, textualBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
textualBodyVm.setReplacingEventId(props.replacingEventId);
|
||||
}, [props.replacingEventId, textualBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
textualBodyVm.setIsSeeingThroughMessageHiddenForModeration(props.isSeeingThroughMessageHiddenForModeration);
|
||||
}, [props.isSeeingThroughMessageHiddenForModeration, textualBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
textualBodyVm.setTimelineRenderingType(roomContext.timelineRenderingType);
|
||||
}, [roomContext.timelineRenderingType, textualBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
eventContentBodyVm.setEventContent(props.mxEvent, content);
|
||||
}, [content, props.mxEvent, eventContentBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
eventContentBodyVm.setStripReply(stripReply);
|
||||
}, [stripReply, eventContentBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
eventContentBodyVm.setAs(willHaveWrapper ? "span" : "div");
|
||||
}, [willHaveWrapper, eventContentBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
eventContentBodyVm.setHighlights(props.highlights);
|
||||
}, [props.highlights, eventContentBodyVm]);
|
||||
|
||||
useEffect(() => {
|
||||
const eventElement = contentRef.current;
|
||||
if (!eventElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
void urlPreviewVm.updateEventElement(eventElement).catch((error) => {
|
||||
logger.warn("UrlPreviewViewModel failed to updateEventElement", error);
|
||||
});
|
||||
}, [
|
||||
props.mxEvent,
|
||||
props.highlights,
|
||||
props.replacingEventId,
|
||||
props.isSeeingThroughMessageHiddenForModeration,
|
||||
urlPreviewVm,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
void urlPreviewVm.updateHidden(props.showUrlPreview ?? false, mediaVisible).catch((error) => {
|
||||
logger.warn("UrlPreviewViewModel failed to updateHidden", error);
|
||||
});
|
||||
}, [props.showUrlPreview, mediaVisible, urlPreviewVm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previews.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews);
|
||||
}, [props.mxEvent, previews]);
|
||||
|
||||
if (props.editState) {
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
|
||||
return isWysiwygComposerEnabled ? (
|
||||
<EditWysiwygComposer editorStateTransfer={props.editState} className="mx_EventTile_content" />
|
||||
) : (
|
||||
<EditMessageComposer editState={props.editState} className="mx_EventTile_content" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextualBodyView
|
||||
vm={textualBodyVm}
|
||||
body={<EventContentBodyView vm={eventContentBodyVm} as={willHaveWrapper ? "span" : "div"} />}
|
||||
bodyRef={contentRef}
|
||||
urlPreviews={<UrlPreviewGroupView vm={urlPreviewVm} />}
|
||||
className={getTextualBodyClassName(content.msgtype as MsgType | undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user