Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Improved matrix.to preview #8668

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions src/components/views/rooms/LinkPreviewGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,19 @@ limitations under the License.

import React, { useContext, useEffect } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IPreviewUrlResponse, MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/@types/event";

import { useStateToggle } from "../../../hooks/useStateToggle";
import LinkPreviewWidget from "./LinkPreviewWidget";
import LinkPreviewWidget, { Preview } from "./LinkPreviewWidget";
import AccessibleButton from "../elements/AccessibleButton";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import MatrixToPermalinkConstructor from "../../../utils/permalinks/MatrixToPermalinkConstructor";
import { getCachedRoomIDForAlias } from "../../../RoomAliasCache";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";

const INITIAL_NUM_PREVIEWS = 2;

Expand All @@ -40,7 +44,7 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onH
const [expanded, toggleExpanded] = useStateToggle();

const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
const previews = useAsyncMemo<[string, Preview][]>(async () => {
return fetchPreviews(cli, links, ts);
}, [links, ts], []);

Expand Down Expand Up @@ -84,18 +88,59 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onH
</div>;
};

const fetchPreviews = (cli: MatrixClient, links: string[], ts: number):
Promise<[string, IPreviewUrlResponse][]> => {
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => {
const fetchPreviews = (cli: MatrixClient, links: string[], ts: number): Promise<[string, Preview][]> => {
return Promise.all<[string, Preview] | void>(links.map(async link => {
try {
const preview = await cli.getUrlPreview(link, ts);
// For comprehensible matrix.to links try to preview them better, using firstly local data,
// falling back to the room summary API, falling back to a boring old server-side preview otherwise.
const decoded = decodeURIComponent(link);
let preview: Preview;
if (decoded) {
const permalink = new MatrixToPermalinkConstructor().parsePermalink(decoded);
const roomId = permalink.roomIdOrAlias[0] === "!"
? permalink.roomIdOrAlias
: getCachedRoomIDForAlias(permalink.roomIdOrAlias);
const room = cli.getRoom(roomId);
if (room) {
const topic = room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic;
const event = permalink.eventId && room.findEventById(permalink.eventId);
if (event) {
preview = {
title: room.name,
summary: topic,
description: <>
{ MessagePreviewStore.instance.generatePreviewForEvent(event) }
</>,
avatarUrl: room.getMxcAvatarUrl(),
};
} else {
preview = {
title: room.name,
description: topic,
avatarUrl: room.getMxcAvatarUrl(),
};
}
} else {
preview = await cli.getRoomSummary(permalink.roomIdOrAlias, permalink.viaServers).then(summary => ({
title: summary.name,
description: summary.topic,
avatarUrl: summary.avatar_url,
}));
}
}

// Fall back to a server-side preview always
if (!preview) {
preview = await cli.getUrlPreview(link, ts);
}

if (preview && Object.keys(preview).length > 0) {
return [link, preview];
}
} catch (error) {
logger.error("Failed to get URL preview: " + error);
}
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
})).then(a => a.filter(Boolean)) as Promise<[string, Preview][]>;
};

export default LinkPreviewGroup;
65 changes: 46 additions & 19 deletions src/components/views/rooms/LinkPreviewWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { ComponentProps, createRef } from 'react';
import React, { ComponentProps, createRef, ReactNode } from 'react';
import { AllHtmlEntities } from 'html-entities';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client';
Expand All @@ -26,9 +26,18 @@ import * as ImageUtils from "../../../ImageUtils";
import { mediaFromMxc } from "../../../customisations/Media";
import ImageView from '../elements/ImageView';

export interface IPreview {
title: string;
summary?: string;
description: ReactNode;
avatarUrl: string;
}

export type Preview = IPreviewUrlResponse | IPreview;

interface IProps {
link: string;
preview: IPreviewUrlResponse;
preview: Preview;
mxEvent: MatrixEvent; // the Event associated with the preview
}

Expand Down Expand Up @@ -81,55 +90,73 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
};

private get preview(): IPreview {
if (this.props.preview.title) {
return this.props.preview as IPreview;
}

return {
title: this.props.preview["og:title"],
summary: this.props.preview["og:site_name"],
// 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.
description: AllHtmlEntities.decode(this.props.preview["og:description"] ?? ""),
avatarUrl: this.props.preview["og:image"],
};
}

render() {
const p = this.props.preview;
if (!p || Object.keys(p).length === 0) {
if (Object.keys(this.props.preview).length === 0) {
return <div />;
}

const preview = this.preview;
// FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing?
let image = p["og:image"];
let image = preview.avatarUrl;
if (!SettingsStore.getValue("showImages")) {
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://")) {
if (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');
}

let thumbHeight = imageMaxHeight;
if (p["og:image:width"] && p["og:image:height"]) {
if (this.props.preview["og:image:width"] && this.props.preview["og:image:height"]) {
thumbHeight = ImageUtils.thumbHeight(
p["og:image:width"], p["og:image:height"],
imageMaxWidth, imageMaxHeight,
this.props.preview["og:image:width"],
this.props.preview["og:image:height"],
imageMaxWidth,
imageMaxHeight,
);
}

let img;
let img: JSX.Element;
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} />
<img
ref={this.image}
style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }}
src={image}
onClick={this.onImageClick}
/>
</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 = AllHtmlEntities.decode(p["og:description"] || "");

return (
<div className="mx_LinkPreviewWidget">
{ img }
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title">
<a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a>
{ p["og:site_name"] && <span className="mx_LinkPreviewWidget_siteName">
{ (" - " + p["og:site_name"]) }
<a href={this.props.link} target="_blank" rel="noreferrer noopener">{ preview.title }</a>
{ preview.summary && <span className="mx_LinkPreviewWidget_siteName">
{ (" - " + preview.summary) }
</span> }
</div>
<div className="mx_LinkPreviewWidget_description" ref={this.description}>
{ description }
{ preview.description }
</div>
</div>
{ this.props.children }
Expand Down