diff --git a/apps/web/src/utils/UrlPreviewFetcher.ts b/apps/web/src/utils/UrlPreviewFetcher.ts index 3b6a4a21812..d01b092faad 100644 --- a/apps/web/src/utils/UrlPreviewFetcher.ts +++ b/apps/web/src/utils/UrlPreviewFetcher.ts @@ -12,6 +12,7 @@ import { decode } from "html-entities"; import type { UrlPreview } from "@element-hq/web-shared-components"; import { mediaFromMxc } from "../customisations/Media"; import { thumbHeight } from "../ImageUtils"; +import { type UnstableBundledUrlPreviewSingle } from "../../@types/url-preview"; const logger = rootLogger.getChild("UrlPreviewFetcher"); @@ -201,4 +202,50 @@ export class UrlPreviewFetcher { this.cache.set(link, result); return result; } + + // Convert an MSC4095 URL preview bundle item to a UrlPreview + public previewFromBundle(single: UnstableBundledUrlPreviewSingle): UrlPreview { + // TODO: missing fields from the bundle are: + // - siteName (can be computed) + // - siteIcon + // - media is a video or audio? + // TODO: URL previews in encrypted chat? + const hasImage = + typeof single["og:image"] === "string" && + typeof single["og:image:type"] === "string" && + typeof single["og:image:width"] === "number" && + typeof single["og:image:height"] === "number"; + + const preview: UrlPreview = { + link: single.matched_url, + title: single["og:title"] ?? single.matched_url, + siteName: new URL(single.matched_url).hostname, + showTooltipOnLink: !!(single.matched_url !== single["og:title"] && this.showTooltips), + description: single["og:description"], + ogUrl: single["og:url"], + }; + + if (hasImage) { + const media = mediaFromMxc(single["og:image"], this.client); + const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale"); + + // cannot rule out the mxc:// url is malformed because + // the sender can specify anything + if (media.srcHttp === null || thumb === null) { + return preview; + } + + preview.image = { + imageThumb: thumb, + imageFull: media.srcHttp, + imageType: single["og:image:type"] as string, + mxcImageFull: single["og:image"] as string, + width: single["og:image:width"] as number, + height: single["og:image:height"] as number, + playable: false, // TODO: do we know? + }; + } + + return preview; + } } diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts index 5b421d64559..d86553bbcc3 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MsgType, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, type UrlPreview, @@ -17,6 +17,8 @@ import { type UrlPreviewVisibilityChanged } from "@matrix-org/analytics-events/t import { PosthogAnalytics } from "../../PosthogAnalytics"; import { isPermalinkHost } from "../../utils/permalinks/Permalinks"; import { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher"; +import { type RoomMessageEventContent } from "../../../@types/url-preview"; +import SettingsStore from "../../settings/SettingsStore"; // From https://github.com/matrix-org/matrix-spec-proposals/pull/4095 export const BUNDLED_LINK_PREVIEWS = "com.beeper.linkpreviews"; @@ -45,8 +47,7 @@ export interface UrlPreviewGroupViewModelProps { export class UrlPreviewGroupViewModel extends BaseViewModel - implements UrlPreviewGroupViewActions -{ + implements UrlPreviewGroupViewActions { /** * Determine if an anchor element can be rendered into a preview. * If it can, return the value of `href` @@ -167,14 +168,31 @@ export class UrlPreviewGroupViewModel } const loadMedia = this.visibility === PreviewVisibility.Visible; - const previews = - this.visibility <= PreviewVisibility.UserHidden - ? [] - : await Promise.all( - this.links - .slice(0, this.limitPreviews ? MAX_PREVIEWS_WHEN_LIMITED : undefined) - .map((link) => this.fetcher.fetchPreview(link, loadMedia)), - ); + let previews: (UrlPreview | null)[] | undefined; + + if (this.visibility <= PreviewVisibility.UserHidden) { + previews = []; + } + + const content = this.props.mxEvent.getContent(); + if (content.msgtype === MsgType.Text && SettingsStore.getValue("feature_msc4095_url_preview_bundle")) { + const messageContent = content as RoomMessageEventContent; + + if (messageContent[BUNDLED_LINK_PREVIEWS] !== undefined) { + previews = messageContent[BUNDLED_LINK_PREVIEWS] + .slice(0, this.limitPreviews ? MAX_PREVIEWS_WHEN_LIMITED : undefined) + .map((preview) => this.fetcher.previewFromBundle(preview)); + } + } + + if (previews === undefined) { + previews = await Promise.all( + this.links + .slice(0, this.limitPreviews ? MAX_PREVIEWS_WHEN_LIMITED : undefined) + .map((link) => this.fetcher.fetchPreview(link, loadMedia)), + ); + } + this.snapshot.merge({ previews: previews.filter((p) => !!p), totalPreviewCount: this.links.length,