diff --git a/src/components/views/rooms/LinkPreviewGroup.tsx b/src/components/views/rooms/LinkPreviewGroup.tsx index a74dc134576..96653e1b7ae 100644 --- a/src/components/views/rooms/LinkPreviewGroup.tsx +++ b/src/components/views/rooms/LinkPreviewGroup.tsx @@ -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; @@ -40,7 +44,7 @@ const LinkPreviewGroup: React.FC = ({ 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], []); @@ -84,18 +88,59 @@ const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick, onH ; }; -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; diff --git a/src/components/views/rooms/LinkPreviewWidget.tsx b/src/components/views/rooms/LinkPreviewWidget.tsx index d14c504dd8c..ac1b37f90b0 100644 --- a/src/components/views/rooms/LinkPreviewWidget.tsx +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -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'; @@ -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 } @@ -81,55 +90,73 @@ export default class LinkPreviewWidget extends React.Component { 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
; } + 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 =
- +
; } - // 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 (
{ img }
- { p["og:title"] } - { p["og:site_name"] && - { (" - " + p["og:site_name"]) } + { preview.title } + { preview.summary && + { (" - " + preview.summary) } }
- { description } + { preview.description }
{ this.props.children }