diff --git a/.changeset/add_msc4095_displaying.md b/.changeset/add_msc4095_displaying.md new file mode 100644 index 000000000..a8b83ed00 --- /dev/null +++ b/.changeset/add_msc4095_displaying.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add support for rendering bundled urls per MSC4095 diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 56517903b..e147610bf 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'; @@ -42,6 +42,7 @@ type RenderMessageContentProps = { edited?: boolean; getContent: () => T; mediaAutoLoad?: boolean; + bundledPreview?: boolean; urlPreview?: boolean; clientUrlPreview?: boolean; highlightRegex?: RegExp; @@ -68,6 +69,7 @@ function RenderMessageContentInternal({ edited, getContent, mediaAutoLoad, + bundledPreview, urlPreview, clientUrlPreview, highlightRegex, @@ -112,18 +114,17 @@ function RenderMessageContentInternal({ const mediaLinks = analyzed.filter((item) => item.type !== null); const toRender = mediaLinks.length > 0 ? mediaLinks : [analyzed[0]]; - return ( {toRender.map(({ url, type }) => { if (type) { - return ; + return ; } if (clientUrlPreview && youtubeUrl(url)) { return ; } if (urlPreview) { - return ; + return ; } return null; })} @@ -132,7 +133,23 @@ function RenderMessageContentInternal({ }, [ts, clientUrlPreview, urlPreview] ); + const renderBundledPreviews = useCallback( + (bundles: IPreviewUrlResponse[]) => ( + + {bundles.map((bundle) => ( + + ))} + + ), + [urlPreview] + ); const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined; + const messageBundlePreview = bundledPreview ? renderBundledPreviews : undefined; const renderCaption = () => { const hasCaption = content.body && content.body.trim().length > 0; @@ -146,6 +163,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={messageUrlsPreview} + renderBundledPreviews={messageBundlePreview} /> ); return ( @@ -165,6 +183,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={messageUrlsPreview} + renderBundledPreviews={messageBundlePreview} /> ); @@ -227,6 +246,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={messageUrlsPreview} + renderBundledPreviews={messageBundlePreview} /> ); } @@ -248,6 +268,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={messageUrlsPreview} + renderBundledPreviews={messageBundlePreview} /> ); } @@ -259,6 +280,7 @@ function RenderMessageContentInternal({ content={content} renderBody={renderBody} renderUrlsPreview={messageUrlsPreview} + renderBundledPreviews={messageBundlePreview} /> ); } diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 6d1a63cce..bd3c72ce5 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 bundleContent: object[] | undefined; + const urlsMatch = trimmedBody.match(URL_REG); + let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + bundleContent = content['com.beeper.linkpreviews'] as object[]; + bundleContent = bundleContent?.filter((bundle) => !!urls?.includes((bundle as any).matched_url)); + if (renderUrlsPreview && bundleContent) + urls = bundleContent.map((bundle) => (bundle as any).matched_url); 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 as IPreviewUrlResponse[]))} ); } @@ -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 as IPreviewUrlResponse[]))} ); } @@ -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,14 @@ 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 bundleContent: object[] | undefined; + const urlsMatch = trimmedBody.match(URL_REG); + const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + bundleContent = content['com.beeper.linkpreviews'] as object[]; + bundleContent = bundleContent?.filter((bundle) => !!urls?.includes((bundle as any).matched_url)); + return ( <> } - {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} + {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || + (renderBundledPreviews && + bundleContent && + bundleContent.length > 0 && + renderBundledPreviews(bundleContent as IPreviewUrlResponse[]))} ); } @@ -239,8 +270,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 +286,14 @@ 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; const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody); + let bundleContent: object[] | undefined; + const urlsMatch = trimmedBody.match(URL_REG); + const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + bundleContent = content['com.beeper.linkpreviews'] as object[]; + bundleContent = bundleContent?.filter((bundle) => !!urls?.includes((bundle as any).matched_url)); + return ( <> } - {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} + {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || + (renderBundledPreviews && + bundleContent && + bundleContent.length > 0 && + renderBundledPreviews(bundleContent as IPreviewUrlResponse[]))} ); } diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 3ac780f74..63586b436 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -41,177 +41,90 @@ 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', + { + urlPreview: boolean; + url: string; + ts?: number; + mediaType?: string | null; + bundle?: IPreviewUrlResponse; + } +>(({ urlPreview, 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 [previewStatus, loadPreview] = useAsyncCallback( + useCallback(() => { + if (isDirect) return Promise.resolve(null); + if (!ts && !bundle) return Promise.resolve(null); + if (urlPreview && ts) { 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 previewResult = mx?.getUrlPreview(url, ts); + clientCache.set(url, previewResult); + previewResult.finally(() => clientCache.delete(url)); + return previewResult; + } + return Promise.resolve(bundle); + }, [isDirect, ts, bundle, urlPreview, mx, url]) + ); - 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'] && ( +