mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-09 23:41:50 +01:00
Port url preview logic to a view model.
This commit is contained in:
parent
c5d56b3acf
commit
fae453b85a
@ -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<IBodyProps, IState> {
|
||||
export default class TextualBody extends React.Component<IBodyProps> {
|
||||
private readonly contentRef = createRef<HTMLDivElement>();
|
||||
private readonly urlPreviewVMRef = createRef<UrlPreviewViewModel>();
|
||||
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
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<IBodyProps>): 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<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() ||
|
||||
@ -80,94 +75,10 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
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({
|
||||
@ -202,14 +113,11 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
|
||||
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<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 = this.urlPreviewVMRef.current && (
|
||||
<LinkPreviewGroup vm={this.urlPreviewVMRef.current} mxEvent={mxEvent} />
|
||||
);
|
||||
|
||||
if (isEmote) {
|
||||
return (
|
||||
@ -395,7 +296,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
</span>
|
||||
|
||||
{body}
|
||||
{widgets}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -403,7 +304,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>
|
||||
);
|
||||
}
|
||||
@ -411,14 +312,14 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 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<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">
|
||||
<ElementLinkedText>{description}</ElementLinkedText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
261
apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts
Normal file
261
apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts
Normal file
@ -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<HTMLDivElement | null>;
|
||||
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<UrlPreviewViewSnapshotInterfacePreview>;
|
||||
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<UrlPreviewViewSnapshotInterface, UrlPreviewViewModelProps> {
|
||||
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<Element>): string[] {
|
||||
let links = new Set<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.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<UrlPreviewViewSnapshotInterfacePreview | null> {
|
||||
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<HTMLDivElement | null>;
|
||||
private eventSendTime: number;
|
||||
private showUrlPreview: boolean;
|
||||
private readonly storageKey: string;
|
||||
private limitPreviews = true;
|
||||
private previewCache = new Map<string, UrlPreviewViewSnapshotInterfacePreview>();
|
||||
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user