Merge remote-tracking branch 'origin/main' into feat/projects-copy-link

Signed-off-by: Thomas Petersen <thomasp@squareup.com>

# Conflicts:
#	desktop/src/shared/lib/useResolvedLinkPreviews.ts
This commit is contained in:
Thomas Petersen
2026-08-11 05:50:47 -04:00
24 changed files with 1951 additions and 238 deletions

View File

@@ -1,10 +1,10 @@
{
"schema": 2,
"version": "0.5.8",
"base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f",
"previous_tag": "desktop-v0.5.7",
"previous_base_sha": "74b913cff8512c015dc6f1a7473b253fa803f954",
"previous_merge_sha": "13c9e900c84cac1e2c8eeb7551bd1510ecb544d3",
"tag": "desktop-v0.5.8",
"commit_count": 4
"version": "0.5.9",
"base_sha": "f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b",
"previous_tag": "desktop-v0.5.8",
"previous_base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f",
"previous_merge_sha": "c815a9c6e1a1d1818a0547f60f894e4a5388761a",
"tag": "desktop-v0.5.9",
"commit_count": 29
}

View File

@@ -1,5 +1,44 @@
# Changelog
## v0.5.9
### Desktop and shared changes
- Polish desktop onboarding flow ([#5310](https://github.com/block/buzz/pull/5310)) ([`3f2f32641f4093d087fd9506bfac1fa0329e8b2e`](https://github.com/block/buzz/commit/3f2f32641f4093d087fd9506bfac1fa0329e8b2e))
- fix(desktop): quiesce renderer polling while hidden (#3677) ([#5490](https://github.com/block/buzz/pull/5490)) ([`07a3c768d619db31fee3f0590f9433cdd1213e8f`](https://github.com/block/buzz/commit/07a3c768d619db31fee3f0590f9433cdd1213e8f))
- fix(channels): restore member invitations to private channels ([#5493](https://github.com/block/buzz/pull/5493)) ([`2777189d960fa5b1d863166f36d6e37ff8ce0819`](https://github.com/block/buzz/commit/2777189d960fa5b1d863166f36d6e37ff8ce0819))
- fix(desktop): bound nine unbounded localStorage stores ([#5454](https://github.com/block/buzz/pull/5454)) ([`9c074bb89b290721f839bbc84fdf4701269e43a0`](https://github.com/block/buzz/commit/9c074bb89b290721f839bbc84fdf4701269e43a0))
- feat(desktop): time-based sweep for stale localStorage caches ([#5453](https://github.com/block/buzz/pull/5453)) ([`bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead`](https://github.com/block/buzz/commit/bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead))
- feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 ([#4000](https://github.com/block/buzz/pull/4000)) ([`5e4c05f90b062898e1827ba45cb826c6ff913741`](https://github.com/block/buzz/commit/5e4c05f90b062898e1827ba45cb826c6ff913741))
- fix(desktop): resolve overlapping member mentions ([#5225](https://github.com/block/buzz/pull/5225)) ([`44456e200e3ca6a5d2882b58b447b80474041347`](https://github.com/block/buzz/commit/44456e200e3ca6a5d2882b58b447b80474041347))
- chore(deps): update rust crate anyhow to v1.0.104 ([#4447](https://github.com/block/buzz/pull/4447)) ([`e1ff91ecc1269682a50c17da2c0708d1448b336f`](https://github.com/block/buzz/commit/e1ff91ecc1269682a50c17da2c0708d1448b336f))
- fix(desktop): preserve Welcome banner dismissal ([#5406](https://github.com/block/buzz/pull/5406)) ([`97aa9e31856edb9d8abcdcb33c472027f5588890`](https://github.com/block/buzz/commit/97aa9e31856edb9d8abcdcb33c472027f5588890))
- fix(agent): retry LLM completion on malformed 2xx JSON body ([#5351](https://github.com/block/buzz/pull/5351)) ([`5bf78671f45178f8de02ba18d3d321cbbf19cd1f`](https://github.com/block/buzz/commit/5bf78671f45178f8de02ba18d3d321cbbf19cd1f))
- fix(desktop): welcome banner overlap and missing dismiss control ([#5330](https://github.com/block/buzz/pull/5330)) ([`f029deafae6ad3b63e13c29104f3be76122cb1df`](https://github.com/block/buzz/commit/f029deafae6ad3b63e13c29104f3be76122cb1df))
- fix(desktop): prevent horizontal clipping in Prompt Context modal ([#5324](https://github.com/block/buzz/pull/5324)) ([`fbf89e3bed9adebc033a26b7c43362c004e816a2`](https://github.com/block/buzz/commit/fbf89e3bed9adebc033a26b7c43362c004e816a2))
- fix(buzz-agent): recover from 400-shaped image rejections; unbound benchmark agent rounds ([#5318](https://github.com/block/buzz/pull/5318)) ([`261c46076166c6de5bb9a71fb4a0fd0b70aa1efa`](https://github.com/block/buzz/commit/261c46076166c6de5bb9a71fb4a0fd0b70aa1efa))
### Other repository changes
- feat(cli): add --visibility flag to channels update ([#5119](https://github.com/block/buzz/pull/5119)) ([`f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b`](https://github.com/block/buzz/commit/f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b))
- perf(ci): experiment with sccache for relay builds ([#5224](https://github.com/block/buzz/pull/5224)) ([`5a3b3d23226474f835a1cf41d2ecc5f53cacb070`](https://github.com/block/buzz/commit/5a3b3d23226474f835a1cf41d2ecc5f53cacb070))
- ci(release): gate OSS desktop auto-update promotion ([#5398](https://github.com/block/buzz/pull/5398)) ([`43573d114b5bfaf7cefa75eee7e219dc05cf1cd1`](https://github.com/block/buzz/commit/43573d114b5bfaf7cefa75eee7e219dc05cf1cd1))
- fix(release): pin desktop PR operations to block/buzz ([#5212](https://github.com/block/buzz/pull/5212)) ([`c1e20a814bf694db2af959adacb375ced27af023`](https://github.com/block/buzz/commit/c1e20a814bf694db2af959adacb375ced27af023))
- fix(search): surface exact short profile names ([#5480](https://github.com/block/buzz/pull/5480)) ([`3c76f682c3c2dfe2cd296c277c5e63799d3424f9`](https://github.com/block/buzz/commit/3c76f682c3c2dfe2cd296c277c5e63799d3424f9))
- Reduce repeated ACP session context ([#5423](https://github.com/block/buzz/pull/5423)) ([`563e4346da37d0fb2e9ec1c95e7f1eba79f83040`](https://github.com/block/buzz/commit/563e4346da37d0fb2e9ec1c95e7f1eba79f83040))
- chore(deps): update react monorepo ([#4441](https://github.com/block/buzz/pull/4441)) ([`119a84897f225c1e3213a09cd149abb37dcb3abc`](https://github.com/block/buzz/commit/119a84897f225c1e3213a09cd149abb37dcb3abc))
- ci(security): allow retired relay pool advisory ([#5404](https://github.com/block/buzz/pull/5404)) ([`d2ebaa95a7d2565fb217fdfae56bafb9509be444`](https://github.com/block/buzz/commit/d2ebaa95a7d2565fb217fdfae56bafb9509be444))
- chore(deps): update dependency @tanstack/react-virtual to v3.14.9 ([#4439](https://github.com/block/buzz/pull/4439)) ([`c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888`](https://github.com/block/buzz/commit/c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888))
- chore(deps): update all non-major dependencies ([#3049](https://github.com/block/buzz/pull/3049)) ([`856cdb848b0a849e33620887b145b7e598dfd95c`](https://github.com/block/buzz/commit/856cdb848b0a849e33620887b145b7e598dfd95c))
- chore(deps): update rust crate arc-swap to v1.9.2 ([#4448](https://github.com/block/buzz/pull/4448)) ([`08de85c592106ea2ffe22ba16e3a0fc10687db54`](https://github.com/block/buzz/commit/08de85c592106ea2ffe22ba16e3a0fc10687db54))
- chore(deps): update rust crate async-trait to v0.1.91 ([#4458](https://github.com/block/buzz/pull/4458)) ([`12b1f566480d4feddc171739097f9359d3f255c1`](https://github.com/block/buzz/commit/12b1f566480d4feddc171739097f9359d3f255c1))
- chore(deps): update rust crate diffy to v0.5.1 ([#4466](https://github.com/block/buzz/pull/4466)) ([`d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4`](https://github.com/block/buzz/commit/d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4))
- chore(deps): update rust crate async-compression to v0.4.43 ([#4456](https://github.com/block/buzz/pull/4456)) ([`7dd8791d0765e9f15fed3299b6948e2babbfd763`](https://github.com/block/buzz/commit/7dd8791d0765e9f15fed3299b6948e2babbfd763))
- chore(deps): update rust crate clap to v4.6.6 ([#4465](https://github.com/block/buzz/pull/4465)) ([`e668c6bb4913e36e58d7f947dbaf982e704e9132`](https://github.com/block/buzz/commit/e668c6bb4913e36e58d7f947dbaf982e704e9132))
- chore(release): release Buzz Relay version 0.2.1 ([#2856](https://github.com/block/buzz/pull/2856)) ([`6e5c462ac524de60d7edb46c66130fd779cc9006`](https://github.com/block/buzz/commit/6e5c462ac524de60d7edb46c66130fd779cc9006))
[Compare desktop-v0.5.8...desktop-v0.5.9](https://github.com/block/buzz/compare/desktop-v0.5.8...desktop-v0.5.9)
## v0.5.8
### Desktop and shared changes

View File

@@ -1,7 +1,7 @@
{
"name": "buzz",
"private": true,
"version": "0.5.8",
"version": "0.5.9",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -62,6 +62,7 @@ export default defineConfig({
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/composer-link-shortcut.spec.ts",
"**/entity-link-recipient-cards.spec.ts",
"**/composer-selection-formatting.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",

View File

@@ -1062,7 +1062,7 @@ dependencies = [
[[package]]
name = "buzz-desktop"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"anyhow",
"arboard",

View File

@@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"]
[package]
name = "buzz-desktop"
version = "0.5.8"
version = "0.5.9"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Buzz",
"version": "0.5.8",
"version": "0.5.9",
"identifier": "xyz.block.buzz.app",
"build": {
"beforeDevCommand": {

View File

@@ -58,6 +58,7 @@ import { useComposerContentState } from "./useComposerContentState";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
import { submitMessageEdit } from "./submitMessageEdit";
import { useComposerLinkPreviews } from "./useComposerLinkPreviews";
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit";
import type { MessageComposerProps } from "./MessageComposer.types";
function MessageComposerImpl({
audienceContext = null,
@@ -100,11 +101,13 @@ function MessageComposerImpl({
syncContentRefFromEditorRef,
} = useComposerContentState();
const [previewContent, setPreviewContent] = React.useState("");
const deferredPreviewContent = React.useDeferredValue(previewContent);
const {
previewList: composerLinkPreviews,
getReadyTags: getReadyLinkPreviewTags,
} = useComposerLinkPreviews(deferredPreviewContent);
hasPendingSnapshots: hasPendingLinkPreviewSnapshots,
// Ref lets the submit guard block Enter/form/auto-submit until snapshots settle.
hasPendingSnapshotsRef: hasPendingLinkPreviewSnapshotsRef,
} = useComposerLinkPreviews(previewContent, editTarget == null);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -198,6 +201,8 @@ function MessageComposerImpl({
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const isUploadingRef = React.useRef(media.isUploading);
// Sync lock: taken before any async send so rapid Enter can't double-submit.
const isSubmitLockedRef = React.useRef(false);
const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave);
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
@@ -562,7 +567,9 @@ function MessageComposerImpl({
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current ||
isSubmitLockedRef.current ||
isUploadingRef.current ||
hasPendingLinkPreviewSnapshotsRef.current ||
mentionSendFlow.isPreparingMentionSend
) {
return;
@@ -574,6 +581,7 @@ function MessageComposerImpl({
) {
return;
}
isSubmitLockedRef.current = true;
onPreparingMentionSendChange?.(true);
persistentMentionHydration.beginSubmit();
try {
@@ -594,6 +602,7 @@ function MessageComposerImpl({
audienceRevision: audienceScope ? persistentAudience.revision : null,
});
} finally {
isSubmitLockedRef.current = false;
persistentMentionHydration.endSubmit();
onPreparingMentionSendChange?.(false);
}
@@ -604,6 +613,7 @@ function MessageComposerImpl({
drafts.loadDraft,
emojiAutocomplete.clearEmojis,
getReadyLinkPreviewTags,
hasPendingLinkPreviewSnapshotsRef,
media.clearQueuedAttachments,
media.pendingImetaRef,
media.queuedAttachmentsRef,
@@ -654,15 +664,10 @@ function MessageComposerImpl({
// Clear the trigger BEFORE firing so any navigation from the send cannot
// loop back with the param still present.
onAutoSubmitCompleteRef.current?.();
// Defer by one macrotask so the draft-persist lifecycle effect (which runs
// synchronously after mount) has a chance to load the draft content into
// the Tiptap editor before we try to submit.
const timer = window.setTimeout(() => {
submitMessageRef.current();
}, 0);
return () => {
window.clearTimeout(timer);
};
return scheduleSettleGatedAutoSubmit({
isPending: () => hasPendingLinkPreviewSnapshotsRef.current,
submit: () => submitMessageRef.current(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // mount-only
const handleSubmit = React.useCallback(
@@ -802,23 +807,14 @@ function MessageComposerImpl({
});
}, [media.setPendingImeta, richText.editor, scrollComposerToBottom]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
() =>
composerDisabled ||
media.isUploading ||
mentionSendFlow.isPreparingMentionSend ||
(isContentEmpty &&
media.pendingImeta.length === 0 &&
media.queuedAttachments.length === 0),
[
composerDisabled,
media.isUploading,
mentionSendFlow.isPreparingMentionSend,
isContentEmpty,
media.pendingImeta.length,
media.queuedAttachments.length,
],
);
const sendDisabled =
composerDisabled ||
media.isUploading ||
hasPendingLinkPreviewSnapshots ||
mentionSendFlow.isPreparingMentionSend ||
(isContentEmpty &&
media.pendingImeta.length === 0 &&
media.queuedAttachments.length === 0);
const handleCaptureSelection = React.useCallback(() => {}, []);
const handlePaperclipClick = React.useCallback(() => {

View File

@@ -0,0 +1,103 @@
/**
* Unit tests for `scheduleSettleGatedAutoSubmit` — the auto-submit scheduler
* that fires a ?autoSend draft submit exactly once, after link-preview settling
* finishes.
*
* Imports and exercises the ACTUAL source helper. Regression guard for the
* auto-send-drop blocker (PR #5245, Blocker A): a confirmed draft with a
* supported link is normally still settling at mount, so an immediate submit
* bails on the pending guard. The prior one-shot `setTimeout(0)` consumed the
* trigger and silently dropped the draft. The scheduler must instead poll while
* pending and submit exactly once when settling clears — never zero, never
* twice.
*
* A controllable fake timer drives the poll deterministically, so there is no
* real-time flakiness (the E2E form could not reliably send inside the ~350 ms
* window headless).
*/
import assert from "node:assert/strict";
import test from "node:test";
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit.ts";
// Minimal deterministic timer: records scheduled callbacks so the test can
// advance them one "tick" at a time and assert exact call counts.
function makeFakeTimers() {
const pending = new Map();
let nextId = 1;
return {
set(fn, _ms) {
const id = nextId++;
pending.set(id, fn);
return id;
},
clear(id) {
pending.delete(id);
},
// Fire the earliest-scheduled still-pending callback.
tick() {
const [id, fn] = pending.entries().next().value ?? [];
if (id === undefined) return false;
pending.delete(id);
fn();
return true;
},
pendingCount() {
return pending.size;
},
};
}
test("submits once immediately when nothing is pending", () => {
const timers = makeFakeTimers();
let submits = 0;
scheduleSettleGatedAutoSubmit({
isPending: () => false,
submit: () => submits++,
timers,
});
timers.tick(); // fire the initial setTimeout(0)
assert.equal(submits, 1);
assert.equal(timers.pendingCount(), 0, "no retry should be scheduled");
});
test("waits while settling then submits exactly once (the drop-guard)", () => {
const timers = makeFakeTimers();
let submits = 0;
let pending = true; // still settling at mount
scheduleSettleGatedAutoSubmit({
isPending: () => pending,
submit: () => submits++,
timers,
});
timers.tick(); // initial attempt: pending → reschedules, does NOT submit
assert.equal(submits, 0, "must not send while a snapshot is still pending");
assert.equal(timers.pendingCount(), 1, "a retry must be scheduled");
timers.tick(); // retry: still pending
assert.equal(submits, 0);
pending = false; // settling finished
timers.tick(); // retry: fires the send
assert.equal(submits, 1, "must send exactly once after settling clears");
assert.equal(timers.pendingCount(), 0);
});
test("cleanup before settling finishes cancels the submit (no orphan send)", () => {
const timers = makeFakeTimers();
let submits = 0;
const cleanup = scheduleSettleGatedAutoSubmit({
isPending: () => true,
submit: () => submits++,
timers,
});
timers.tick(); // initial attempt reschedules a retry
assert.equal(timers.pendingCount(), 1);
cleanup(); // unmount
assert.equal(
timers.pendingCount(),
0,
"cleanup must clear the pending retry",
);
assert.equal(submits, 0);
});

View File

@@ -0,0 +1,45 @@
// Auto-submit scheduler for a confirmed draft that arrived via ?autoSend. A
// draft containing a supported link is normally still settling (350 ms
// debounce + metadata/upload) at mount, so a submit fired immediately bails on
// the pending-snapshot guard. A one-shot `setTimeout(0)` would consume the
// trigger and silently drop the draft; instead poll until settling finishes
// (bounded by the preview hook's own anti-trap cap) then submit exactly once.
// The `didSubmit` guard prevents a double fire, and the initial defer lets the
// draft-persist lifecycle effect load the draft into the editor first.
//
// Extracted from MessageComposer as a pure, timer-injectable helper so the
// retry/one-shot contract is unit-testable without mounting the composer.
export function scheduleSettleGatedAutoSubmit({
isPending,
submit,
retryDelayMs = 50,
timers = {
set: (fn: () => void, ms: number) => window.setTimeout(fn, ms),
clear: (id: number) => window.clearTimeout(id),
},
}: {
isPending: () => boolean;
submit: () => void;
retryDelayMs?: number;
timers?: {
set: (fn: () => void, ms: number) => number;
clear: (id: number) => void;
};
}): () => void {
let didSubmit = false;
let retryTimer = 0;
const attempt = () => {
if (didSubmit) return;
if (isPending()) {
retryTimer = timers.set(attempt, retryDelayMs);
return;
}
didSubmit = true;
submit();
};
const initialTimer = timers.set(attempt, 0);
return () => {
timers.clear(initialTimer);
timers.clear(retryTimer);
};
}

View File

@@ -0,0 +1,78 @@
/**
* Unit tests for `selectSubmitTags` — the pure selector that decides which
* link-preview snapshot tags a composer submit emits.
*
* These import and exercise the ACTUAL source helper (not a mirrored copy), so
* they fail if the submit-tag selection ever regresses.
*
* Regression guard for the "removed-URL tag leak" defect (PR #5245, Blocker B):
* a ready snapshot tag for URL A lingers in the tag map for the 350 ms
* debounce window after A is deleted from the draft. Submit must key off the
* LIVE hrefs in the content being sent — never that debounced set — so deleting
* A and immediately sending replacement text can never attach A's tag (and its
* media refs) to a body that no longer contains A.
*
* The E2E form of this test was flaky: sending inside the 350 ms window from a
* headless browser did not reliably fire a submit, so it could not isolate the
* leak. A pure unit test against the extracted selector is deterministic and
* targets the fix logic directly.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { selectSubmitTags } from "./useComposerLinkPreviews.tsx";
const tagA = ["link-preview", "snapshot", "1", "https://a.example/x", "A"];
const tagB = ["link-preview", "snapshot", "1", "https://b.example/y", "B"];
test("emits the tag for a live href that has a ready snapshot", () => {
const tags = selectSubmitTags(
["https://a.example/x"],
{ "https://a.example/x": tagA },
false,
);
assert.deepEqual(tags, [tagA]);
});
test("LEAK GUARD: a ready tag whose href is no longer live is NOT emitted", () => {
// A resolved (tag still cached), but A was deleted from the draft and the
// live content is now different — the debounced map still holds A's tag.
const tags = selectSubmitTags(
[], // live content no longer contains A
{ "https://a.example/x": tagA },
false,
);
assert.deepEqual(tags, [], "removed URL A must never leak its snapshot tag");
});
test("LEAK GUARD: replacing A with a live B emits only B's tag, never A's", () => {
const tags = selectSubmitTags(
["https://b.example/y"], // A deleted, B is what's live now
{ "https://a.example/x": tagA, "https://b.example/y": tagB },
false,
);
assert.deepEqual(tags, [tagB]);
});
test("a live href with no ready tag is omitted (sends as a bare link)", () => {
const tags = selectSubmitTags(["https://a.example/x"], {}, false);
assert.deepEqual(tags, []);
});
test("preserves live href order for multiple ready tags", () => {
const tags = selectSubmitTags(
["https://a.example/x", "https://b.example/y"],
{ "https://b.example/y": tagB, "https://a.example/x": tagA },
false,
);
assert.deepEqual(tags, [tagA, tagB]);
});
test("suppressed emits only the 'none' marker, ignoring any ready tags", () => {
const tags = selectSubmitTags(
["https://a.example/x"],
{ "https://a.example/x": tagA },
true,
);
assert.deepEqual(tags, [["link-preview", "none"]]);
});

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { ImageOff, LoaderCircle, X } from "lucide-react";
import { toast } from "sonner";
import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri";
import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
@@ -11,8 +12,12 @@ import {
beginRelayOriginFetch,
getCachedRelayOrigin,
} from "@/shared/lib/mediaUrl";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
import {
isBuzzEntityPreview,
type ResolvedLinkPreview,
useResolvedLinkPreviews,
withEntityFallbacks,
} from "@/shared/lib/useResolvedLinkPreviews";
import {
Attachment,
AttachmentContent,
@@ -24,6 +29,17 @@ import {
} from "@/shared/ui/attachment";
import { Button } from "@/shared/ui/button";
// Idle time after the last keystroke before link-preview resolution runs, so
// typing a URL does not flicker a card per character (debounce, not throttle:
// throttle would still fire mid-type).
const LINK_PREVIEW_DEBOUNCE_MS = 350;
// Upper bound on how long Send stays disabled while a preview is still settling
// (metadata resolving, or snapshot media uploading). Past this the button
// re-enables even if the tag never lands, so a dead or slow link never traps
// the composer — the message then sends as a bare link.
const SNAPSHOT_SETTLE_DISABLE_CAP_MS = 2000;
function previewHostname(href: string): string {
try {
return new URL(href).hostname.replace(/^www\./, "");
@@ -32,10 +48,32 @@ function previewHostname(href: string): string {
}
}
// Pure selector for the snapshot tags emitted on submit. Keyed off `liveHrefs`
// (the hrefs in the content being sent RIGHT NOW), never the debounced active
// set — a ready tag for URL A lingers in `tagsByHref` for the 350 ms until the
// debounce drops A, so keying off live hrefs is what stops "delete A, send
// replacement text within the window" from leaking A's tag (and media refs)
// onto a body that no longer contains A. When `suppressed`, emit only the
// "none" marker. Live hrefs without a ready tag (dead/slow link past the
// anti-trap cap) are omitted and the message sends as a bare link.
export function selectSubmitTags(
liveHrefs: readonly string[],
tagsByHref: Record<string, string[]>,
suppressed: boolean,
): string[][] {
if (suppressed) return [["link-preview", "none"]];
return liveHrefs.flatMap((href) => {
const tag = tagsByHref[href];
return tag ? [tag] : [];
});
}
function ComposerLinkPreviewCard({
preview,
tagReady,
}: {
preview: ResolvedLinkPreview;
tagReady: boolean;
}) {
const imageSrc = preview.imageState === "image" ? preview.imageDataUrl : null;
const [failedImageSrc, setFailedImageSrc] = React.useState<string | null>(
@@ -43,6 +81,11 @@ function ComposerLinkPreviewCard({
);
const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc);
const hostname = previewHostname(preview.href);
// External cards are send-ready only once their snapshot tag exists. Buzz
// entities never snapshot; recipients resolve them from the relay, so they
// are complete as soon as the recognized entity card exists.
const snapshotTagReady = Boolean(preview.snapshotReady && tagReady);
const done = snapshotTagReady || isBuzzEntityPreview(preview);
let path = "";
try {
const url = new URL(preview.href);
@@ -55,7 +98,8 @@ function ComposerLinkPreviewCard({
data-image-state={preview.imageState}
data-link-preview={preview.kind}
data-link-preview-composer-card=""
state={preview.snapshotReady ? "done" : "processing"}
data-snapshot-tag-ready={snapshotTagReady ? "true" : "false"}
state={done ? "done" : "processing"}
>
<AttachmentMedia
className="h-[55px] w-[55px] rounded-none rounded-l-2xl bg-muted"
@@ -86,10 +130,10 @@ function ComposerLinkPreviewCard({
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="line-clamp-1" data-link-preview-hostname="">
{preview.snapshotReady ? preview.title : hostname}
{done ? preview.title : hostname}
</AttachmentTitle>
<AttachmentDescription>
{preview.snapshotReady
{done
? preview.provider || hostname
: path && path !== "/"
? path
@@ -131,21 +175,81 @@ async function uploadDataUrl(
return { url: uploaded.url, sha256: uploaded.sha256 };
}
export function useComposerLinkPreviews(content: string) {
// Upload one snapshot media (image or favicon) independently so a single
// failure degrades gracefully instead of dropping the whole preview: on
// failure we return empty url/sha256 (a valid "no media" snapshot field) and
// report which media failed so the caller can toast the user once.
async function uploadSnapshotMedia(
dataUrl: string | null | undefined,
filename: string,
label: "thumbnail" | "favicon",
): Promise<{ url: string; sha256: string; failed: null | typeof label }> {
try {
const { url, sha256 } = await uploadDataUrl(dataUrl, filename);
return { url, sha256, failed: null };
} catch {
return { url: "", sha256: "", failed: dataUrl ? label : null };
}
}
export function useComposerLinkPreviews(content: string, enabled = true) {
const [suppressed, setSuppressed] = React.useState(false);
const candidates = React.useMemo(
() =>
extractSupportedLinkPreviews(content).filter((preview) =>
preview.href.startsWith("buzz://")
? true
: isValidLinkPreviewSnapshotCanonicalUrl(preview.href),
),
[content],
);
const previews = useResolvedLinkPreviews(suppressed ? [] : candidates);
// Debounce the content that drives resolution so typing a URL character by
// character does not churn a new candidate href (and a flickering card) per
// keystroke. `content` is the live editor value; `debounced` is what actually
// resolves. A fast paste-and-Enter before the debounce fires is held by
// `hasUnresolvedLiveCandidates` below, which keeps Send disabled until the
// live candidates resolve — so no synchronous flush is needed at submit.
const [debounced, setDebounced] = React.useState(content);
const debouncedRef = React.useRef(debounced);
debouncedRef.current = debounced;
React.useEffect(() => {
if (candidates.length === 0) setSuppressed(false);
}, [candidates.length]);
if (content === debouncedRef.current) return;
const timer = window.setTimeout(
() => setDebounced(content),
LINK_PREVIEW_DEBOUNCE_MS,
);
return () => window.clearTimeout(timer);
}, [content]);
const extractCandidates = React.useCallback(
(source: string) =>
enabled
? extractSupportedLinkPreviews(source).filter((preview) =>
preview.href.startsWith("buzz://")
? true
: isValidLinkPreviewSnapshotCanonicalUrl(preview.href),
)
: [],
[enabled],
);
const candidates = React.useMemo(
() => extractCandidates(debounced),
[extractCandidates, debounced],
);
// Supported candidates in the LIVE content. When these differ from what has
// resolved (debounce not yet fired after a paste/keystroke), Send must still
// treat the preview as pending so a fast Enter cannot ship a bare link ahead
// of resolution.
const liveCandidatesRef = React.useRef<string[]>([]);
liveCandidatesRef.current = extractCandidates(content).map(
(preview) => preview.href,
);
const resolvedPreviews = useResolvedLinkPreviews(
suppressed ? [] : candidates,
);
// Entity links resolve to null metadata when the relay lookup has nothing
// for them; keep their safe fallback cards rather than dropping them.
const previews = React.useMemo(
() => withEntityFallbacks(suppressed ? [] : candidates, resolvedPreviews),
[suppressed, candidates, resolvedPreviews],
);
// Clear a "hide previews" suppression as soon as the LIVE draft has no
// supported candidates — not the debounced set, whose lag would otherwise let
// a clear-then-retype race keep suppression stuck on after the draft changed.
const liveCandidatesEmpty = liveCandidatesRef.current.length === 0;
React.useEffect(() => {
if (liveCandidatesEmpty) setSuppressed(false);
}, [liveCandidatesEmpty]);
const [readyTags, setReadyTags] = React.useState<Record<string, string[]>>(
{},
);
@@ -184,12 +288,33 @@ export function useComposerLinkPreviews(content: string) {
)
continue;
uploadsRef.current.add(preview.href);
void Promise.all([
uploadDataUrl(preview.imageDataUrl, "link-preview-image.png"),
uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png"),
// Upload image and favicon independently so one failure degrades to the
// surviving media instead of dropping the whole preview. A snapshot tag
// with empty media fields is valid (renders as text + favicon, or
// text-only), so a partial or total media failure still ships a real
// inline preview and the card never spins forever.
const uploadPromise = Promise.all([
uploadSnapshotMedia(
preview.imageDataUrl,
"link-preview-image.png",
"thumbnail",
),
uploadSnapshotMedia(
preview.faviconDataUrl,
"link-preview-favicon.png",
"favicon",
),
])
.then(([image, favicon]) => {
if (!activeHrefsRef.current.has(preview.href)) return;
const failedMedia = [image.failed, favicon.failed].filter(
(label): label is "thumbnail" | "favicon" => label !== null,
);
if (failedMedia.length > 0) {
toast.error(
`Something went wrong with the ${failedMedia.join(" and ")}`,
);
}
const tag = buildLinkPreviewSnapshotTag({
canonicalUrl: preview.href,
title: preview.title,
@@ -201,10 +326,18 @@ export function useComposerLinkPreviews(content: string) {
faviconSha256: favicon.sha256,
});
if (!tag) return;
// Update the ref alongside state so a submit reading
// `readyTagsByHrefRef` sees the tag before the next render commits.
readyTagsByHrefRef.current = {
...readyTagsByHrefRef.current,
[preview.href]: tag,
};
setReadyTags((current) => ({ ...current, [preview.href]: tag }));
})
.catch(() => {})
.finally(() => uploadsRef.current.delete(preview.href));
.finally(() => {
uploadsRef.current.delete(preview.href);
});
void uploadPromise;
}
}, [previews, readyTags]);
@@ -213,17 +346,73 @@ export function useComposerLinkPreviews(content: string) {
: candidates.flatMap((candidate) =>
readyTags[candidate.href] ? [readyTags[candidate.href]] : [],
);
// A preview is "settling" from paste until its sendable tag exists: metadata
// is still resolving, or it resolved and the snapshot media is uploading.
// Send stays disabled across the whole window so the button never flickers
// ready -> not-ready -> ready (buzz:// links never snapshot, so they never
// report settling). `imageState === "none"` is terminal (no snapshot), so it
// does not block. See the disable cap below for the dead/slow-link escape.
const hasResolvingSnapshots =
!suppressed &&
previews.some(
(preview) =>
!preview.href.startsWith("buzz://") &&
(preview.imageState === "pending" ||
(preview.snapshotReady && !readyTags[preview.href])),
);
// A supported link in the LIVE content that resolution has not caught up to
// yet (debounce pending, or resolved for an older revision) also counts as
// settling — otherwise a paste-and-immediate-Enter would ship a bare link
// before resolution even starts. buzz:// links never snapshot, so ignore them.
const hasUnresolvedLiveCandidates =
!suppressed &&
liveCandidatesRef.current.some(
(href) =>
!href.startsWith("buzz://") &&
!readyTags[href] &&
!candidates.some((candidate) => candidate.href === href),
);
const hasSettlingSnapshots =
hasResolvingSnapshots || hasUnresolvedLiveCandidates;
// Re-enable Send once the disable cap elapses even if a preview is still
// settling, so a link whose metadata or upload stalls never traps the
// composer. Resets whenever settling ends or the live candidate set changes.
const [settleDisableExpired, setSettleDisableExpired] = React.useState(false);
const liveCandidatesKey = liveCandidatesRef.current.join("\n");
// biome-ignore lint/correctness/useExhaustiveDependencies: liveCandidatesKey intentionally restarts the anti-trap cap when the link set changes while still settling, so a replaced/added link gets a fresh disable window rather than inheriting the prior link's near-expired timer.
React.useEffect(() => {
if (!hasSettlingSnapshots) {
setSettleDisableExpired(false);
return;
}
setSettleDisableExpired(false);
const timer = window.setTimeout(
() => setSettleDisableExpired(true),
SNAPSHOT_SETTLE_DISABLE_CAP_MS,
);
return () => window.clearTimeout(timer);
}, [hasSettlingSnapshots, liveCandidatesKey]);
const hasPendingSnapshots = hasSettlingSnapshots && !settleDisableExpired;
// Ref mirror so a synchronous submit guard can read the pending state on any
// entry point (Enter, form, auto-submit), not just the reactive button prop.
const hasPendingSnapshotsRef = React.useRef(hasPendingSnapshots);
hasPendingSnapshotsRef.current = hasPendingSnapshots;
const hideAll = React.useCallback(() => setSuppressed(true), []);
const previewList = previews.length ? (
<div
className="mb-2"
data-composer-link-previews=""
data-has-pending-snapshots={hasPendingSnapshots ? "true" : "false"}
data-ready-snapshot-count={readyTagsRef.current.length}
>
<div className="flex max-w-full items-start gap-1">
<AttachmentGroup className="max-w-full flex-row flex-wrap items-start overflow-visible pb-0">
{previews.map((preview) => (
<ComposerLinkPreviewCard key={preview.href} preview={preview} />
<ComposerLinkPreviewCard
key={preview.href}
preview={preview}
tagReady={Boolean(readyTags[preview.href])}
/>
))}
</AttachmentGroup>
<Button
@@ -241,12 +430,25 @@ export function useComposerLinkPreviews(content: string) {
</div>
</div>
) : null;
const getReadyTags = React.useCallback(() => {
if (suppressedRef.current) return [["link-preview", "none"]];
return [...activeHrefsRef.current].flatMap((href) => {
const tag = readyTagsByHrefRef.current[href];
return tag ? [tag] : [];
});
}, []);
return { previewList, getReadyTags };
// Snapshot tags for a submit, read synchronously at submit start from the
// LIVE candidate set (liveCandidatesRef) via `selectSubmitTags` — so the tags
// always correspond to the content actually being sent, never a debounced set
// that still holds a just-removed URL. No await: Send is disabled until every
// settling preview has its tag (or the anti-trap cap fires), so at submit time
// the tags that will ever exist already exist.
const getReadyTags = React.useCallback(
() =>
selectSubmitTags(
liveCandidatesRef.current,
readyTagsByHrefRef.current,
suppressedRef.current,
),
[],
);
return {
previewList,
getReadyTags,
hasPendingSnapshots,
hasPendingSnapshotsRef,
};
}

View File

@@ -20,6 +20,37 @@ test("parseSupportedLinkPreview parses GitHub pull request URLs", () => {
);
});
test("parseSupportedLinkPreview strips the fragment from the preview href", () => {
// A `#fragment` is a client-only anchor; the preview and its signed snapshot
// canonical URL are of the page. Keeping it would fail the fragmentless
// snapshot-URL guard and drop the preview entirely.
assert.equal(
parseSupportedLinkPreview(
"https://github.com/block/sprout/pull/1234#pullrequestreview-99",
)?.href,
"https://github.com/block/sprout/pull/1234",
);
});
test("extractSupportedLinkPreviews collapses fragment variants of one page", () => {
const previews = extractSupportedLinkPreviews(
[
"https://github.com/block/sprout/pull/1234#pullrequestreview-99",
"https://github.com/block/sprout/pull/1234#issuecomment-1",
"https://github.com/block/sprout/pull/5678",
].join("\n"),
);
// Two anchors into the same page dedupe to one card at first occurrence; the
// distinct second page keeps its own card.
assert.deepEqual(
previews.map((preview) => preview.href),
[
"https://github.com/block/sprout/pull/1234",
"https://github.com/block/sprout/pull/5678",
],
);
});
test("parseSupportedLinkPreview parses GitHub repository URLs", () => {
assert.deepEqual(
parseSupportedLinkPreview("https://github.com/block/sprout"),

View File

@@ -274,9 +274,17 @@ function createPreview(
typeLabel: SupportedLinkPreview["typeLabel"],
title: string,
): SupportedLinkPreview {
// Strip the `#fragment` from the preview identity. A fragment is a
// client-only anchor into the page — the preview (and the signed snapshot's
// canonicalUrl) is of the page itself. Keeping it would fail the
// fragment-free snapshot-URL guard, so a link like `pull/3767#review-1`
// would silently get no preview at all. The message body keeps the raw URL,
// so click-through to the anchor is preserved.
const canonical = new URL(parsed.href);
canonical.hash = "";
return {
kind,
href: parsed.href,
href: canonical.href,
provider,
title,
typeLabel,

View File

@@ -3,7 +3,10 @@ import test from "node:test";
import {
__linkPreviewMetadataTest,
fetchBuzzEntityMetadata,
isBuzzEntityPreview,
resolveLinkPreview,
withEntityFallbacks,
} from "./useResolvedLinkPreviews.ts";
const preview = {
@@ -27,13 +30,27 @@ function metadata(overrides = {}) {
};
}
test("pending metadata reserves the image treatment", () => {
test("pending external metadata reserves the image treatment", () => {
assert.deepEqual(resolveLinkPreview(preview, undefined), {
...preview,
imageState: "pending",
});
});
test("pending Buzz entity metadata remains image-less", () => {
const entityPreview = {
kind: "buzz-repository",
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: "buzz",
typeLabel: "repo",
};
assert.deepEqual(resolveLinkPreview(entityPreview, undefined), {
...entityPreview,
imageState: "none",
});
});
test("resolved image metadata keeps the reserved image treatment", () => {
const resolved = resolveLinkPreview(preview, {
title: "A story",
@@ -180,3 +197,292 @@ test("metadata loader coalesces fragment variants and bounds concurrency", async
assert.equal(calls, 3);
assert.equal(maxActive, 2);
});
test("withEntityFallbacks re-adds previews dropped by null metadata", () => {
const entityPreview = {
kind: "buzz-pull-request",
href: `buzz://pr?id=${"ab".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: `buzz #${"ab".repeat(4)}`,
typeLabel: "PR",
};
assert.deepEqual(withEntityFallbacks([entityPreview], []), [
{ ...entityPreview, imageState: "none" },
]);
});
test("withEntityFallbacks keeps resolved previews and preserves order", () => {
const first = {
kind: "buzz-repository",
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: "buzz",
typeLabel: "repo",
};
const second = {
kind: "buzz-issue",
href: `buzz://issue?id=${"ef".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: `buzz #${"ef".repeat(4)}`,
typeLabel: "issue",
};
const resolvedSecond = {
...second,
title: "Fix the preview cards",
imageState: "none",
};
assert.deepEqual(withEntityFallbacks([first, second], [resolvedSecond]), [
{ ...first, imageState: "none" },
resolvedSecond,
]);
});
test("entity fallback eligibility is kind-scoped", () => {
assert.equal(
isBuzzEntityPreview({
...preview,
kind: "buzz-repository",
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
}),
true,
);
assert.equal(
isBuzzEntityPreview({ ...preview, href: "buzz://future?id=example" }),
false,
);
});
test("withEntityFallbacks still drops unresolved external links", () => {
assert.deepEqual(withEntityFallbacks([preview], []), []);
assert.deepEqual(
withEntityFallbacks([{ ...preview, href: "buzz://future?id=example" }], []),
[],
);
});
function relayEvent({
id,
kind,
pubkey,
content = "",
tags = [],
createdAt = 1,
}) {
return { id, kind, pubkey, created_at: createdAt, content, tags, sig: "" };
}
test("Buzz PR metadata includes repository identity and trusted root context", async () => {
const owner = "cd".repeat(32);
const attacker = "ef".repeat(32);
const id = "ab".repeat(32);
const repoAddress = `30617:${owner}:buzz`;
const commit = "1234567".padEnd(40, "0");
const events = [
relayEvent({
id: "01".repeat(32),
kind: 30617,
pubkey: owner,
tags: [
["d", "buzz"],
["name", "Buzz Desktop"],
["default-branch", "main"],
],
}),
relayEvent({
id,
kind: 1618,
pubkey: owner,
content: "Body",
tags: [
["a", repoAddress],
["subject", "Restore entity cards"],
["branch-name", "fix/cards"],
["target-branch", "release"],
["c", commit],
],
}),
relayEvent({
id: "02".repeat(32),
kind: 1633,
pubkey: attacker,
createdAt: 20,
tags: [["e", id]],
}),
relayEvent({
id: "03".repeat(32),
kind: 1630,
pubkey: owner,
createdAt: 10,
tags: [["e", id]],
}),
...Array.from({ length: 25 }, (_, index) =>
relayEvent({
id: index.toString(16).padStart(64, "0"),
kind: 1633,
pubkey: owner,
createdAt: 100 + index,
tags: [["e", index.toString(16).padStart(64, "f")]],
}),
),
];
const fetchEvents = async (filter) =>
events
.filter(
(event) =>
(!filter.kinds || filter.kinds.includes(event.kind)) &&
(!filter.ids || filter.ids.includes(event.id)) &&
(!filter.authors || filter.authors.includes(event.pubkey)) &&
(!filter["#d"] ||
event.tags.some(
(tag) => tag[0] === "d" && filter["#d"].includes(tag[1]),
)) &&
(!filter["#a"] ||
event.tags.some(
(tag) => tag[0] === "a" && filter["#a"].includes(tag[1]),
)) &&
(!filter["#e"] ||
event.tags.some(
(tag) => tag[0] === "e" && filter["#e"].includes(tag[1]),
)),
)
.sort((left, right) => right.created_at - left.created_at)
.slice(0, filter.limit);
const result = await fetchBuzzEntityMetadata(
`buzz://pr?id=${id}&owner=${owner}&d=buzz`,
fetchEvents,
);
assert.equal(result?.siteName, "Buzz Desktop");
assert.equal(result?.title, "Restore entity cards");
assert.equal(result?.description, "Open · fix/cards → release · 1234567");
assert.equal(result?.faviconDataUrl, null);
assert.equal(result?.imageDataUrl, null);
});
test("Buzz entity roots reject ambiguous repository tags", async () => {
const owner = "cd".repeat(32);
const attacker = "ef".repeat(32);
const targetAddress = `30617:${owner}:buzz`;
const attackerAddress = `30617:${attacker}:other`;
const repository = relayEvent({
id: "01".repeat(32),
kind: 30617,
pubkey: owner,
tags: [
["d", "buzz"],
["name", "Buzz Desktop"],
["default-branch", "main"],
],
});
for (const [type, kind] of [
["pr", 1618],
["issue", 1621],
]) {
const id = (type === "pr" ? "ab" : "bc").repeat(32);
const root = relayEvent({
id,
kind,
pubkey: attacker,
tags: [
["a", attackerAddress],
["a", targetAddress],
["subject", "Misbound entity"],
],
});
const result = await fetchBuzzEntityMetadata(
`buzz://${type}?id=${id}&owner=${owner}&d=buzz`,
async (filter) =>
filter.kinds?.includes(30617)
? [repository]
: filter.ids?.includes(id)
? [root]
: [],
);
assert.equal(result, null, `${type} with multiple repository tags`);
}
});
test("Buzz repository metadata stays image-less and exposes default branch", async () => {
const owner = "cd".repeat(32);
const result = await fetchBuzzEntityMetadata(
`buzz://repo?owner=${owner}&d=relay-tools`,
async () => [
relayEvent({
id: "01".repeat(32),
kind: 30617,
pubkey: owner,
content: "Fallback description",
tags: [
["d", "relay-tools"],
["name", "Relay Tools"],
["description", "Operator tooling for relays"],
["status", "active"],
["default-branch", "trunk"],
],
}),
],
);
assert.equal(result?.siteName, "Relay Tools");
assert.equal(result?.title, "Operator tooling for relays");
assert.equal(result?.description, "active · default: trunk");
assert.equal(result?.faviconDataUrl, null);
assert.equal(result?.imageDataUrl, null);
assert.equal(result?.imageDomain, null);
});
test("Buzz project metadata resolves from the 30621 announcement", async () => {
const owner = "cd".repeat(32);
const result = await fetchBuzzEntityMetadata(
`buzz://project?owner=${owner}&d=pollinator`,
async () => [
relayEvent({
id: "01".repeat(32),
kind: 30621,
pubkey: owner,
tags: [
["d", "pollinator"],
["name", "Pollinator"],
["description", "Cross-repo pollination pipeline"],
["a", `30617:${owner}:flappy-bee`],
["a", `30617:${owner}:hive-tools`],
],
}),
],
);
assert.equal(result?.siteName, "Pollinator");
assert.equal(result?.title, "Cross-repo pollination pipeline");
assert.equal(result?.description, "2 repositories");
assert.equal(result?.imageDataUrl, null);
});
test("Buzz project metadata declines a missing or invalid announcement", async () => {
const owner = "cd".repeat(32);
assert.equal(
await fetchBuzzEntityMetadata(
`buzz://project?owner=${owner}&d=pollinator`,
async () => [],
),
null,
);
// Two `d` tags fail NIP-MP envelope validation.
assert.equal(
await fetchBuzzEntityMetadata(
`buzz://project?owner=${owner}&d=pollinator`,
async () => [
relayEvent({
id: "01".repeat(32),
kind: 30621,
pubkey: owner,
tags: [
["d", "pollinator"],
["d", "duplicate"],
],
}),
],
),
null,
);
});

View File

@@ -2,9 +2,20 @@ import * as React from "react";
import { invokeTauri } from "@/shared/api/tauri";
import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import {
eventToExplicitProject,
eventToRepository,
} from "@/features/projects/projectModels";
import { eventToProjectIssue } from "@/features/projects/projectIssues.mjs";
import { eventToProjectPullRequest } from "@/features/projects/projectPullRequests.mjs";
import {
KIND_GIT_ISSUE,
KIND_GIT_PULL_REQUEST,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_OPEN,
KIND_PROJECT_ANNOUNCEMENT,
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
@@ -202,91 +213,147 @@ function fetchLinkPreviewMetadata(
const metadataLoader = createMetadataLoader({
fetcher: fetchLinkPreviewMetadata,
});
function buzzEntityMetadata(
title: string | null | undefined,
description?: string | null,
): LinkPreviewMetadata | null {
return title
? {
title,
siteName: "Buzz",
description: description || null,
imageDataUrl: null,
imageDomain: null,
}
: null;
const ENTITY_STATUS_KINDS = [
KIND_GIT_STATUS_OPEN,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
];
type EntityEventFetcher = (
filter: Parameters<typeof relayClient.fetchEvents>[0],
) => Promise<RelayEvent[]>;
function compactMetadata(
parts: Array<string | null | undefined>,
): string | null {
const values = parts.filter((part): part is string => Boolean(part));
return values.length > 0 ? values.join(" · ") : null;
}
/**
* Resolve the display name of an addressable coordinate (`kind:owner:d`) from
* its announcement event. Returns null when the relay has no such event, which
* drops the card rather than advertising a coordinate nobody can open.
*/
async function loadCoordinateMetadata(
kind: number,
owner: string,
dtag: string,
/** Resolve builder-focused metadata only from the active relay. */
export async function fetchBuzzEntityMetadata(
href: string,
fetchEvents: EntityEventFetcher = (filter) => relayClient.fetchEvents(filter),
): Promise<LinkPreviewMetadata | null> {
const events = await relayClient.fetchEvents({
kinds: [kind],
const parsed = parseEntityLink(href);
if (!parsed.ok) return null;
const { owner, dtag } = parsed.value;
if (parsed.value.type === "project") {
const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${owner}:${dtag}`;
const projectEvents = await fetchEvents({
kinds: [KIND_PROJECT_ANNOUNCEMENT],
authors: [owner],
"#d": [dtag],
limit: 1,
});
// Repository maps are only needed to resolve a project's repository read
// models, which the card does not show — empty maps still validate the
// announcement envelope and resolve name/description.
const project = projectEvents
.map((event) => eventToExplicitProject(event, new Map(), new Map()))
.find((candidate) => candidate?.projectAddress === projectAddress);
if (!project) return null;
const repositoryCount = project.repositoryAddresses.length;
return {
siteName: project.name,
faviconDataUrl: null,
imageDataUrl: null,
imageDomain: null,
title: project.description || project.name,
description: compactMetadata([
repositoryCount > 0
? `${repositoryCount} ${repositoryCount === 1 ? "repository" : "repositories"}`
: null,
]),
};
}
const repoAddress = `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`;
const repoEvents = await fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
authors: [owner],
"#d": [dtag],
limit: 1,
});
const event = events[0];
if (!event) return null;
const repository = repoEvents
.map((event) => eventToRepository(event))
.find((candidate) => candidate?.repoAddress === repoAddress);
if (!repository) return null;
const tag = (name: string) =>
event.tags.find((entry) => entry[0] === name)?.[1] || null;
return buzzEntityMetadata(tag("name") ?? dtag, tag("description"));
const base = {
siteName: repository.name,
faviconDataUrl: null,
imageDataUrl: null,
imageDomain: null,
};
if (parsed.value.type === "repo") {
return {
...base,
title: repository.description || repository.name,
description: compactMetadata([
repository.status,
`default: ${repository.defaultBranch}`,
]),
};
}
const { id, type } = parsed.value;
const rootEvents = await fetchEvents({
kinds: [type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE],
ids: [id],
limit: 1,
});
const root = rootEvents.find((event) => {
const repositoryTags = event.tags.filter((tag) => tag[0] === "a");
return (
event.id === id &&
repositoryTags.length === 1 &&
repositoryTags[0][1] === repoAddress
);
});
if (!root) return null;
const trustedAuthors = [...new Set([root.pubkey.toLowerCase(), owner])];
const statusEvents = await fetchEvents({
kinds: ENTITY_STATUS_KINDS,
authors: trustedAuthors,
"#e": [id],
limit: 20,
});
if (type === "issue") {
const issue = eventToProjectIssue(root, statusEvents);
return {
...base,
title: issue.title,
description: compactMetadata([issue.status, ...issue.labels.slice(0, 2)]),
};
}
const pullRequest = eventToProjectPullRequest(root, [], [], statusEvents);
const source = pullRequest.branchName;
const target = pullRequest.targetBranch ?? repository.defaultBranch;
return {
...base,
title: pullRequest.title,
description: compactMetadata([
pullRequest.status,
source ? `${source} → ${target}` : null,
pullRequest.commit?.slice(0, 7),
]),
};
}
const entityTitleLoader = createMetadataLoader({
fetcher: async (href) => {
const parsed = parseEntityLink(href);
if (!parsed.ok) return null;
const link = parsed.value;
if (link.type === "repo") {
return loadCoordinateMetadata(
KIND_REPO_ANNOUNCEMENT,
link.owner,
link.dtag,
);
}
if (link.type === "project") {
return loadCoordinateMetadata(
KIND_PROJECT_ANNOUNCEMENT,
link.owner,
link.dtag,
);
}
const { id, owner, dtag } = link;
const expectedCoordinate = `30617:${owner}:${dtag}`;
const events = await relayClient.fetchEvents({
kinds: [link.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE],
ids: [id],
limit: 1,
});
const event = events[0];
if (
!event?.tags.some(
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
)
) {
return null;
}
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
return buzzEntityMetadata(subject || event.content.split("\n")[0]);
},
const entityMetadataLoader = createMetadataLoader({
fetcher: fetchBuzzEntityMetadata,
});
/** Clear ephemeral metadata when the active relay/community changes. */
export function resetLinkPreviewMetadataCache(): void {
metadataLoader.reset();
entityTitleLoader.reset();
entityMetadataLoader.reset();
}
export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none";
@@ -316,7 +383,10 @@ export function resolveLinkPreview(
metadata: LinkPreviewMetadata | null | undefined,
): ResolvedLinkPreview {
if (metadata === undefined) {
return { ...preview, imageState: "pending" };
return {
...preview,
imageState: isBuzzEntityPreview(preview) ? "none" : "pending",
};
}
if (metadata === null) {
return { ...preview, imageState: "none" };
@@ -337,7 +407,8 @@ export function resolveLinkPreview(
description: metadata.description,
faviconDataUrl: metadata.faviconDataUrl,
provider:
preview.kind === "generic-link" && metadata.siteName
(preview.kind === "generic-link" || isBuzzEntityPreview(preview)) &&
metadata.siteName
? metadata.siteName
: preview.provider,
imageDataUrl: hasImage ? metadata.imageDataUrl : null,
@@ -346,6 +417,38 @@ export function resolveLinkPreview(
};
}
export function isBuzzEntityPreview(preview: SupportedLinkPreview): boolean {
return (
preview.kind === "buzz-pull-request" ||
preview.kind === "buzz-issue" ||
preview.kind === "buzz-repository" ||
preview.kind === "buzz-project"
);
}
/**
* Recipient-side `buzz://` entity cards must render even when the relay
* lookup yields no metadata: `useResolvedLinkPreviews` drops null-metadata
* previews (correct for external links — no metadata means no card), but
* entity links always carry a usable fallback title (the repo d-tag, or
* `<dtag> #<id8>` for PRs/issues — see `buzzEntityFallbackTitle`). Re-adds
* recognized entity previews on their fallback title; non-entity previews
* keep the hook's drop behavior.
*/
export function withEntityFallbacks(
previews: SupportedLinkPreview[],
resolved: ResolvedLinkPreview[],
): ResolvedLinkPreview[] {
const byHref = new Map(resolved.map((preview) => [preview.href, preview]));
return previews.flatMap((preview) => {
const match = byHref.get(preview.href);
if (match) return [match];
return isBuzzEntityPreview(preview)
? [{ ...preview, imageState: "none" as const }]
: [];
});
}
export function useResolvedLinkPreviews(
previews: SupportedLinkPreview[],
): ResolvedLinkPreview[] {
@@ -383,7 +486,7 @@ export function useResolvedLinkPreviews(
const cancelScheduledLoads: Array<() => void> = [];
for (const preview of previews) {
const loader = preview.href.startsWith("buzz://")
? entityTitleLoader
? entityMetadataLoader
: metadataLoader;
const cached = loader.peek(preview.href);
if (cached !== undefined) {

View File

@@ -12,8 +12,10 @@ import {
AttachmentTrigger,
} from "@/shared/ui/attachment";
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
function getHostname(preview: ResolvedLinkPreview): string {
if (preview.href.startsWith("buzz://")) return preview.provider;
try {
return new URL(preview.href).hostname.replace(/^www\./, "");
} catch {
@@ -66,14 +68,18 @@ export function CompactLinkPreviewAttachment({
const showFallback =
preview.imageState === "fallback" || Boolean(imageSrc && !showImage);
const hostname = getHostname(preview);
const showBuzzMark =
preview.kind === "buzz-pull-request" ||
preview.kind === "buzz-issue" ||
preview.kind === "buzz-repository";
return (
<div className={cn("relative w-96 max-w-full shrink-0", className)}>
<Attachment
className={cn(
"h-21 min-h-21 max-h-21 w-full bg-transparent no-underline shadow-none hover:bg-transparent",
"w-full bg-transparent no-underline shadow-none hover:bg-transparent",
reserveImage
? "gap-0 border-0 p-0 hover:border-transparent"
? "h-21 min-h-21 max-h-21 gap-0 border-0 p-0 hover:border-transparent"
: "rounded-none border-0 border-l-[3px] border-border px-0 py-1 pl-3 hover:border-border",
)}
data-image-state={preview.imageState}
@@ -120,7 +126,15 @@ export function CompactLinkPreviewAttachment({
rel="noreferrer"
target="_blank"
>
{preview.faviconDataUrl ? (
{showBuzzMark ? (
<span
aria-hidden="true"
className="flex size-3 shrink-0 items-center text-foreground/70"
data-link-preview-hostname-buzz-mark=""
>
<BuzzMark className="h-auto w-full" />
</span>
) : preview.faviconDataUrl ? (
<img
alt=""
aria-hidden="true"

View File

@@ -23,7 +23,6 @@ import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview";
import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { AttachmentGroup } from "@/shared/ui/attachment";
@@ -114,6 +113,7 @@ import { MarkdownTable } from "./markdown/MarkdownTable";
import { ProgressiveImage } from "./markdown/ProgressiveImage";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { renderCachedMarkdown } from "./markdown/nodeCache";
import { useMessageLinkPreviews } from "./markdown/useMessageLinkPreviews";
import {
MarkdownRuntimeContext,
useMarkdownRuntime,
@@ -1809,19 +1809,13 @@ function MarkdownInner({
[goChannel],
);
const relayOrigin = useRelayOrigin();
const resolvedLinkPreviews = React.useMemo(
() =>
interactive && !linkPreviewsSuppressed
? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin)
: [],
[
content,
interactive,
linkPreviewTags,
linkPreviewsSuppressed,
relayOrigin,
],
);
const resolvedLinkPreviews = useMessageLinkPreviews({
content,
interactive,
linkPreviewTags,
linkPreviewsSuppressed,
relayOrigin,
});
const configNudge = React.useMemo(
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
[content, interactive, configNudgeAuthorPubkey],

View File

@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractSupportedLinkPreviews } from "../../lib/linkPreview.ts";
import { parseLinkPreviewSnapshots } from "../../lib/linkPreviewSnapshot.ts";
import { mergeMessageLinkPreviews } from "./useMessageLinkPreviews.ts";
const OWNER = "a".repeat(64);
const EVENT_ID = "b".repeat(64);
const ENTITY_HREF = `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`;
const EXTERNAL_HREF = "https://example.com/story";
const RELAY_ORIGIN = "https://relay.example";
function snapshotTag(href, title, siteName) {
return [
"link-preview",
"snapshot",
"1",
href,
title,
siteName,
"",
"",
"",
"",
"",
];
}
test("relay-resolved Buzz entities beat conflicting sender snapshots in content order", () => {
const content = `${ENTITY_HREF} then ${EXTERNAL_HREF}`;
const candidates = extractSupportedLinkPreviews(content, RELAY_ORIGIN);
const snapshots = parseLinkPreviewSnapshots(
[
snapshotTag(ENTITY_HREF, "Forged sender title", "Definitely Real Buzz"),
snapshotTag(EXTERNAL_HREF, "External story", "Example"),
],
content,
RELAY_ORIGIN,
);
const entity = {
...candidates[0],
title: "Relay-authenticated PR title",
imageState: "none",
};
assert.deepEqual(
mergeMessageLinkPreviews(candidates, snapshots, [entity]).map(
({ href, title, provider }) => ({ href, title, provider }),
),
[
{
href: ENTITY_HREF,
title: "Relay-authenticated PR title",
provider: "Buzz",
},
{
href: EXTERNAL_HREF,
title: "External story",
provider: "Example",
},
],
);
});

View File

@@ -0,0 +1,102 @@
import * as React from "react";
import {
extractSupportedLinkPreviews,
type SupportedLinkPreview,
} from "@/shared/lib/linkPreview";
import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot";
import {
isBuzzEntityPreview,
type ResolvedLinkPreview,
useResolvedLinkPreviews,
withEntityFallbacks,
} from "@/shared/lib/useResolvedLinkPreviews";
/**
* Resolve the link-preview cards for a rendered message.
*
* External URLs render exclusively from sender-authored
* `["link-preview","snapshot",…]` tags (`parseLinkPreviewSnapshots`) — the
* privacy model shipped in the rich-link-previews work: recipients never
* contact external sites.
*
* `buzz://pr|issue|repo` entity links (and relay clone URLs, which normalize
* onto `buzz://repo`) are the exception and are rendered recipient-side:
* their metadata comes from the community relay itself via the entity metadata
* loader, so the sender-snapshot privacy model does not apply — and senders
* (CLI, agents, mobile) attach no snapshot tags for them. Recognized entity
* cards win
* over conflicting sender snapshots, and cards retain their first-seen order in
* the message.
*/
export function mergeMessageLinkPreviews(
candidates: SupportedLinkPreview[],
snapshots: ResolvedLinkPreview[],
entities: ResolvedLinkPreview[],
): ResolvedLinkPreview[] {
const snapshotsByHref = new Map(
snapshots.map((preview) => [preview.href, preview]),
);
const entitiesByHref = new Map(
entities.map((preview) => [preview.href, preview]),
);
return candidates.flatMap((candidate) => {
const preview = isBuzzEntityPreview(candidate)
? entitiesByHref.get(candidate.href)
: snapshotsByHref.get(candidate.href);
return preview ? [preview] : [];
});
}
export function useMessageLinkPreviews({
content,
interactive,
linkPreviewTags,
linkPreviewsSuppressed,
relayOrigin,
}: {
content: string;
interactive: boolean;
linkPreviewTags?: readonly (readonly string[])[];
linkPreviewsSuppressed: boolean;
relayOrigin: string | null;
}): ResolvedLinkPreview[] {
const linkPreviewCandidates = React.useMemo(
() =>
interactive && !linkPreviewsSuppressed
? extractSupportedLinkPreviews(content, relayOrigin)
: [],
[content, interactive, linkPreviewsSuppressed, relayOrigin],
);
const entityLinkPreviews = React.useMemo(
() => linkPreviewCandidates.filter(isBuzzEntityPreview),
[linkPreviewCandidates],
);
const relayResolvedEntityLinkPreviews =
useResolvedLinkPreviews(entityLinkPreviews);
const resolvedEntityLinkPreviews = React.useMemo(
() =>
withEntityFallbacks(entityLinkPreviews, relayResolvedEntityLinkPreviews),
[entityLinkPreviews, relayResolvedEntityLinkPreviews],
);
return React.useMemo(() => {
const snapshots =
interactive && !linkPreviewsSuppressed
? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin)
: [];
return mergeMessageLinkPreviews(
linkPreviewCandidates,
snapshots,
resolvedEntityLinkPreviews,
);
}, [
content,
interactive,
linkPreviewCandidates,
linkPreviewTags,
linkPreviewsSuppressed,
relayOrigin,
resolvedEntityLinkPreviews,
]);
}

View File

@@ -365,6 +365,13 @@ type E2eConfig = {
linkPreviewMetadataDelayMs?: number;
/** Simulates native cold-cache startup work before the async response. */
linkPreviewMetadataStartBlockMs?: number;
/** Delays link-preview snapshot media uploads so specs can exercise the
* composer's settle-gated disabled state before the snapshot tag is ready. */
linkPreviewUploadDelayMs?: number;
/** Substrings of `link-preview-*` upload filenames whose `upload_media_bytes`
* call should reject, so specs can drive a per-media snapshot upload failure
* (e.g. `["link-preview-image"]` fails only the thumbnail, favicon survives). */
linkPreviewUploadErrorFilenames?: string[];
searchProfiles?: MockSearchProfileSeed[];
updateAvailable?: boolean;
updateChannelDelayMs?: number;
@@ -8930,6 +8937,16 @@ async function resolveMockUploadDescriptorForBytes(
args: { data: number[] | Uint8Array; filename?: string | null },
config: E2eConfig | undefined,
): Promise<RawBlobDescriptor> {
const uploadDelayMs = config?.mock?.linkPreviewUploadDelayMs ?? 0;
if (args.filename?.startsWith("link-preview-")) {
if (uploadDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, uploadDelayMs));
}
const errorFilenames = config?.mock?.linkPreviewUploadErrorFilenames;
if (errorFilenames?.some((needle) => args.filename?.includes(needle))) {
throw new Error(`mock upload failed for ${args.filename}`);
}
}
const configured = config?.mock?.uploadDescriptors;
if (configured !== undefined) {
const descriptors = await resolveMockUploadDescriptors(config);

View File

@@ -0,0 +1,167 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/entity-link-recipient-cards";
// Regression coverage for buzz:// entity links posted WITHOUT sender
// snapshot tags (CLI / agent senders): #3818 moved external-link previews to
// sender-authored snapshots, which silently killed the recipient-side
// buzz://pr|issue|repo cards from #4695. These cards resolve their titles
// from the active relay itself, so they must render for recipients even when
// the message carries no link-preview tags.
const ALICE_PUBKEY = TEST_IDENTITIES.alice.pubkey;
const REPO_ADDRESS = `30617:${ALICE_PUBKEY}:relay-tools`;
const PR_ID = `e0${"ca4d".repeat(15)}ff`; // 64-hex event id
const PR_SUBJECT = "Restore recipient-side entity cards";
test("agent-style message with bare buzz:// links renders entity cards without snapshot tags", async ({
page,
}) => {
await page.addInitScript(
({ repoAddress, prId, alicePubkey, subject }) => {
window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
{
id: prId,
kind: 1618, // KIND_GIT_PULL_REQUEST
pubkey: alicePubkey,
created_at: Math.floor(Date.now() / 1000) - 60,
content: "PR body",
tags: [
["a", repoAddress],
["subject", subject],
["c", "abc123".padEnd(40, "0")],
["branch-name", "fix/entity-cards"],
["clone", "https://github.com/block/relay-tools.git"],
],
},
];
},
{
repoAddress: REPO_ADDRESS,
prId: PR_ID,
alicePubkey: ALICE_PUBKEY,
subject: PR_SUBJECT,
},
);
await installMockBridge(page);
await page.setViewportSize({ width: 900, height: 800 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Simulate an agent/CLI sender: plain kind-9 message with bare buzz://
// URLs in the content and NO link-preview snapshot tags.
await page.evaluate(
({ prId, alicePubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
pubkey: alicePubkey,
content: [
"PR is up — review when you can:",
`buzz://pr?id=${prId}&owner=${alicePubkey}&d=relay-tools`,
`Repo: buzz://repo?owner=${alicePubkey}&d=relay-tools`,
].join("\n"),
});
},
{ prId: PR_ID, alicePubkey: ALICE_PUBKEY },
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "PR is up" })
.last();
await expect(row).toBeVisible();
// The PR card resolves builder metadata from the signed root and repository.
const prCard = row.locator('[data-link-preview="buzz-pull-request"]');
await expect(prCard).toBeVisible();
await expect(prCard).toContainText("relay-tools");
await expect(prCard).toContainText(PR_SUBJECT);
await expect(prCard).toContainText(
"Open · fix/entity-cards → main · abc1230",
);
await expect(prCard).toHaveAttribute("data-image-state", "none");
await expect(prCard.locator("[data-link-preview-thumbnail]")).toHaveCount(0);
await expect(
prCard.locator("[data-link-preview-hostname-buzz-mark]"),
).toBeVisible();
await expect(
prCard.locator("[data-link-preview-hostname-favicon]"),
).toHaveCount(0);
// The repository card uses its signed announcement metadata and remains
// image-less.
const repoCard = row.locator('[data-link-preview="buzz-repository"]');
await expect(repoCard).toBeVisible();
await expect(repoCard).toContainText("relay-tools");
await expect(repoCard).toContainText(
"Operator tooling and admin CLI for relay deployments.",
);
await expect(repoCard).toContainText("active · default: main");
await expect(repoCard).toHaveAttribute("data-image-state", "none");
await expect(repoCard.locator("[data-link-preview-thumbnail]")).toHaveCount(
0,
);
await expect(
repoCard.locator("[data-link-preview-hostname-buzz-mark]"),
).toBeVisible();
await expect(
repoCard.locator("[data-link-preview-hostname-favicon]"),
).toHaveCount(0);
expect(
await repoCard.evaluate((card) => card.getBoundingClientRect().height),
).toBeLessThan(84);
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${SHOTS}/01-recipient-entity-cards.png`,
});
});
test("desktop composer shows entity card and send is not blocked by missing snapshot", async ({
page,
}) => {
await installMockBridge(page);
await page.setViewportSize({ width: 900, height: 800 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
const repoLink = `buzz://repo?owner=${ALICE_PUBKEY}&d=relay-tools`;
await page.getByTestId("message-input").fill(`Check out ${repoLink}`);
// buzz:// links never produce snapshot tags, so the composer card must
// show as done (not stuck "processing") with zero ready snapshots.
const composerCard = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="buzz-repository"]');
await expect(composerCard).toBeVisible();
await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute(
"data-ready-snapshot-count",
"0",
);
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${SHOTS}/02-composer-entity-card.png`,
});
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
const repoCard = row.locator('[data-link-preview="buzz-repository"]');
await expect(repoCard).toBeVisible();
await expect(repoCard).toContainText("relay-tools");
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${SHOTS}/03-sent-entity-card.png`,
});
});

View File

@@ -141,114 +141,185 @@ test.beforeEach(async ({ page }, testInfo) => {
imageDomain: "pbs.twimg.com",
},
}
: testInfo.title.includes("mixed link preview image outcomes")
: testInfo.title.includes("fragment link previews")
? {
// Metadata is keyed by the canonical, fragment-less URL — the
// shape a real OpenGraph/HTML fetch resolves against. A resolver
// that fetches with the raw `#fragment` attached would miss these
// keys and drop the card, which is exactly the bug under test.
linkPreviewMetadataByHref: {
"https://github.com/block/buzz/pull/4001": {
title: "Loaded preview image",
"https://github.com/block/buzz/pull/3767": {
title: "Buzz pull request 3767",
siteName: "GitHub",
description: "The image request completed.",
imageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageDomain: "opengraph.githubassets.com",
imageFetchState: "image",
imageRetryAfterMs: null,
},
"https://github.com/block/buzz/pull/4002": {
title: "Rate-limited preview image",
siteName: "GitHub",
description: "Metadata remains available during cooldown.",
description: "Fragment-bearing PR link.",
imageDataUrl: null,
imageDomain: null,
},
"https://github.com/block/buzz/pull/3867": {
title: "Buzz pull request 3867",
siteName: "GitHub",
description: "Plain PR link.",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
},
},
}
: testInfo.title.includes("link preview browser image error")
: testInfo.title.includes("mixed link preview image outcomes")
? {
linkPreviewMetadata: {
title: "Invalid decoded preview image",
siteName: "GitHub",
description: "The browser should replace this image.",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "rejected",
imageRetryAfterMs: null,
},
}
: testInfo.title.includes("link preview image geometry")
? {
linkPreviewMetadata: {
title:
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
linkPreviewMetadataByHref: {
"https://github.com/block/buzz/pull/4001": {
title: "Loaded preview image",
siteName: "GitHub",
description: "A polished, stable preview for shared links.",
description: "The image request completed.",
imageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageDomain: "opengraph.githubassets.com",
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageFetchState: "image",
imageRetryAfterMs: null,
},
"https://github.com/block/buzz/pull/4002": {
title: "Rate-limited preview image",
siteName: "GitHub",
description: "Metadata remains available during cooldown.",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
},
},
}
: testInfo.title.includes("link preview browser image error")
? {
linkPreviewMetadata: {
title: "Invalid decoded preview image",
siteName: "GitHub",
description: "The browser should replace this image.",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "rejected",
imageRetryAfterMs: null,
},
linkPreviewMetadataDelayMs: 800,
}
: testInfo.title.includes("link preview no-image layout") ||
testInfo.title.includes("composer no-image link embeds")
: testInfo.title.includes("link preview image geometry")
? {
linkPreviewMetadata: {
title: "Buzz",
title:
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
siteName: "GitHub",
description:
"Open-source collaboration for the Buzz app.",
imageDataUrl: null,
imageDomain: null,
"A polished, stable preview for shared links.",
imageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageDomain: "opengraph.githubassets.com",
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
linkPreviewMetadataDelayMs: 2_000,
linkPreviewMetadataDelayMs: 800,
}
: testInfo.title.includes(
"rich link preview preserves description newlines",
)
: testInfo.title.includes("link preview no-image layout") ||
testInfo.title.includes("composer no-image link embeds")
? {
linkPreviewMetadata: {
title: "Buzz pull request",
title: "Buzz",
siteName: "GitHub",
description:
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
"Open-source collaboration for the Buzz app.",
imageDataUrl: null,
imageDomain: null,
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
linkPreviewMetadataDelayMs: 2_000,
}
: testInfo.title.includes("link preview") ||
testInfo.title.includes("supported Compact")
: testInfo.title.includes(
"rich link preview preserves description newlines",
)
? {
linkPreviewMetadata: {
title: "Buzz pull request",
siteName: "GitHub",
description: "A sender-authored preview snapshot.",
description:
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
imageDataUrl: null,
imageDomain: null,
},
linkPreviewMetadataDelayMs: testInfo.title.includes(
"loading card before cold resolver work",
)
? 10_000
: testInfo.title.includes("style defaults") ||
testInfo.title.includes("send does not wait") ||
testInfo.title.includes("attachment-sized")
? 1_500
: undefined,
linkPreviewMetadataStartBlockMs:
testInfo.title.includes(
"loading card before cold resolver work",
)
? 150
: undefined,
}
: undefined;
: testInfo.title.includes(
"Enter during an in-flight snapshot upload",
)
? {
linkPreviewMetadata: {
title: "Buzz pull request",
siteName: "GitHub",
description: "A sender-authored preview snapshot.",
imageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageDomain: "opengraph.githubassets.com",
},
linkPreviewMetadataDelayMs: 300,
linkPreviewUploadDelayMs: 1_200,
}
: testInfo.title.includes(
"snapshot thumbnail upload failure",
)
? {
linkPreviewMetadata: {
title: "Buzz pull request",
siteName: "GitHub",
description:
"A sender-authored preview snapshot.",
imageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageDomain: "opengraph.githubassets.com",
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
// Fail only the thumbnail upload; the favicon survives,
// so the snapshot degrades to a favicon-only preview.
linkPreviewUploadErrorFilenames: [
"link-preview-image",
],
}
: testInfo.title.includes("link preview") ||
testInfo.title.includes("supported Compact")
? {
linkPreviewMetadata: {
title: "Buzz pull request",
siteName: "GitHub",
description:
"A sender-authored preview snapshot.",
imageDataUrl: null,
imageDomain: null,
},
linkPreviewMetadataDelayMs:
testInfo.title.includes(
"loading card before cold resolver work",
)
? 10_000
: testInfo.title.includes(
"send does not wait",
)
? 3_000
: testInfo.title.includes("draft auto-send")
? 500
: testInfo.title.includes(
"style defaults",
) ||
testInfo.title.includes(
"attachment-sized",
)
? 1_500
: undefined,
linkPreviewMetadataStartBlockMs:
testInfo.title.includes(
"loading card before cold resolver work",
)
? 150
: undefined,
}
: undefined;
const mock = testInfo.title.includes("unresolvable preview")
? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 150 }
? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 800 }
: baseMock;
await installMockBridge(page, mock);
});
@@ -613,14 +684,22 @@ test("rich link preview preserves description newlines after sending", async ({
);
});
test("completed link previews send when one URL has an unsnapshotable fragment", async ({
test("completed link previews normalize a trailing-fragment URL and still send", async ({
page,
}) => {
// The third URL carries a trailing `#` (empty fragment). It is normalized to
// its fragmentless canonical form for the preview and snapshot tag, so it now
// gets a card like the others; the message body keeps the original URL.
const previewUrls = [
"https://twitter.com/tellaho",
"https://github.com/block/buzz/pull/3246",
"https://x.com/tellaho/status/1884289176381841506#",
];
const canonicalUrls = [
"https://twitter.com/tellaho",
"https://github.com/block/buzz/pull/3246",
"https://x.com/tellaho/status/1884289176381841506",
];
const pastedText = previewUrls.join("\n");
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -640,11 +719,8 @@ test("completed link previews send when one URL has an unsnapshotable fragment",
const composerPreviewCards = page.locator(
"[data-link-preview-composer-card]",
);
await expect(composerPreviewCards).toHaveCount(2);
await expect(
composerPreviewCards.locator(`a[href="${previewUrls[2]}"]`),
).toHaveCount(0);
await waitForReadyComposerSnapshots(page, 2);
await expect(composerPreviewCards).toHaveCount(3);
await waitForReadyComposerSnapshots(page, 3);
const send = page.getByTestId("send-message");
await expect(send).toBeEnabled();
@@ -664,7 +740,7 @@ test("completed link previews send when one URL has an unsnapshotable fragment",
(
calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags?.map((tag) => tag[3]),
).toEqual(previewUrls.slice(0, 2));
).toEqual(canonicalUrls);
});
test("unresolvable preview disappears after the terminal miss", async ({
@@ -705,6 +781,20 @@ test("send does not wait for a pending link preview snapshot", async ({
composerPreviews.locator('[data-link-preview="github-pull-request"]'),
).toHaveAttribute("data-image-state", "pending");
// While metadata is still resolving Send is disabled so the button does not
// flicker ready -> not-ready. But a link whose metadata stalls must not trap
// the composer: past the disable cap Send re-enables even though the card is
// still pending, and sending ships a bare link with no snapshot tag.
await expect(page.getByTestId("send-message")).toBeDisabled();
await expect(composerPreviews).toHaveAttribute(
"data-has-pending-snapshots",
"false",
);
await expect(
composerPreviews.locator('[data-link-preview="github-pull-request"]'),
).toHaveAttribute("data-image-state", "pending");
await expect(page.getByTestId("send-message")).toBeEnabled();
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(previewUrl);
@@ -721,6 +811,314 @@ test("send does not wait for a pending link preview snapshot", async ({
expect(linkPreviewTags ?? []).toEqual([]);
});
test("Enter during an in-flight snapshot upload cannot ship a bare link", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246";
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill(previewUrl);
const composerPreviews = page.locator("[data-composer-link-previews]");
const card = composerPreviews.locator("[data-link-preview-composer-card]");
await expect(card).toBeVisible();
// Metadata resolves (image painted) but the sendable tag is not ready yet:
// the snapshot media upload is still in flight (linkPreviewUploadDelayMs), so
// the composer reports the preview as still pending.
await expect(card).toHaveAttribute("data-image-state", "image");
await expect(card).toHaveAttribute("data-snapshot-tag-ready", "false");
await expect(composerPreviews).toHaveAttribute(
"data-has-pending-snapshots",
"true",
);
// Drive Enter (not a disabled-button click, which the browser swallows on its
// own) while the upload is deterministically in flight. The synchronous submit
// guard must reject it: no send_channel_message call may occur before the tag
// is ready, or the link would ship bare. This is the core Enter-bypass fix —
// the disabled state is enforced on the keyboard path, not just the button.
await expect(input).toBeFocused();
await input.press("Enter");
await input.press("Enter");
const sendsDuringUpload = await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "send_channel_message",
).length,
);
expect(sendsDuringUpload).toBe(0);
// Once the upload settles the tag is captured and Send re-enables. Sending
// now lands the preview snapshot matching the body.
await expect(card).toHaveAttribute("data-snapshot-tag-ready", "true");
await expect(page.getByTestId("send-message")).toBeEnabled();
await input.press("Enter");
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(previewUrl);
await expect(row.locator("[data-link-preview]")).toBeVisible();
const linkPreviewTags = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "send_channel_message");
return (
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags;
});
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
});
test("draft auto-send with a link preview waits for settling and sends exactly once", async ({
page,
}) => {
// Regression for the one-shot auto-submit blocker: a confirmed Drafts-panel
// "Send message" for a draft containing a supported link is normally still
// inside the preview settling window when the mount-only auto-submit effect
// fires. The old effect cleared the ?autoSend trigger then fired submit once
// at setTimeout(0); submit bailed at the pending-snapshot guard and the
// one-shot never retried, so the confirmed draft was silently never sent.
// The effect must instead wait until settling finishes, then send exactly
// once — with the resolved snapshot tag attached.
const previewUrl = "https://github.com/block/buzz/pull/3246?draft=autosend";
const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
// Seed a channel draft under the legacy store key (migrated on startup). The
// main composer keys its draft off the bare channel id, and the Drafts panel
// navigates with ?autoSend=<that key>, so seeding under the bare id mirrors
// the real "Send message" target exactly.
await page.addInitScript(
({ storeKey, draftKey, content, channel }) => {
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storeKey,
JSON.stringify({
[draftKey]: {
channelId: channel,
content,
createdAt: timestamp,
pendingImeta: [],
selectionEnd: content.length,
selectionStart: content.length,
spoileredAttachmentUrls: [],
status: "active",
updatedAt: timestamp,
},
}),
);
},
{
storeKey: `buzz-drafts.v1:${"deadbeef".repeat(8)}`,
draftKey: channelId,
content: previewUrl,
channel: channelId,
},
);
// Drive the real Drafts-panel "Send message" confirm flow. This does an
// in-app client navigation to the channel with ?autoSend=<draftKey>, arming
// the main composer's auto-submit effect — the exact production path.
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
await page.keyboard.press("Escape");
const draftRow = page.locator(`[data-testid='home-draft-item-${channelId}']`);
await expect(draftRow).toBeVisible({ timeout: 8_000 });
await draftRow.hover();
await draftRow
.getByRole("button", { name: "Send message", exact: true })
.click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 4_000 });
await dialog.getByRole("button", { name: "Send", exact: true }).click();
// Exactly one send eventually fires (after the ~500 ms metadata settle), and
// it carries the link preview snapshot tag — proving the draft was not
// dropped during the settling window and did not double-send on retry.
await expect
.poll(async () =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "send_channel_message",
).length,
),
)
.toBe(1);
const linkPreviewTags = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "send_channel_message");
return (
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags;
});
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
});
test("rapid Enter presses on a ready link preview send exactly once", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?rapid=1";
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill(previewUrl);
// Wait until the snapshot is fully ready and Send is enabled, so the only
// thing under test is the composer-local send lock — not preview settling.
await waitForReadyComposerSnapshots(page);
await expect(page.getByTestId("send-message")).toBeEnabled();
// Mash Enter. The synchronous submit lock (isSubmitLockedRef), acquired before
// any await, must collapse these into exactly one send_channel_message so a
// duplicate cannot clear shared prep/hydration state mid-send.
await input.press("Enter");
await input.press("Enter");
await input.press("Enter");
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(previewUrl);
await expect(row.locator("[data-link-preview]")).toBeVisible();
await expect
.poll(async () =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "send_channel_message",
).length,
),
)
.toBe(1);
});
test("pasting a link preview and immediately pressing Enter waits for resolution", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?fast=send";
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
// Fill the URL and press Enter within the debounce window, before resolution
// has even started. The live-candidate guard must treat the unresolved link
// as pending and reject the Enter, so the message cannot ship bare.
await input.fill(previewUrl);
await input.press("Enter");
const sendsBeforeResolution = await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "send_channel_message",
).length,
);
expect(sendsBeforeResolution).toBe(0);
// The debounce fires, resolution + upload complete, and only then does Send
// become available. A press now lands the snapshot.
await waitForReadyComposerSnapshots(page);
await input.press("Enter");
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(previewUrl);
await expect(row.locator("[data-link-preview]")).toBeVisible();
const linkPreviewTags = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "send_channel_message");
return (
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags;
});
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
});
test("a snapshot thumbnail upload failure toasts and still sends with the favicon", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?upload=fail";
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill(previewUrl);
// The thumbnail upload is configured to reject while the favicon succeeds.
// The preview must degrade to the surviving favicon rather than dropping the
// whole card or spinning forever: a tag still lands, Send still enables.
await waitForReadyComposerSnapshots(page);
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: "Something went wrong with the thumbnail" }),
).toBeVisible();
await expect(page.getByTestId("send-message")).toBeEnabled();
await input.press("Enter");
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(previewUrl);
await expect(row.locator("[data-link-preview]")).toBeVisible();
// The snapshot tag exists (survivor media) but carries no image url — proving
// the graceful per-media degrade rather than a dropped or all-or-nothing tag.
const imageUrl = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "send_channel_message");
const tags = (
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags;
const snapshot = tags?.find(
(tag) => tag[0] === "link-preview" && tag[1] === "snapshot",
);
// Snapshot tag layout: ["link-preview","snapshot",<version>,<url>,...pairs].
const pairs = snapshot?.slice(4) ?? [];
const imageIndex = pairs.indexOf("image");
return imageIndex >= 0 ? pairs[imageIndex + 1] : null;
});
expect(imageUrl).toBeFalsy();
});
test("editing a message excludes link previews entirely", async ({ page }) => {
const message = `Edit-me ${Date.now()}`;
const previewUrl = "https://github.com/block/buzz/pull/3246?edit=1";
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
// Send a plain message with no link, then edit it to add a supported URL.
await input.fill(message);
await input.press("Enter");
await expect(page.getByTestId("message-timeline")).toContainText(message);
await expect(input).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(page.getByTestId("edit-target")).toBeVisible();
// Adding a link while editing must NOT resolve, upload, gate Save, or render a
// composer preview card — edit mode does not persist snapshots (decision A).
await input.fill(`${message} ${previewUrl}`);
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
// No snapshot upload was attempted for the edited link.
const uploadedPreviewMedia = await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) =>
entry.command === "upload_media_bytes" &&
typeof (entry.payload as { filename?: string })?.filename ===
"string" &&
(entry.payload as { filename: string }).filename.startsWith(
"link-preview-",
),
).length,
);
expect(uploadedPreviewMedia).toBe(0);
// Save is not blocked waiting on a snapshot the edit will never emit.
await expect(page.getByTestId("send-message")).toBeEnabled();
});
test("hiding composer link previews suppresses the whole draft and emits the blanket marker", async ({
page,
}) => {
@@ -882,6 +1280,44 @@ test("mixed link preview image outcomes keep Compact and Rich fallbacks stable",
).toHaveCount(0);
});
test("fragment link previews render a card per canonical URL", async ({
page,
}) => {
// Two links into the SAME page differing only by `#fragment`, plus a link
// to a second page. The fragment variants collapse to one card (the preview
// is of the page, not the anchor); the second page adds a second card — two
// cards total. A resolver that keys previews on the raw fragment-bearing URL
// drops the fragment cards entirely (the reported bug).
const fragmentUrlA =
"https://github.com/block/buzz/pull/3767#pullrequestreview-4857569498";
const fragmentUrlB = "https://github.com/block/buzz/pull/3767#issuecomment-1";
const plainUrl = "https://github.com/block/buzz/pull/3867";
await page.goto("/");
await page.getByTestId("channel-general").click();
await page
.getByTestId("message-input")
.fill(`${fragmentUrlA}\n${fragmentUrlB}\n${plainUrl}`);
const composerCards = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="github-pull-request"]');
await expect(composerCards).toHaveCount(2);
await waitForReadyComposerSnapshots(page, 2);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
// Two preview cards: the fragment variants collapsed to the 3767 page, plus
// the 3867 page.
await expect(
row.locator('[data-link-preview="github-pull-request"]'),
).toHaveCount(2);
// Both original fragment-bearing prose links survive intact and clickable —
// the fragment is a navigation anchor, only the preview is normalized.
await expect(row.locator(`a[href="${fragmentUrlA}"]`)).toBeVisible();
await expect(row.locator(`a[href="${fragmentUrlB}"]`)).toBeVisible();
});
test("link preview browser image errors render a fallback", async ({
page,
}) => {

View File

@@ -321,6 +321,13 @@ type MockBridgeOptions = {
linkPreviewMetadataDelayMs?: number;
/** Simulates native cold-cache startup work before the async response. */
linkPreviewMetadataStartBlockMs?: number;
/** Delays link-preview snapshot media uploads so specs can drive an in-flight
* snapshot upload. See e2eBridge mock.linkPreviewUploadDelayMs. */
linkPreviewUploadDelayMs?: number;
/** Substrings of `link-preview-*` upload filenames whose upload should reject,
* so specs can drive a per-media snapshot upload failure. See e2eBridge
* mock.linkPreviewUploadErrorFilenames. */
linkPreviewUploadErrorFilenames?: string[];
searchProfiles?: MockSearchProfileSeed[];
updateAvailable?: boolean;
updateChannelDelayMs?: number;