From 7570a84dfdb0d97315e43271dae781e50216961b Mon Sep 17 00:00:00 2001 From: LeaPhant Date: Sat, 14 Mar 2026 06:34:55 +0100 Subject: [PATCH] Show image viewer when clicking url preview thumbnail (#2309) * Show large image overlay when clicking url preview thumbnail * Move image overlay into its own component * Move ImageOverlay props into extended type * Remove export for internal type --- src/app/components/ImageOverlay.tsx | 45 ++++++ src/app/components/RenderMessageContent.tsx | 7 +- .../components/url-preview/UrlPreviewCard.tsx | 145 ++++++++++-------- 3 files changed, 135 insertions(+), 62 deletions(-) create mode 100644 src/app/components/ImageOverlay.tsx diff --git a/src/app/components/ImageOverlay.tsx b/src/app/components/ImageOverlay.tsx new file mode 100644 index 00000000..ea690924 --- /dev/null +++ b/src/app/components/ImageOverlay.tsx @@ -0,0 +1,45 @@ +import FocusTrap from 'focus-trap-react'; +import { as, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds'; +import React, { ReactNode } from 'react'; +import { ModalWide } from '../styles/Modal.css'; +import { stopPropagation } from '../utils/keyboard'; + +export type RenderViewerProps = { + src: string; + alt: string; + requestClose: () => void; +}; + +type ImageOverlayProps = RenderViewerProps & { + viewer: boolean; + renderViewer: (props: RenderViewerProps) => ReactNode; +}; + +export const ImageOverlay = as<'div', ImageOverlayProps>( + ({ src, alt, viewer, requestClose, renderViewer, ...props }, ref) => ( + }> + + requestClose(), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + evt.stopPropagation()} + > + {renderViewer({ + src, + alt, + requestClose, + })} + + + + + ) +); diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 4cfcb7dc..bc937427 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -64,7 +64,12 @@ export function RenderMessageContent({ return ( {filteredUrls.map((url) => ( - + } + ts={ts} + /> ))} ); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index cbe85df2..f4efd33a 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; +import { RenderViewerProps, ImageOverlay } from '../ImageOverlay'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview'; @@ -15,72 +16,94 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; const linkStyles = { color: color.Success.Main }; -export const UrlPreviewCard = as<'div', { url: string; ts: number }>( - ({ url, ts, ...props }, ref) => { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const [previewStatus, loadPreview] = useAsyncCallback( - useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]) +export const UrlPreviewCard = as< + 'div', + { url: string; ts: number; renderViewer: (props: RenderViewerProps) => ReactNode } +>(({ url, ts, renderViewer, ...props }, ref) => { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [viewer, setViewer] = useState(false); + const [previewStatus, loadPreview] = useAsyncCallback( + useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]) + ); + + useEffect(() => { + loadPreview(); + }, [loadPreview]); + + if (previewStatus.status === AsyncStatus.Error) return null; + + const renderContent = (prev: IPreviewUrlResponse) => { + const thumbUrl = mxcUrlToHttp( + mx, + prev['og:image'] || '', + useAuthentication, + 256, + 256, + 'scale', + false ); - useEffect(() => { - loadPreview(); - }, [loadPreview]); - - if (previewStatus.status === AsyncStatus.Error) return null; - - const renderContent = (prev: IPreviewUrlResponse) => { - const imgUrl = mxcUrlToHttp( - mx, - prev['og:image'] || '', - useAuthentication, - 256, - 256, - 'scale', - false - ); - - return ( - <> - {imgUrl && } - - - {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `} - {tryDecodeURIComponent(url)} - - - {prev['og:title']} - - - {prev['og:description']} - - - - ); - }; + const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication); return ( - - {previewStatus.status === AsyncStatus.Success ? ( - renderContent(previewStatus.data) - ) : ( - - - + <> + {thumbUrl && ( + setViewer(true)} + /> )} - + {imgUrl && ( + { + setViewer(false); + }} + renderViewer={renderViewer} + /> + )} + + + {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `} + {tryDecodeURIComponent(url)} + + + {prev['og:title']} + + + {prev['og:description']} + + + ); - } -); + }; + + return ( + + {previewStatus.status === AsyncStatus.Success ? ( + renderContent(previewStatus.data) + ) : ( + + + + )} + + ); +}); export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { const scrollRef = useRef(null);