diff --git a/apps/web/playwright/e2e/editing/editing.spec.ts b/apps/web/playwright/e2e/editing/editing.spec.ts index 3754bcfb62..9b0efecb26 100644 --- a/apps/web/playwright/e2e/editing/editing.spec.ts +++ b/apps/web/playwright/e2e/editing/editing.spec.ts @@ -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(); }); }); diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png index 1004849ecb..cf42921ba6 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png index 3673c5a441..179e65ac77 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png index e1b094bd14..34b0d5f4ea 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png index b9e65fce21..e6ba28a064 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png index 10082f77f5..feec9daa94 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png index 6e9d930a22..739fe83bff 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png index 01fbc69609..b0ad809230 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png index f6799ebcca..2fc60fd96a 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png index 0dc4ad4846..08b47c44f7 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png index 33903a62db..c439f47c80 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png index c37018b8b5..8383107083 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png differ diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index bac006d7f5..bec2a22b47 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss index d14bb3ca60..5f136e9927 100644 --- a/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.pcss +++ b/apps/web/res/css/views/dialogs/_MessageEditHistoryDialog.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; diff --git a/apps/web/res/css/views/messages/_MEmoteBody.pcss b/apps/web/res/css/views/messages/_MEmoteBody.pcss deleted file mode 100644 index ad7abb0176..0000000000 --- a/apps/web/res/css/views/messages/_MEmoteBody.pcss +++ /dev/null @@ -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; -} diff --git a/apps/web/res/css/views/messages/_MNoticeBody.pcss b/apps/web/res/css/views/messages/_MNoticeBody.pcss deleted file mode 100644 index f82c2b8fcc..0000000000 --- a/apps/web/res/css/views/messages/_MNoticeBody.pcss +++ /dev/null @@ -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; -} diff --git a/apps/web/res/css/views/messages/_MTextBody.pcss b/apps/web/res/css/views/messages/_MTextBody.pcss deleted file mode 100644 index 973fd3a354..0000000000 --- a/apps/web/res/css/views/messages/_MTextBody.pcss +++ /dev/null @@ -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; -} diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index 63d1bdaee2..ffe0fcc13d 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -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); diff --git a/apps/web/res/css/views/rooms/_ReplyTile.pcss b/apps/web/res/css/views/rooms/_ReplyTile.pcss index 64492762ad..aea4bae626 100644 --- a/apps/web/res/css/views/rooms/_ReplyTile.pcss +++ b/apps/web/res/css/views/rooms/_ReplyTile.pcss @@ -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; } diff --git a/apps/web/src/components/views/messages/EditHistoryMessage.tsx b/apps/web/src/components/views/messages/EditHistoryMessage.tsx index 9d533a792a..ced10ce290 100644 --- a/apps/web/src/components/views/messages/EditHistoryMessage.tsx +++ b/apps/web/src/components/views/messages/EditHistoryMessage.tsx @@ -164,7 +164,7 @@ export default class EditHistoryMessage extends React.PureComponent *  - {name} + {name}  {contentElements} ); diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 549f38226c..d8de22a427 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -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 { @@ -64,9 +64,9 @@ export interface IOperableEventTile { } const baseBodyTypes = new Map>([ - [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 (
- +
); diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx deleted file mode 100644 index caf5df344d..0000000000 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ /dev/null @@ -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 { - private readonly contentRef = createRef(); - - public static contextType = RoomContext; - declare public context: React.ContextType; - - private EventContentBodyViewModel: EventContentBodyViewModel; - - public constructor(props: Props, context: React.ContextType) { - 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): 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): 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 `` may contain children, e.g. an anchor wrapping an inline code section - target = target.closest("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: ( -
- {_t("timeline|scalar_starter_link|dialog_description", { integrationsUrl: integrationsUrl })} -
- ), - 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 => { - Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent }); - }; - - private renderEditedMarker(): JSX.Element { - const date = this.props.mxEvent.replacingEventDate(); - const dateString = date && formatDate(date); - - return ( - - {`(${_t("common|edited")})`} - - ); - } - - /** - * 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 {`(${text})`}; - } - - public componentDidMount(): void { - this.updateURLPreviewViewModel(); - } - - public render(): React.ReactNode { - if (this.props.editState) { - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); - return isWysiwygComposerEnabled ? ( - - ) : ( - - ); - } - - 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 = ( - - ); - - if (this.props.replacingEventId) { - body = ( -
- {body} - {this.renderEditedMarker()} -
- ); - } - if (this.props.isSeeingThroughMessageHiddenForModeration) { - body = ( -
- {body} - {this.renderPendingModerationMarker()} -
- ); - } - - if (this.props.highlightLink) { - body =
{body}; - } else if (content.data && typeof content.data["org.matrix.neb.starter_link"] === "string") { - body = ( - - {body} - - ); - } - - const urlPreviewWidget = ; - - if (isEmote) { - return ( -
- *  - - {mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()} - -   - {body} - {urlPreviewWidget} -
- ); - } - if (isNotice) { - return ( -
- {body} - {urlPreviewWidget} -
- ); - } - if (isCaption) { - return ( -
- {body} - {urlPreviewWidget} -
- ); - } - return ( -
- {body} - {urlPreviewWidget} -
- ); - } -} - -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 ; -} diff --git a/apps/web/src/components/views/messages/TextualBodyFactory.tsx b/apps/web/src/components/views/messages/TextualBodyFactory.tsx new file mode 100644 index 0000000000..d947bb982e --- /dev/null +++ b/apps/web/src/components/views/messages/TextualBodyFactory.tsx @@ -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): 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(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 ? ( + + ) : ( + + ); + } + + return ( + } + bodyRef={contentRef} + urlPreviews={} + className={getTextualBodyClassName(content.msgtype as MsgType | undefined)} + /> + ); +} diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts index 6cafe3b5af..55109ac876 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts @@ -424,7 +424,7 @@ export class UrlPreviewGroupViewModel * Trigger a recalculation of the links in an event. * @param eventElement */ - public async updateEventElement(eventElement: HTMLDivElement): Promise { + public async updateEventElement(eventElement: HTMLDivElement | HTMLSpanElement): Promise { 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))) { diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx new file mode 100644 index 0000000000..e6ab537f33 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx @@ -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.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 + 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 => { + 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 => { + 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): void => { + let target: HTMLLinkElement | null = event.target as HTMLLinkElement; + + if (target.dataset?.[LINKIFIED_DATA_ATTRIBUTE]) { + return; + } + + if (target.nodeName !== "A") { + target = target.closest("a"); + } + + if (!target) { + return; + } + + const localHref = tryTransformPermalinkToLocalHref(target.href); + if (localHref !== target.href) { + event.preventDefault(); + window.location.hash = localHref; + } + }; + + public onBodyActionClick = (event: MouseEvent): 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:
{_t("timeline|scalar_starter_link|dialog_description", { integrationsUrl })}
, + 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, + }); + }; +} diff --git a/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap b/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap index 9f2d6906b4..4b072e701c 100644 --- a/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap @@ -44,7 +44,7 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines
({ renderMBody: () =>
, })); +jest.mock("../../../../../src/components/views/messages/TextualBodyFactory", () => ({ + __esModule: true, + TextualBodyFactory: () =>
, +})); + jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () => ({ __esModule: true, default: () =>
, @@ -53,11 +58,6 @@ jest.mock("../../../../../src/components/views/messages/MStickerBody", () => ({ default: () =>
, })); -jest.mock("../../../../../src/components/views/messages/TextualBody.tsx", () => ({ - __esModule: true, - default: () =>
, -})); - describe("MessageEvent", () => { let room: Room; let client: MatrixClient; diff --git a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx index 286808abd6..c229eebee8 100644 --- a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -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("", () => { 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("", () => { 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)( - + , ); @@ -163,9 +171,7 @@ describe("", () => { 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", () => { diff --git a/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap b/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap index d4bfa3dc5f..9df73e702a 100644 --- a/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap @@ -130,7 +130,7 @@ exports[` renders formatted m.text correctly pills appear for an exports[` renders formatted m.text correctly pills appear for event permalinks without a custom label 1`] = `
renders formatted m.text correctly pills appear for eve exports[` renders formatted m.text correctly pills appear for room links with vias 1`] = `
renders formatted m.text correctly pills appear for roo exports[` renders formatted m.text correctly pills do not appear for event permalinks with a custom label 1`] = `
should show two pinned messages 1`] = `
should show two pinned messages 1`] = `
unpin all should not allow to unpinall 1`] = `
unpin all should not allow to unpinall 1`] = `
should render pinned event 1`] = `
should render pinned event with thread info 1`] = `
should render 1`] = ` id="1" >
should render 1`] = ` id="2" >
should render 1`] = ` id="3" >

-
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • diff --git a/apps/web/test/viewmodels/message-body/TextualBodyViewModel-test.tsx b/apps/web/test/viewmodels/message-body/TextualBodyViewModel-test.tsx new file mode 100644 index 0000000000..e11534fb9c --- /dev/null +++ b/apps/web/test/viewmodels/message-body/TextualBodyViewModel-test.tsx @@ -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, + 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[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); + + 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); + + 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); + 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); + + 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(); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png index 5a6416705f..546e9cc1fa 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png index f71301034c..0fe444cb7c 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png index cda879c609..fbd29e42ff 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png differ diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css index 683c8a8a2a..fe64ec04a4 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css @@ -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, diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx index 5e4d8a3e89..e004d74a66 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx @@ -74,7 +74,7 @@ describe("TextualBodyView", () => { implements TextualBodyViewActions { public onEditedMarkerClick?: MouseEventHandler; - public onBodyActionClick?: MouseEventHandler; + public onBodyActionClick?: MouseEventHandler; public onEmoteSenderClick?: MouseEventHandler; public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) { @@ -160,7 +160,7 @@ describe("TextualBodyView", () => { extends MockViewModel implements TextualBodyViewActions { - public onBodyActionClick?: MouseEventHandler; + public onBodyActionClick?: MouseEventHandler; public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) { super(snapshot); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx index 340bed6bd8..e4d0c022aa 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx @@ -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; + onBodyActionClick?: MouseEventHandler; /** * 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="" > {editedMarkerText} @@ -218,7 +225,7 @@ export function TextualBodyView({ if (showPendingModerationMarker) { markers.push( - + {pendingModerationText} , ); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap index 79201640bc..e33064185f 100644 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap @@ -40,6 +40,7 @@ exports[`TextualBodyView > renders emote messages with annotations 1`] = `