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

180 lines
4.6 KiB
TypeScript

import {
extractSupportedLinkPreviews,
parseSupportedLinkPreview,
} from "./linkPreview";
import type { ResolvedLinkPreview } from "./useResolvedLinkPreviews";
export const LINK_PREVIEW_SNAPSHOT_VERSION = "1";
const MAX_SNAPSHOTS = 8;
const SHA256_RE = /^[0-9a-f]{64}$/;
const IMAGE_EXT_RE = /^(?:jpg|png|gif|webp)$/;
export function isValidLinkPreviewSnapshotCanonicalUrl(value: string): boolean {
if (value.includes("#")) return false;
try {
const url = new URL(value);
return (
url.protocol === "https:" && !url.username && !url.password && !url.hash
);
} catch {
return false;
}
}
export type LinkPreviewSnapshot = {
canonicalUrl: string;
title: string;
siteName: string;
description: string;
imageUrl: string;
imageSha256: string;
faviconUrl: string;
faviconSha256: string;
};
function isControlCharacter(char: string, allowNewlines = false): boolean {
if (allowNewlines && char === "\n") return false;
const code = char.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
}
function sanitizeSnapshotText(
value: string,
maxBytes: number,
allowNewlines = false,
): string {
let result = "";
let byteLength = 0;
for (const rawChar of value) {
const char = isControlCharacter(rawChar, allowNewlines) ? " " : rawChar;
const charBytes = new TextEncoder().encode(char).length;
if (byteLength + charBytes > maxBytes) break;
result += char;
byteLength += charBytes;
}
return result;
}
function validText(value: string, max: number, allowNewlines = false): boolean {
return (
new TextEncoder().encode(value).length <= max &&
!Array.from(value).some((char) => {
return isControlCharacter(char, allowNewlines);
})
);
}
function isRelayMediaPair(
url: string,
sha256: string,
relayOrigin: string,
): boolean {
if (!url && !sha256) return true;
if (!url || !SHA256_RE.test(sha256)) return false;
try {
const parsed = new URL(url);
if (
parsed.origin !== relayOrigin ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
return false;
const match = /^\/media\/([0-9a-f]{64})\.([a-z0-9]{1,8})$/.exec(
parsed.pathname,
);
return Boolean(
match && match[1] === sha256 && IMAGE_EXT_RE.test(match[2] ?? ""),
);
} catch {
return false;
}
}
export function parseLinkPreviewSnapshots(
tags: readonly (readonly string[])[] | undefined,
content: string,
relayOrigin: string | null,
): ResolvedLinkPreview[] {
if (!relayOrigin || !tags) return [];
const contentUrls = new Set(
extractSupportedLinkPreviews(content).map((preview) => preview.href),
);
const seen = new Set<string>();
const snapshots: ResolvedLinkPreview[] = [];
for (const tag of tags) {
if (tag[0] !== "link-preview" || tag[1] !== "snapshot") continue;
if (
snapshots.length >= MAX_SNAPSHOTS ||
tag.length !== 11 ||
tag[2] !== LINK_PREVIEW_SNAPSHOT_VERSION
)
continue;
const [
,
,
,
canonicalUrl,
title,
siteName,
description,
imageUrl,
imageSha256,
faviconUrl,
faviconSha256,
] = tag;
const parsed = parseSupportedLinkPreview(canonicalUrl);
if (
!parsed ||
parsed.href !== canonicalUrl ||
!contentUrls.has(canonicalUrl) ||
seen.has(canonicalUrl)
)
continue;
if (
!validText(title, 300) ||
!validText(siteName, 100) ||
!validText(description, 1000, true)
)
continue;
if (
!isRelayMediaPair(imageUrl, imageSha256, relayOrigin) ||
!isRelayMediaPair(faviconUrl, faviconSha256, relayOrigin)
)
continue;
seen.add(canonicalUrl);
snapshots.push({
...parsed,
title: title || parsed.title,
provider: siteName || parsed.provider,
description: description || null,
faviconDataUrl: faviconUrl || null,
imageDataUrl: imageUrl || null,
imageDomain: imageUrl ? new URL(imageUrl).hostname : null,
imageState: imageUrl ? "image" : "none",
});
}
return snapshots;
}
export function buildLinkPreviewSnapshotTag(
snapshot: LinkPreviewSnapshot,
): string[] | null {
if (!isValidLinkPreviewSnapshotCanonicalUrl(snapshot.canonicalUrl))
return null;
return [
"link-preview",
"snapshot",
LINK_PREVIEW_SNAPSHOT_VERSION,
snapshot.canonicalUrl,
sanitizeSnapshotText(snapshot.title, 300),
sanitizeSnapshotText(snapshot.siteName, 100),
sanitizeSnapshotText(snapshot.description, 1000, true),
snapshot.imageUrl,
snapshot.imageSha256,
snapshot.faviconUrl,
snapshot.faviconSha256,
];
}