From 5db28ca3273ccb4108156ddd96746c0a2816dd6a Mon Sep 17 00:00:00 2001 From: Shea Duma Date: Sun, 29 Mar 2026 19:55:58 +0300 Subject: [PATCH 1/3] added basic bundled rendering --- src/app/components/RenderMessageContent.tsx | 20 +- .../components/message/MsgTypeRenderers.tsx | 76 +++- .../components/url-preview/UrlPreviewCard.tsx | 386 +++++++++--------- 3 files changed, 274 insertions(+), 208 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 6f3617858..985f15ce3 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useCallback } from 'react'; -import { MsgType } from '$types/matrix-sdk'; +import { IPreviewUrlResponse, MsgType } from '$types/matrix-sdk'; import { testMatrixTo } from '$plugins/matrix-to'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom, CaptionPosition } from '$state/settings'; @@ -111,7 +111,6 @@ function RenderMessageContentInternal({ const mediaLinks = analyzed.filter((item) => item.type !== null); const toRender = mediaLinks.length > 0 ? mediaLinks : [analyzed[0]]; - return ( {toRender.map(({ url, type }) => { @@ -131,6 +130,16 @@ function RenderMessageContentInternal({ }, [ts, clientUrlPreview, urlPreview] ); + const renderBundledPreviews = useCallback( + (bundles: IPreviewUrlResponse[]) => ( + + {bundles.map((bundle) => ( + + ))} + + ), + [] + ); const renderCaption = () => { const hasCaption = content.body && content.body.trim().length > 0; @@ -144,6 +153,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderBundledPreviews={renderBundledPreviews} /> ); return ( @@ -163,6 +173,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderBundledPreviews={renderBundledPreviews} /> ); @@ -225,7 +236,8 @@ function RenderMessageContentInternal({ edited={edited} content={content} renderBody={renderBody} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderUrlsPreview={renderUrlsPreview} + renderBundledPreviews={renderBundledPreviews} /> ); } @@ -247,6 +259,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderBundledPreviews={renderBundledPreviews} /> ); } @@ -258,6 +271,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderBundledPreviews={renderBundledPreviews} /> ); } diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 6d1a63cce..647e10dd2 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -1,6 +1,6 @@ import { CSSProperties, ReactNode, useMemo } from 'react'; import { Box, Chip, Icon, Icons, Text, toRem } from 'folds'; -import { IContent } from '$types/matrix-sdk'; +import { IContent, IPreviewUrlResponse } from '$types/matrix-sdk'; import { JUMBO_EMOJI_REG, URL_REG } from '$utils/regex'; import { trimReplyFromBody } from '$utils/room'; import { @@ -82,9 +82,17 @@ type MTextProps = { content: Record; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; + renderBundledPreviews?: (bundles: IPreviewUrlResponse[]) => ReactNode; style?: CSSProperties; }; -export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) { +export function MText({ + edited, + content, + renderBody, + renderUrlsPreview, + renderBundledPreviews, + style, +}: MTextProps) { const [jumboEmojiSize] = useSetting(settingsAtom, 'jumboEmojiSize'); const body = typeof content.body === 'string' ? content.body : ''; @@ -139,8 +147,13 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }: if (!body && !customBody) return ; - const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + let urls: string[] | undefined; + let bundleContent: IPreviewUrlResponse[] | undefined; + if (!content['com.beeper.linkpreviews']) { + const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); + urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + } else if ((content['com.beeper.linkpreviews'] as Array).length > 0) + bundleContent = content['com.beeper.linkpreviews'] as IPreviewUrlResponse[]; if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) { // unwrap per-message profile fallback if present @@ -167,7 +180,11 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }: customBody: unwrappedForwardedContent, })} {edited && } - {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} + {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || + (renderBundledPreviews && + bundleContent && + bundleContent.length > 0 && + renderBundledPreviews(bundleContent))} ); } @@ -185,7 +202,11 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }: })} {edited && } - {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} + {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || + (renderBundledPreviews && + bundleContent && + bundleContent.length > 0 && + renderBundledPreviews(bundleContent))} ); } @@ -196,6 +217,7 @@ type MEmoteProps = { content: Record; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; + renderBundledPreviews?: (bundles: IPreviewUrlResponse[]) => ReactNode; }; export function MEmote({ displayName, @@ -203,6 +225,7 @@ export function MEmote({ content, renderBody, renderUrlsPreview, + renderBundledPreviews, }: MEmoteProps) { const { body, formatted_body: customBody } = content; const [jumboEmojiSize] = useSetting(settingsAtom, 'jumboEmojiSize'); @@ -211,10 +234,16 @@ export function MEmote({ return ; } const trimmedBody = trimReplyFromBody(body); - const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody); + let urls: string[] | undefined; + let bundleContent: IPreviewUrlResponse[] | undefined; + if (!content['com.beeper.linkpreviews']) { + const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); + urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + } else if ((content['com.beeper.linkpreviews'] as Array).length > 0) + bundleContent = content['com.beeper.linkpreviews'] as IPreviewUrlResponse[]; + return ( <> } - {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} + {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || + (renderBundledPreviews && + bundleContent && + bundleContent.length > 0 && + renderBundledPreviews(bundleContent))} ); } @@ -239,8 +272,15 @@ type MNoticeProps = { content: Record; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; + renderBundledPreviews?: (bundles: IPreviewUrlResponse[]) => ReactNode; }; -export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) { +export function MNotice({ + edited, + content, + renderBody, + renderUrlsPreview, + renderBundledPreviews, +}: MNoticeProps) { const { body, formatted_body: customBody } = content; const [jumboEmojiSize] = useSetting(settingsAtom, 'jumboEmojiSize'); @@ -248,10 +288,16 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot return ; } const trimmedBody = trimReplyFromBody(body); - const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + let urls: string[] | undefined; const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody); + let bundleContent: IPreviewUrlResponse[] | undefined; + if (!content['com.beeper.linkpreviews']) { + const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); + urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + } else if ((content['com.beeper.linkpreviews'] as Array).length > 0) + bundleContent = content['com.beeper.linkpreviews'] as IPreviewUrlResponse[]; + return ( <> } - {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} + {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || + (renderBundledPreviews && + bundleContent && + bundleContent.length > 0 && + renderBundledPreviews(bundleContent))} ); } diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 3ac780f74..529325f3c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -41,177 +41,81 @@ const openMediaInNewTab = async (url: string | undefined) => { window.open(blobUrl, '_blank'); }; -export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: string | null }>( - ({ url, ts, mediaType, ...props }, ref) => { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); +export const UrlPreviewCard = as< + 'div', + { url: string; ts?: number; mediaType?: string | null; bundle?: IPreviewUrlResponse } +>(({ url, ts, mediaType, bundle, ...props }, ref) => { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); - const isDirect = !!mediaType; + const isDirect = !!mediaType; - const [previewStatus, loadPreview] = useAsyncCallback( - useCallback(() => { - if (isDirect) return Promise.resolve(null); - const clientCache = getClientCache(mx); - const cached = clientCache.get(url); - if (cached !== undefined) return cached; - const urlPreview = mx.getUrlPreview(url, ts); - clientCache.set(url, urlPreview); - urlPreview.finally(() => clientCache.delete(url)); - return urlPreview; - }, [url, ts, mx, isDirect]) - ); + const [previewStatus, loadPreview] = useAsyncCallback( + useCallback(() => { + if (isDirect) return Promise.resolve(null); + if (!ts && !bundle) return Promise.resolve(null); + const clientCache = getClientCache(mx); + const cached = clientCache.get(url); + if (cached !== undefined) return cached; + const urlPreview = !bundle ? mx?.getUrlPreview(url, ts ?? -1) : Promise.resolve(bundle); + clientCache.set(url, urlPreview); + urlPreview.finally(() => clientCache.delete(url)); + return urlPreview; + }, [isDirect, mx, url, ts, bundle]) + ); - useEffect(() => { - loadPreview(); - }, [url, loadPreview]); + useEffect(() => { + loadPreview(); + }, [url, loadPreview]); - if (previewStatus.status === AsyncStatus.Error) return null; + if (previewStatus.status === AsyncStatus.Error) return null; - const renderContent = (prev: IPreviewUrlResponse) => { - const siteName = prev['og:site_name']; - const title = prev['og:title']; - const description = prev['og:description']; - const imgUrl = mxcUrlToHttp( - mx, - prev['og:image'] || '', - useAuthentication, - 256, - 256, - 'scale', - false - ); - const handleAuxClick = (ev: React.MouseEvent) => { - if (!prev['og:image']) { - console.warn('No image'); + const renderContent = (prev: IPreviewUrlResponse) => { + const siteName = prev['og:site_name']; + const title = prev['og:title']; + const description = prev['og:description']; + const imgUrl = mxcUrlToHttp( + mx, + prev['og:image'] || '', + useAuthentication, + 256, + 256, + 'scale', + false + ); + const handleAuxClick = (ev: React.MouseEvent) => { + if (!prev['og:image']) { + console.warn('No image'); + return; + } + if (ev.button === 1) { + ev.preventDefault(); + const mxcUrl = mxcUrlToHttp(mx, prev['og:image'], /* useAuthentication */ true); + if (!mxcUrl) { + console.error('Error converting mxc:// url.'); return; } - if (ev.button === 1) { - ev.preventDefault(); - const mxcUrl = mxcUrlToHttp(mx, prev['og:image'], /* useAuthentication */ true); - if (!mxcUrl) { - console.error('Error converting mxc:// url.'); - return; - } - openMediaInNewTab(mxcUrl); - } - }; + openMediaInNewTab(mxcUrl); + } + }; - return ( - + - - - {typeof siteName === 'string' && `${siteName} | `} - {safeDecodeUrl(url)} - - {title && ( - - {title} - - )} - {description && ( - - {description} - - )} - - {prev['og:video'] && ( - - ); - }; - - let previewContent; - if (previewStatus.status === AsyncStatus.Success) { - previewContent = previewStatus.data ? ( - renderContent(previewStatus.data) - ) : ( - + {typeof siteName === 'string' && `${siteName} | `} {safeDecodeUrl(url)} + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} - ); - } else { - previewContent = ( - - - - ); - } - return ( - - {previewContent} - + {prev['og:video'] && ( +