mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +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>
180 lines
4.6 KiB
TypeScript
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,
|
|
];
|
|
}
|