From f9ade5b9866bba59db4dc22bae1e7fdaad3a9ef8 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 16:42:07 -0500 Subject: [PATCH 1/3] add ability to cap preview size and minor adjustment to restore codeblock background of misc data --- .../message/content/ImageContent.tsx | 91 +++++++- .../components/message/content/style.css.ts | 9 + .../components/url-preview/UrlPreview.css.tsx | 7 + .../components/url-preview/UrlPreviewCard.tsx | 219 +++++++++++++----- .../user-profile/UserRoomProfile.tsx | 21 +- .../settings/cosmetics/Themes.test.tsx | 2 + .../features/settings/cosmetics/Themes.tsx | 43 ++++ .../features/settings/settingsLink.test.ts | 9 + src/app/features/settings/settingsLink.ts | 1 + src/app/state/settings.ts | 2 + 10 files changed, 330 insertions(+), 74 deletions(-) diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 183cb35f5..13868c4bf 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -19,6 +19,7 @@ import { TooltipProvider, as, config, + toRem, } from 'folds'; import classNames from 'classnames'; import { BlurhashCanvas } from 'react-blurhash'; @@ -37,6 +38,23 @@ import { validBlurHash } from '$utils/blurHash'; import * as css from './style.css'; import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME } from '../../../../unstable/prefixes'; +function thumbnailDimsForMaxEdge( + maxEdge: number, + w?: number, + h?: number +): { tw: number; th: number } { + const safeEdge = Math.max(1, Math.round(maxEdge)); + const iw = typeof w === 'number' && Number.isFinite(w) && w > 0 ? w : safeEdge; + const ih = typeof h === 'number' && Number.isFinite(h) && h > 0 ? h : safeEdge; + const longest = Math.max(iw, ih); + if (longest <= safeEdge) return { tw: Math.round(iw), th: Math.round(ih) }; + const scale = safeEdge / longest; + return { + tw: Math.max(1, Math.round(iw * scale)), + th: Math.max(1, Math.round(ih * scale)), + }; +} + type RenderViewerProps = { src: string; alt: string; @@ -62,6 +80,10 @@ export type ImageContentProps = { spoilerReason?: string; renderViewer: (props: RenderViewerProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode; + matrixThumbnailMaxEdge?: number; + mediaLayout?: 'default' | 'contained'; + containedStripMinPx?: number; + fillsPreviewSlot?: boolean; }; export const ImageContent = as<'div', ImageContentProps>( ( @@ -78,6 +100,10 @@ export const ImageContent = as<'div', ImageContentProps>( spoilerReason, renderViewer, renderImage, + matrixThumbnailMaxEdge, + mediaLayout = 'default', + containedStripMinPx, + fillsPreviewSlot, ...props }, ref @@ -89,6 +115,7 @@ export const ImageContent = as<'div', ImageContentProps>( const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [viewer, setViewer] = useState(false); + const [viewerFullSrc, setViewerFullSrc] = useState(null); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [isHovered, setIsHovered] = useState(false); @@ -96,6 +123,12 @@ export const ImageContent = as<'div', ImageContentProps>( useCallback(async () => { if (url.startsWith('http')) return url; + if (typeof matrixThumbnailMaxEdge === 'number' && matrixThumbnailMaxEdge > 0 && !encInfo) { + const { tw, th } = thumbnailDimsForMaxEdge(matrixThumbnailMaxEdge, info?.w, info?.h); + const thumbUrl = mxcUrlToHttp(mx, url, useAuthentication, tw, th, 'scale', false); + if (thumbUrl) return thumbUrl; + } + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); if (encInfo) { @@ -105,9 +138,33 @@ export const ImageContent = as<'div', ImageContentProps>( return URL.createObjectURL(fileContent); } return mediaUrl; - }, [mx, url, useAuthentication, mimeType, encInfo]) + }, [mx, url, useAuthentication, mimeType, encInfo, matrixThumbnailMaxEdge, info?.w, info?.h]) ); + useEffect(() => { + if (!viewer) { + setViewerFullSrc(null); + return; + } + if ( + typeof matrixThumbnailMaxEdge !== 'number' || + matrixThumbnailMaxEdge <= 0 || + encInfo || + url.startsWith('http') + ) { + return; + } + let cancelled = false; + void (async () => { + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl || cancelled) return; + setViewerFullSrc(mediaUrl); + })(); + return () => { + cancelled = true; + }; + }, [viewer, matrixThumbnailMaxEdge, encInfo, url, mx, useAuthentication]); + const handleLoad = () => { setLoad(true); }; @@ -126,12 +183,36 @@ export const ImageContent = as<'div', ImageContentProps>( }, [autoPlay, loadSrc]); const hasDimensions = typeof info?.w === 'number' && typeof info?.h === 'number'; + const isContained = mediaLayout === 'contained'; + const fillsSlot = Boolean(fillsPreviewSlot && isContained); + const containedReserveStrip = + !fillsSlot && + isContained && + (srcState.status === AsyncStatus.Loading || + srcState.status === AsyncStatus.Error || + error || + (srcState.status === AsyncStatus.Success && !load)); + + const rootClass = isContained ? css.ContainedMediaRoot : css.RelativeBase; + const stripMin = containedStripMinPx ?? 56; + const intrinsicSizingStyle = fillsSlot + ? {} + : isContained + ? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined } + : hasDimensions + ? { aspectRatio: `${info!.w} / ${info!.h}` } + : { minHeight: '150px' }; + + const fillPreviewSlotStyle = fillsSlot + ? ({ width: '100%', height: '100%' } as const) + : undefined; return ( ( onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ - src: srcState.data, + src: viewerFullSrc ?? srcState.data, alt: body, requestClose: () => setViewer(false), })} @@ -196,7 +277,7 @@ export const ImageContent = as<'div', ImageContentProps>( {srcState.status === AsyncStatus.Success && ( { window.open(blobUrl, '_blank'); }; +function ogPositiveDimension(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value; + if (typeof value === 'string') { + const n = Number.parseFloat(value); + if (Number.isFinite(n) && n > 0) return n; + } + return undefined; +} + +function isLikelyPlayableOgVideo(prev: IPreviewUrlResponse): boolean { + const raw = prev['og:video']; + if (typeof raw !== 'string') return false; + const url = raw.trim(); + if (!url) return false; + const mime = + typeof prev['og:video:type'] === 'string' ? prev['og:video:type'].toLowerCase().trim() : ''; + if (mime.startsWith('video/')) return true; + if (/^mxc:\/\//i.test(url)) { + return mime.startsWith('video/') || /\.(mp4|webm|ogg|mov|m4v)(\?|$)/i.test(url); + } + if (/^https?:\/\//i.test(url)) { + return /\.(mp4|webm|ogg|mov|m4v)(\?|$)/i.test(url) || mime.startsWith('video/'); + } + return false; +} + export const UrlPreviewCard = as< 'div', { @@ -54,6 +97,7 @@ export const UrlPreviewCard = as< >(({ urlPreview, url, ts, mediaType, bundle, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const [linkPreviewImageMaxHeight] = useSetting(settingsAtom, 'linkPreviewImageMaxHeight'); const isDirect = !!mediaType; @@ -109,14 +153,49 @@ export const UrlPreviewCard = as< } }; - const ogW = ((prev['og:video'] && prev['og:video:width']) || - (prev['og:image'] && prev['og:image:width']) || - 1) as number; - const ogH = ((prev['og:video'] && prev['og:video:height']) || - (prev['og:image'] && prev['og:image:height']) || - 1) as number; + const videoW = prev['og:video'] ? ogPositiveDimension(prev['og:video:width']) : undefined; + const videoH = prev['og:video'] ? ogPositiveDimension(prev['og:video:height']) : undefined; + const ogImgW = ogPositiveDimension(prev['og:image:width']); + const ogImgH = ogPositiveDimension(prev['og:image:height']); + + const aspectRatio = + videoW && videoH + ? `${videoW} / ${videoH}` + : ogImgW && ogImgH + ? `${ogImgW} / ${ogImgH}` + : undefined; + + const previewBlurRaw = + typeof prev['matrix:image:blurhash'] === 'string' ? prev['matrix:image:blurhash'].trim() : ''; - const aspectRatio = ogW && ogH ? `${ogW} / ${ogH}` : undefined; + const ogImageInfo: IImageInfo | undefined = (() => { + const matrixSize = prev['matrix:image:size']; + const size = + typeof matrixSize === 'number' && Number.isFinite(matrixSize) ? matrixSize : undefined; + if (ogImgW && ogImgH) { + return { + w: ogImgW, + h: ogImgH, + ...(size !== undefined ? { size } : {}), + ...(previewBlurRaw ? { [MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME]: previewBlurRaw } : {}), + }; + } + if (previewBlurRaw || size !== undefined) { + return { + w: 16, + h: 9, + ...(size !== undefined ? { size } : {}), + ...(previewBlurRaw ? { [MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME]: previewBlurRaw } : {}), + }; + } + return undefined; + })(); + + const previewThumbMaxEdge = Math.min( + 2048, + Math.max(1, Math.round(Math.max(1, linkPreviewImageMaxHeight) * 2)) + ); + const showOgVideo = isLikelyPlayableOgVideo(prev); return ( )} - {prev['og:video'] && ( - )} - {!prev['og:video'] && - prev['og:image'] && - (() => ( - + - } - renderImage={(p) => ( - - )} - /> - - ))()} - {!prev['og:video'] && !prev['og:image'] && prev['og:audio'] && ( + mediaLayout="contained" + fillsPreviewSlot + autoPlay + onAuxClick={handleAuxClick} + body={prev['og:title']} + url={prev['og:image']} + info={ogImageInfo} + matrixThumbnailMaxEdge={previewThumbMaxEdge} + renderViewer={(p) => } + renderImage={(p) => ( + + )} + /> + + )} + {!showOgVideo && !prev['og:image'] && prev['og:audio'] && ( setShowMisc(!showMisc)} - after={miscDataIndex === -1 && } + after={ + + } style={{ padding: '1rem', justifyContent: 'flex-start', @@ -330,14 +332,7 @@ function UserExtendedSection({ overflow: 'hidden', }} > - + {unknownFields.length > 1 && (