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:
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user