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:
Zack 2026-04-28 09:07:19 +02:00 committed by GitHub
parent 7766ae92d7
commit 4e9655dc6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1061 additions and 555 deletions

View File

@ -50,7 +50,7 @@ test.describe("Editing", () => {
const eventTile = page.locator(".mx_EventTile", { hasText: edited });
await expect(eventTile).toBeVisible();
// Click to display the message edit history dialog
await eventTile.getByText("(edited)").click();
await eventTile.getByRole("button", { name: /Edited at .*? Click to view edits\./ }).click();
};
const clickButtonViewSource = async (locator: Locator) => {
@ -89,7 +89,7 @@ test.describe("Editing", () => {
await editLastMessage(page, "Massage");
// Assert that the edit label is visible
await expect(page.locator(".mx_EventTile_edited")).toBeVisible();
await expect(page.getByRole("button", { name: /Edited at .*? Click to view edits\./ })).toBeVisible();
await clickEditedMessage(page, "Massage");
@ -213,7 +213,7 @@ test.describe("Editing", () => {
await editLastMessage(page, "Massage");
// Assert that the edit label is visible
await expect(page.locator(".mx_EventTile_edited")).toBeVisible();
await expect(page.getByRole("button", { name: /Edited at .*? Click to view edits\./ })).toBeVisible();
await clickEditedMessage(page, "Massage");
@ -369,6 +369,6 @@ test.describe("Editing", () => {
// nevertheless, the event should be updated
await expect(messageTile.locator(".mx_EventTile_body")).toHaveText("Edited body");
await expect(messageTile.locator(".mx_EventTile_edited")).toBeVisible();
await expect(messageTile.getByRole("button", { name: /Edited at .*? Click to view edits\./ })).toBeVisible();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -225,16 +225,13 @@
@import "./views/messages/_HiddenBody.pcss";
@import "./views/messages/_HiddenMediaPlaceholder.pcss";
@import "./views/messages/_LegacyCallEvent.pcss";
@import "./views/messages/_MEmoteBody.pcss";
@import "./views/messages/_MFileBody.pcss";
@import "./views/messages/_MImageBody.pcss";
@import "./views/messages/_MImageReplyBody.pcss";
@import "./views/messages/_MJitsiWidgetEvent.pcss";
@import "./views/messages/_MLocationBody.pcss";
@import "./views/messages/_MNoticeBody.pcss";
@import "./views/messages/_MPollBody.pcss";
@import "./views/messages/_MStickerBody.pcss";
@import "./views/messages/_MTextBody.pcss";
@import "./views/messages/_MediaBody.pcss";
@import "./views/messages/_MessageActionBar.pcss";
@import "./views/messages/_MjolnirBody.pcss";

View File

@ -27,6 +27,10 @@ Please see LICENSE files in the repository root for full details.
padding: 0;
color: $primary-content;
.mx_EditHistoryMessage_emoteSender {
cursor: pointer;
}
span.mx_EditHistoryMessage_deletion,
span.mx_EditHistoryMessage_insertion {
padding: 0px 2px;

View File

@ -1,16 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector 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.
*/
.mx_MEmoteBody {
white-space: pre-wrap;
text-align: start;
}
.mx_MEmoteBody_sender {
cursor: pointer;
}

View File

@ -1,12 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket 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.
*/
.mx_MNoticeBody {
white-space: pre-wrap;
color: $secondary-content;
}

View File

@ -1,11 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket 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.
*/
.mx_MTextBody {
white-space: pre-wrap;
}

View File

@ -636,19 +636,6 @@ $left-gutter: 64px;
overflow-x: hidden;
margin-right: var(--EventTile_content-margin-inline-end);
.mx_EventTile_edited,
.mx_EventTile_pendingModeration {
user-select: none;
font-size: $font-12px;
color: $secondary-content;
display: inline-block;
margin-inline-start: 9px;
}
.mx_EventTile_edited {
cursor: pointer;
}
.markdown-body {
font: var(--cpd-font-body-md-regular) !important;
letter-spacing: var(--cpd-font-letter-spacing-body-md);
@ -1374,14 +1361,6 @@ $left-gutter: 64px;
}
}
.mx_EventTile_annotated {
display: flex;
}
.mx_EventTile_annotatedInline {
display: inline-flex;
}
.mx_EventTile_footer {
display: flex;
gap: var(--cpd-space-2x);

View File

@ -68,7 +68,7 @@ Please see LICENSE files in the repository root for full details.
// Hide line numbers and edited indicator
.mx_EventTile_lineNumbers,
.mx_EventTile_edited {
[data-textual-body-edited-marker] {
display: none;
}

View File

@ -164,7 +164,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
contentContainer = (
<div className="mx_EventTile_content" ref={this.content}>
*&nbsp;
<span className="mx_MEmoteBody_sender">{name}</span>
<span className="mx_EditHistoryMessage_emoteSender">{name}</span>
&nbsp;{contentElements}
</div>
);

View File

@ -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>
);

View File

@ -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"
>
*&nbsp;
<span className="mx_MEmoteBody_sender" onClick={this.onEmoteSenderClick}>
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()}
</span>
&nbsp;
{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} />;
}

View File

@ -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)}
/>
);
}

View File

@ -424,7 +424,7 @@ export class UrlPreviewGroupViewModel
* Trigger a recalculation of the links in an event.
* @param eventElement
*/
public async updateEventElement(eventElement: HTMLDivElement): Promise<void> {
public async updateEventElement(eventElement: HTMLDivElement | HTMLSpanElement): Promise<void> {
const newLinks = UrlPreviewGroupViewModel.findLinks([eventElement]);
// Only recalculate if the set of links has changed.
if (newLinks.some((x) => !this.links.includes(x)) || this.links.some((x) => !newLinks.includes(x))) {

View File

@ -0,0 +1,330 @@
/*
* 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 MouseEvent } from "react";
import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
LINKIFIED_DATA_ATTRIBUTE,
TextualBodyViewBodyWrapperKind,
TextualBodyViewKind,
type TextualBodyViewModel as TextualBodyViewModelInterface,
type TextualBodyViewSnapshot,
} from "@element-hq/web-shared-components";
import { formatDate } from "../../../../../DateUtils";
import Modal from "../../../../../Modal";
import dis from "../../../../../dispatcher/dispatcher";
import { _t } from "../../../../../languageHandler";
import { IntegrationManagers } from "../../../../../integrations/IntegrationManagers";
import { tryTransformPermalinkToLocalHref } from "../../../../../utils/permalinks/Permalinks";
import { Action } from "../../../../../dispatcher/actions";
import QuestionDialog from "../../../../../components/views/dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../../../../../components/views/dialogs/MessageEditHistoryDialog";
import { type TimelineRenderingType } from "../../../../../contexts/RoomContext";
const CAPTION_MESSAGE_TYPES = new Set<MsgType>([MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video]);
export interface TextualBodyViewModelProps {
/**
* Optional DOM id forwarded to the root textual body element.
*/
id?: string;
/**
* Matrix event used to derive the body kind, sender label, starter link, edit state, and moderation state.
*/
mxEvent: MatrixEvent;
/**
* Optional URL that wraps the body when the message should behave as a highlighted link target.
*/
highlightLink?: string;
/**
* Event id of the replacement event, when this message has been edited.
*/
replacingEventId?: string;
/**
* Whether the user is viewing a hidden message while moderation is pending.
*/
isSeeingThroughMessageHiddenForModeration?: boolean;
/**
* Timeline context used when dispatching actions from textual body interactions.
*/
timelineRenderingType: TimelineRenderingType;
}
export class TextualBodyViewModel
extends BaseViewModel<TextualBodyViewSnapshot, TextualBodyViewModelProps>
implements TextualBodyViewModelInterface
{
private static readonly getKind = (mxEvent: MatrixEvent): TextualBodyViewKind => {
const msgtype = mxEvent.getContent().msgtype as MsgType | undefined;
if (msgtype === MsgType.Notice) {
return TextualBodyViewKind.NOTICE;
}
if (msgtype === MsgType.Emote) {
return TextualBodyViewKind.EMOTE;
}
if (msgtype && CAPTION_MESSAGE_TYPES.has(msgtype)) {
return TextualBodyViewKind.CAPTION;
}
return TextualBodyViewKind.TEXT;
};
private static readonly getStarterLink = (mxEvent: MatrixEvent): string | undefined => {
const starterLink = mxEvent.getContent().data?.["org.matrix.neb.starter_link"];
return typeof starterLink === "string" ? starterLink : undefined;
};
private static readonly computeBodyWrapperSnapshot = (
props: TextualBodyViewModelProps,
): Pick<TextualBodyViewSnapshot, "bodyWrapper" | "bodyLinkHref" | "bodyActionAriaLabel"> => {
if (props.highlightLink) {
return {
bodyWrapper: TextualBodyViewBodyWrapperKind.LINK,
bodyLinkHref: props.highlightLink,
bodyActionAriaLabel: undefined,
};
}
if (TextualBodyViewModel.getStarterLink(props.mxEvent)) {
return {
bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION,
bodyLinkHref: undefined,
bodyActionAriaLabel: undefined,
};
}
return {
bodyWrapper: TextualBodyViewBodyWrapperKind.NONE,
bodyLinkHref: undefined,
bodyActionAriaLabel: undefined,
};
};
private static readonly computeEditedMarkerSnapshot = (
props: TextualBodyViewModelProps,
): Pick<
TextualBodyViewSnapshot,
| "showEditedMarker"
| "editedMarkerText"
| "editedMarkerAriaLabel"
| "editedMarkerTooltip"
| "editedMarkerCaption"
> => {
if (!props.replacingEventId) {
return {
showEditedMarker: false,
editedMarkerText: undefined,
editedMarkerAriaLabel: undefined,
editedMarkerTooltip: undefined,
editedMarkerCaption: undefined,
};
}
const replacingDate = props.mxEvent.replacingEventDate();
const date = replacingDate ? formatDate(replacingDate) : undefined;
return {
showEditedMarker: true,
editedMarkerText: `(${_t("common|edited")})`,
editedMarkerAriaLabel: _t("timeline|edits|tooltip_label", { date }),
editedMarkerTooltip: _t("timeline|edits|tooltip_title", { date }),
editedMarkerCaption: _t("timeline|edits|tooltip_sub"),
};
};
private static readonly computePendingModerationSnapshot = (
props: TextualBodyViewModelProps,
): Pick<TextualBodyViewSnapshot, "showPendingModerationMarker" | "pendingModerationText"> => {
if (!props.isSeeingThroughMessageHiddenForModeration) {
return {
showPendingModerationMarker: false,
pendingModerationText: undefined,
};
}
const visibility = props.mxEvent.messageVisibility();
if (visibility.visible) {
throw new Error("TextualBodyViewModel should only render pending moderation for hidden messages");
}
const text = visibility.reason
? _t("timeline|pending_moderation_reason", { reason: visibility.reason })
: _t("timeline|pending_moderation");
return {
showPendingModerationMarker: true,
pendingModerationText: `(${text})`,
};
};
private static readonly computeEventSnapshot = (
props: TextualBodyViewModelProps,
): Pick<
TextualBodyViewSnapshot,
| "kind"
| "bodyWrapper"
| "bodyLinkHref"
| "bodyActionAriaLabel"
| "showEditedMarker"
| "editedMarkerText"
| "editedMarkerTooltip"
| "editedMarkerCaption"
| "showPendingModerationMarker"
| "pendingModerationText"
| "emoteSenderName"
> => ({
kind: TextualBodyViewModel.getKind(props.mxEvent),
emoteSenderName: props.mxEvent.sender?.name ?? props.mxEvent.getSender(),
...TextualBodyViewModel.computeBodyWrapperSnapshot(props),
...TextualBodyViewModel.computeEditedMarkerSnapshot(props),
...TextualBodyViewModel.computePendingModerationSnapshot(props),
});
private static readonly computeSnapshot = (props: TextualBodyViewModelProps): TextualBodyViewSnapshot => ({
id: props.id,
...TextualBodyViewModel.computeEventSnapshot(props),
});
public constructor(props: TextualBodyViewModelProps) {
super(props, TextualBodyViewModel.computeSnapshot(props));
}
public setId(id: string | undefined): void {
this.props = {
...this.props,
id,
};
this.snapshot.merge({ id });
}
public setEvent(mxEvent: MatrixEvent): void {
this.props = {
...this.props,
mxEvent,
};
this.snapshot.merge(TextualBodyViewModel.computeEventSnapshot(this.props));
}
public setHighlightLink(highlightLink: string | undefined): void {
this.props = {
...this.props,
highlightLink,
};
this.snapshot.merge(TextualBodyViewModel.computeBodyWrapperSnapshot(this.props));
}
public setReplacingEventId(replacingEventId: string | undefined): void {
this.props = {
...this.props,
replacingEventId,
};
this.snapshot.merge(TextualBodyViewModel.computeEditedMarkerSnapshot(this.props));
}
public setIsSeeingThroughMessageHiddenForModeration(
isSeeingThroughMessageHiddenForModeration: boolean | undefined,
): void {
this.props = {
...this.props,
isSeeingThroughMessageHiddenForModeration,
};
this.snapshot.merge(TextualBodyViewModel.computePendingModerationSnapshot(this.props));
}
public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void {
this.props = {
...this.props,
timelineRenderingType,
};
}
public onRootClick = (event: MouseEvent<HTMLDivElement>): void => {
let target: HTMLLinkElement | null = event.target as HTMLLinkElement;
if (target.dataset?.[LINKIFIED_DATA_ATTRIBUTE]) {
return;
}
if (target.nodeName !== "A") {
target = target.closest<HTMLLinkElement>("a");
}
if (!target) {
return;
}
const localHref = tryTransformPermalinkToLocalHref(target.href);
if (localHref !== target.href) {
event.preventDefault();
window.location.hash = localHref;
}
};
public onBodyActionClick = (event: MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
const starterLink = TextualBodyViewModel.getStarterLink(this.props.mxEvent);
if (!starterLink) {
return;
}
const managers = IntegrationManagers.sharedInstance();
if (!managers.hasManager()) {
managers.openNoManagerDialog();
return;
}
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 })}</div>,
button: _t("action|continue"),
});
finished.then(([confirmed]) => {
if (!confirmed) {
return;
}
const width = Math.min(window.screen.width, 1024);
const height = Math.min(window.screen.height, 800);
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;
});
});
};
public onEditedMarkerClick = (): void => {
Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
};
public onEmoteSenderClick = (): void => {
dis.dispatch({
action: Action.ComposerInsert,
userId: this.props.mxEvent.getSender(),
timelineRenderingType: this.props.timelineRenderingType,
});
};
}

