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 <kalvin@block.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
Kalvin C
2026-08-25 11:34:16 -07:00
committed by GitHub
parent 113a33b7e4
commit a526dca9bc
6 changed files with 417 additions and 41 deletions

View File

@@ -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. */}
<div
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
data-image-gallery-scope="thread"
ref={threadContentRef}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined

View File

@@ -43,6 +43,7 @@ import {
markdownPropsAreEqual,
} from "./markdownUtils";
import { ImageMosaic } from "./markdown/ImageMosaic";
import { ImageGalleryStatus } from "./markdown/ImageGalleryStatus";
import { ImageLightboxZoomControls } from "./markdown/ImageLightboxZoomControls";
import {
CODE_BLOCK_CLASS,
@@ -145,12 +146,30 @@ type WebKitGestureLikeEvent = Event & {
scale?: number;
};
function copyImageToClipboard(src: string | undefined) {
if (!src) return;
invokeTauri("copy_image_to_clipboard", { url: src })
.then(() => {
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(
<div
@@ -859,7 +876,7 @@ function ImageZoomOverlay({
custom={galleryDirection}
exit="exit"
initial="enter"
key={currentItem.resolvedSrc}
key={`${currentIndex}:${currentItem.resolvedSrc}`}
src={currentItem.resolvedSrc}
transition={{
duration: prefersReducedMotion
@@ -936,7 +953,7 @@ function ImageZoomOverlay({
type="button"
onClick={(event) => {
event.stopPropagation();
onDownload(currentItem.src);
downloadImage(currentItem.src);
}}
>
<Download className="h-4 w-4" />
@@ -952,6 +969,7 @@ function ImageZoomOverlay({
updateZoom={updateZoom}
zoom={zoom}
/>
<ImageGalleryStatus {...{ currentIndex, itemCount: items.length }} />
</div>
</div>
{menu && canActOnCurrentImage ? (
@@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
const triggerRef = React.useRef<HTMLButtonElement | null>(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}

View File

@@ -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 (
<>
<div
aria-hidden="true"
className="h-5 w-px shrink-0 bg-muted-foreground/15"
/>
<span
aria-label={`Image ${position} of ${itemCount}`}
aria-live="polite"
className="min-w-9 text-center text-xs font-medium tabular-nums text-muted-foreground"
role="status"
>
{position} / {itemCount}
</span>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
type ImageLightboxCornerRadii,
imageLightboxBoxFromRect,
imageLightboxCornerRadiiFromElement,
imageLightboxSourceScopeForTrigger,
visibleImageGalleryForTrigger,
} from "./imageLightbox";
@@ -18,8 +19,6 @@ type ImageZoomOverlayProps = {
galleryIndex?: number;
galleryItems?: ImageGalleryItem[];
onClose: () => void;
onCopy: (src: string | undefined) => void;
onDownload: (src: string | undefined) => void;
resolvedSrc: string;
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
@@ -27,8 +26,6 @@ type ImageZoomOverlayProps = {
src: string | undefined;
};
const ignoreUnavailableImageAction = () => undefined;
export function createLinkPreviewImageLightbox(
ImageZoomOverlay: ComponentType<ImageZoomOverlayProps>,
): ComponentType<LinkPreviewImageLightboxProps> {
@@ -52,7 +49,7 @@ export function createLinkPreviewImageLightbox(
const sourceBox = imageLightboxBoxFromRect(rect);
const sourceCornerRadii = imageLightboxCornerRadiiFromElement(image);
const sourceScope = trigger.closest("[data-link-preview-list]");
const sourceScope = imageLightboxSourceScopeForTrigger(trigger);
const dim =
image.naturalWidth > 0 && image.naturalHeight > 0
? `${image.naturalWidth}x${image.naturalHeight}`
@@ -62,6 +59,7 @@ export function createLinkPreviewImageLightbox(
{
alt,
dim,
trigger,
resolvedSrc: src,
src: undefined,
thumbnailBox: sourceBox,
@@ -103,8 +101,6 @@ export function createLinkPreviewImageLightbox(
galleryIndex={lightboxState.galleryIndex}
galleryItems={lightboxState.galleryItems}
onClose={() => setLightboxState(null)}
onCopy={ignoreUnavailableImageAction}
onDownload={ignoreUnavailableImageAction}
resolvedSrc={src}
sourceBox={lightboxState.sourceBox}
sourceCornerRadii={lightboxState.sourceCornerRadii}

View File

@@ -32,6 +32,7 @@ export type ImageGalleryDirection = "forward" | "backward";
export type ImageGalleryItem = {
alt: string | undefined;
dim?: string;
trigger?: HTMLElement;
resolvedSrc: string;
src: string | undefined;
thumbnailBox?: ImageLightboxBox;
@@ -339,12 +340,15 @@ function imageLightboxThumbnailTargetForItem(
sourceScope: Element | null | undefined,
): ImageLightboxThumbnailTarget | null {
const root = sourceScope?.isConnected ? sourceScope : document.body;
const triggers = Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);
const triggers = item.trigger
? [item.trigger]
: Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);
for (const trigger of triggers) {
const isCurrentItem =
item.trigger != null ||
trigger.dataset.imageLightboxResolvedSrc === item.resolvedSrc ||
(item.src != null && trigger.dataset.imageLightboxSrc === item.src);
if (!isCurrentItem) {
@@ -386,6 +390,7 @@ export function imageLightboxSourceScopeForTrigger(
trigger: HTMLElement,
): Element | null {
return (
trigger.closest("[data-image-gallery-scope]") ??
trigger.closest(IMAGE_LIGHTBOX_MARKDOWN_SCOPE_SELECTOR) ??
trigger.closest("[data-testid='message-row']")
);
@@ -409,6 +414,7 @@ function imageGalleryItemFromTrigger(
return {
alt: trigger.dataset.imageLightboxAlt || undefined,
dim: trigger.dataset.imageLightboxDim || inferredDim,
trigger,
resolvedSrc,
src: trigger.dataset.imageLightboxSrc || undefined,
thumbnailBox: thumbnail?.box,

View File

@@ -35,6 +35,41 @@ async function waitForMockLiveSubscription(page: Page, channelName: string) {
.toBe(true);
}
async function emitMockMessage(
page: Page,
content: string,
parentEventId?: string,
createdAt?: number,
extraTags?: string[][],
): Promise<string> {
const eventId = await page.evaluate(
({ body, createdAt: timestamp, parent, tags }) => {
const event = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt?: number;
parentEventId?: string;
extraTags?: string[][];
}) => { id?: string } | undefined;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
createdAt: timestamp,
parentEventId: parent,
extraTags: tags,
});
return event?.id ?? null;
},
{ body: content, createdAt, parent: parentEventId, tags: extraTags },
);
if (!eventId) throw new Error("Expected mock message event id");
return eventId;
}
function imageImetaTag({
dim,
filename,
@@ -318,6 +353,310 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => {
).toBeLessThan(2);
});
test("thread lightbox navigates images across messages", async ({ page }) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const threadTimestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
["thread gallery root", `![root](${NO_DIM_WIDE_URL})`].join("\n"),
undefined,
threadTimestamp,
);
await emitMockMessage(
page,
["thread gallery reply one", `![reply one](${NO_DIM_WIDE_URL})`].join("\n"),
rootId,
threadTimestamp + 1,
);
await emitMockMessage(
page,
["thread gallery reply two", `![reply two](${NO_DIM_SECOND_URL})`].join(
"\n",
),
rootId,
threadTimestamp + 2,
);
const threadSummary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(threadSummary).toBeVisible();
await threadSummary.click();
const panel = page.getByTestId("message-thread-panel");
await expect(panel).toBeVisible();
const replies = panel.getByTestId("message-thread-replies");
const finalImage = replies
.getByTestId("message-row")
.filter({ hasText: "thread gallery reply two" })
.getByTestId("message-image-lightbox-trigger");
await finalImage.click();
const dialog = page.getByRole("dialog");
const position = dialog.getByRole("status");
await expect(dialog.locator(`img[src="${NO_DIM_SECOND_URL}"]`)).toBeVisible();
await expect(position).toHaveText("3 / 3");
await expect(position).toHaveAttribute("aria-label", "Image 3 of 3");
await expect(dialog.getByRole("button", { name: "Next image" })).toHaveCount(
0,
);
await page.keyboard.press("ArrowLeft");
await expect(dialog.getByRole("img", { name: "reply one" })).toBeVisible();
await expect(position).toHaveText("2 / 3");
const replyOneImage = replies
.getByTestId("message-row")
.filter({ hasText: "thread gallery reply one" })
.getByTestId("message-image-lightbox-trigger");
const replyOneThumbnailBox = await replyOneImage.locator("img").boundingBox();
if (!replyOneThumbnailBox) {
throw new Error("Expected duplicate reply thumbnail to have a layout box");
}
const lightboxFrame = page.locator("[data-image-lightbox-frame]");
await page.waitForTimeout(500);
await page.mouse.click(20, 20);
const closingFrameBox = await lightboxFrame.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox frame");
}
return {
height: Number.parseFloat(element.style.height),
left: Number.parseFloat(element.style.left),
top: Number.parseFloat(element.style.top),
width: Number.parseFloat(element.style.width),
};
});
expect(Math.abs(closingFrameBox.left - replyOneThumbnailBox.x)).toBeLessThan(
2,
);
expect(Math.abs(closingFrameBox.top - replyOneThumbnailBox.y)).toBeLessThan(
2,
);
expect(
Math.abs(closingFrameBox.width - replyOneThumbnailBox.width),
).toBeLessThan(2);
expect(
Math.abs(closingFrameBox.height - replyOneThumbnailBox.height),
).toBeLessThan(2);
await expect(dialog).toHaveCount(0);
await finalImage.click();
await expect(position).toHaveText("3 / 3");
await page.keyboard.press("ArrowLeft");
await expect(position).toHaveText("2 / 3");
await dialog.getByRole("button", { name: "Previous image" }).click();
await expect(dialog.getByRole("img", { name: "root" })).toBeVisible();
await expect(position).toHaveText("1 / 3");
await expect(
dialog.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
});
test("thread gallery includes only currently rendered media", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const timestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
["rendered gallery root", `![root](${NO_DIM_WIDE_URL})`].join("\n"),
undefined,
timestamp,
);
const branchId = await emitMockMessage(
page,
["rendered branch", `![branch](${NO_DIM_PORTRAIT_URL})`].join("\n"),
rootId,
timestamp + 1,
);
await emitMockMessage(
page,
["collapsed child", `![child](${NO_DIM_SECOND_URL})`].join("\n"),
branchId,
timestamp + 2,
);
await emitMockMessage(
page,
["rendered sibling", `![sibling](${NO_DIM_WIDE_URL})`].join("\n"),
rootId,
timestamp + 3,
);
await page
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
)
.click();
const replies = page
.getByTestId("message-thread-panel")
.getByTestId("message-thread-replies");
await expect(
replies.getByTestId("message-row").filter({ hasText: "collapsed child" }),
).toHaveCount(0);
await replies
.getByTestId("message-row")
.filter({ hasText: "rendered sibling" })
.getByTestId("message-image-lightbox-trigger")
.click();
const dialog = page.getByRole("dialog");
const position = dialog.getByRole("status");
await expect(position).toHaveText("3 / 3");
await dialog.getByRole("button", { name: "Previous image" }).click();
await expect(dialog.getByRole("img", { name: "branch" })).toBeVisible();
await expect(position).toHaveText("2 / 3");
await expect(dialog.getByRole("img", { name: "child" })).toHaveCount(0);
});
test("preview-first galleries retain Markdown image actions", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/6705";
await installMockBridge(page, {
uploadDescriptors: [
{
url: `http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
sha256: IMAGE_SHAS[2],
size: 3456,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "140x140",
filename: "preview.png",
},
],
});
await installNoDimImageRoutes(page);
await page.route("http://127.0.0.1:54321/media/**", (route) =>
route.fulfill({
body: '<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><rect width="100%" height="100%" fill="#60a5fa"/></svg>',
contentType: "image/svg+xml",
}),
);
await page.addInitScript(() => {
localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich");
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await waitForMockLiveSubscription(page, "general");
const timestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
"preview action root",
undefined,
timestamp,
);
await emitMockMessage(
page,
[
`preview action reply ${previewUrl}`,
`![body image](${NO_DIM_WIDE_URL})`,
].join("\n"),
rootId,
timestamp + 1,
[
[
"link-preview",
"snapshot",
"1",
previewUrl,
"Preview title",
"Example",
"Preview description",
`http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
IMAGE_SHAS[2],
"",
"",
],
],
);
await page
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
)
.click();
const reply = page
.getByTestId("message-thread-panel")
.getByTestId("message-row")
.filter({ hasText: "preview action reply" });
await reply.getByRole("button", { name: /Zoom image: Preview from/ }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("status")).toHaveText("2 / 2");
await dialog.getByRole("button", { name: "Previous image" }).click();
const bodyImage = dialog.getByRole("img", { name: "body image" });
await expect(bodyImage).toBeVisible();
await dialog.getByRole("button", { name: "Download image" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_image");
await bodyImage.click({ button: "right" });
await dialog.getByRole("button", { name: "Copy image" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("copy_image_to_clipboard");
});
test("adjacent channel messages remain separate image galleries", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(
page,
["separate gallery one", `![one](${NO_DIM_WIDE_URL})`].join("\n"),
);
await emitMockMessage(
page,
["separate gallery two", `![two](${NO_DIM_PORTRAIT_URL})`].join("\n"),
);
const firstRow = page
.getByTestId("message-row")
.filter({ hasText: "separate gallery one" })
.last();
await firstRow.getByTestId("message-image-lightbox-trigger").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("status")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Next image" })).toHaveCount(
0,
);
});
test("hidden spoiler images are excluded from gallery navigation until revealed", async ({
page,
}) => {