From a526dca9bcaa08dfb5db77999cc1f584a17a9d64 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 25 Aug 2026 11:34:16 -0700 Subject: [PATCH] feat: navigate images across message threads (#6705) ## Summary - navigate across image occurrences that are currently rendered inside the nearest explicit open-thread DOM scope - preserve rendered DOM order, duplicate occurrence identity, hidden-spoiler exclusion, preview-image entry parity, and the exact thumbnail return target - keep adjacent timeline messages and unrelated surfaces as separate galleries - make Copy and Download follow the current gallery item, including when a link-preview image opens the gallery first and navigation reaches Markdown media ## Scope contract This is intentionally a **rendered-media gallery only**. Gallery membership comes from mounted image triggers under the nearest explicit thread scope. Collapsed or unloaded descendants are excluded until the thread UI renders them. There is no canonical thread fetch, descendant projection, depth override, or synchronous message reparse in this change. `ChannelPane.tsx` remains unchanged from `main`; `useSearchHighlightProps` and both timeline/thread prop spreads are preserved. ## Verification - rebased onto `origin/main` at `113a33b7e49b7173ee1767c49ef2f49c63803034` - `git diff --check` - differential file-size check - full Desktop check (Biome plus text/pubkey policy checks; only pre-existing informational diagnostics) - Desktop TypeScript typecheck - Desktop production build and rebuilt E2E build - full Desktop JS units: `5,476/5,476` passed - full Desktop Tauri workspace tests passed (one documented native performance test ignored) - rebuilt image gallery Playwright spec: `14/14` passed - search-highlight smoke cases: `5/5` passed - reduced diff: 6 files, `+417/-41`; production `+78/-41`, E2E `+339/-0` The four added E2E cases cover cross-message thread navigation and return targeting, rendered-only exclusion of collapsed descendants, preview-first current-item actions, and adjacent-message isolation. --------- Signed-off-by: Kalvin Chau Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- .../messages/ui/MessageThreadPanel.tsx | 4 + desktop/src/shared/ui/markdown.tsx | 62 ++-- .../shared/ui/markdown/ImageGalleryStatus.tsx | 31 ++ .../ui/markdown/LinkPreviewImageLightbox.tsx | 10 +- .../src/shared/ui/markdown/imageLightbox.ts | 12 +- .../e2e/image-attachment-gallery.spec.ts | 339 ++++++++++++++++++ 6 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d3b1394829..8797bec7a0 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -510,8 +510,12 @@ export function MessageThreadPanel({ tabIndex={-1} ref={threadBodyRef} > + {/* The gallery is intentionally DOM-scoped: only media currently rendered + in this open thread participates. Collapsed or unloaded descendants + join only after the thread UI renders them. */}
{ + toast.success("Copied to clipboard"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Copy failed"; + toast.error(msg); + }); +} + +function downloadImage(src: string | undefined) { + if (!src) return; + invokeTauri("download_image", { url: src }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Download failed"; + toast.error(msg); + }); +} + function ImageZoomOverlay({ alt, galleryIndex = 0, galleryItems, - onCopy, - onDownload, onClose, resolvedSrc, sourceBox, @@ -161,8 +180,6 @@ function ImageZoomOverlay({ alt: string | undefined; galleryIndex?: number; galleryItems?: ImageGalleryItem[]; - onCopy: (src: string | undefined) => void; - onDownload: (src: string | undefined) => void; onClose: () => void; resolvedSrc: string; sourceBox: ImageLightboxBox; @@ -718,13 +735,13 @@ function ImageZoomOverlay({ const handleMenuCopy = React.useCallback(() => { setMenu(null); markControlGesture(); - onCopy(currentItem.src); - }, [currentItem.src, markControlGesture, onCopy]); + copyImageToClipboard(currentItem.src); + }, [currentItem.src, markControlGesture]); const handleMenuDownload = React.useCallback(() => { setMenu(null); markControlGesture(); - onDownload(currentItem.src); - }, [currentItem.src, markControlGesture, onDownload]); + downloadImage(currentItem.src); + }, [currentItem.src, markControlGesture]); return createPortal(
{ event.stopPropagation(); - onDownload(currentItem.src); + downloadImage(currentItem.src); }} > @@ -952,6 +969,7 @@ function ImageZoomOverlay({ updateZoom={updateZoom} zoom={zoom} /> +
{menu && canActOnCurrentImage ? ( @@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const triggerRef = React.useRef(null); useSmoothCorners(inlineImageRef); useSmoothCorners(thumbnailImageRef); - const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{ height: number; src: string; @@ -1078,7 +1095,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { return () => observer.disconnect(); }, []); - const closeMenu = React.useCallback(() => setMenu(null), []); useDismissMediaContextMenu(Boolean(menu), closeMenu); @@ -1089,7 +1105,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { e.nativeEvent.stopImmediatePropagation(); setMenu({ x: e.clientX, y: e.clientY }); }; - const openLightbox = React.useCallback( (image: HTMLImageElement) => { if (!resolvedSrc || isInsideHiddenSpoiler(image)) { @@ -1113,6 +1128,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { { alt, dim, + trigger: triggerRef.current, resolvedSrc, src, thumbnailBox: sourceBox, @@ -1140,27 +1156,13 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const handleCopyImage = React.useCallback((copySrc: string | undefined) => { setMenu(null); - if (!copySrc) return; - invokeTauri("copy_image_to_clipboard", { url: copySrc }) - .then(() => { - toast.success("Copied to clipboard"); - }) - .catch((err: unknown) => { - const msg = err instanceof Error ? err.message : "Copy failed"; - toast.error(msg); - }); + copyImageToClipboard(copySrc); }, []); const handleDownload = React.useCallback( (downloadSrc: string | undefined) => { setMenu(null); - if (!downloadSrc) return; - invokeTauri("download_image", { url: downloadSrc }).catch( - (err: unknown) => { - const msg = err instanceof Error ? err.message : "Download failed"; - toast.error(msg); - }, - ); + downloadImage(downloadSrc); }, [], ); @@ -1215,8 +1217,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { alt={alt} galleryIndex={lightboxState.galleryIndex} galleryItems={lightboxState.galleryItems} - onCopy={handleCopyImage} - onDownload={handleDownload} onClose={() => setLightboxState(null)} resolvedSrc={resolvedSrc} sourceBox={lightboxState.sourceBox} diff --git a/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx new file mode 100644 index 0000000000..66e7b68b8b --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx @@ -0,0 +1,31 @@ +type ImageGalleryStatusProps = { + currentIndex: number; + itemCount: number; +}; + +export function ImageGalleryStatus({ + currentIndex, + itemCount, +}: ImageGalleryStatusProps) { + if (itemCount <= 1) { + return null; + } + + const position = currentIndex + 1; + return ( + <> +