Port URL Preview components to MVVM (#32525)

* Port over linkifyJS to shared-components.

* Drop rubbish

* update lock

* quickfix test

* drop group id

* Modernize tests

* Remove stories that aren't in use.

* Complete working version

* Add copyright

* tidy up

* update lock

* Update snaps

* update snap

* undo change

* remove unused

* More test updates

* fix typo

* fix margin on preview

* move margin block

* snapupdate

* prettier

* Port url preview logic to a view model.

* More fiddling with VM logic

* Note to self

* Refactor away into a shared component.

* Even more lovely lovely code that makes it look prettier

* translation cleanup

* Even more stuff that I need to fix yay

* Remove .last-run.json

* Update snaps

* Ensure we set showUrlPreview

* Cleanup tests

* lint + add png support

* Add a label

* Cleanup

* Add snaps

* Update snaps

* update playwright

* Refactors

* update snap

* Add missing snap

* Remove editing code (we check this in a better way in componentDidUpdate)

* Add README

* fix the one unused import

* Style shuffling

* Update vis tests

* Finally fix the tooltip

* Remove unused prop

* Add some padding

* fix lint issue

* Design improvements

* new screens

* Update snaps

* Fix CSS specificity

* Remove stale screenshot

* Rename function to match reality

* Port viewmodel tests to snapshots

* finish documenting types

* Stop being dangerous

* Use Linkify+decode for description

* Remove ability for VM to do linkifying.

* Port over linkifyJS to shared-components.

* Drop rubbish

* update lock

* quickfix test

* drop group id

* Modernize tests

* Remove stories that aren't in use.

* Complete working version

* Add copyright

* tidy up

* update lock

* Update snaps

* update snap

* undo change

* remove unused

* More test updates

* fix typo

* fix margin on preview

* move margin block

* snapupdate

* prettier

* cleanup a test mistake

* Fixup sonar issues

* Don't expose linkifyjs to applications, just provide helper functions.

* Add story for documentation.

* remove $

* Use a const

* typo

* cleanup var name

* remove console line

* Changes checkpoint

* Convert to context

* Revert unrelated change.

* more cleanup

* Add a test to cover ignoring incoming data elements

* Make tests happy

* Update tests for LinkedText

* Underlines!

* fix lock

* remove unused linkify packages

* import move

* Remove mod to remove underline

* undo

* fix snap

* another snapshot fix

* More cleanup

* Tidy up based on review.

* fix story

* Pass in args

* update snap

* cleanup

* use source image

* oops

* remove client peg

* Remove unused state

* tidy up code

* Ensure we update the preview when the event content may have changed.

* s/global/globalThis/

* Ensure we don't stretch images

* Update screenshots

* Cleanup
This commit is contained in:
Will Hunt
2026-03-16 10:05:34 +00:00
committed by GitHub
parent b54e4e3b98
commit 3b4027846d
43 changed files with 2244 additions and 500 deletions
@@ -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<IRoomProps, IRoomState> {
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,
@@ -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<IBodyProps, IState> {
class InnerTextualBody extends React.Component<Props> {
private readonly contentRef = createRef<HTMLDivElement>();
public static contextType = RoomContext;
@@ -46,12 +52,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
private EventContentBodyViewModel: EventContentBodyViewModel;
public state = {
links: [],
widgetHidden: false,
};
public constructor(props: IBodyProps, context: React.ContextType<typeof RoomContext>) {
public constructor(props: Props, context: React.ContextType<typeof RoomContext>) {
super(props, context);
const mxEvent = props.mxEvent;
const content = mxEvent.getContent();
@@ -78,10 +79,18 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
});
}
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<IBodyProps>): void {
@@ -113,125 +122,25 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
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<IBodyProps>, nextState: Readonly<IState>): boolean {
//console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>): boolean {
// exploit that events are immutable :)
return (
nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
nextProps.highlights !== this.props.highlights ||
nextProps.replacingEventId !== this.props.replacingEventId ||
nextProps.highlightLink !== this.props.highlightLink ||
nextProps.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<Element>): 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<IBodyProps, IState> {
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<IBodyProps, IState> {
return <span className="mx_EventTile_pendingModeration">{`(${text})`}</span>;
}
public componentDidMount(): void {
this.updateURLPreviewViewModel();
}
public render(): React.ReactNode {
if (this.props.editState) {
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
@@ -424,16 +341,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
);
}
let widgets;
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
widgets = (
<LinkPreviewGroup
links={this.state.links}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
/>
);
}
const urlPreviewWidget = <UrlPreviewGroupView vm={this.props.urlPreviewViewModel} />;
if (isEmote) {
return (
@@ -449,7 +357,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
</span>
&nbsp;
{body}
{widgets}
{urlPreviewWidget}
</div>
);
}
@@ -457,7 +365,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
return (
<div id={this.props.id} className="mx_MNoticeBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
{body}
{widgets}
{urlPreviewWidget}
</div>
);
}
@@ -465,15 +373,59 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
return (
<div id={this.props.id} className="mx_MTextBody mx_EventTile_caption" onClick={this.onBodyLinkClick}>
{body}
{widgets}
{urlPreviewWidget}
</div>
);
}
return (
<div id={this.props.id} className="mx_MTextBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
{body}
{widgets}
{urlPreviewWidget}
</div>
);
}
}
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 <InnerTextualBody urlPreviewViewModel={vm} {...props} />;
}
@@ -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<IProps> = ({ 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 = (
<AccessibleButton onClick={toggleExpanded}>
{expanded
? _t("action|collapse")
: _t("timeline|url_preview|show_n_more", { count: previews.length - showPreviews.length })}
</AccessibleButton>
);
}
return (
<div className="mx_LinkPreviewGroup">
{showPreviews.map(([link, preview], i) => (
<LinkPreviewWidget
mediaVisible={mediaVisible}
key={link}
link={link}
preview={preview}
mxEvent={mxEvent}
>
{i === 0 ? (
<AccessibleButton
className="mx_LinkPreviewGroup_hide"
onClick={onCancelClick}
aria-label={_t("timeline|url_preview|close")}
>
<CloseIcon width="20px" height="20px" />
</AccessibleButton>
) : undefined}
</LinkPreviewWidget>
))}
{toggleButton}
</div>
);
};
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;
@@ -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<IProps> {
private image = createRef<HTMLImageElement>();
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<ComponentProps<typeof ImageView>, "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 = (
<div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
<img
ref={this.image}
style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }}
src={image}
onClick={this.onImageClick}
alt=""
/>
</div>
);
}
// 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 = (
<a href={this.props.link} target="_blank" rel="noreferrer noopener">
{title}
</a>
);
const needsTooltip = PlatformPeg.get()?.needsUrlTooltips() && this.props.link !== title;
return (
<div className="mx_LinkPreviewWidget">
<div className="mx_LinkPreviewWidget_wrapImageCaption">
{img}
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title">
{needsTooltip ? (
<LinkWithTooltip tooltip={new URL(this.props.link, window.location.href).toString()}>
{anchor}
</LinkWithTooltip>
) : (
anchor
)}
{p["og:site_name"] && (
<span className="mx_LinkPreviewWidget_siteName">{" - " + p["og:site_name"]}</span>
)}
</div>
<div className="mx_LinkPreviewWidget_description">
<LinkedText>{description}</LinkedText>
</div>
</div>
</div>
{this.props.children}
</div>
);
}
}