feat(composer): add draft-wide preview suppression

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-08-03 16:15:08 -07:00
parent 9b1f09b36e
commit b602548a96
4 changed files with 305 additions and 122 deletions

View File

@@ -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), "")

View File

@@ -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 [

View File

@@ -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<string | null>(
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 (
<Attachment
className="h-[55px] w-80 max-w-full gap-2 overflow-hidden p-0 pr-2 shadow-none"
data-image-state={preview.imageState}
data-link-preview={preview.kind}
data-link-preview-composer-card=""
state={preview.snapshotReady ? "done" : "processing"}
>
<AttachmentMedia
className="h-[55px] w-[55px] rounded-none rounded-l-2xl bg-muted"
data-link-preview-thumbnail=""
variant="image"
>
{showImage ? (
<img
alt=""
className="h-full w-full object-cover"
onError={() => setFailedImageSrc(imageSrc ?? null)}
src={imageSrc ?? undefined}
/>
) : preview.imageState === "pending" ? (
<LoaderCircle
aria-label="Loading link preview"
className="size-4 animate-spin"
/>
) : preview.faviconDataUrl ? (
<img
alt=""
className="size-7 rounded-md object-contain opacity-70"
src={preview.faviconDataUrl}
/>
) : (
<ImageOff aria-hidden="true" className="size-4 opacity-60" />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="line-clamp-1" data-link-preview-hostname="">
{preview.snapshotReady ? preview.title : hostname}
</AttachmentTitle>
<AttachmentDescription>
{preview.snapshotReady
? preview.provider || hostname
: path && path !== "/"
? path
: preview.typeLabel}
</AttachmentDescription>
</AttachmentContent>
{showHideControl ? (
<AttachmentActions>
<AttachmentAction
aria-label="Hide all link previews"
data-testid="composer-hide-link-previews"
onClick={onHideAll}
title="Hide previews"
type="button"
>
<X />
</AttachmentAction>
</AttachmentActions>
) : null}
<AttachmentTrigger asChild>
<a
aria-label={`Open ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
>
<span className="sr-only">Open {preview.title}</span>
</a>
</AttachmentTrigger>
</Attachment>
);
}
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<Record<string, string[]>>(
{},
);
@@ -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 ? (
<div
className="message-markdown mb-2"
className="mb-2"
data-composer-link-previews=""
data-ready-snapshot-count={readyTagsRef.current.length}
>
<LinkPreviewList
ImageLightbox={LinkPreviewImageLightbox}
previews={previews}
/>
<AttachmentGroup className="max-w-full flex-row flex-wrap items-start overflow-visible pb-0">
{previews.map((preview, index) => (
<ComposerLinkPreviewCard
key={preview.href}
onHideAll={hideAll}
preview={preview}
showHideControl={index === 0}
/>
))}
</AttachmentGroup>
</div>
) : null;
const getReadyTags = React.useCallback(() => readyTagsRef.current, []);

View File

@@ -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 ({