diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 843ac0dd7d..d682a4041a 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -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 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca9525074..178b0475b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/desktop/package.json b/desktop/package.json index 14c412ff1d..bff1671083 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.8", + "version": "0.5.9", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..b574e026e5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index a69f425024..d7d7258ebc 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1062,7 +1062,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9b2de6a575..6b6f2b9a7b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 3f332ddbf1..006d478181 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -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": { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0dc289eefa..aec94c6f37 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -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(() => { diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs new file mode 100644 index 0000000000..e9a62e42de --- /dev/null +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs @@ -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); +}); diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts new file mode 100644 index 0000000000..f15422b93d --- /dev/null +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts @@ -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); + }; +} diff --git a/desktop/src/features/messages/ui/selectSubmitTags.test.mjs b/desktop/src/features/messages/ui/selectSubmitTags.test.mjs new file mode 100644 index 0000000000..f61f4cbadd --- /dev/null +++ b/desktop/src/features/messages/ui/selectSubmitTags.test.mjs @@ -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"]]); +}); diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 39d5d9db8e..3f251a719d 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -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, + 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( @@ -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"} > - {preview.snapshotReady ? preview.title : hostname} + {done ? preview.title : hostname} - {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([]); + 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>( {}, ); @@ -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 ? (
{previews.map((preview) => ( - + ))}
) : 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, + }; } diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index 31c4654962..d0f9adfba4 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -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"), diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index ad25a464f2..22354837f7 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -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, diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs index bf7e393612..f722da9201 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 722c544288..a8cdafa330 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -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[0], +) => Promise; + +function compactMetadata( + parts: Array, +): 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 { - 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 + * ` #` 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) { diff --git a/desktop/src/shared/ui/compact-link-preview-attachment.tsx b/desktop/src/shared/ui/compact-link-preview-attachment.tsx index 89ec2c34e7..7b2fb48ff9 100644 --- a/desktop/src/shared/ui/compact-link-preview-attachment.tsx +++ b/desktop/src/shared/ui/compact-link-preview-attachment.tsx @@ -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 (
- {preview.faviconDataUrl ? ( + {showBuzzMark ? ( + + ) : preview.faviconDataUrl ? ( - 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], diff --git a/desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs new file mode 100644 index 0000000000..a4f5a528ec --- /dev/null +++ b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs @@ -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", + }, + ], + ); +}); diff --git a/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts new file mode 100644 index 0000000000..0f76412a0d --- /dev/null +++ b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts @@ -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, + ]); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ed988de259..751b06f484 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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 { + 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); diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts new file mode 100644 index 0000000000..5f62077bef --- /dev/null +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -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`, + }); +}); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index a0808e4a6a..2d7f3eacda 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -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=, 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=, 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",,,...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, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index fe0c1bf2a6..50f792447c 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -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;