mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
31
desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx
Normal file
31
desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ``].join("\n"),
|
||||
undefined,
|
||||
threadTimestamp,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["thread gallery reply one", ``].join("\n"),
|
||||
rootId,
|
||||
threadTimestamp + 1,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["thread gallery reply two", ``].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", ``].join("\n"),
|
||||
undefined,
|
||||
timestamp,
|
||||
);
|
||||
const branchId = await emitMockMessage(
|
||||
page,
|
||||
["rendered branch", ``].join("\n"),
|
||||
rootId,
|
||||
timestamp + 1,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["collapsed child", ``].join("\n"),
|
||||
branchId,
|
||||
timestamp + 2,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["rendered sibling", ``].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}`,
|
||||
``,
|
||||
].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", ``].join("\n"),
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["separate gallery two", ``].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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user