Files
buzz/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs
Taylor Ho 1922d49cb2 feat(desktop): adding rich link previews to messages (#3818)
## 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 |
|---|---|
| ![Compact composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/compact-composer-loading.png)
| ![Compact composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/compact-composer-ready.png)
|

### Rich composer

| Loading | Ready |
|---|---|
| ![Rich composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-composer-loading.png)
| ![Rich composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-composer-ready.png)
|

### Responsive composer

| 800px loading | 800px ready |
|---|---|
| ![800px composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-800-loading.png)
| ![800px composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-800-ready.png)
|

| 420px loading | 420px ready |
|---|---|
| ![420px composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-420-loading.png)
| ![420px composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-420-ready.png)
|

### Recipient presentation

| Compact | Rich |
|---|---|
| ![Recipient
compact](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/recipient-compact.png)
| ![Recipient
rich](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/recipient-rich.png)
|

### Display-text Markdown link

| Composer | Recipient |
|---|---|
| ![Display-text link in
composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/display-text-composer.png)
| ![Display-text link with recipient
preview](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/display-text-recipient.png)
|

### Rich multiline description

![Rich preview preserving description
paragraphs](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-multiline-recipient.png)

### Immediate dismissal

| Before × | Immediately after × |
|---|---|
| ![Preview before immediate
dismissal](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/dismissal-before.png)
| ![Composer immediately after preview
dismissal](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/dismissal-after.png)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-07 10:56:08 -07:00

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);
});