View File

@ -44,7 +44,7 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines
</div>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body translate"

View File

@ -38,6 +38,11 @@ jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
renderMBody: () => <div data-testid="file-body" />,
}));
jest.mock("../../../../../src/components/views/messages/TextualBodyFactory", () => ({
__esModule: true,
TextualBodyFactory: () => <div data-testid="textual-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () => ({
__esModule: true,
default: () => <div data-testid="image-reply-body" />,
@ -53,11 +58,6 @@ jest.mock("../../../../../src/components/views/messages/MStickerBody", () => ({
default: () => <div data-testid="sticker-body" />,
}));
jest.mock("../../../../../src/components/views/messages/TextualBody.tsx", () => ({
__esModule: true,
default: () => <div data-testid="textual-body" />,
}));
describe("MessageEvent", () => {
let room: Room;
let client: MatrixClient;

View File

@ -21,13 +21,18 @@ import {
} from "../../../../test-utils";
import * as languageHandler from "../../../../../src/languageHandler";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import TextualBody from "../../../../../src/components/views/messages/TextualBody";
import { TextualBodyFactory as TextualBody } from "../../../../../src/components/views/messages/TextualBodyFactory";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../../../src/contexts/RoomContext";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import { getRoomContext } from "../../../../test-utils/room";
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
__esModule: true,
useMediaVisible: () => [true, jest.fn()],
}));
const room1Id = "!room1:example.com";
const room2Id = "!room2:example.com";
const room2Name = "Room 2";
@ -104,7 +109,6 @@ describe("<TextualBody />", () => {
defaultMatrixClient.pushProcessor = new PushProcessor(defaultMatrixClient);
defaultRoom = mkStubRoom(room1Id, "test room", defaultMatrixClient);
defaultProps.permalinkCreator = new RoomPermalinkCreator(defaultRoom);
otherRoom = mkStubRoom(room2Id, room2Name, defaultMatrixClient);
mocked(defaultRoom).findEventById.mockImplementation((eventId: string) => {
@ -117,10 +121,14 @@ describe("<TextualBody />", () => {
const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) => {
const mergedProps = { ...defaultProps, ...props };
const room = matrixClient.getRoom(mergedProps.mxEvent.getRoomId()) ?? defaultRoom;
const finalProps = {
...mergedProps,
permalinkCreator: mergedProps.permalinkCreator ?? new RoomPermalinkCreator(room),
};
return (renderingFn ?? render)(
<MatrixClientContext.Provider value={matrixClient}>
<RoomContext.Provider value={getRoomContext(room, {})}>
<TextualBody {...mergedProps} />
<TextualBody {...finalProps} />
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
@ -163,9 +171,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev, replacingEventId: ev.getId() });
const annotated = container.querySelector(".mx_MEmoteBody > .mx_EventTile_annotatedInline");
expect(annotated).not.toBeNull();
expect(annotated?.tagName).toBe("DIV");
expect(container).toHaveTextContent("* sender winks(edited)");
});
it("renders m.notice correctly", () => {

View File

@ -130,7 +130,7 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for an
exports[`<TextualBody /> renders formatted m.text correctly pills appear for event permalinks without a custom label 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body markdown-body translate"
@ -177,7 +177,7 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for eve
exports[`<TextualBody /> renders formatted m.text correctly pills appear for room links with vias 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body markdown-body translate"
@ -225,7 +225,7 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for roo
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear for event permalinks with a custom label 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body markdown-body translate"

View File

@ -198,7 +198,7 @@ exports[`<PinnedMessagesCard /> should show two pinned messages 1`] = `
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
id="_r_r_"
>
<div
@ -279,7 +279,7 @@ exports[`<PinnedMessagesCard /> should show two pinned messages 1`] = `
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
id="_r_13_"
>
<div
@ -426,7 +426,7 @@ exports[`<PinnedMessagesCard /> unpin all should not allow to unpinall 1`] = `
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
id="_r_10l_"
>
<div
@ -507,7 +507,7 @@ exports[`<PinnedMessagesCard /> unpin all should not allow to unpinall 1`] = `
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
id="_r_10t_"
>
<div

View File

@ -64,7 +64,7 @@ exports[`<PinnedEventTile /> should render pinned event 1`] = `
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
id="_r_0_"
>
<div
@ -143,7 +143,7 @@ exports[`<PinnedEventTile /> should render pinned event with thread info 1`] = `
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
id="_r_9_"
>
<div

View File

@ -102,7 +102,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
id="1"
>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body translate"
@ -196,7 +196,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
id="2"
>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body translate"
@ -293,7 +293,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
id="3"
>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body translate"

View File

@ -245,7 +245,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
id="1"
>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body translate"
@ -339,7 +339,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
id="2"
>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body translate"
@ -436,7 +436,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
id="3"
>
<div
class="mx_MTextBody mx_EventTile_content"
class="mx_MTextBody mx_EventTile_content _root_1hgc7_8 _text_1hgc7_13"
>
<div
class="mx_EventTile_body translate"

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,436 @@
/*
* 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 {
LINKIFIED_DATA_ATTRIBUTE,
TextualBodyViewBodyWrapperKind,
TextualBodyViewKind,
} from "@element-hq/web-shared-components";
import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { flushPromises, mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { TextualBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel";
import Modal from "../../../src/Modal";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { IntegrationManagers } from "../../../src/integrations/IntegrationManagers";
import * as permalinkUtils from "../../../src/utils/permalinks/Permalinks";
import QuestionDialog from "../../../src/components/views/dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../../../src/components/views/dialogs/MessageEditHistoryDialog";
describe("TextualBodyViewModel", () => {
const createEvent = (
content: Record<string, unknown>,
overrides?: Partial<{ room: string; user: string }>,
): MatrixEvent =>
mkEvent({
event: true,
type: "m.room.message",
room: overrides?.room ?? "!room:example.com",
user: overrides?.user ?? "@alice:example.com",
content,
});
const createVm = (
overrides?: Partial<ConstructorParameters<typeof TextualBodyViewModel>[0]>,
): TextualBodyViewModel =>
new TextualBodyViewModel({
mxEvent: createEvent({
body: "Hello world",
msgtype: MsgType.Text,
}),
timelineRenderingType: TimelineRenderingType.Room,
...overrides,
});
afterEach(() => {
jest.restoreAllMocks();
window.location.hash = "";
});
it("computes the initial snapshot from props", () => {
const event = createEvent({
body: "Caption",
msgtype: MsgType.Image,
});
jest.spyOn(event, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
jest.spyOn(event, "messageVisibility").mockReturnValue({
visible: false,
reason: "copyright",
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({
id: "event-id",
mxEvent: event,
highlightLink: "https://example.com",
replacingEventId: "$replacement",
isSeeingThroughMessageHiddenForModeration: true,
});
const snapshot = vm.getSnapshot();
expect(snapshot.id).toBe("event-id");
expect(snapshot.kind).toBe(TextualBodyViewKind.CAPTION);
expect(snapshot.bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.LINK);
expect(snapshot.bodyLinkHref).toBe("https://example.com");
expect(snapshot.showEditedMarker).toBe(true);
expect(snapshot.editedMarkerText).toContain("edited");
expect(snapshot.showPendingModerationMarker).toBe(true);
expect(snapshot.pendingModerationText).toContain("copyright");
});
it("updates message-derived fields when the event changes", () => {
const vm = createVm();
const emoteEvent = createEvent(
{
body: "waves",
msgtype: MsgType.Emote,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
},
{ user: "@bob:example.com" },
);
emoteEvent.sender = { name: "Bob" } as MatrixEvent["sender"];
vm.setEvent(emoteEvent);
expect(vm.getSnapshot().kind).toBe(TextualBodyViewKind.EMOTE);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
expect(vm.getSnapshot().emoteSenderName).toBe("Bob");
});
it("updates wrapper state when the highlight link changes", () => {
const starterLinkEvent = createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
});
const vm = createVm({ mxEvent: starterLinkEvent });
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
vm.setHighlightLink("https://element.io");
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.LINK);
expect(vm.getSnapshot().bodyLinkHref).toBe("https://element.io");
vm.setHighlightLink(undefined);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
});
it("uses the notice kind and no action wrapper for non-string starter links", () => {
const noticeEvent = createEvent({
body: "Notice",
msgtype: MsgType.Notice,
data: {
"org.matrix.neb.starter_link": 42,
},
});
const vm = createVm({ mxEvent: noticeEvent });
expect(vm.getSnapshot().kind).toBe(TextualBodyViewKind.NOTICE);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.NONE);
expect(vm.getSnapshot().bodyActionAriaLabel).toBeUndefined();
});
it("updates the moderation marker from the dedicated setter", () => {
const hiddenEvent = createEvent({
body: "hidden",
msgtype: MsgType.Text,
});
jest.spyOn(hiddenEvent, "messageVisibility").mockReturnValue({
visible: false,
reason: "spam",
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({ mxEvent: hiddenEvent });
vm.setIsSeeingThroughMessageHiddenForModeration(true);
expect(vm.getSnapshot().showPendingModerationMarker).toBe(true);
expect(vm.getSnapshot().pendingModerationText).toContain("spam");
});
it("updates id and edited marker from dedicated setters", () => {
const event = createEvent({
body: "edited",
msgtype: MsgType.Text,
});
jest.spyOn(event, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
const vm = createVm({ mxEvent: event });
vm.setId("updated-id");
vm.setReplacingEventId("$edit");
expect(vm.getSnapshot().id).toBe("updated-id");
expect(vm.getSnapshot().showEditedMarker).toBe(true);
expect(vm.getSnapshot().editedMarkerText).toContain("edited");
vm.setReplacingEventId(undefined);
expect(vm.getSnapshot().showEditedMarker).toBe(false);
expect(vm.getSnapshot().editedMarkerTooltip).toBeUndefined();
});
it("renders the generic pending moderation text when there is no reason", () => {
const hiddenEvent = createEvent({
body: "hidden",
msgtype: MsgType.Text,
});
jest.spyOn(hiddenEvent, "messageVisibility").mockReturnValue({
visible: false,
reason: null,
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({ mxEvent: hiddenEvent });
vm.setIsSeeingThroughMessageHiddenForModeration(true);
expect(vm.getSnapshot().showPendingModerationMarker).toBe(true);
expect(vm.getSnapshot().pendingModerationText).toMatch(/^\(.+\)$/);
expect(vm.getSnapshot().pendingModerationText).not.toContain("undefined");
});
it("throws when pending moderation is requested for a visible message", () => {
const visibleEvent = createEvent({
body: "visible",
msgtype: MsgType.Text,
});
jest.spyOn(visibleEvent, "messageVisibility").mockReturnValue({
visible: true,
} as ReturnType<MatrixEvent["messageVisibility"]>);
expect(() =>
createVm({
mxEvent: visibleEvent,
isSeeingThroughMessageHiddenForModeration: true,
}),
).toThrow("TextualBodyViewModel should only render pending moderation for hidden messages");
});
it("ignores linkified root clicks", () => {
const vm = createVm();
const preventDefault = jest.fn();
const transformSpy = jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref");
vm.onRootClick({
preventDefault,
target: {
dataset: {
[LINKIFIED_DATA_ATTRIBUTE]: "true",
},
href: "https://example.org",
nodeName: "A",
},
} as any);
expect(transformSpy).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("rewrites permalink clicks to local hashes", () => {
const vm = createVm();
const preventDefault = jest.fn();
const anchor = { href: "https://element.example/#/room/!room:example.org", nodeName: "A" };
jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref").mockReturnValue("#/room/!room:example.org");
vm.onRootClick({
preventDefault,
target: {
nodeName: "SPAN",
closest: jest.fn().mockReturnValue(anchor),
},
} as any);
expect(preventDefault).toHaveBeenCalled();
expect(window.location.hash).toBe("#/room/!room:example.org");
});
it("leaves external root clicks alone when no local permalink is found", () => {
const vm = createVm();
const preventDefault = jest.fn();
const href = "https://example.org";
jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref").mockReturnValue(href);
vm.onRootClick({
preventDefault,
target: {
href,
nodeName: "A",
},
} as any);
expect(preventDefault).not.toHaveBeenCalled();
expect(window.location.hash).toBe("");
});
it("does nothing for body actions without a starter link", () => {
const vm = createVm();
const preventDefault = jest.fn();
const hasManagerSpy = jest.spyOn(IntegrationManagers.sharedInstance(), "hasManager");
vm.onBodyActionClick({ preventDefault } as any);
expect(preventDefault).toHaveBeenCalled();
expect(hasManagerSpy).not.toHaveBeenCalled();
});
it("opens the no-manager dialog for starter links when integrations are unavailable", () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const preventDefault = jest.fn();
const managers = IntegrationManagers.sharedInstance();
jest.spyOn(managers, "hasManager").mockReturnValue(false);
const openNoManagerDialogSpy = jest.spyOn(managers, "openNoManagerDialog").mockImplementation(() => {});
vm.onBodyActionClick({ preventDefault } as any);
expect(preventDefault).toHaveBeenCalled();
expect(openNoManagerDialogSpy).toHaveBeenCalled();
});
it("opens the scalar starter link after confirmation", async () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const preventDefault = jest.fn();
const managers = IntegrationManagers.sharedInstance();
const connect = jest.fn().mockResolvedValue(undefined);
const getStarterLink = jest.fn().mockReturnValue("https://scalar.example/complete");
const scalarClient = { connect, getStarterLink };
const integrationManager = {
getScalarClient: jest.fn().mockReturnValue(scalarClient),
uiUrl: "https://scalar.example/ui",
};
const popup = { opener: "initial" };
jest.spyOn(managers, "hasManager").mockReturnValue(true);
jest.spyOn(managers, "getPrimaryManager").mockReturnValue(integrationManager as any);
jest.spyOn(Modal, "createDialog").mockReturnValue({
close: jest.fn(),
finished: Promise.resolve([true]),
} as any);
const openSpy = jest.spyOn(window, "open").mockImplementation(() => popup as any);
vm.onBodyActionClick({ preventDefault } as any);
await flushPromises();
expect(connect).toHaveBeenCalled();
expect(Modal.createDialog).toHaveBeenCalledWith(
QuestionDialog,
expect.objectContaining({
button: expect.any(String),
title: expect.any(String),
}),
);
expect(openSpy).toHaveBeenCalledWith(
"https://scalar.example/complete",
"_blank",
expect.stringContaining("width="),
);
expect(popup.opener).toBeNull();
});
it("does not open the scalar starter link when the dialog is cancelled", async () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const managers = IntegrationManagers.sharedInstance();
const scalarClient = {
connect: jest.fn().mockResolvedValue(undefined),
getStarterLink: jest.fn().mockReturnValue("https://scalar.example/complete"),
};
jest.spyOn(managers, "hasManager").mockReturnValue(true);
jest.spyOn(managers, "getPrimaryManager").mockReturnValue({
getScalarClient: jest.fn().mockReturnValue(scalarClient),
uiUrl: "https://scalar.example/ui",
} as any);
jest.spyOn(Modal, "createDialog").mockReturnValue({
close: jest.fn(),
finished: Promise.resolve([false]),
} as any);
const openSpy = jest.spyOn(window, "open").mockImplementation(() => ({ opener: "initial" }) as any);
vm.onBodyActionClick({ preventDefault: jest.fn() } as any);
await flushPromises();
expect(openSpy).not.toHaveBeenCalled();
});
it("opens the edit history dialog from the edited marker", () => {
const event = createEvent({
body: "edited",
msgtype: MsgType.Text,
});
const vm = createVm({ mxEvent: event });
const createDialogSpy = jest
.spyOn(Modal, "createDialog")
.mockImplementation(() => ({ close: jest.fn() }) as any);
vm.onEditedMarkerClick();
expect(createDialogSpy).toHaveBeenCalledWith(MessageEditHistoryDialog, { mxEvent: event });
});
it("dispatches composer insert for the emote sender using the current rendering type", () => {
const event = createEvent({
body: "waves",
msgtype: MsgType.Emote,
});
const vm = createVm({ mxEvent: event });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(() => {});
vm.setTimelineRenderingType(TimelineRenderingType.Thread);
vm.onEmoteSenderClick();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ComposerInsert,
timelineRenderingType: TimelineRenderingType.Thread,
userId: event.getSender(),
});
});
it("does not emit for unchanged setter values", () => {
const mxEvent = createEvent({
body: "Hello world",
msgtype: MsgType.Text,
});
const vm = createVm({ mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setId(undefined);
vm.setEvent(mxEvent);
vm.setHighlightLink(undefined);
vm.setReplacingEventId(undefined);
vm.setIsSeeingThroughMessageHiddenForModeration(undefined);
vm.setTimelineRenderingType(TimelineRenderingType.Room);
expect(listener).not.toHaveBeenCalled();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -37,7 +37,7 @@
user-select: none;
display: inline-block;
margin-inline-start: 9px; /* Preserve legacy EventTile spacing for inline annotations like (edited) */
font: var(--cpd-font-body-xs-regular);
font-size: 12px; /* Match the legacy EventTile edited-marker size */
color: var(--cpd-color-text-secondary);
}
@ -45,8 +45,12 @@
appearance: none;
background: none;
border: none;
height: max-content;
padding: 0;
cursor: pointer;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.bodyLink,
@ -66,10 +70,19 @@
}
.emoteSender {
all: unset;
font: inherit;
appearance: none;
background: none;
border: none;
padding: 0;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-style: inherit;
font-weight: inherit;
letter-spacing: inherit;
line-height: inherit;
text-align: inherit;
}
.editedMarker:focus-visible,

View File

@ -74,7 +74,7 @@ describe("TextualBodyView", () => {
implements TextualBodyViewActions
{
public onEditedMarkerClick?: MouseEventHandler<HTMLButtonElement>;
public onBodyActionClick?: MouseEventHandler<HTMLElement>;
public onBodyActionClick?: MouseEventHandler<HTMLButtonElement>;
public onEmoteSenderClick?: MouseEventHandler<HTMLButtonElement>;
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
@ -160,7 +160,7 @@ describe("TextualBodyView", () => {
extends MockViewModel<TextualBodyViewSnapshot>
implements TextualBodyViewActions
{
public onBodyActionClick?: MouseEventHandler<HTMLElement>;
public onBodyActionClick?: MouseEventHandler<HTMLButtonElement>;
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
super(snapshot);

View File

@ -62,6 +62,10 @@ export interface TextualBodyViewSnapshot {
* Visible label for the edited marker.
*/
editedMarkerText?: string;
/**
* Accessible label announced for the edited marker action.
*/
editedMarkerAriaLabel?: string;
/**
* Tooltip description for the edited marker.
*/
@ -92,7 +96,7 @@ export interface TextualBodyViewActions {
/**
* Activation handler used when `bodyWrapper` is `ACTION`.
*/
onBodyActionClick?: MouseEventHandler<HTMLElement>;
onBodyActionClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Click handler for the edited marker.
*/
@ -165,6 +169,7 @@ export function TextualBodyView({
bodyActionAriaLabel,
showEditedMarker,
editedMarkerText,
editedMarkerAriaLabel,
editedMarkerTooltip,
editedMarkerCaption,
showPendingModerationMarker,
@ -195,6 +200,8 @@ export function TextualBodyView({
type="button"
className={classNames(styles.annotation, styles.editedMarker)}
onClick={onEditedMarkerClick}
aria-label={editedMarkerAriaLabel}
data-textual-body-edited-marker=""
>
<span>{editedMarkerText}</span>
</button>
@ -218,7 +225,7 @@ export function TextualBodyView({
if (showPendingModerationMarker) {
markers.push(
<span key="pending-moderation-marker" className={styles.annotation}>
<span key="pending-moderation-marker" className={styles.annotation} data-textual-body-pending-moderation="">
{pendingModerationText}
</span>,
);

View File

@ -40,6 +40,7 @@ exports[`TextualBodyView > renders emote messages with annotations 1`] = `
</span>
<button
class="TextualBody-module_annotation TextualBody-module_editedMarker"
data-textual-body-edited-marker=""
type="button"
>
<span>