mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
## Overview **Category:** improvement **User impact:** Link previews appear in the composer and travel as privacy-safe sender-authored snapshots, so recipients never contact the linked site merely by opening a conversation. **Problem:** Cold-cache link paste could freeze the composer before the URL painted; recipient-side unfurling leaked visits; invalid or unresolved preview work could interfere with sending or leave dead cards behind. **Solution:** Paint pasted links before starting cold resolver work, resolve only in the sender's composer, attach only complete validated snapshots at Send, and render authored snapshots without recipient fallback fetching. ## Behavior - **Cold paste stays responsive:** bare and angle-bracket URL paste paths commit the visible link before resolver work begins. - **Sender-only fetching:** metadata is resolved while composing; recipients render only the sender-authored snapshot. - **Send never waits:** pending, failed, invalid, and unsendable previews are omitted. They do not block or cancel the message. - **Terminal misses disappear:** failed, timed-out, or 404 resolver results remove the composer card while preserving visible link text. - **Display-text links work:** Markdown links such as `[review the pull request](…)` produce and send the same snapshots as bare URLs. - **Compact and Rich presentation:** Compact remains the default; Rich preserves source description line breaks and paragraphs. - **Immediate draft-wide dismissal:** clicking × immediately hides all previews for the draft, suppresses links pasted later, and emits only `["link-preview", "none"]`. No confirmation detour. Suppression resets after send or clearing the draft. - **Zero recipient fallback:** missing, stale, malformed, off-relay, unsupported, or suppressed snapshots remain ordinary visible links; recipients never regenerate them. ## Implementation - Resolve previews from deferred composer URL state so paste can paint first. - Upload finished preview media to the active community relay and snapshot only valid, sendable media references. - Atomically capture ready snapshots at submit time; never append a late preview after send. - Validate snapshot and suppression tags in desktop/native and relay ingestion, rejecting duplicate or mixed forms. - Render composer previews as stable 55px attachment cards at desktop and narrow widths. - Add deterministic E2E coverage for cold paste, ready/pending/failed/invalid previews, display-text links, multiline Rich descriptions, immediate dismissal, later-pasted links, and suppression reset. ## Validation Validated head: `9807ba8952f190e76153834abf8ab61dd40be5e2` - Push hooks passed: `check-push-org`, branch skew, desktop check, mobile tests, desktop tests, Rust tests, and desktop Tauri checks. - Focused screenshot E2E at the validated head: 5/5 passed across Compact/Rich composer and recipient states, 800px/420px geometry, display-text links, multiline descriptions, and immediate dismissal. - PR CI was triggered for this exact head and is currently running; completed checks are green at the time of this update. - Worktree is clean and both PR head and validated branch resolve to `9807ba895…`. ## Screenshots ### Compact composer | Loading | Ready | |---|---| |  |  | ### Rich composer | Loading | Ready | |---|---| |  |  | ### Responsive composer | 800px loading | 800px ready | |---|---| |  |  | | 420px loading | 420px ready | |---|---| |  |  | ### Recipient presentation | Compact | Rich | |---|---| |  |  | ### Display-text Markdown link | Composer | Recipient | |---|---| |  |  | ### Rich multiline description  ### Immediate dismissal | Before × | Immediately after × | |---|---| |  |  | --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
183 lines
4.7 KiB
JavaScript
183 lines
4.7 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
__linkPreviewMetadataTest,
|
|
resolveLinkPreview,
|
|
} from "./useResolvedLinkPreviews.ts";
|
|
|
|
const preview = {
|
|
kind: "generic-link",
|
|
href: "https://example.com/story",
|
|
provider: "example.com",
|
|
title: "example.com/story",
|
|
typeLabel: "link",
|
|
};
|
|
|
|
function metadata(overrides = {}) {
|
|
return {
|
|
title: "A story",
|
|
siteName: "Example",
|
|
description: "Story description",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
imageFetchState: "none",
|
|
imageRetryAfterMs: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("pending metadata reserves the image treatment", () => {
|
|
assert.deepEqual(resolveLinkPreview(preview, undefined), {
|
|
...preview,
|
|
imageState: "pending",
|
|
});
|
|
});
|
|
|
|
test("resolved image metadata keeps the reserved image treatment", () => {
|
|
const resolved = resolveLinkPreview(preview, {
|
|
title: "A story",
|
|
siteName: "Example",
|
|
imageDataUrl: "data:image/jpeg;base64,abc",
|
|
imageDomain: "cdn.example.com",
|
|
});
|
|
|
|
assert.equal(resolved.imageState, "image");
|
|
assert.equal(resolved.provider, "Example");
|
|
assert.equal(resolved.imageDomain, "cdn.example.com");
|
|
});
|
|
|
|
test("resolved metadata without a complete image collapses to the compact treatment", () => {
|
|
const resolved = resolveLinkPreview(preview, {
|
|
title: "A story",
|
|
siteName: "Example",
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
});
|
|
|
|
assert.equal(resolved.imageState, "none");
|
|
assert.equal(resolved.imageDataUrl, null);
|
|
assert.equal(resolved.imageDomain, null);
|
|
});
|
|
|
|
test("transient and rejected image fetches use the stable fallback treatment", () => {
|
|
const transient = resolveLinkPreview(
|
|
preview,
|
|
metadata({
|
|
imageFetchState: "transient_failure",
|
|
imageRetryAfterMs: 900_000,
|
|
}),
|
|
);
|
|
const rejected = resolveLinkPreview(
|
|
preview,
|
|
metadata({ imageFetchState: "rejected" }),
|
|
);
|
|
|
|
assert.equal(transient.imageState, "fallback");
|
|
assert.equal(rejected.imageState, "fallback");
|
|
});
|
|
|
|
test("metadata cache keys deduplicate URL fragments", () => {
|
|
assert.equal(
|
|
__linkPreviewMetadataTest.metadataCacheKey(
|
|
"https://github.com/block/buzz/pull/3834#issuecomment-1",
|
|
),
|
|
"https://github.com/block/buzz/pull/3834",
|
|
);
|
|
});
|
|
|
|
test("transient metadata expires at the server retry boundary", () => {
|
|
assert.equal(
|
|
__linkPreviewMetadataTest.metadataExpiry(
|
|
metadata({
|
|
imageFetchState: "transient_failure",
|
|
imageRetryAfterMs: 900_000,
|
|
}),
|
|
1_000,
|
|
),
|
|
901_000,
|
|
);
|
|
});
|
|
|
|
test("metadata loader retries transient images after the server cooldown", async () => {
|
|
let now = 1_000;
|
|
let calls = 0;
|
|
const loader = __linkPreviewMetadataTest.createMetadataLoader({
|
|
fetcher: async () => {
|
|
calls += 1;
|
|
return calls === 1
|
|
? metadata({
|
|
imageFetchState: "transient_failure",
|
|
imageRetryAfterMs: 10_000,
|
|
})
|
|
: metadata({
|
|
imageDataUrl: "data:image/jpeg;base64,abc",
|
|
imageDomain: "images.example.com",
|
|
imageFetchState: "image",
|
|
});
|
|
},
|
|
now: () => now,
|
|
});
|
|
|
|
assert.equal(
|
|
(await loader.load(preview.href)).metadata?.imageFetchState,
|
|
"transient_failure",
|
|
);
|
|
assert.equal(calls, 1);
|
|
|
|
now += 10_000;
|
|
assert.equal(
|
|
(await loader.load(preview.href)).metadata?.imageFetchState,
|
|
"image",
|
|
);
|
|
assert.equal(calls, 2);
|
|
});
|
|
|
|
test("metadata loader retries rejected requests after the negative-cache TTL", async () => {
|
|
let now = 1_000;
|
|
let calls = 0;
|
|
const loader = __linkPreviewMetadataTest.createMetadataLoader({
|
|
fetcher: async () => {
|
|
calls += 1;
|
|
if (calls === 1) throw new Error("temporary failure");
|
|
return metadata();
|
|
},
|
|
now: () => now,
|
|
});
|
|
|
|
assert.equal((await loader.load(preview.href)).metadata, null);
|
|
assert.equal((await loader.load(preview.href)).metadata, null);
|
|
assert.equal(calls, 1);
|
|
|
|
now += 5 * 60_000;
|
|
assert.deepEqual((await loader.load(preview.href)).metadata, metadata());
|
|
assert.equal(calls, 2);
|
|
});
|
|
|
|
test("metadata loader coalesces fragment variants and bounds concurrency", async () => {
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
let calls = 0;
|
|
const loader = __linkPreviewMetadataTest.createMetadataLoader({
|
|
concurrency: 2,
|
|
fetcher: async () => {
|
|
calls += 1;
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
active -= 1;
|
|
return metadata();
|
|
},
|
|
});
|
|
|
|
await Promise.all([
|
|
loader.load("https://example.com/one#first"),
|
|
loader.load("https://example.com/one#second"),
|
|
loader.load("https://example.com/two"),
|
|
loader.load("https://example.com/three"),
|
|
]);
|
|
|
|
assert.equal(calls, 3);
|
|
assert.equal(maxActive, 2);
|
|
});
|