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
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
217
apps/web/src/components/views/messages/TextualBodyFactory.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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))) {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 23 KiB |
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||