diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png index 32981108e6..534571fe13 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png index 8da0373d20..d18c74a3b1 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png differ diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index f775cd1698..78cb4b9b4a 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -276,8 +276,6 @@ @import "./views/rooms/_JumpToBottomButton.pcss"; @import "./views/rooms/_LegacyRoomList.pcss"; @import "./views/rooms/_LegacyRoomListHeader.pcss"; -@import "./views/rooms/_LinkPreviewGroup.pcss"; -@import "./views/rooms/_LinkPreviewWidget.pcss"; @import "./views/rooms/_LiveContentSummary.pcss"; @import "./views/rooms/_MemberListHeaderView.pcss"; @import "./views/rooms/_MemberListView.pcss"; diff --git a/apps/web/res/css/views/rooms/_LinkPreviewGroup.pcss b/apps/web/res/css/views/rooms/_LinkPreviewGroup.pcss deleted file mode 100644 index a98966bc20..0000000000 --- a/apps/web/res/css/views/rooms/_LinkPreviewGroup.pcss +++ /dev/null @@ -1,30 +0,0 @@ -/* -Copyright 2024 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. -*/ - -.mx_LinkPreviewGroup { - .mx_LinkPreviewGroup_hide { - cursor: pointer; - width: 18px; - height: 18px; - - svg { - flex: 0 0 40px; - visibility: hidden; - } - } - - &:hover .mx_LinkPreviewGroup_hide svg, - .mx_LinkPreviewGroup_hide:focus-visible:focus svg { - visibility: visible; - } - - > .mx_AccessibleButton { - color: $accent; - text-align: center; - } -} diff --git a/apps/web/res/css/views/rooms/_LinkPreviewWidget.pcss b/apps/web/res/css/views/rooms/_LinkPreviewWidget.pcss deleted file mode 100644 index 99f4418c31..0000000000 --- a/apps/web/res/css/views/rooms/_LinkPreviewWidget.pcss +++ /dev/null @@ -1,69 +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_LinkPreviewWidget { - margin: $spacing-16 0 $spacing-16 auto; - display: flex; - column-gap: $spacing-4; - border-inline-start: 2px solid $preview-widget-bar-color; - border-radius: 2px; - color: $info-plinth-fg-color; - - .mx_MatrixChat_useCompactLayout & { - margin-top: 6px; - margin-bottom: 6px; - } - - /* Exclude mx_LinkPreviewGroup_hide from wrapping */ - .mx_LinkPreviewWidget_wrapImageCaption { - display: flex; - flex-wrap: wrap; - row-gap: $spacing-8; - flex: 1; - - .mx_LinkPreviewWidget_image, - .mx_LinkPreviewWidget_caption { - margin-inline-start: $spacing-16; - min-width: 0; /* Prevent blowout */ - } - - .mx_LinkPreviewWidget_image { - flex: 0 0 100px; - text-align: center; - cursor: pointer; - } - - .mx_LinkPreviewWidget_caption { - flex: 1; - overflow: hidden; /* cause it to wrap rather than clip */ - } - - .mx_LinkPreviewWidget_title, - .mx_LinkPreviewWidget_description { - display: -webkit-box; - -webkit-box-orient: vertical; - overflow: hidden; - white-space: normal; - } - - .mx_LinkPreviewWidget_title { - font-weight: bold; - -webkit-line-clamp: 2; - - .mx_LinkPreviewWidget_siteName { - font-weight: normal; - } - } - - .mx_LinkPreviewWidget_description { - margin-top: $spacing-8; - word-wrap: break-word; - -webkit-line-clamp: 3; - } - } -} diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index 5dfa69ede8..327e472e8b 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -271,7 +271,7 @@ export interface IRoomState { showAvatarChanges: boolean; showDisplaynameChanges: boolean; matrixClientIsReady: boolean; - showUrlPreview?: boolean; + showUrlPreview: boolean; e2eStatus?: E2EStatus; rejecting?: boolean; hasPinnedWidgets?: boolean; @@ -499,6 +499,8 @@ export class RoomView extends React.Component { showJoinLeaves: true, showAvatarChanges: true, showDisplaynameChanges: true, + // Default to false to avoid any accidental leakage. + showUrlPreview: false, matrixClientIsReady: context.client?.isInitialSyncComplete(), mainSplitContentType: MainSplitContentType.Timeline, timelineRenderingType: TimelineRenderingType.Room, diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index 5c84fa2d9e..fe9ed8e910 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -6,9 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react"; +import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent, useCallback, useEffect } from "react"; import { MsgType } from "matrix-js-sdk/src/matrix"; -import { EventContentBodyView, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components"; +import { + UrlPreviewGroupView, + type UrlPreviewViewSnapshotPreview, + useCreateAutoDisposedViewModel, + EventContentBodyView, + LINKIFIED_DATA_ATTRIBUTE, +} 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"; @@ -17,28 +24,27 @@ 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"; import EditMessageComposer from "../rooms/EditMessageComposer"; -import LinkPreviewGroup from "../rooms/LinkPreviewGroup"; 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 { UrlPreviewViewModel } from "../../../viewmodels/message-body/UrlPreviewViewModel"; +import { useMediaVisible } from "../../../hooks/useMediaVisible.ts"; +import ImageView from "../elements/ImageView.tsx"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; -interface IState { - // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. - links: string[]; +const logger = rootLogger.getChild("TextualBody"); - // track whether the preview widget is hidden - widgetHidden: boolean; -} +type Props = IBodyProps & { urlPreviewViewModel: UrlPreviewViewModel }; -export default class TextualBody extends React.Component { +class InnerTextualBody extends React.Component { private readonly contentRef = createRef(); public static contextType = RoomContext; @@ -46,12 +52,7 @@ export default class TextualBody extends React.Component { private EventContentBodyViewModel: EventContentBodyViewModel; - public state = { - links: [], - widgetHidden: false, - }; - - public constructor(props: IBodyProps, context: React.ContextType) { + public constructor(props: Props, context: React.ContextType) { super(props, context); const mxEvent = props.mxEvent; const content = mxEvent.getContent(); @@ -78,10 +79,18 @@ export default class TextualBody extends React.Component { }); } - public componentDidMount(): void { - if (!this.props.editState) { - this.applyFormatting(); + 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 { @@ -113,125 +122,25 @@ export default class TextualBody extends React.Component { this.EventContentBodyViewModel.setHighlights(this.props.highlights); } } - - // Handle formatting updates - 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.updateURLPreviewViewModel(); } public componentWillUnmount(): void { this.EventContentBodyViewModel.dispose(); } - private applyFormatting(): void { - this.calculateUrlPreview(); - } - - 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() || nextProps.highlights !== this.props.highlights || nextProps.replacingEventId !== this.props.replacingEventId || 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({ @@ -266,14 +175,18 @@ export default class TextualBody extends React.Component { public getEventTileOps = (): IEventTileOps => ({ isWidgetHidden: () => { - return this.state.widgetHidden; + // This controls whether the Show preview button is visibile. + return this.props.urlPreviewViewModel.isPreviewHiddenByUser; }, unhideWidget: () => { - this.setState({ widgetHidden: false }); - if (global.localStorage) { - global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId()); - } + (async () => { + try { + await this.props.urlPreviewViewModel.onShowClick(); + } catch (ex) { + logger.warn("UrlPreviewViewModel failed to onShowClick", ex); + } + })(); }, }); @@ -365,6 +278,10 @@ export default class TextualBody extends React.Component { return {`(${text})`}; } + public componentDidMount(): void { + this.updateURLPreviewViewModel(); + } + public render(): React.ReactNode { if (this.props.editState) { const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); @@ -424,16 +341,7 @@ export default class TextualBody extends React.Component { ); } - let widgets; - if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { - widgets = ( - - ); - } + const urlPreviewWidget = ; if (isEmote) { return ( @@ -449,7 +357,7 @@ export default class TextualBody extends React.Component {   {body} - {widgets} + {urlPreviewWidget} ); } @@ -457,7 +365,7 @@ export default class TextualBody extends React.Component { return (
{body} - {widgets} + {urlPreviewWidget}
); } @@ -465,15 +373,59 @@ export default class TextualBody extends React.Component { return (
{body} - {widgets} + {urlPreviewWidget}
); } return (
{body} - {widgets} + {urlPreviewWidget}
); } } + +export default function TextualBody(props: IBodyProps): React.ReactElement { + const [mediaVisible] = useMediaVisible(props.mxEvent); + const client = useMatrixClientContext(); + + const onUrlPreviewImageClicked = useCallback((preview: UrlPreviewViewSnapshotPreview): 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 UrlPreviewViewModel({ + 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]); + + return ; +} 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 5e0b42f650..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 { LinkedText } from "@element-hq/web-shared-components"; - -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"; - -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/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 7b03818bed..1cdceacbe4 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -3740,14 +3740,7 @@ "one_user": "%(displayName)s is typing …", "two_users": "%(names)s and %(lastPerson)s are typing …" }, - "undecryptable_tooltip": "This message could not be decrypted", - "url_preview": { - "close": "Close preview", - "show_n_more": { - "one": "Show %(count)s other preview", - "other": "Show %(count)s other previews" - } - } + "undecryptable_tooltip": "This message could not be decrypted" }, "truncated_list_n_more": { "other": "And %(count)s more..." 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..0ec667a6c6 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts @@ -0,0 +1,426 @@ +/* + * 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, + type UrlPreviewGroupViewSnapshot, + type UrlPreviewGroupViewActions, + type UrlPreviewViewSnapshotPreview, +} from "@element-hq/web-shared-components"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; +import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { decode } from "html-entities"; + +import { isPermalinkHost } from "../../utils/permalinks/Permalinks"; +import { mediaFromMxc } from "../../customisations/Media"; +import PlatformPeg from "../../PlatformPeg"; +import { thumbHeight } from "../../ImageUtils"; +import SettingsStore from "../../settings/SettingsStore"; + +const logger = rootLogger.getChild("UrlPreviewViewModel"); + +export interface UrlPreviewViewModelProps { + client: MatrixClient; + mxEvent: MatrixEvent; + mediaVisible: boolean; + visible: boolean; + onImageClicked: (preview: UrlPreviewViewSnapshotPreview) => void; +} + +export const MAX_PREVIEWS_WHEN_LIMITED = 2; +export const PREVIEW_WIDTH = 100; +export const PREVIEW_HEIGHT = 100; + +export enum PreviewVisibility { + /** + * Preview is entirely hidden from view and can not be changed. + */ + Hidden, + /** + * Preview is entirely hidden from view but the user may change this. + */ + UserHidden, + /** + * Preview is visible but media should not be rendered. + */ + MediaHidden, + /** + * Preview is visible and media should be rendered. + */ + Visible, +} + +/** + * ViewModel for fetching and rendering URL previews for an individual event. + */ +export class UrlPreviewViewModel + extends BaseViewModel + implements UrlPreviewGroupViewActions +{ + /** + * Parse a numeric value from OpenGraph. The OpenGraph spec defines all values as strings + * although Synapse may return these values as numbers. To be compatible, test strings + * and numbers. + * @param value The numeric value + * @returns A number if the value parsed correctly, or undefined otherwise. + */ + private static 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; + } + + /** + * Calculate the best possible title from an opengraph response. + * @param response The opengraph response + * @param link The link being used to preview. + * @returns The title value. + */ + private static getBaseMetadataFromResponse( + response: IPreviewUrlResponse, + link: string, + ): Pick { + let title = + typeof response["og:title"] === "string" && response["og:title"].trim() + ? response["og:title"].trim() + : undefined; + let description = + typeof response["og:description"] === "string" && response["og:description"].trim() + ? response["og:description"].trim() + : undefined; + let siteName = + typeof response["og:site_name"] === "string" && response["og:site_name"].trim() + ? response["og:site_name"].trim() + : undefined; + + if (!title && description) { + title = description; + description = undefined; + } else if (!title && siteName) { + title = siteName; + siteName = undefined; + } else if (!title) { + title = link; + } + + return { + title, + description: description && decode(description), + siteName, + }; + } + + /** + * Determine if an anchor element can be rendered into a preview. + * If it can, return the value of `href` + * @param node The anchor element DOM node. + * @returns The value of the `href` of the node, or null if this node cannot be previewed. + */ + private static getAnchorLink(node: HTMLAnchorElement): string | null { + // don't try to preview relative links + const href = node.getAttribute("href"); + if (!href || !URL.canParse(href)) { + return null; + } + + const url = new URL(href); + if (!["http:", "https:"].includes(url.protocol)) { + return null; + } + // 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 null; + } + + // 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. + if (node.textContent?.includes("/")) { + return href; + } + + if (node.textContent?.toLowerCase().trim().startsWith(url.host.toLowerCase())) { + // it's a "foo.pl" style link + return null; + } + // it's a [foo bar](http://foo.com) style link + return href; + } + + /** + * Calculate the set of links from a set of DOM nodes. + * @param nodes An array of DOM elements that may be or contain anchor elements. + * @returns A unique array of links that can be previewed, in order of discovery. + */ + private static findLinks(nodes: Iterable): string[] { + let links = new Set(); + + for (const node of nodes) { + if (node.tagName === "A") { + const href = this.getAnchorLink(node as HTMLAnchorElement); + if (href) { + links.add(href); + } + } else if (node.tagName === "PRE" || node.tagName === "CODE" || node.tagName === "BLOCKQUOTE") { + continue; + } else if (node.children && node.children.length) { + links = new Set([...links, ...this.findLinks(node.children)]); + } + } + return [...links]; + } + + private readonly client: MatrixClient; + private readonly storageKey: string; + private readonly eventSendTime: number; + private readonly useCompactLayoutSettingWatcher: string; + + /** + * Should the URL preview render according to the application. + */ + private urlPreviewVisible: boolean; + /** + * Should media be rendered in the preview. + */ + private mediaVisible: boolean; + /** + * Has the user opted to render this individual preview, or hide it. + */ + private urlPreviewEnabledByUser: boolean; + + /** + * Calculated set of links from the provided DOM element. + */ + private links: Array = []; + + /** + * Should the preview limit how many links are rendered. If `false`, all + * links will be rendered. + */ + private limitPreviews = true; + + /** + * A cache containing all previously calculated previews. + */ + private readonly previewCache = new Map(); + + /** + * Called when the user clicks on the preview thumbnail. + */ + public readonly onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void; + + public constructor(props: UrlPreviewViewModelProps) { + const storageKey = `hide_preview_${props.mxEvent.getId()}`; + super(props, { + previews: [], + totalPreviewCount: 0, + previewsLimited: true, + overPreviewLimit: false, + compactLayout: SettingsStore.getValue("useCompactLayout"), + }); + this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(storageKey) !== "1"; + this.urlPreviewVisible = props.visible; + this.mediaVisible = props.mediaVisible; + this.storageKey = storageKey; + this.client = props.client; + this.eventSendTime = props.mxEvent.getTs(); + this.onImageClick = props.onImageClicked; + this.useCompactLayoutSettingWatcher = SettingsStore.watchSetting( + "useCompactLayout", + null, + (_setting, _roomid, _level, compactLayout) => { + this.snapshot.merge({ + compactLayout, + }); + }, + ); + } + + /** + * Fetch a complete preview of a given URL. + * Will always return a cached response if it was previously calculated. + * @param link A URL to be previewed. + * @returns A Promise that returns the snapshot needed to render the preview, or null + * if the resource could not be previewed. + */ + private async fetchPreview(link: string): Promise { + const cached = this.previewCache.get(link); + if (cached) { + return cached; + } + let preview: IPreviewUrlResponse; + + try { + preview = await this.client.getUrlPreview(link, this.eventSendTime); + } 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; + } + + const { title, description, siteName } = UrlPreviewViewModel.getBaseMetadataFromResponse(preview, link); + const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string"; + // Ensure we have something relevant to render. + // The title must not just be the link, or we must have an image. + if (title === link && !hasImage) { + return null; + } + let image: UrlPreviewViewSnapshotPreview["image"]; + if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) { + const media = mediaFromMxc(preview["og:image"], this.client); + const declaredHeight = UrlPreviewViewModel.getNumberFromOpenGraph(preview["og:image:height"]); + const declaredWidth = UrlPreviewViewModel.getNumberFromOpenGraph(preview["og:image:width"]); + const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH); + const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH; + const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale"); + // No thumb, no preview. + if (thumb) { + image = { + imageThumb: thumb, + imageFull: media.srcHttp ?? thumb, + width, + height, + fileSize: UrlPreviewViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]), + }; + } + } + + const result = { + link, + title, + description, + siteName, + showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(), + image, + } satisfies UrlPreviewViewSnapshotPreview; + this.previewCache.set(link, result); + return result; + } + + public dispose(): void { + super.dispose(); + SettingsStore.unwatchSetting(this.useCompactLayoutSettingWatcher); + } + + private get visibility(): PreviewVisibility { + if (!this.urlPreviewVisible) { + return PreviewVisibility.Hidden; + } else if (!this.urlPreviewEnabledByUser) { + return PreviewVisibility.UserHidden; + } else if (!this.mediaVisible) { + return PreviewVisibility.MediaHidden; + } + return PreviewVisibility.Visible; + } + + /** + * Recompute the snapshot for the view model, generating previews + * for the previously-calculated links. + */ + private async computeSnapshot(): Promise { + const previews = + this.visibility <= PreviewVisibility.UserHidden + ? [] + : await Promise.all( + this.links + .slice(0, this.limitPreviews ? MAX_PREVIEWS_WHEN_LIMITED : undefined) + .map((link) => this.fetchPreview(link)), + ); + this.snapshot.merge({ + previews: previews.filter((m) => !!m), + totalPreviewCount: this.links.length, + previewsLimited: this.limitPreviews, + overPreviewLimit: this.links.length > MAX_PREVIEWS_WHEN_LIMITED, + }); + } + + /** + * Trigger a recalculation of the links in an event. + * @param eventElement + */ + public async updateEventElement(eventElement: HTMLDivElement): Promise { + const newLinks = UrlPreviewViewModel.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))) { + this.links = newLinks; + return this.computeSnapshot(); + } + } + + /** + * Update the view model about the status of whether the event should be + * viewable. + * @param urlPreviewVisible Whether URL previews are hidden for this room. + * @param mediaVisible Whether media is hidden for this room or event. + * + * @returns A promise that completes when the snapshot has been recomputed. + */ + public readonly updateHidden = (urlPreviewVisible: boolean, mediaVisible: boolean): Promise => { + this.urlPreviewVisible = urlPreviewVisible; + this.mediaVisible = mediaVisible; + // Changing the visibility here means we need to clear cache as we may need to load + // the media again. + this.previewCache.clear(); + return this.computeSnapshot(); + }; + + /** + * Called when the user has requsted previews be visible. The provided + * props `urlPreviewVisible` state will always override this. + * + * @returns A promise that completes when the snapshot has been recomputed. + */ + public readonly onShowClick = (): Promise => { + // FIXME: persist this somewhere smarter than local storage + this.urlPreviewEnabledByUser = true; + globalThis.localStorage?.removeItem(this.storageKey); + return this.computeSnapshot(); + }; + + /** + * Called when the user has requsted previews be hidden. Will take precedence + * over other settings. + * + * @returns A promise that completes when the snapshot has been recomputed. + */ + public readonly onHideClick = (): Promise => { + // FIXME: persist this somewhere smarter than local storage + globalThis.localStorage?.setItem(this.storageKey, "1"); + this.urlPreviewEnabledByUser = false; + return this.computeSnapshot(); + }; + + /** + * Called when the user toggles the number of previews visible. + * + * @returns A promise that completes when the snapshot has been recomputed. + */ + public readonly onTogglePreviewLimit = (): Promise => { + this.limitPreviews = !this.limitPreviews; + return this.computeSnapshot(); + }; + + /** + * `true` only when the user has chosen to hide previews. + */ + public get isPreviewHiddenByUser(): boolean { + return this.visibility === PreviewVisibility.UserHidden; + } +} diff --git a/apps/web/test/test-utils/room.ts b/apps/web/test/test-utils/room.ts index ecce6818b0..8c05e02c05 100644 --- a/apps/web/test/test-utils/room.ts +++ b/apps/web/test/test-utils/room.ts @@ -75,6 +75,7 @@ export function getRoomContext(room: Room, override: Partial): showJoinLeaves: true, showAvatarChanges: true, showDisplaynameChanges: true, + showUrlPreview: false, matrixClientIsReady: false, timelineRenderingType: TimelineRenderingType.Room, mainSplitContentType: MainSplitContentType.Timeline, 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 7060c91418..8351394533 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 @@ -469,7 +469,10 @@ describe("", () => { expect(container.querySelector(".mx_LinkPreviewGroup")).toBeNull(); getComponent({ mxEvent: ev, showUrlPreview: true }, matrixClient, rerender); - expect(container.querySelector(".mx_LinkPreviewGroup")).toBeTruthy(); + waitFor(() => { + // Asynchronous check since the VM needs to recalcuate. + expect(container.querySelector(".mx_LinkPreviewGroup")).toBeTruthy(); + }); }); }); }); diff --git a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index fcc215a73f..c6e5109406 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -67,6 +67,7 @@ describe("", () => { showJoinLeaves: true, showAvatarChanges: true, showDisplaynameChanges: true, + showUrlPreview: false, matrixClientIsReady: false, timelineRenderingType: TimelineRenderingType.Room, mainSplitContentType: MainSplitContentType.Timeline, diff --git a/apps/web/test/viewmodels/message-body/UrlPreviewViewModel-test.ts b/apps/web/test/viewmodels/message-body/UrlPreviewViewModel-test.ts new file mode 100644 index 0000000000..7b86057df2 --- /dev/null +++ b/apps/web/test/viewmodels/message-body/UrlPreviewViewModel-test.ts @@ -0,0 +1,254 @@ +/* + * 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 { expect } from "@jest/globals"; + +import type { MockedObject } from "jest-mock"; +import type { MatrixClient, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix"; +import { UrlPreviewViewModel } from "../../../src/viewmodels/message-body/UrlPreviewViewModel"; +import type { UrlPreviewViewSnapshotPreview } from "@element-hq/web-shared-components"; +import { getMockClientWithEventEmitter, mkEvent } from "../../test-utils"; + +const IMAGE_MXC = "mxc://example.org/abc"; +const BASIC_PREVIEW_OGDATA = { + "og:title": "This is an example!", + "og:description": "This is a description", + "og:type": "document", + "og:url": "https://example.org", + "og:site_name": "Example.org", +}; + +function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible: true }): { + vm: UrlPreviewViewModel; + client: MockedObject; + onImageClicked: jest.Mock; +} { + const client = getMockClientWithEventEmitter({ + getUrlPreview: jest.fn(), + mxcUrlToHttp: jest.fn(), + }); + const onImageClicked = jest.fn(); + const vm = new UrlPreviewViewModel({ + client, + mediaVisible, + visible, + onImageClicked, + mxEvent: mkEvent({ + event: true, + user: "@foo:bar", + type: "m.room.message", + content: {}, + id: "$id", + }), + }); + return { vm, client, onImageClicked }; +} + +describe("UrlPreviewViewModel", () => { + it("should return no previews by default", () => { + expect(getViewModel().vm.getSnapshot()).toMatchSnapshot(); + }); + it("should preview a single valid URL", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot()).toMatchSnapshot(); + }); + it("should preview nested URLs but ignore some element types", async () => { + const { vm, client } = getViewModel(); + vm.onTogglePreviewLimit(); + client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA); + const msg = document.createElement("div"); + msg.innerHTML = ` + +
Test4
+ Test5 +
Test6
`; + await vm.updateEventElement(msg); + const { previews } = vm.getSnapshot(); + expect(previews).toHaveLength(3); + expect(previews).toMatchObject([ + { + link: "https://example.org/1", + }, + { + link: "https://example.org/2", + }, + { + link: "https://example.org/3", + }, + ]); + }); + it("should hide preview when invisible", async () => { + const { vm, client } = getViewModel({ visible: false, mediaVisible: true }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot()).toMatchSnapshot(); + expect(client.getUrlPreview).not.toHaveBeenCalled(); + }); + it("should preview a URL with media", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "This is an example!", + "og:type": "document", + "og:url": "https://example.org", + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 10000, + }); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url, width) => { + expect(url).toEqual(IMAGE_MXC); + if (width) { + return "https://example.org/image/thumb"; + } + return "https://example.org/image/src"; + }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot()).toMatchSnapshot(); + }); + it("should ignore media when mediaVisible is false", async () => { + const { vm, client } = getViewModel({ mediaVisible: false, visible: true }); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "This is an example!", + "og:type": "document", + "og:url": "https://example.org", + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 10000, + }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot()).toMatchSnapshot(); + // eslint-disable-next-line no-restricted-properties + expect(client.mxcUrlToHttp).not.toHaveBeenCalled(); + }); + it("should deduplicate multiple versions of the same URL", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); + const msg = document.createElement("div"); + msg.innerHTML = + 'TestTestTest'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot()).toMatchSnapshot(); + expect(client.getUrlPreview).toHaveBeenCalledTimes(1); + }); + it("should ignore failed previews", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockRejectedValue(new Error("Forced test failure")); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot()).toMatchSnapshot(); + }); + it("should handle image clicks", async () => { + const { vm, client, onImageClicked } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "This is an example!", + "og:type": "document", + "og:url": "https://example.org", + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 10000, + }); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url, width) => { + expect(url).toEqual(IMAGE_MXC); + if (width) { + return "https://example.org/image/thumb"; + } + return "https://example.org/image/src"; + }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + const { previews } = vm.getSnapshot(); + vm.onImageClick(previews[0]); + expect(onImageClicked).toHaveBeenCalled(); + }); + it("should handle being hidden and shown by the user", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + await vm.onHideClick(); + expect(vm.getSnapshot()).toMatchSnapshot(); + + await vm.onShowClick(); + expect(vm.getSnapshot()).toMatchSnapshot(); + }); + + it.each([ + { text: "", href: "", hasPreview: false }, + { text: "test", href: "noprotocol.example.org", hasPreview: false }, + { text: "matrix link", href: "https://matrix.to", hasPreview: false }, + { text: "email", href: "mailto:example.org", hasPreview: false }, + { text: "", href: "https://example.org", hasPreview: true }, + ])("handles different kinds of links %s", async (item) => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA); + const msg = document.createElement("div"); + msg.innerHTML = `${item.text}`; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews).toHaveLength(item.hasPreview ? 1 : 0); + }); + + // og:url, og:type are ignored. + const baseOg = { + "og:url": "https://example.org", + "og:type": "document", + }; + + it.each([ + { ...baseOg, "og:title": "Basic title" }, + { ...baseOg, "og:site_name": "Site name", "og:title": "" }, + { ...baseOg, "og:description": "A description", "og:title": "" }, + { ...baseOg, "og:title": "Cool blog", "og:site_name": "Cool site" }, + { + ...baseOg, + "og:title": "Media test", + // API *may* return a string, so check we parse correctly. + "og:image:height": "500" as unknown as number, + "og:image:width": 500, + "matrix:image:size": 1024, + "og:image": IMAGE_MXC, + }, + ])("handles different kinds of opengraph responses %s", async (og) => { + const { vm, client } = getViewModel(); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url, width) => { + expect(url).toEqual(IMAGE_MXC); + if (width) { + return "https://example.org/image/thumb"; + } + return "https://example.org/image/src"; + }); + client.getUrlPreview.mockResolvedValueOnce(og); + const msg = document.createElement("div"); + msg.innerHTML = `test`; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews[0]).toMatchSnapshot(); + }); +}); diff --git a/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewViewModel-test.ts.snap b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewViewModel-test.ts.snap new file mode 100644 index 0000000000..29c607dd0a --- /dev/null +++ b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewViewModel-test.ts.snap @@ -0,0 +1,203 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = ` +{ + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": undefined, + "title": "A description", +} +`; + +exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = ` +{ + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": undefined, + "title": "Site name", +} +`; + +exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = ` +{ + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": undefined, + "title": "Basic title", +} +`; + +exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = ` +{ + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": "Cool site", + "title": "Cool blog", +} +`; + +exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 1024,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = ` +{ + "description": undefined, + "image": { + "fileSize": 1024, + "height": 100, + "imageFull": "https://example.org/image/src", + "imageThumb": "https://example.org/image/thumb", + "width": 100, + }, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": undefined, + "title": "Media test", +} +`; + +exports[`UrlPreviewViewModel should deduplicate multiple versions of the same URL 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [ + { + "description": "This is a description", + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": "Example.org", + "title": "This is an example!", + }, + ], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should handle being hidden and shown by the user 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should handle being hidden and shown by the user 2`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [ + { + "description": "This is a description", + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": "Example.org", + "title": "This is an example!", + }, + ], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should hide preview when invisible 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should ignore failed previews 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should ignore media when mediaVisible is false 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [ + { + "description": undefined, + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": undefined, + "title": "This is an example!", + }, + ], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should preview a URL with media 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [ + { + "description": undefined, + "image": { + "fileSize": 10000, + "height": 100, + "imageFull": "https://example.org/image/src", + "imageThumb": "https://example.org/image/thumb", + "width": 100, + }, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": undefined, + "title": "This is an example!", + }, + ], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should preview a single valid URL 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [ + { + "description": "This is a description", + "image": undefined, + "link": "https://example.org", + "showTooltipOnLink": undefined, + "siteName": "Example.org", + "title": "This is an example!", + }, + ], + "previewsLimited": true, + "totalPreviewCount": 1, +} +`; + +exports[`UrlPreviewViewModel should return no previews by default 1`] = ` +{ + "compactLayout": false, + "overPreviewLimit": false, + "previews": [], + "previewsLimited": true, + "totalPreviewCount": 0, +} +`; diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/default-auto.png new file mode 100644 index 0000000000..ff1cfa2b24 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/title-and-description-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/title-and-description-auto.png new file mode 100644 index 0000000000..5767d6aa32 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/title-and-description-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/title-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/title-auto.png new file mode 100644 index 0000000000..390c358959 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/title-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/with-tooltip-auto.png new file mode 100644 index 0000000000..d4b4048145 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/with-tooltip-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/with-very-long-text-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/with-very-long-text-auto.png new file mode 100644 index 0000000000..263f3b1eef Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/LinkPreview.stories.tsx/with-very-long-text-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..b41968503d Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png new file mode 100644 index 0000000000..fc3272f314 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png new file mode 100644 index 0000000000..2a5a79992b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png new file mode 100644 index 0000000000..bca73a6e3b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png differ diff --git a/packages/shared-components/src/@types/global.d.ts b/packages/shared-components/src/@types/global.d.ts index 5e8049628c..5a5e216cd8 100644 --- a/packages/shared-components/src/@types/global.d.ts +++ b/packages/shared-components/src/@types/global.d.ts @@ -1,5 +1,6 @@ /* Copyright 2025 New Vector Ltd. +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. @@ -12,3 +13,9 @@ declare module "*.md?raw" { const content: string; export default content; } + +// For importing PNGs for use in testing +declare module "*.png" { + const content: string; + export default content; +} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.module.css b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.module.css new file mode 100644 index 0000000000..86e67b73c7 --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.module.css @@ -0,0 +1,85 @@ +/* + * 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. + */ + +.thumbnail { + /* Thumbnails are always limited to a maximum of 100px */ + max-width: 100px; + max-height: 100px; + /* Ensure we don't stretch the image */ + object-fit: cover; +} + +.link { + color: var(--cpd-color-text-link-external); + text-decoration-line: none; +} + +.container { + display: inline flex; + column-gap: var(--cpd-space-1x); + border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary); + border-radius: 2px; + color: var(--cpd-color-gray-900); + + .wrapImageCaption { + display: inline-flex; + flex-direction: row; + flex-wrap: wrap; + row-gap: var(--cpd-space-2x); + flex: 1; + } + + .image, + .caption { + display: inline-flex; + flex-direction: column; + margin-inline-start: var(--cpd-space-4x); + min-width: 0; /* Prevent blowout */ + } + + .image { + /* Clear default