From b602548a968df605709cf3ace28db260ebf086f4 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 16:15:08 -0700 Subject: [PATCH] feat(composer): add draft-wide preview suppression Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- crates/buzz-relay/src/handlers/ingest.rs | 64 +++++- desktop/src-tauri/src/link_preview_tags.rs | 28 +++ .../messages/ui/useComposerLinkPreviews.tsx | 149 ++++++++++++-- desktop/tests/e2e/messaging.spec.ts | 186 +++++++----------- 4 files changed, 305 insertions(+), 122 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 0cb6178bef..21ed0fd67c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -169,6 +169,7 @@ fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(), const MAX_DESCRIPTION: usize = 1000; let mut count = 0; + let mut suppressed = false; let mut seen = std::collections::HashSet::new(); for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -176,7 +177,19 @@ fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(), continue; } count += 1; - if count > MAX_SNAPSHOTS || parts.len() != 11 || parts[1] != "snapshot" || parts[2] != "1" { + if parts == ["link-preview", "none"] { + if count > 1 { + return Err("link-preview suppression cannot include snapshots".into()); + } + suppressed = true; + continue; + } + if suppressed + || count > MAX_SNAPSHOTS + || parts.len() != 11 + || parts[1] != "snapshot" + || parts[2] != "1" + { return Err("invalid link-preview snapshot tag".into()); } let canonical = @@ -3549,6 +3562,55 @@ mod tests { assert!(validate_diff_event(&event).is_err()); } + #[test] + fn link_preview_suppression_accepts_blanket_marker() { + let event = make_event_with_tags( + KIND_STREAM_MESSAGE, + "https://example.com", + &[&["link-preview", "none"]], + ); + + assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok()); + } + + #[test] + fn link_preview_suppression_rejects_duplicate_marker() { + let event = make_event_with_tags( + KIND_STREAM_MESSAGE, + "https://example.com", + &[&["link-preview", "none"], &["link-preview", "none"]], + ); + + assert_eq!( + validate_link_preview_tags(&event, "https://media.example.com"), + Err("link-preview suppression cannot include snapshots".into()) + ); + } + + #[test] + fn link_preview_suppression_rejects_mixed_snapshot_tags_in_either_order() { + let snapshot = [ + "link-preview", + "snapshot", + "1", + "https://example.com", + "Example", + "Example", + "Description", + "", + "", + "", + "", + ]; + for tags in [ + vec![&["link-preview", "none"][..], &snapshot[..]], + vec![&snapshot[..], &["link-preview", "none"][..]], + ] { + let event = make_event_with_tags(KIND_STREAM_MESSAGE, "https://example.com", &tags); + assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err()); + } + } + fn make_dummy_event() -> Event { let keys = nostr::Keys::generate(); nostr::EventBuilder::new(nostr::Kind::Custom(9), "") diff --git a/desktop/src-tauri/src/link_preview_tags.rs b/desktop/src-tauri/src/link_preview_tags.rs index e9e21d385f..b7e3ef5435 100644 --- a/desktop/src-tauri/src/link_preview_tags.rs +++ b/desktop/src-tauri/src/link_preview_tags.rs @@ -57,6 +57,16 @@ pub fn append( let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL")?; let mut seen = HashSet::new(); for preview_tag in preview_tags { + if preview_tag.as_slice() == ["link-preview", "none"] { + if preview_tags.len() != 1 { + return Err("link-preview suppression cannot include snapshots".into()); + } + tags.push( + Tag::parse(["link-preview", "none"]) + .map_err(|e| format!("invalid link-preview tag: {e}"))?, + ); + continue; + } let valid = preview_tag.len() == 11 && preview_tag[0] == "link-preview" && preview_tag[1] == "snapshot" @@ -119,6 +129,24 @@ mod tests { assert_eq!(tags.len(), 1); } + #[test] + fn append_accepts_blanket_suppression_by_itself() { + let mut tags = Vec::new(); + append( + &[vec!["link-preview".into(), "none".into()]], + BASE, + &mut tags, + ) + .unwrap(); + assert_eq!(tags[0].as_slice(), ["link-preview", "none"]); + assert!(append( + &[vec!["link-preview".into(), "none".into()], tag("", ""),], + BASE, + &mut Vec::new(), + ) + .is_err()); + } + #[test] fn append_rejects_untrusted_or_malformed_snapshot_media() { for url in [ diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 0b29acdc48..adc6945f4f 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { ImageOff, LoaderCircle, X } from "lucide-react"; import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri"; import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; @@ -7,9 +8,122 @@ import { beginRelayOriginFetch, getCachedRelayOrigin, } from "@/shared/lib/mediaUrl"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; -import { LinkPreviewList } from "@/shared/ui/link-preview-list"; -import { LinkPreviewImageLightbox } from "@/shared/ui/markdown"; +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, + AttachmentTrigger, +} from "@/shared/ui/attachment"; + +function previewHostname(href: string): string { + try { + return new URL(href).hostname.replace(/^www\./, ""); + } catch { + return href; + } +} + +function ComposerLinkPreviewCard({ + onHideAll, + preview, + showHideControl, +}: { + onHideAll: () => void; + preview: ResolvedLinkPreview; + showHideControl: boolean; +}) { + const imageSrc = preview.imageState === "image" ? preview.imageDataUrl : null; + const [failedImageSrc, setFailedImageSrc] = React.useState( + null, + ); + const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); + const hostname = previewHostname(preview.href); + let path = ""; + try { + const url = new URL(preview.href); + path = `${url.pathname}${url.search}`; + } catch {} + + return ( + + + {showImage ? ( + setFailedImageSrc(imageSrc ?? null)} + src={imageSrc ?? undefined} + /> + ) : preview.imageState === "pending" ? ( + + ) : preview.faviconDataUrl ? ( + + ) : ( + + + + {preview.snapshotReady ? preview.title : hostname} + + + {preview.snapshotReady + ? preview.provider || hostname + : path && path !== "/" + ? path + : preview.typeLabel} + + + {showHideControl ? ( + + + + + + ) : null} + + + Open {preview.title} + + + + ); +} function dataUrlBytes(dataUrl: string): Uint8Array | null { const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); @@ -33,11 +147,15 @@ async function uploadDataUrl( } export function useComposerLinkPreviews(content: string) { + const [suppressed, setSuppressed] = React.useState(false); const candidates = React.useMemo( () => extractSupportedLinkPreviews(content), [content], ); - const previews = useResolvedLinkPreviews(candidates); + const previews = useResolvedLinkPreviews(suppressed ? [] : candidates); + React.useEffect(() => { + if (candidates.length === 0) setSuppressed(false); + }, [candidates.length]); const [readyTags, setReadyTags] = React.useState>( {}, ); @@ -95,19 +213,28 @@ export function useComposerLinkPreviews(content: string) { } }, [previews, readyTags]); - readyTagsRef.current = candidates.flatMap((candidate) => - readyTags[candidate.href] ? [readyTags[candidate.href]] : [], - ); + readyTagsRef.current = suppressed + ? [["link-preview", "none"]] + : candidates.flatMap((candidate) => + readyTags[candidate.href] ? [readyTags[candidate.href]] : [], + ); + const hideAll = React.useCallback(() => setSuppressed(true), []); const previewList = previews.length ? (
- + + {previews.map((preview, index) => ( + + ))} +
) : null; const getReadyTags = React.useCallback(() => readyTagsRef.current, []); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 3e7c7a0aa2..6397eacedd 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -218,7 +218,8 @@ test.beforeEach(async ({ page }, testInfo) => { }, linkPreviewMetadataDelayMs: testInfo.title.includes("style defaults") || - testInfo.title.includes("send does not wait") + testInfo.title.includes("send does not wait") || + testInfo.title.includes("attachment-sized") ? 1_500 : undefined, } @@ -504,7 +505,41 @@ test("send does not wait for a pending link preview snapshot", async ({ expect(linkPreviewTags ?? []).toEqual([]); }); -test("link preview image geometry stays stable while loading", async ({ +test("hiding composer previews suppresses the whole draft and emits the blanket marker", async ({ + page, +}) => { + const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all"; + const secondUrl = "https://linear.app/acme/issue/ABC-123/hidden-too"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(firstUrl); + await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); + + await page.getByTestId("composer-hide-link-previews").click(); + await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0); + await page.getByTestId("message-input").fill(`${firstUrl} ${secondUrl}`); + await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0); + await page.getByTestId("send-message").click(); + + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(firstUrl); + await expect(row).toContainText(secondUrl); + await expect(row.locator("[data-link-preview]")).toHaveCount(0); + const linkPreviewTags = await page.evaluate(() => { + const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((entry) => entry.command === "send_channel_message"); + return ( + call?.payload as { linkPreviewTags?: string[][] | null } | undefined + )?.linkPreviewTags; + }); + expect(linkPreviewTags).toEqual([["link-preview", "none"]]); + + await page.getByTestId("message-input").fill(firstUrl); + await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); +}); + +test("composer link preview embeds stay attachment-sized while loading and ready", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246"; @@ -520,32 +555,10 @@ test("link preview image geometry stays stable while loading", async ({ const card = page .locator("[data-composer-link-previews]") .locator('[data-link-preview="github-pull-request"]'); - await expect(card).toHaveAttribute("data-image-state", "pending"); await expect(card).toBeVisible(); - await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible(); - if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { - await waitForAnimations(page); - await card.screenshot({ - animations: "disabled", - path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-pending.png`, - }); - } - const pending = await card.evaluate((element) => ({ + const initial = await card.evaluate((element) => ({ height: element.getBoundingClientRect().height, width: element.getBoundingClientRect().width, - textLeft: element - .querySelector('[data-slot="attachment-content"]') - ?.getBoundingClientRect().left, - textInset: (() => { - const content = element.querySelector("[data-link-preview-hostname]"); - const thumbnail = element.querySelector( - "[data-link-preview-thumbnail]", - ); - return content && thumbnail - ? content.getBoundingClientRect().left - - thumbnail.getBoundingClientRect().right - : undefined; - })(), thumbnailHeight: element .querySelector("[data-link-preview-thumbnail]") ?.getBoundingClientRect().height, @@ -553,26 +566,22 @@ test("link preview image geometry stays stable while loading", async ({ .querySelector("[data-link-preview-thumbnail]") ?.getBoundingClientRect().width, })); - - await expect(card).toHaveAttribute("data-image-state", "image"); - await expect( - card.locator("[data-link-preview-hostname-favicon]"), - ).toHaveAttribute("src", /data:image\/png;base64/); if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { await waitForAnimations(page); - await card.screenshot({ - path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-loaded.png`, + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-loading.png`, }); } - const loaded = await card.evaluate((element) => ({ - description: element.querySelector('[data-slot="attachment-description"]') - ?.textContent, + + await expect + .poll(() => + card.evaluate((element) => element.getAttribute("data-state")), + ) + .toBe("done"); + const ready = await card.evaluate((element) => ({ height: element.getBoundingClientRect().height, - titleClass: element.querySelector('[data-slot="attachment-title"]') - ?.className, - textLeft: element - .querySelector('[data-slot="attachment-content"]') - ?.getBoundingClientRect().left, + width: element.getBoundingClientRect().width, thumbnailHeight: element .querySelector("[data-link-preview-thumbnail]") ?.getBoundingClientRect().height, @@ -580,81 +589,38 @@ test("link preview image geometry stays stable while loading", async ({ .querySelector("[data-link-preview-thumbnail]") ?.getBoundingClientRect().width, })); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-ready.png`, + }); + } - expect(pending.width).toBe(width < 640 ? 345 : 384); - expect(pending.height).toBe(84); - expect(pending.textInset).toBe(8); - expect(pending.thumbnailHeight).toBe(84); - expect(loaded.height).toBe(pending.height); - expect(loaded.textLeft).toBe(pending.textLeft); - expect(loaded.thumbnailHeight).toBe(pending.thumbnailHeight); - expect(loaded.thumbnailWidth).toBe(pending.thumbnailWidth); - expect(loaded.thumbnailWidth).toBe(width < 640 ? 120 : 136); - expect(loaded.titleClass).toContain("line-clamp-2"); - expect(loaded.description).toBe( - "A polished, stable preview for shared links.", - ); + expect(initial.height).toBe(55); + expect(initial.width).toBe(320); + expect(initial.thumbnailHeight).toBe(55); + expect(initial.thumbnailWidth).toBe(55); + expect(ready).toEqual(initial); } }); -test("link preview no-image layout keeps compact height", async ({ page }) => { +test("composer no-image link embeds keep the attachment footprint", async ({ + page, +}) => { const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none"; - - for (const width of [800, 420]) { - await page.setViewportSize({ width: 800, height: 700 }); - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await page.setViewportSize({ width, height: 700 }); - await page - .getByTestId("message-input") - .fill(`${previewUrl}&viewport=${width}`); - const card = page - .locator("[data-composer-link-previews]") - .locator('[data-link-preview="github-pull-request"]'); - await expect(card).toHaveAttribute("data-image-state", "pending"); - await expect(card).toBeVisible(); - await expect - .poll(() => - card.evaluate((element) => element.getBoundingClientRect().height), - ) - .toBe(84); - const pending = await card.evaluate((element) => ({ - height: element.getBoundingClientRect().height, - textLeft: element - .querySelector('[data-slot="attachment-content"]') - ?.getBoundingClientRect().left, - })); - - await expect(card).toHaveAttribute("data-image-state", "none"); - await expect(card.locator("[data-link-preview-thumbnail]")).toHaveCount(0); - await expect(card.locator('[data-slot="attachment-media"]')).toHaveCount(0); - const hostnameLink = card.locator("[data-link-preview-hostname]"); - await expect(hostnameLink).toHaveText("github.com"); - await expect(hostnameLink).toHaveAttribute( - "href", - `${previewUrl}&viewport=${width}`, - ); - await expect( - card.locator("[data-link-preview-hostname-favicon]"), - ).toHaveAttribute("src", /data:image\/png;base64/); - await expect( - card.locator('[data-slot="attachment-description"]'), - ).toHaveText("Open-source collaboration for the Buzz app."); - await expect(card).toHaveCSS("border-left-width", "3px"); - const title = card.locator('[data-slot="attachment-title"]'); - await card.hover(); - await expect(hostnameLink).toHaveCSS("text-decoration-line", "underline"); - await expect(title).toHaveCSS("text-decoration-line", "underline"); - const resolved = await card.evaluate((element) => ({ - height: element.getBoundingClientRect().height, - textLeft: element - .querySelector('[data-slot="attachment-content"]') - ?.getBoundingClientRect().left, - })); - - expect(resolved.height).toBe(pending.height); - expect(resolved.textLeft).toBeLessThan(pending.textLeft ?? 0); - } + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + const card = page + .locator("[data-composer-link-previews]") + .locator('[data-link-preview="github-pull-request"]'); + await expect(card).toHaveAttribute("data-image-state", "none"); + await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible(); + await expect(card.locator('[data-slot="attachment-title"]')).toContainText( + /github\.com|Buzz/, + ); + await expect(card).toHaveCSS("height", "55px"); }); test("mixed link preview image outcomes keep Compact and Rich fallbacks stable", async ({