From fae453b85a7dfe447429bb5a9c8b0d95ceb635cd Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 16 Feb 2026 18:43:04 +0000 Subject: [PATCH] Port url preview logic to a view model. --- .../components/views/messages/TextualBody.tsx | 155 ++--------- .../views/rooms/LinkPreviewGroup.tsx | 108 -------- .../views/rooms/LinkPreviewWidget.tsx | 139 ---------- .../message-body/UrlPreviewViewModel.ts | 261 ++++++++++++++++++ 4 files changed, 289 insertions(+), 374 deletions(-) delete mode 100644 apps/web/src/components/views/rooms/LinkPreviewGroup.tsx delete mode 100644 apps/web/src/components/views/rooms/LinkPreviewWidget.tsx create mode 100644 apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index 49cb5ef2ce..303e83d62f 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -16,7 +16,7 @@ import dis from "../../../dispatcher/dispatcher"; import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; -import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; +import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; import { Action } from "../../../dispatcher/actions"; import QuestionDialog from "../dialogs/QuestionDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; @@ -28,50 +28,45 @@ import AccessibleButton from "../elements/AccessibleButton"; import { getParentEventId } from "../../../utils/Reply"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { type IEventTileOps } from "../rooms/EventTile"; +import { UrlPreviewViewModel } from "../../../viewmodels/message-body/UrlPreviewViewModel.ts"; +import { MatrixClientPeg } from "../../../MatrixClientPeg.ts"; -interface IState { - // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. - links: string[]; - - // track whether the preview widget is hidden - widgetHidden: boolean; -} - -export default class TextualBody extends React.Component { +export default class TextualBody extends React.Component { private readonly contentRef = createRef(); + private readonly urlPreviewVMRef = createRef(); public static contextType = RoomContext; declare public context: React.ContextType; - public state = { - links: [], - widgetHidden: false, - }; - public componentDidMount(): void { if (!this.props.editState) { - this.applyFormatting(); + this.urlPreviewVMRef.current?.recomputeSnapshot(); } } - private applyFormatting(): void { - this.calculateUrlPreview(); - } - public componentDidUpdate(prevProps: Readonly): void { + if (!this.urlPreviewVMRef.current || prevProps.mxEvent !== this.props.mxEvent) { + this.urlPreviewVMRef.current?.dispose(); + const urlPreviewVM = new UrlPreviewViewModel({ + client: MatrixClientPeg.safeGet(), + eventRef: this.contentRef, + eventSendTime: this.props.mxEvent.getTs(), + eventId: this.props.mxEvent.getId(), + }); + this.urlPreviewVMRef.current = urlPreviewVM; + } + if (!this.props.editState) { const stoppedEditing = prevProps.editState && !this.props.editState; const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId; const urlPreviewChanged = prevProps.showUrlPreview !== this.props.showUrlPreview; if (messageWasEdited || stoppedEditing || urlPreviewChanged) { - this.applyFormatting(); + this.urlPreviewVMRef.current?.recomputeSnapshot(); } } } - public shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean { - //console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); - + public shouldComponentUpdate(nextProps: Readonly): boolean { // exploit that events are immutable :) return ( nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || @@ -80,94 +75,10 @@ export default class TextualBody extends React.Component { nextProps.highlightLink !== this.props.highlightLink || nextProps.showUrlPreview !== this.props.showUrlPreview || nextProps.editState !== this.props.editState || - nextState.links !== this.state.links || - nextState.widgetHidden !== this.state.widgetHidden || nextProps.isSeeingThroughMessageHiddenForModeration !== this.props.isSeeingThroughMessageHiddenForModeration ); } - private calculateUrlPreview(): void { - //console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); - - if (this.props.showUrlPreview && this.contentRef.current) { - // pass only the first child which is the event tile otherwise this recurses on edited events - let links = this.findLinks([this.contentRef.current]); - if (links.length) { - // de-duplicate the links using a set here maintains the order - links = Array.from(new Set(links)); - this.setState({ links }); - - // lazy-load the hidden state of the preview widget from localstorage - if (window.localStorage) { - const hidden = !!window.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId()); - this.setState({ widgetHidden: hidden }); - } - } else if (this.state.links.length) { - this.setState({ links: [] }); - } - } - } - - private findLinks(nodes: ArrayLike): string[] { - let links: string[] = []; - - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (node.tagName === "A" && node.getAttribute("href")) { - if (this.isLinkPreviewable(node)) { - links.push(node.getAttribute("href")!); - } - } else if (node.tagName === "PRE" || node.tagName === "CODE" || node.tagName === "BLOCKQUOTE") { - continue; - } else if (node.children && node.children.length) { - links = links.concat(this.findLinks(node.children)); - } - } - return links; - } - - private isLinkPreviewable(node: Element): boolean { - // don't try to preview relative links - const href = node.getAttribute("href") ?? ""; - if (!href.startsWith("http://") && !href.startsWith("https://")) { - return false; - } - - const url = node.getAttribute("href"); - const host = url?.match(/^https?:\/\/(.*?)(\/|$)/)?.[1]; - - // never preview permalinks (if anything we should give a smart - // preview of the room/user they point to: nobody needs to be reminded - // what the matrix.to site looks like). - if (!host || isPermalinkHost(host)) return false; - - // as a random heuristic to avoid highlighting things like "foo.pl" - // we require the linked text to either include a / (either from http:// - // or from a full foo.bar/baz style schemeless URL) - or be a markdown-style - // link, in which case we check the target text differs from the link value. - // TODO: make this configurable? - if (node.textContent?.includes("/")) { - return true; - } - - if (node.textContent?.toLowerCase().trim().startsWith(host.toLowerCase())) { - // it's a "foo.pl" style link - return false; - } else { - // it's a [foo bar](http://foo.com) style link - return true; - } - } - - private onCancelClick = (): void => { - this.setState({ widgetHidden: true }); - // FIXME: persist this somewhere smarter than local storage - if (global.localStorage) { - global.localStorage.setItem("hide_preview_" + this.props.mxEvent.getId(), "1"); - } - this.forceUpdate(); - }; - private onEmoteSenderClick = (): void => { const mxEvent = this.props.mxEvent; dis.dispatch({ @@ -202,14 +113,11 @@ export default class TextualBody extends React.Component { public getEventTileOps = (): IEventTileOps => ({ isWidgetHidden: () => { - return this.state.widgetHidden; + return this.urlPreviewVMRef.current?.getSnapshot().hidden ?? false; }, unhideWidget: () => { - this.setState({ widgetHidden: false }); - if (global.localStorage) { - global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId()); - } + this.urlPreviewVMRef.current?.onShowClick(); }, }); @@ -370,16 +278,9 @@ export default class TextualBody extends React.Component { ); } - let widgets; - if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { - widgets = ( - - ); - } + const urlPreviewWidget = this.urlPreviewVMRef.current && ( + + ); if (isEmote) { return ( @@ -395,7 +296,7 @@ export default class TextualBody extends React.Component {   {body} - {widgets} + {urlPreviewWidget} ); } @@ -403,7 +304,7 @@ export default class TextualBody extends React.Component { return (
{body} - {widgets} + {urlPreviewWidget}
); } @@ -411,14 +312,14 @@ export default class TextualBody extends React.Component { return (
{body} - {widgets} + {urlPreviewWidget}
); } return (
{body} - {widgets} + {urlPreviewWidget}
); } diff --git a/apps/web/src/components/views/rooms/LinkPreviewGroup.tsx b/apps/web/src/components/views/rooms/LinkPreviewGroup.tsx deleted file mode 100644 index 880bb3a24b..0000000000 --- a/apps/web/src/components/views/rooms/LinkPreviewGroup.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/* -Copyright 2024, 2025 New Vector Ltd. -Copyright 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, useContext } from "react"; -import { type MatrixEvent, MatrixError, type IPreviewUrlResponse, type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; -import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; - -import { useStateToggle } from "../../../hooks/useStateToggle"; -import LinkPreviewWidget from "./LinkPreviewWidget"; -import AccessibleButton from "../elements/AccessibleButton"; -import { _t } from "../../../languageHandler"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import { useMediaVisible } from "../../../hooks/useMediaVisible"; - -const INITIAL_NUM_PREVIEWS = 2; - -interface IProps { - links: string[]; // the URLs to be previewed - mxEvent: MatrixEvent; // the Event associated with the preview - onCancelClick(this: void): void; // called when the preview's cancel ('hide') button is clicked -} - -const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick }) => { - const cli = useContext(MatrixClientContext); - const [expanded, toggleExpanded] = useStateToggle(); - const [mediaVisible] = useMediaVisible(mxEvent); - - const ts = mxEvent.getTs(); - const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>( - async () => { - return fetchPreviews(cli, links, ts); - }, - [links, ts], - [], - ); - - const showPreviews = expanded ? previews : previews.slice(0, INITIAL_NUM_PREVIEWS); - - let toggleButton: JSX.Element | undefined; - if (previews.length > INITIAL_NUM_PREVIEWS) { - toggleButton = ( - - {expanded - ? _t("action|collapse") - : _t("timeline|url_preview|show_n_more", { count: previews.length - showPreviews.length })} - - ); - } - - return ( -
- {showPreviews.map(([link, preview], i) => ( - - {i === 0 ? ( - - - - ) : undefined} - - ))} - {toggleButton} -
- ); -}; - -const fetchPreviews = (cli: MatrixClient, links: string[], ts: number): Promise<[string, IPreviewUrlResponse][]> => { - return Promise.all<[string, IPreviewUrlResponse] | void>( - links.map(async (link): Promise<[string, IPreviewUrlResponse] | undefined> => { - try { - const preview = await cli.getUrlPreview(link, ts); - // Ensure at least one of the rendered fields is truthy - if ( - preview?.["og:image"]?.startsWith("mxc://") || - !!preview?.["og:description"] || - !!preview?.["og:title"] - ) { - return [link, preview]; - } - } catch (error) { - if (error instanceof MatrixError && error.httpStatus === 404) { - // Quieten 404 Not found errors, not all URLs can have a preview generated - logger.debug("Failed to get URL preview: ", error); - } else { - logger.error("Failed to get URL preview: ", error); - } - } - }), - ).then((a) => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>; -}; - -export default LinkPreviewGroup; diff --git a/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx b/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx deleted file mode 100644 index 4362979b59..0000000000 --- a/apps/web/src/components/views/rooms/LinkPreviewWidget.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* -Copyright 2024, 2025 New Vector Ltd. -Copyright 2016-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, type ComponentProps, createRef, type ReactNode } from "react"; -import { decode } from "html-entities"; -import { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix"; - -import Modal from "../../../Modal"; -import * as ImageUtils from "../../../ImageUtils"; -import { mediaFromMxc } from "../../../customisations/Media"; -import ImageView from "../elements/ImageView"; -import LinkWithTooltip from "../elements/LinkWithTooltip"; -import PlatformPeg from "../../../PlatformPeg"; -import { ElementLinkedText } from "../../../Linkify"; - -interface IProps { - link: string; - preview: IPreviewUrlResponse; - mxEvent: MatrixEvent; // the Event associated with the preview - children?: ReactNode; - mediaVisible: boolean; -} - -export default class LinkPreviewWidget extends React.Component { - private image = createRef(); - - private onImageClick = (ev: React.MouseEvent): void => { - const p = this.props.preview; - if (ev.button != 0 || ev.metaKey) return; - ev.preventDefault(); - - let src: string | null | undefined = p["og:image"]; - if (src?.startsWith("mxc://")) { - src = mediaFromMxc(src).srcHttp; - } - - if (!src) return; - - const params: Omit, "onFinished"> = { - src: src, - width: p["og:image:width"], - height: p["og:image:height"], - name: p["og:title"] || p["og:description"] || this.props.link, - fileSize: p["matrix:image:size"], - link: this.props.link, - }; - - if (this.image.current) { - const clientRect = this.image.current.getBoundingClientRect(); - - params.thumbnailInfo = { - width: clientRect.width, - height: clientRect.height, - positionX: clientRect.x, - positionY: clientRect.y, - }; - } - - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); - }; - - public render(): React.ReactNode { - const p = this.props.preview; - - // FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing? - let image: string | null = p["og:image"] ?? null; - if (!this.props.mediaVisible) { - image = null; // Don't render a button to show the image, just hide it outright - } - const imageMaxWidth = 100; - const imageMaxHeight = 100; - if (image && image.startsWith("mxc://")) { - // We deliberately don't want a square here, so use the source HTTP thumbnail function - image = mediaFromMxc(image).getThumbnailOfSourceHttp(imageMaxWidth, imageMaxHeight, "scale"); - } - - const thumbHeight = - ImageUtils.thumbHeight(p["og:image:width"], p["og:image:height"], imageMaxWidth, imageMaxHeight) ?? - imageMaxHeight; - - let img: JSX.Element | undefined; - if (image) { - img = ( -
- -
- ); - } - - // The description includes &-encoded HTML entities, we decode those as React treats the thing as an - // opaque string. This does not allow any HTML to be injected into the DOM. - const description = decode(p["og:description"] || ""); - - const title = p["og:title"]?.trim() ?? ""; - const anchor = ( - - {title} - - ); - const needsTooltip = PlatformPeg.get()?.needsUrlTooltips() && this.props.link !== title; - - return ( -
-
- {img} -
-
- {needsTooltip ? ( - - {anchor} - - ) : ( - anchor - )} - {p["og:site_name"] && ( - {" - " + p["og:site_name"]} - )} -
-
- {description} -
-
-
- {this.props.children} -
- ); - } -} diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts new file mode 100644 index 0000000000..4a2ed337d8 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts @@ -0,0 +1,261 @@ +/* + * 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 { BaseViewModel } from "@element-hq/web-shared-components"; +import { decode } from "html-entities"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; +import { type IPreviewUrlResponse, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; + +import type { RefObject } from "react"; +import { isPermalinkHost } from "../../utils/permalinks/Permalinks"; +import { mediaFromMxc } from "../../customisations/Media"; + +const logger = rootLogger.getChild("UrlPreviewViewModel"); + +export interface UrlPreviewViewModelProps { + client: MatrixClient; + eventSendTime: number; + eventRef: RefObject; + eventId?: string; +} + +export interface UrlPreviewViewSnapshotInterfacePreview { + link: string; + title: string; + siteName?: string; + description?: string; + image?: { + imageThumb: string; + imageFull: string; + size?: number; + width?: number; + height?: number; + }; +} + +interface UrlPreviewViewSnapshotInterface { + previews: Array; + hidden: boolean; + totalPreviewCount: number; + previewsLimited: boolean; + overPreviewLimit: boolean; +} + +export const MAX_PREVIEWS_WHEN_LIMITED = 2; +export const PREVIEW_WIDTH = 100; +export const PREVIEW_HEIGHT = 100; + +function getNumberFromOpenGraph(value: number | string | undefined): number | undefined { + if (typeof value === "number") { + return value; + } else if (typeof value === "string" && value) { + const i = parseInt(value, 10); + if (!isNaN(i)) { + return i; + } + } + return undefined; +} + +function getTitleFromOpenGraph(response: IPreviewUrlResponse, link: string): string { + if (typeof response["og:title"] === "string" && response["og:title"]) { + return response["og:title"].trim(); + } + if (typeof response["og:description"] === "string" && response["og:description"]) { + return response["og:description"].trim(); + } + return link; +} + +/** + * ViewModel for fetching and rendering room previews. + */ +export class UrlPreviewViewModel extends BaseViewModel { + private static isLinkPreviewable(node: Element): boolean { + // don't try to preview relative links + const href = node.getAttribute("href"); + if (!href || !URL.canParse(href)) { + return false; + } + + const url = new URL(href); + if (!["http:", "https:"].includes(url.protocol)) { + return false; + } + // never preview permalinks (if anything we should give a smart + // preview of the room/user they point to: nobody needs to be reminded + // what the matrix.to site looks like). + if (isPermalinkHost(url.host)) { + return false; + } + + // as a random heuristic to avoid highlighting things like "foo.pl" + // we require the linked text to either include a / (either from http:// + // or from a full foo.bar/baz style schemeless URL) - or be a markdown-style + // link, in which case we check the target text differs from the link value. + // TODO: make this configurable? + if (node.textContent?.includes("/")) { + return true; + } + + if (node.textContent?.toLowerCase().trim().startsWith(url.host.toLowerCase())) { + // it's a "foo.pl" style link + return false; + } else { + // it's a [foo bar](http://foo.com) style link + return true; + } + } + + private static findLinks(nodes: ArrayLike): string[] { + let links = new Set(); + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.tagName === "A" && node.getAttribute("href")) { + if (this.isLinkPreviewable(node)) { + links.add(node.getAttribute("href")!); + } + } else if (node.tagName === "PRE" || node.tagName === "CODE" || node.tagName === "BLOCKQUOTE") { + continue; + } else if (node.children && node.children.length) { + links = new Set([...this.findLinks(node.children), ...links]); + } + } + return [...links]; + } + + private async fetchPreview(link: string, ts: number): Promise { + const cached = this.previewCache.get(link); + if (cached) { + return cached; + } + try { + const preview = await this.client.getUrlPreview(link, ts); + const hasTitle = preview["og:title"] && typeof preview?.["og:title"] === "string"; + const hasDescription = preview["og:description"] && typeof preview?.["og:description"] === "string"; + const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string"; + // Ensure at least one of the rendered fields is truthy + if (!hasTitle || !hasDescription || !hasImage) { + return null; + } + const media = + typeof preview["og:image"] === "string" ? mediaFromMxc(preview["og:image"], this.client) : undefined; + const result = { + link, + title: getTitleFromOpenGraph(preview, link), + description: + typeof preview["og:description"] === "string" ? decode(preview["og:description"]) : undefined, + image: media + ? { + // TODO: Check nulls + imageThumb: media.getThumbnailHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale")!, + imageFull: media.srcHttp!, + width: getNumberFromOpenGraph(preview["og:image:width"]), + height: getNumberFromOpenGraph(preview["og:image:height"]), + size: getNumberFromOpenGraph(preview["matrix:image:size"]), + } + : undefined, + }; + this.previewCache.set(link, result); + return result; + } catch (error) { + if (error instanceof MatrixError && error.httpStatus === 404) { + // Quieten 404 Not found errors, not all URLs can have a preview generated + logger.debug("Failed to get URL preview: ", error); + } else { + logger.error("Failed to get URL preview: ", error); + } + } + return null; + } + + private readonly client: MatrixClient; + private readonly eventRef: RefObject; + private eventSendTime: number; + private showUrlPreview: boolean; + private readonly storageKey: string; + private limitPreviews = true; + private previewCache = new Map(); + + public constructor(props: UrlPreviewViewModelProps) { + super(props, { + previews: [], + hidden: false, + totalPreviewCount: 0, + previewsLimited: true, + overPreviewLimit: false, + }); + this.client = props.client; + this.eventRef = props.eventRef; + this.eventSendTime = props.eventSendTime; + this.storageKey = props.eventId ?? `hide_preview_${props.eventId}`; + this.showUrlPreview = window.localStorage.getItem(this.storageKey) !== "1"; + void this.computeSnapshot(); + } + + private async computeSnapshot(): Promise { + if (!this.showUrlPreview) { + this.snapshot.set({ + previews: [], + hidden: true, + totalPreviewCount: 0, + previewsLimited: this.limitPreviews, + overPreviewLimit: false, + }); + return; + } + if (!this.eventRef.current) { + // Event hasn't rendered...yet + this.snapshot.set({ + previews: [], + hidden: false, + totalPreviewCount: 0, + previewsLimited: this.limitPreviews, + overPreviewLimit: false, + }); + return; + } + + const links = UrlPreviewViewModel.findLinks([this.eventRef.current]); + const previews = await Promise.all( + links + .slice(0, this.limitPreviews ? MAX_PREVIEWS_WHEN_LIMITED : undefined) + .map((link) => this.fetchPreview(link, this.eventSendTime)), + ); + this.snapshot.set({ + previews: previews.filter((m) => !!m), + totalPreviewCount: links.length, + hidden: false, + previewsLimited: this.limitPreviews, + overPreviewLimit: links.length > MAX_PREVIEWS_WHEN_LIMITED, + }); + } + + public recomputeSnapshot(): void { + void this.computeSnapshot(); + } + + public onShowClick(): void { + this.showUrlPreview = true; + void this.computeSnapshot(); + // FIXME: persist this somewhere smarter than local storage + global.localStorage?.removeItem(this.storageKey); + } + + public onHideClick(): void { + this.showUrlPreview = false; + void this.computeSnapshot(); + // FIXME: persist this somewhere smarter than local storage + global.localStorage?.setItem(this.storageKey, "1"); + } + + public onTogglePreviewLimit(): void { + this.limitPreviews = !this.limitPreviews; + void this.computeSnapshot(); + } +}