Files
buzz/desktop/src/shared/lib/linkPreviewSnapshot.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

117 lines
3.3 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
buildLinkPreviewSnapshotTag,
parseLinkPreviewSnapshots,
} from "./linkPreviewSnapshot.ts";
const HASH = "a".repeat(64);
const ORIGIN = "https://relay.example";
const CONTENT = "Read https://linear.app/acme/issue/ABC-123/example";
const URL = "https://linear.app/acme/issue/ABC-123/example";
const valid = [
"link-preview",
"snapshot",
"1",
URL,
"Example",
"Linear",
"Description",
`${ORIGIN}/media/${HASH}.png`,
HASH,
"",
"",
];
test("authored snapshots render only exact local media tied to message content", () => {
assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, ORIGIN).length, 1);
assert.equal(
parseLinkPreviewSnapshots([valid], "no matching link", ORIGIN).length,
0,
);
assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, null).length, 0);
});
test("authored snapshots reject remote, malformed, credentialed, and hash-mismatched media", () => {
for (const image of [
`https://evil.example/media/${HASH}.png`,
`${ORIGIN}/media/${HASH}.png?token=leak`,
`${ORIGIN}/media/${HASH}.png#fragment`,
`https://user@relay.example/media/${HASH}.png`,
`${ORIGIN}/media/${HASH}.svg`,
`${ORIGIN}/media/${HASH}.png/extra`,
]) {
const tag = [...valid];
tag[7] = image;
assert.equal(
parseLinkPreviewSnapshots([tag], CONTENT, ORIGIN).length,
0,
image,
);
}
const mismatch = [...valid];
mismatch[8] = "b".repeat(64);
assert.equal(
parseLinkPreviewSnapshots([mismatch], CONTENT, ORIGIN).length,
0,
);
});
test("snapshot tags sanitize control characters and enforce UTF-8 byte limits", () => {
const tag = buildLinkPreviewSnapshotTag({
canonicalUrl: URL,
title: `Title\n${"😀".repeat(100)}`,
siteName: `Linear\u0000${"é".repeat(100)}`,
description: `First\nSecond\t${"😀".repeat(300)}`,
imageUrl: "",
imageSha256: "",
faviconUrl: "",
faviconSha256: "",
});
assert.ok(tag);
assert.equal(tag[6].startsWith("First\nSecond"), true);
assert.equal(tag[6].includes("\t"), false);
for (const value of tag.slice(4, 6)) {
assert.ok(
Array.from(value).every((char) => {
const code = char.charCodeAt(0);
return code > 0x1f && code !== 0x7f;
}),
);
}
assert.ok(Buffer.byteLength(tag[4], "utf8") <= 300);
assert.ok(Buffer.byteLength(tag[5], "utf8") <= 100);
assert.ok(Buffer.byteLength(tag[6], "utf8") <= 1000);
});
test("snapshot tags omit canonical URLs rejected by native validation", () => {
for (const canonicalUrl of [
`${URL}#`,
`${URL}#details`,
`http://linear.app/acme/issue/ABC-123/example`,
`https://user@linear.app/acme/issue/ABC-123/example`,
]) {
assert.equal(
buildLinkPreviewSnapshotTag({
canonicalUrl,
title: "Example",
siteName: "Linear",
description: "Description",
imageUrl: "",
imageSha256: "",
faviconUrl: "",
faviconSha256: "",
}),
null,
canonicalUrl,
);
}
});
test("messages without authored snapshots never create recipient previews", () => {
assert.deepEqual(parseLinkPreviewSnapshots([], CONTENT, ORIGIN), []);
assert.deepEqual(parseLinkPreviewSnapshots(undefined, CONTENT, ORIGIN), []);
});