From 538e5e113fc33571f939c87b925567fd4e277109 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 10 Aug 2026 16:21:14 -0600 Subject: [PATCH 1/3] chore(release): release Buzz Desktop version 0.5.9 (#5521) ## Buzz Desktop release v0.5.9 - **Frozen main:** `f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b` - **Reviewed candidate:** `ee33722615ca1e7b8efb03e2ed641d99448c8899` - **Previous desktop release:** `desktop-v0.5.8` - **Proposed immutable tag:** `desktop-v0.5.9` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++++------ CHANGELOG.md | 39 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 50 insertions(+), 11 deletions(-) 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/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": { From 7e6e9c547fa97abff6929cf2702b956586eec9bc Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 18:38:38 -0700 Subject: [PATCH 2/3] fix(link-preview): restore Buzz entity link cards (#5494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Buzz pull request, issue, and repository links now show compact, useful metadata cards in received messages, including messages sent by agents and the CLI. **Problem:** Sender-authored snapshots protect recipients from external preview fetches, but that change also removed recipient-side cards for trusted Buzz entity links when the sender did not attach snapshots. **Solution:** Resolve recognized Buzz entities only against the active relay and show signed repository identity, title, and compact builder context with the current inline Buzz mark in the favicon slot, but without avatars, thumbnails, or external image fetches. Entity metadata wins over conflicting sender snapshots, while unsupported or unavailable metadata retains a safe text fallback.
File changes **desktop/playwright.config.ts** Adds the entity-link regression spec to the smoke test project. **desktop/src/features/messages/ui/useComposerLinkPreviews.tsx** Treats recognized Buzz entity cards as complete without generating snapshot tags and retains fallback cards when relay metadata is absent. **desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs** Covers kind-scoped entity detection, trusted relay metadata, root-scoped lifecycle queries, exact single-repository root binding, image-less pending state, and fallback behavior. **desktop/src/shared/lib/useResolvedLinkPreviews.ts** Resolves signed repository, pull request, and issue metadata from the active relay. Entity roots fail closed unless they carry exactly one matching repository tag; lifecycle queries are root-scoped before limits; successful metadata remains stable until relay/community reset, and PR commit context uses the immutable root event rather than an unindexed update query. **desktop/src/shared/ui/compact-link-preview-attachment.tsx** Uses Buzz repository identity as the compact card provider and avoids reserving thumbnail space for image-less entity cards. **desktop/src/shared/ui/markdown.tsx** Routes message cards through the combined entity/snapshot preview hook. **desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs** Proves relay-authenticated entity metadata beats a forged sender snapshot while preserving mixed-link content order. **desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts** Combines recipient-resolved Buzz entities with sender-authored external snapshots using explicit trust precedence and first-seen ordering. **desktop/tests/e2e/entity-link-recipient-cards.spec.ts** Exercises repository identity, PR workflow context, repository metadata, image-less rendering, and composer send behavior for agent/CLI-style entity links.
## Reproduction steps 1. Open a channel containing a message sent without `link-preview` tags whose content includes valid `buzz://pr`, `buzz://issue`, or `buzz://repo` links. 2. Confirm each card shows its repository identity and signed title; PRs/issues also show compact lifecycle context, and repositories show description/status/default branch. 3. Confirm the cards use the Buzz mark in the favicon slot with no avatar, thumbnail, or reserved image area. 4. Compose and send a message containing a Buzz entity link; confirm sending is not blocked waiting for a snapshot. 5. Send a message containing both a Buzz entity link and a snapshot-backed HTTPS link; confirm cards follow content order and the HTTPS link remains sender-snapshot-only. ## Screenshots ### Recipient view — Buzz-branded metadata cards Repository identity, title, and compact builder context render with the current inline Buzz mark in the favicon slot and no avatar, thumbnail, or reserved image space. ![Recipient view showing Buzz-branded PR and repository cards](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5494/01-recipient-entity-cards-current-buzz-mark.png) ## Validation At commit `7bc70b0a9f70392bd062ed25b1d2362cc4021a40` with a clean working tree: - Pre-push hooks passed: branch skew, desktop check, desktop typecheck, and full desktop unit suite - Full desktop unit suite: 4,560 passed - Purpose-built Playwright regression after a fresh E2E build: 2 passed - Screenshot regenerated from the same commit and visually inspected Originating conversation: Buzz channel `c2859932-b679-4091-9c7e-f5a65deddd64`, thread `93c3e7be59a8d1ec10b4992efd783a2a79f253a10f10d39746c6ad41b0d5bb42`. --------- Signed-off-by: Taylor Ho --- desktop/playwright.config.ts | 1 + .../messages/ui/useComposerLinkPreviews.tsx | 31 ++- .../lib/useResolvedLinkPreviews.test.mjs | 254 +++++++++++++++++- .../src/shared/lib/useResolvedLinkPreviews.ts | 187 ++++++++++--- .../ui/compact-link-preview-attachment.tsx | 20 +- desktop/src/shared/ui/markdown.tsx | 22 +- .../markdown/useMessageLinkPreviews.test.mjs | 64 +++++ .../ui/markdown/useMessageLinkPreviews.ts | 102 +++++++ .../e2e/entity-link-recipient-cards.spec.ts | 167 ++++++++++++ 9 files changed, 786 insertions(+), 62 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs create mode 100644 desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts create mode 100644 desktop/tests/e2e/entity-link-recipient-cards.spec.ts 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/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 39d5d9db8e..25ae7ada29 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -11,8 +11,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, @@ -43,6 +47,10 @@ function ComposerLinkPreviewCard({ ); const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); const hostname = previewHostname(preview.href); + // `buzz://` entity links never produce snapshot tags (recipients render + // them from message content against the relay), so they are "done" as soon + // as they exist — there is no snapshot to wait for. + const done = preview.snapshotReady || isBuzzEntityPreview(preview); let path = ""; try { const url = new URL(preview.href); @@ -55,7 +63,7 @@ function ComposerLinkPreviewCard({ data-image-state={preview.imageState} data-link-preview={preview.kind} data-link-preview-composer-card="" - state={preview.snapshotReady ? "done" : "processing"} + state={done ? "done" : "processing"} > - {preview.snapshotReady ? preview.title : hostname} + {done ? preview.title : hostname} - {preview.snapshotReady + {done ? preview.provider || hostname : path && path !== "/" ? path @@ -136,13 +144,22 @@ export function useComposerLinkPreviews(content: string) { const candidates = React.useMemo( () => extractSupportedLinkPreviews(content).filter((preview) => - preview.href.startsWith("buzz://") + isBuzzEntityPreview(preview) ? true : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), ), [content], ); - const previews = useResolvedLinkPreviews(suppressed ? [] : candidates); + const resolvedPreviews = useResolvedLinkPreviews( + suppressed ? [] : candidates, + ); + // Entity links resolve to null metadata when the relay lookup has nothing + // for them (repo links always do — only PR/issue titles are fetched); keep + // their cards on the fallback title rather than dropping them. + const previews = React.useMemo( + () => withEntityFallbacks(suppressed ? [] : candidates, resolvedPreviews), + [suppressed, candidates, resolvedPreviews], + ); React.useEffect(() => { if (candidates.length === 0) setSuppressed(false); }, [candidates.length]); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs index bf7e393612..c0c91d9f16 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,238 @@ 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); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 136f8f4b1c..5f2a5535b5 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -2,9 +2,18 @@ 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 { 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_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; import { parseEntityLink } from "./entityLink"; @@ -200,47 +209,116 @@ function fetchLinkPreviewMetadata( const metadataLoader = createMetadataLoader({ fetcher: fetchLinkPreviewMetadata, }); -const entityTitleLoader = createMetadataLoader({ - fetcher: async (href) => { - const parsed = parseEntityLink(href); - if (!parsed.ok || parsed.value.type === "repo") return null; +const ENTITY_STATUS_KINDS = [ + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, +]; - const { id, owner, dtag } = parsed.value; - const expectedCoordinate = `30617:${owner}:${dtag}`; - const events = await relayClient.fetchEvents({ - kinds: [ - parsed.value.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; - } +type EntityEventFetcher = ( + filter: Parameters[0], +) => Promise; - const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; - const title = subject || event.content.split("\n")[0] || null; - return title - ? { - title, - siteName: "Buzz", - description: null, - imageDataUrl: null, - imageDomain: null, - } - : null; - }, +function compactMetadata( + parts: Array, +): string | null { + const values = parts.filter((part): part is string => Boolean(part)); + return values.length > 0 ? values.join(" · ") : null; +} + +/** Resolve builder-focused metadata only from the active relay. */ +export async function fetchBuzzEntityMetadata( + href: string, + fetchEvents: EntityEventFetcher = (filter) => relayClient.fetchEvents(filter), +): Promise { + const parsed = parseEntityLink(href); + if (!parsed.ok) return null; + + const { owner, dtag } = parsed.value; + const repoAddress = `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`; + const repoEvents = await fetchEvents({ + kinds: [KIND_REPO_ANNOUNCEMENT], + authors: [owner], + "#d": [dtag], + limit: 1, + }); + const repository = repoEvents + .map((event) => eventToRepository(event)) + .find((candidate) => candidate?.repoAddress === repoAddress); + if (!repository) return null; + + 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 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"; @@ -272,7 +350,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" }; @@ -293,7 +374,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, @@ -302,6 +384,37 @@ export function resolveLinkPreview( }; } +export function isBuzzEntityPreview(preview: SupportedLinkPreview): boolean { + return ( + preview.kind === "buzz-pull-request" || + preview.kind === "buzz-issue" || + preview.kind === "buzz-repository" + ); +} + +/** + * 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[] { @@ -339,7 +452,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/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`, + }); +}); From be48ce98bd163899197b79a82ad5b2bcf0bc9b54 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 20:27:30 -0700 Subject: [PATCH 3/3] fix(link-preview): reliably render previews sent right after they resolve (#5245) ## Overview **Category:** fix **User impact:** Link previews no longer disappear when a message is sent while preview metadata or media is still settling. Fast Enter, rapid Enter, and confirmed-draft auto-send now preserve the preview without duplicate sends or stale tags. **Problem:** The composer could look ready before its sender-authored snapshot tag existed. Send paths could then race preview resolution/upload, while debounced preview state could attach a tag for a URL that had already been removed. The same timing also caused confirmed-draft auto-send to be consumed without sending. **Solution:** - Debounce preview resolution to avoid card flicker while typing, then disable every submit path while a supported external preview settles. A 2-second escape cap still permits a bare-link send if resolution stalls. - Keep submit synchronous: acquire a composer-local lock before asynchronous send work, read ready tags from the live URL set, and reject Enter/form submits while a snapshot is pending. - Retry confirmed-draft auto-submit until preview settling clears, then submit exactly once. - Upload thumbnail and favicon independently. A failed upload shows a toast and degrades to the surviving media (or text-only) rather than leaving the card spinning. - Exclude message-edit mode from preview resolution, upload, and Save gating. Edit-time preview snapshots remain follow-up #5273. - Canonicalize fragment-bearing URLs for preview lookup/snapshot identity while preserving the original fragment links in message text. ## Link preview state walkthrough Captured using PR #5245's actual public Open Graph metadata and artwork. The deterministic E2E bridge controls only upload timing so the transient disabled state can be captured reliably. | State | Expected behavior | Screenshot | | --- | --- | --- | | **1. Snapshot upload pending** | The real PR preview is visible, but Submit remains disabled until its sendable snapshot tag is ready. Click and Enter cannot send a bare link during the settling window. | ![PR 5245 pasted with its real preview visible and Submit disabled](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5245/01-real-pasted-submit-disabled.png) | | **2. Snapshot ready** | Once snapshot upload settles and the tag is ready, the same preview remains and Submit becomes active. | ![PR 5245 preview ready in the composer with Submit enabled](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5245/02-real-resolved-submit-enabled.png) | | **3. Message sent** | The sent event carries the snapshot tag and renders the PR title, description, and artwork inline instead of degrading to a bare URL. | ![PR 5245 real link preview rendered inline in the message list](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5245/03-real-sent-preview-inline.png) | ## Regression coverage - Enter during metadata resolution or snapshot upload cannot send early. - Paste-and-immediate-Enter sends after settling; rapid Enter submits exactly once. - Confirmed-draft auto-send waits for settling and fires exactly once. - Removed/replaced URLs cannot leak stale snapshot tags or media refs. - Thumbnail upload failure toasts and sends with the surviving favicon. - Edit mode does not resolve/upload previews or gate Save. - Fragment variants share a canonical preview while original fragment links remain clickable. - Existing ready-preview, suppression, bare-link fallback, and multi-preview behavior remains covered. ## Reproduction steps 1. Open a channel and paste a supported external URL into the composer. 2. Press Enter immediately, before preview metadata/media finishes settling. 3. Before this fix, the event could be sent without its preview snapshot (or confirmed-draft auto-send could be lost). With this fix, submit waits behind the disabled state and fires once with the matching snapshot tag. 4. Remove or replace the URL and press Enter inside the debounce window. The sent event contains tags only for URLs still present in the submitted content. ## Validation All required PR checks are green, including Desktop Core, Desktop Smoke E2E shards, Desktop E2E Integration shards, macOS build, security checks, and DCO. --------- Signed-off-by: Taylor Ho Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- .../features/messages/ui/MessageComposer.tsx | 52 +- .../ui/messageComposerAutoSubmit.test.mjs | 103 +++ .../messages/ui/messageComposerAutoSubmit.ts | 45 ++ .../messages/ui/selectSubmitTags.test.mjs | 78 +++ .../messages/ui/useComposerLinkPreviews.tsx | 245 +++++++- desktop/src/shared/lib/linkPreview.test.mjs | 31 + desktop/src/shared/lib/linkPreview.ts | 10 +- desktop/src/testing/e2eBridge.ts | 17 + desktop/tests/e2e/messaging.spec.ts | 586 +++++++++++++++--- desktop/tests/helpers/bridge.ts | 7 + 10 files changed, 1040 insertions(+), 134 deletions(-) create mode 100644 desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs create mode 100644 desktop/src/features/messages/ui/messageComposerAutoSubmit.ts create mode 100644 desktop/src/features/messages/ui/selectSubmitTags.test.mjs 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 25ae7ada29..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"; @@ -28,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\./, ""); @@ -36,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( @@ -47,10 +81,11 @@ function ComposerLinkPreviewCard({ ); const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); const hostname = previewHostname(preview.href); - // `buzz://` entity links never produce snapshot tags (recipients render - // them from message content against the relay), so they are "done" as soon - // as they exist — there is no snapshot to wait for. - const done = preview.snapshotReady || isBuzzEntityPreview(preview); + // 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); @@ -63,6 +98,7 @@ function ComposerLinkPreviewCard({ data-image-state={preview.imageState} data-link-preview={preview.kind} data-link-preview-composer-card="" + data-snapshot-tag-ready={snapshotTagReady ? "true" : "false"} state={done ? "done" : "processing"} > { + 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); + // 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 (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( - () => - extractSupportedLinkPreviews(content).filter((preview) => - isBuzzEntityPreview(preview) - ? true - : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), - ), - [content], + () => 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 (repo links always do — only PR/issue titles are fetched); keep - // their cards on the fallback title rather than dropping them. + // 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 (candidates.length === 0) setSuppressed(false); - }, [candidates.length]); + if (liveCandidatesEmpty) setSuppressed(false); + }, [liveCandidatesEmpty]); const [readyTags, setReadyTags] = React.useState>( {}, ); @@ -201,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, @@ -218,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]); @@ -230,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 4bf3245dbf..b4807f82fd 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 58d8739ef1..4a7772580b 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -271,9 +271,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/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/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;