diff --git a/AGENTS.md b/AGENTS.md index 7cd43061b2..571871c3a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -507,6 +507,7 @@ reconnects preserve pending avatar verification work): - `resetRenderScopedReactionHydration()` — reaction hydration cache - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache +- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events) **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 12e5c42909..1d85221f11 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -23,6 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | +| `buzz issues` | `create`, `get`, `list`, `status` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -30,6 +31,8 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. + ## Conversational Agent Creation When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..ee8868ad92 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1387,19 +1387,25 @@ pub fn extract_p_tags(event: &serde_json::Value) -> Vec { .unwrap_or_default() } -/// Return a create-command response with an entity ID injected. -pub fn create_response_with_id(resp: &str, id_key: &str, id_val: &str) -> String { +/// Return a create-command response, injecting the entity ID **only** when the +/// relay accepted the event (`"accepted": true`). When the relay rejected the +/// event, emitting the locally-computed link would be misleading — callers +/// that copy or share the link would reference an event that was never stored. +pub fn create_response_with_id_if_accepted(resp: &str, id_key: &str, id_val: &str) -> String { let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({})); - v[id_key] = serde_json::json!(id_val); - if v.get("accepted").is_none() { - v["accepted"] = serde_json::json!(true); + let accepted = v.get("accepted").and_then(|a| a.as_bool()).unwrap_or(false); + if accepted { + v[id_key] = serde_json::json!(id_val); } v.to_string() } /// Print a create-command response, injecting the generated entity ID. pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) { - println!("{}", create_response_with_id(resp, id_key, id_val)); + println!( + "{}", + create_response_with_id_if_accepted(resp, id_key, id_val) + ); } /// Extract a JSON field from relay write response messages shaped as @@ -2297,7 +2303,8 @@ mod retry_policy_tests { #[cfg(test)] mod tests { use super::{ - advance_query_cursor, create_response_with_id, extract_relay_response_field, BuzzClient, + advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, + BuzzClient, }; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -2345,15 +2352,30 @@ mod tests { } #[test] - fn create_response_with_id_overrides_local_id_with_relay_id() { + fn create_response_with_id_if_accepted_injects_id_when_accepted() { let raw = r#"{"event_id":"abc","accepted":true,"message":"response:{\"workflow_id\":\"relay-id\"}"}"#; - let out = create_response_with_id(raw, "workflow_id", "relay-id"); + let out = create_response_with_id_if_accepted(raw, "workflow_id", "relay-id"); let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID injected and original fields preserved when accepted. assert_eq!(v["workflow_id"].as_str(), Some("relay-id")); assert_eq!(v["event_id"].as_str(), Some("abc")); assert_eq!(v["accepted"].as_bool(), Some(true)); } + #[test] + fn create_response_with_id_if_accepted_omits_id_when_rejected() { + let raw = r#"{"event_id":"abc","accepted":false,"message":"duplicate"}"#; + let out = create_response_with_id_if_accepted(raw, "workflow_id", "local-id"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID must not be present when relay rejected the event; emitting a + // link to an event that was never stored would mislead callers. + assert!( + v.get("workflow_id").is_none(), + "link field must be absent on rejected create" + ); + assert_eq!(v["accepted"].as_bool(), Some(false)); + } + // --- (a) auth-suppression regression pair --- fn make_auth_tag() -> (Tag, String) { diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index d531e53eb6..91c64a3915 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -31,8 +31,12 @@ pub async fn cmd_create_issue( buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, )?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce issues with it (see base_prompt.md). + let link = crate::links::issue_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 2a689e75a5..74c580a6d6 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -60,8 +60,12 @@ pub async fn cmd_open_pr( buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?, )?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce PRs with it (see base_prompt.md). + let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 15e064d9c3..e54b95ef20 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -261,8 +261,12 @@ pub async fn cmd_create_repo( channel, )?; let event = client.sign_event(builder)?; + let owner = event.pubkey.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce repos with it (see base_prompt.md). + let link = crate::links::repo_link(&owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f745e7b280..8a8bb053b0 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod links; mod validate; use clap::{Parser, Subcommand}; diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs new file mode 100644 index 0000000000..043bdc48b0 --- /dev/null +++ b/crates/buzz-cli/src/links.rs @@ -0,0 +1,51 @@ +//! Canonical `buzz://` deep links for Buzz-hosted git entities. +//! +//! Buzz Desktop renders these links as rich preview cards in chat and +//! navigates in-app when they are clicked. The desktop parser lives in +//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must +//! stay format-compatible (see `golden_format_matches_desktop` below and +//! the mirror test in `entityLink.test.mjs`). +//! +//! Callers are expected to validate inputs first (`validate_hex64`, +//! `validate_repo_id`); the identifier charsets need no URL encoding. + +/// Build a `buzz://repo` link for a repository announcement (kind 30617). +pub fn repo_link(owner: &str, repo_id: &str) -> String { + format!("buzz://repo?owner={owner}&d={repo_id}") +} + +/// Build a `buzz://pr` link for a pull request event (kind 1618). +pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") +} + +/// Build a `buzz://issue` link for an issue event (kind 1621). +pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://issue?id={event_id}&owner={owner}&d={repo_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; + const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + + // Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs + // ("builders emit the canonical cross-language link format"). + #[test] + fn golden_format_matches_desktop() { + assert_eq!( + pull_request_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + issue_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + repo_link(OWNER, "buzz-world"), + format!("buzz://repo?owner={OWNER}&d=buzz-world") + ); + } +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 1bd1e090a7..dd25ec074e 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -13,6 +13,7 @@ import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; +import { resetLinkPreviewTitleCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts, @@ -71,6 +72,7 @@ function resetCommunityState({ resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetLinkPreviewTitleCache(); } type CommunityInitResult = diff --git a/desktop/src/shared/lib/entityLink.test.mjs b/desktop/src/shared/lib/entityLink.test.mjs new file mode 100644 index 0000000000..729006f475 --- /dev/null +++ b/desktop/src/shared/lib/entityLink.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildIssueLink, + buildPullRequestLink, + buildRepoLink, + entityLinkProjectRouteId, + isEntityLink, + parseEntityLink, +} from "./entityLink.ts"; + +const OWNER = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; +const EVENT_ID = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +// Golden format strings — must match the Rust builder in +// crates/buzz-cli/src/links.rs (`golden_format_matches_desktop` test). +test("builders emit the canonical cross-language link format", () => { + assert.equal( + buildPullRequestLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }), + `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`, + ); + assert.equal( + buildIssueLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }), + `buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`, + ); + assert.equal( + buildRepoLink({ owner: OWNER, dtag: "buzz-world" }), + `buzz://repo?owner=${OWNER}&d=buzz-world`, + ); +}); + +test("builders reject invalid identifiers", () => { + assert.throws(() => + buildRepoLink({ owner: "not-a-pubkey", dtag: "buzz-world" }), + ); + assert.throws(() => buildRepoLink({ owner: OWNER, dtag: ".hidden" })); + assert.throws(() => buildRepoLink({ owner: OWNER, dtag: "a..b" })); + assert.throws(() => + buildPullRequestLink({ id: "short", owner: OWNER, dtag: "buzz-world" }), + ); +}); + +test("parseEntityLink round-trips built links", () => { + const link = buildPullRequestLink({ + id: EVENT_ID, + owner: OWNER, + dtag: "buzz-world", + }); + assert.deepEqual(parseEntityLink(link), { + ok: true, + value: { type: "pr", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }, + }); + + const repoLink = buildRepoLink({ owner: OWNER, dtag: "buzz-world" }); + assert.deepEqual(parseEntityLink(repoLink), { + ok: true, + value: { type: "repo", owner: OWNER, dtag: "buzz-world" }, + }); +}); + +test("parseEntityLink lowercase-normalizes hex identifiers", () => { + const parsed = parseEntityLink( + `buzz://issue?id=${EVENT_ID.toUpperCase()}&owner=${OWNER.toUpperCase()}&d=buzz-world`, + ); + assert.deepEqual(parsed, { + ok: true, + value: { type: "issue", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }, + }); +}); + +test("parseEntityLink rejects malformed links", () => { + const cases = [ + ["not a url at all", "invalid-url"], + [`https://pr?id=${EVENT_ID}&owner=${OWNER}&d=repo`, "wrong-scheme"], + [`buzz://message?channel=x&id=${EVENT_ID}`, "wrong-host"], + [`buzz://pr?id=${EVENT_ID}&owner=nope&d=repo`, "invalid-owner"], + [`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=.hidden`, "invalid-dtag"], + [`buzz://pr?id=${EVENT_ID}&owner=${OWNER}`, "invalid-dtag"], + [`buzz://pr?owner=${OWNER}&d=repo`, "invalid-id"], + [`buzz://issue?id=short&owner=${OWNER}&d=repo`, "invalid-id"], + ]; + for (const [href, reason] of cases) { + assert.deepEqual(parseEntityLink(href), { ok: false, reason }, href); + } +}); + +test("isEntityLink matches entity hosts and excludes message links", () => { + assert.equal(isEntityLink(`buzz://pr?id=${EVENT_ID}`), true); + assert.equal(isEntityLink(`buzz://issue?id=${EVENT_ID}`), true); + assert.equal(isEntityLink(`buzz://repo?owner=${OWNER}`), true); + assert.equal(isEntityLink("buzz://message?channel=x&id=y"), false); + assert.equal(isEntityLink("https://github.com/block/buzz"), false); + assert.equal(isEntityLink(null), false); +}); + +test("entityLinkProjectRouteId emits the canonical 30617 coordinate route id", () => { + const parsed = parseEntityLink( + buildRepoLink({ owner: OWNER, dtag: "buzz-world" }), + ); + assert.ok(parsed.ok); + assert.equal( + entityLinkProjectRouteId(parsed.value), + `30617:${OWNER}:buzz-world`, + ); +}); + +test("parseEntityLink rejects noncanonical extras", () => { + // Unexpected path segments — reserved for future versioning. + assert.deepEqual( + parseEntityLink( + `buzz://pr/ignored?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`, + ), + { ok: false, reason: "unexpected-path" }, + ); + // Fragment — not part of the canonical format. + assert.deepEqual( + parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world#section`), + { ok: false, reason: "unexpected-fragment" }, + ); + // Unknown query parameter — reject to preserve forward-compat posture. + assert.deepEqual( + parseEntityLink( + `buzz://repo?owner=${OWNER}&d=buzz-world&relay=wss%3A%2F%2Frelay.example`, + ), + { ok: false, reason: "unknown-param" }, + ); + // Duplicate required parameter — reject. + assert.deepEqual( + parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world&owner=${OWNER}`), + { ok: false, reason: "duplicate-param" }, + ); +}); diff --git a/desktop/src/shared/lib/entityLink.ts b/desktop/src/shared/lib/entityLink.ts new file mode 100644 index 0000000000..4ab8a78fbb --- /dev/null +++ b/desktop/src/shared/lib/entityLink.ts @@ -0,0 +1,194 @@ +/** + * `buzz://` deep links for Buzz-hosted git entities, mirroring + * `features/messages/lib/messageLink.ts` for `buzz://message`. + * + * Formats: + * buzz://repo?owner=&d= + * buzz://pr?id=&owner=&d= + * buzz://issue?id=&owner=&d= + * + * `owner` + `d` identify the NIP-34 repository coordinate + * (`30617::`); `id` is the kind 1618 / 1621 event id. The CLI + * builder in `crates/buzz-cli/src/links.rs` emits the same format — the two + * must stay compatible (see the golden-format tests on both sides). + */ + +const ENTITY_LINK_SCHEME = "buzz:"; + +export type ParsedEntityLink = + | { type: "pr"; id: string; owner: string; dtag: string } + | { type: "issue"; id: string; owner: string; dtag: string } + | { type: "repo"; owner: string; dtag: string }; + +export type EntityLinkParseResult = + | { ok: true; value: ParsedEntityLink } + | { ok: false; reason: string }; + +const HEX64_RE = /^[a-fA-F0-9]{64}$/; +const DTAG_RE = /^[a-zA-Z0-9._-]{1,64}$/; + +function isValidDtag(dtag: string): boolean { + return DTAG_RE.test(dtag) && !dtag.startsWith(".") && !dtag.includes(".."); +} + +function checkCoordinate(owner: string, dtag: string): void { + if (!HEX64_RE.test(owner)) { + throw new Error("entityLink: owner must be a 64-char hex pubkey"); + } + if (!isValidDtag(dtag)) { + throw new Error("entityLink: invalid repository d-tag"); + } +} + +function checkEventId(id: string): void { + if (!HEX64_RE.test(id)) { + throw new Error("entityLink: id must be a 64-char hex event id"); + } +} + +/** Build a `buzz://repo` link for a repository announcement (kind 30617). */ +export function buildRepoLink(input: { owner: string; dtag: string }): string { + checkCoordinate(input.owner, input.dtag); + return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}`; +} + +/** Build a `buzz://pr` link for a pull request event (kind 1618). */ +export function buildPullRequestLink(input: { + id: string; + owner: string; + dtag: string; +}): string { + checkEventId(input.id); + checkCoordinate(input.owner, input.dtag); + return `buzz://pr?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`; +} + +/** Build a `buzz://issue` link for an issue event (kind 1621). */ +export function buildIssueLink(input: { + id: string; + owner: string; + dtag: string; +}): string { + checkEventId(input.id); + checkCoordinate(input.owner, input.dtag); + return `buzz://issue?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`; +} + +/** + * Cheap pre-check used by the markdown renderer and preview extraction + * before parsing. `buzz://message` is intentionally excluded — it has its + * own pill rendering path. + */ +export function isEntityLink(href: string | undefined | null): boolean { + if (!href) return false; + return ( + href.startsWith("buzz://pr?") || + href.startsWith("buzz://issue?") || + href.startsWith("buzz://repo?") + ); +} + +/** + * Parse a `buzz://pr|issue|repo?…` URL. Returns a discriminated result so + * callers can fall back to plain-link rendering without throwing. All + * identifiers are validated; hex values are lowercase-normalized. + * + * Strict canonical form to preserve forward-compatibility: + * - Empty or root path only (no `/extra/segments`) + * - No fragment + * - Each required parameter must appear exactly once + * - Unknown query parameters are rejected (callers that need to add + * parameters must version the format or add them to the known-params set) + * + * This ensures old clients decline rather than silently misinterpret future + * extensions (e.g. the reserved `relay=` cross-community field). + */ +export function parseEntityLink(url: string): EntityLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + + if (parsed.protocol !== ENTITY_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + + const host = parsed.hostname; + if (host !== "pr" && host !== "issue" && host !== "repo") { + return { ok: false, reason: "wrong-host" }; + } + + // Require empty/root path — path segments are reserved for future versioning. + if (parsed.pathname !== "" && parsed.pathname !== "/") { + return { ok: false, reason: "unexpected-path" }; + } + + // Reject fragments — not part of the canonical format. + if (parsed.hash) { + return { ok: false, reason: "unexpected-fragment" }; + } + + // Validate known params and reject unknown ones, and enforce single-instance. + const KNOWN_REPO_PARAMS = new Set(["owner", "d"]); + const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]); + const knownParams = host === "repo" ? KNOWN_REPO_PARAMS : KNOWN_EVENT_PARAMS; + + for (const key of parsed.searchParams.keys()) { + if (!knownParams.has(key)) { + return { ok: false, reason: "unknown-param" }; + } + } + for (const key of knownParams) { + const values = parsed.searchParams.getAll(key); + if (values.length > 1) { + return { ok: false, reason: "duplicate-param" }; + } + } + + const owner = parsed.searchParams.get("owner"); + const dtag = parsed.searchParams.get("d"); + if (!owner || !HEX64_RE.test(owner)) { + return { ok: false, reason: "invalid-owner" }; + } + if (!dtag || !isValidDtag(dtag)) { + return { ok: false, reason: "invalid-dtag" }; + } + + if (host === "repo") { + return { + ok: true, + value: { type: "repo", owner: owner.toLowerCase(), dtag }, + }; + } + + const id = parsed.searchParams.get("id"); + if (!id || !HEX64_RE.test(id)) { + return { ok: false, reason: "invalid-id" }; + } + + return { + ok: true, + value: { + type: host, + id: id.toLowerCase(), + owner: owner.toLowerCase(), + dtag, + }, + }; +} + +/** + * Canonical NIP-34 repository coordinate (`30617::`) used as the + * route id for `/projects/$projectId`. Duncan's #4671 branch resolves 30617 + * coordinates regardless of which explicit project contains the repository, so + * entity links remain stable when a repo's container project changes. + * + * Do NOT use the legacy `:` form — it only matched implicit + * project cards and breaks for repos claimed by an explicit project with a + * different d-tag. + */ +export function entityLinkProjectRouteId(link: ParsedEntityLink): string { + return `30617:${link.owner}:${link.dtag}`; +} diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index a56b35f54a..43cfa66060 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -53,6 +53,160 @@ test("parseSupportedLinkPreview ignores unsupported GitHub URLs", () => { ); }); +const BUZZ_OWNER = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; + +test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => { + // Must pass the active relay origin for host validation. + assert.deepEqual( + parseSupportedLinkPreview( + `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, + "https://buzz.block.builderlab.xyz", + ), + { + kind: "buzz-repository", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`, + provider: "Buzz", + title: "buzz-world-galaxy", + typeLabel: "repo", + }, + ); + // Same URL without a matching origin stays external. + assert.equal( + parseSupportedLinkPreview( + `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, + ), + null, + ); +}); + +test("parseSupportedLinkPreview strips .git suffix from clone URLs", () => { + assert.deepEqual( + parseSupportedLinkPreview( + `http://localhost:3000/git/${BUZZ_OWNER}/buzz-world.git`, + "http://localhost:3000", + ), + { + kind: "buzz-repository", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`, + provider: "Buzz", + title: "buzz-world", + typeLabel: "repo", + }, + ); +}); + +test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => { + for (const href of [ + // Owner segment must be a 64-char lowercase hex pubkey. + "https://relay.example/git/not-a-pubkey/repo", + `https://relay.example/git/${BUZZ_OWNER.toUpperCase()}/repo`, + `https://relay.example/git/${BUZZ_OWNER.slice(0, 32)}/repo`, + // Missing or invalid repo segment. + `https://relay.example/git/${BUZZ_OWNER}`, + `https://relay.example/git/${BUZZ_OWNER}/.hidden`, + // Deeper transport paths are not repo links. + `https://relay.example/git/${BUZZ_OWNER}/repo/info/refs`, + ]) { + // Even with a matching origin, structural issues return null. + assert.equal( + parseSupportedLinkPreview(href, "https://relay.example"), + null, + href, + ); + } +}); + +test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () => { + // Correct path shape but origin does not match the active relay. + assert.equal( + parseSupportedLinkPreview( + `https://evil.example/git/${BUZZ_OWNER}/my-repo`, + "https://buzz.block.builderlab.xyz", + ), + null, + ); + // github.com sharing the path shape must never become a Buzz repo card. + assert.equal( + parseSupportedLinkPreview( + `https://github.com/git/${BUZZ_OWNER}/my-repo`, + "https://buzz.block.builderlab.xyz", + ), + null, + ); + // No relay origin provided — stays external. + assert.equal( + parseSupportedLinkPreview( + `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world`, + null, + ), + null, + ); +}); + +const BUZZ_EVENT_ID = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +test("parseSupportedLinkPreview parses buzz:// PR and issue deep links", () => { + assert.deepEqual( + parseSupportedLinkPreview( + `buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`, + ), + { + kind: "buzz-pull-request", + href: `buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`, + provider: "Buzz", + title: "buzz-world #c3b589fa", + typeLabel: "PR", + }, + ); + assert.deepEqual( + parseSupportedLinkPreview( + `buzz://issue?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`, + )?.typeLabel, + "issue", + ); + assert.deepEqual( + parseSupportedLinkPreview(`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`), + { + kind: "buzz-repository", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`, + provider: "Buzz", + title: "buzz-world", + typeLabel: "repo", + }, + ); +}); + +test("parseSupportedLinkPreview rejects malformed buzz:// entity links", () => { + for (const href of [ + `buzz://pr?owner=${BUZZ_OWNER}&d=buzz-world`, + `buzz://pr?id=short&owner=${BUZZ_OWNER}&d=buzz-world`, + `buzz://issue?id=${BUZZ_EVENT_ID}&owner=nope&d=buzz-world`, + `buzz://repo?owner=${BUZZ_OWNER}&d=.hidden`, + ]) { + assert.equal(parseSupportedLinkPreview(href), null, href); + } +}); + +test("extractSupportedLinkPreviews picks up buzz:// links in prose", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `PR is up: buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world — review please.`, + ).map((preview) => [preview.kind, preview.title]), + [["buzz-pull-request", "buzz-world #c3b589fa"]], + ); +}); + +test("extractSupportedLinkPreviews uses markdown labels for buzz:// links", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `[Add header links](buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world)`, + ).map((preview) => preview.title), + ["Add header links"], + ); +}); + test("parseSupportedLinkPreview parses Linear issue URLs", () => { assert.deepEqual( parseSupportedLinkPreview( @@ -114,6 +268,66 @@ test("extractSupportedLinkPreviews returns unique supported links in order", () ); }); +test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `master pushed; clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy and review please.`, + "https://buzz.block.builderlab.xyz", + ), + [ + { + kind: "buzz-repository", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`, + provider: "Buzz", + title: "buzz-world-galaxy", + typeLabel: "repo", + }, + ], + ); + // Without a relay origin the URL is treated as an ordinary external link. + assert.deepEqual( + extractSupportedLinkPreviews( + `clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, + ), + [], + ); +}); + +test("extractSupportedLinkPreviews uses markdown labels for Buzz repo links", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `[Buzz World](https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy)`, + "https://relay.example", + ).map((preview) => preview.title), + ["Buzz World"], + ); +}); + +test("extractSupportedLinkPreviews dedupes clone URL variants of one repo", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + [ + `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`, + `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy.git`, + ].join(" "), + "https://relay.example", + ).map((preview) => preview.href), + [`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`], + ); +}); + +test("clone URLs and buzz://repo links for the same repo dedupe to one card", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + [ + `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`, + `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`, + ].join(" "), + ).map((preview) => preview.href), + [`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`], + ); +}); + test("extractSupportedLinkPreviews handles markdown link serialization", () => { assert.deepEqual( extractSupportedLinkPreviews( @@ -252,3 +466,106 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => { ); assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false); }); + +// ── useResolvedLinkPreviews: behavioral regression pins ────────────────────── +// +// These tests pin the three behaviors that were implemented without tests in +// the initial fix round. They use the exported pure helpers directly so no +// React hook environment is required. + +import { + getLinkPreviewCacheGeneration, + resetLinkPreviewTitleCache, + shouldResolveTitle, +} from "./useResolvedLinkPreviews.ts"; +import { buzzEntityFallbackTitle } from "./linkPreview.ts"; + +const OWNER_HEX = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; +const EVENT_HEX = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +function makePrPreview(title) { + return { + kind: "buzz-pull-request", + href: `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + title, + provider: "Buzz", + typeLabel: "pr", + }; +} + +// 1. Cache epoch: stale promise cannot seed the new generation. +// `resetLinkPreviewTitleCache` must increment the generation counter so that +// a promise captured before the reset sees a different generation and skips +// writing back. +test("resetLinkPreviewTitleCache_incrementsGenerationCounter", () => { + const before = getLinkPreviewCacheGeneration(); + resetLinkPreviewTitleCache(); + const after = getLinkPreviewCacheGeneration(); + assert.equal(after, before + 1, "each reset must bump the generation by 1"); + resetLinkPreviewTitleCache(); + assert.equal( + getLinkPreviewCacheGeneration(), + before + 2, + "second reset must increment again", + ); +}); + +// 2. Mismatched a-tag: shouldResolveTitle uses buzzEntityFallbackTitle to +// decide whether to attempt a relay lookup. When the link's href parses to a +// PR/issue with the expected fallback title, resolution should proceed. When +// the title has already been set to something else (explicit label or earlier +// relay result), shouldResolveTitle must return false so the label wins. +test("shouldResolveTitle_fallbackTitle_returnsTrue", () => { + const parsed = { + ok: true, + value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" }, + }; + // Construct the expected fallback title and verify shouldResolveTitle allows lookup. + const fallback = buzzEntityFallbackTitle(parsed.value); + const preview = makePrPreview(fallback); + assert.equal( + shouldResolveTitle(preview), + true, + "fallback title should trigger relay lookup", + ); +}); + +test("shouldResolveTitle_customLabel_returnsFalse_labelMustWin", () => { + // User has written `[My custom PR title](buzz://pr?...)` — the label must + // win; shouldResolveTitle must return false to skip writing the relay title. + const preview = makePrPreview("My custom PR title"); + assert.equal( + shouldResolveTitle(preview), + false, + "custom label must suppress relay title lookup (label-must-win invariant)", + ); +}); + +// 3. Label-rerender: converting a bare link to `[label](link)` changes the +// preview title away from the fallback — shouldResolveTitle transitions +// from true to false, so a cached relay title is not applied. +test("shouldResolveTitle_transitionsFromTrueToFalseWhenLabelApplied", () => { + const parsed = { + ok: true, + value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" }, + }; + const fallback = buzzEntityFallbackTitle(parsed.value); + + // Before the label: bare link with fallback title — should resolve. + const barePreview = makePrPreview(fallback); + assert.equal( + shouldResolveTitle(barePreview), + true, + "bare link should resolve", + ); + + // After the label: same href but title is now the user's label — must NOT resolve. + const labeledPreview = makePrPreview("My labeled PR"); + assert.equal( + shouldResolveTitle(labeledPreview), + false, + "labeled link must not overwrite label with cached relay title", + ); +}); diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 5cba96da87..b518b0728e 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -1,4 +1,16 @@ +import { + buildIssueLink, + buildPullRequestLink, + buildRepoLink, + isEntityLink, + parseEntityLink, + type ParsedEntityLink, +} from "./entityLink"; + export type SupportedLinkPreviewKind = + | "buzz-pull-request" + | "buzz-issue" + | "buzz-repository" | "github-pull-request" | "github-issue" | "github-repository" @@ -13,6 +25,7 @@ export type SupportedLinkPreview = { kind: SupportedLinkPreviewKind; href: string; provider: + | "Buzz" | "GitHub" | "Linear" | "Google Drive" @@ -31,10 +44,13 @@ export type SupportedLinkPreview = { | "presentation"; }; +// Buzz relay hosts differ per community, so relay git URLs are recognized by +// their distinctive path shape (`/git/<64-hex-pubkey>/`) rather than by +// hostname, and require an explicit scheme. const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+)/gi; const MARKDOWN_SUPPORTED_LINK_RE = - /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; + /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; type HiddenRange = { @@ -266,6 +282,97 @@ function createPreview( }; } +/** + * Placeholder title shown before (or instead of) the relay event lookup in + * `useResolvedLinkPreviews` resolves the real `subject` / repo name. + * Exported so the resolver can tell "still the fallback" apart from a + * markdown-label override it must not overwrite. + */ +export function buzzEntityFallbackTitle(link: ParsedEntityLink): string { + if (link.type === "repo") return link.dtag; + return `${link.dtag} #${link.id.slice(0, 8)}`; +} + +/** + * Map a `buzz://pr|issue|repo` deep link onto a preview card. The href is + * rebuilt through the canonical builders so equivalent links (case or query + * order variants) dedupe to a single card. + */ +function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null { + const parsed = parseEntityLink(href); + if (!parsed.ok) return null; + + const link = parsed.value; + const title = buzzEntityFallbackTitle(link); + if (link.type === "pr") { + return { + kind: "buzz-pull-request", + href: buildPullRequestLink(link), + provider: "Buzz", + title, + typeLabel: "PR", + }; + } + if (link.type === "issue") { + return { + kind: "buzz-issue", + href: buildIssueLink(link), + provider: "Buzz", + title, + typeLabel: "issue", + }; + } + return { + kind: "buzz-repository", + href: buildRepoLink(link), + provider: "Buzz", + title, + typeLabel: "repo", + }; +} + +const BUZZ_GIT_PATH_RE = + /^\/git\/([a-f0-9]{64})\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/; + +/** + * Recognize a Buzz relay git URL (`{relay-origin}/git//`, + * the clone URL shape agents paste when announcing work). The preview href + * is normalized to the canonical `buzz://repo` deep link: the raw git + * transport endpoint is not a browsable page, and the buzz:// href gives the + * card the same in-app click navigation as explicit entity links (and + * dedupes the two spellings of the same repository). + * + * Security: the URL origin must equal `activeRelayOrigin` (the currently + * connected relay). Path shape alone is not proof that a host belongs to the + * active Buzz relay — an arbitrary external URL sharing the path shape must + * remain an ordinary external link. Pass `null` when the relay origin is not + * yet resolved; the link stays external until it can be verified. + */ +function parseBuzzGitLink( + parsed: URL, + activeRelayOrigin: string | null, +): SupportedLinkPreview | null { + if (!activeRelayOrigin || parsed.origin !== activeRelayOrigin) { + return null; + } + + const match = BUZZ_GIT_PATH_RE.exec(parsed.pathname); + if (!match) return null; + + const [, owner, repo] = match; + if (repo.startsWith(".") || repo.includes("..") || repo.length > 64) { + return null; + } + + return { + kind: "buzz-repository", + href: buildRepoLink({ owner, dtag: repo }), + provider: "Buzz", + title: repo, + typeLabel: "repo", + }; +} + function parseGithubLink(parsed: URL): SupportedLinkPreview | null { if (normalizeHostname(parsed) !== "github.com") { return null; @@ -416,10 +523,15 @@ function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null { /** Parse a supported external URL into a compact preview. */ export function parseSupportedLinkPreview( href: string, + activeRelayOrigin?: string | null, ): SupportedLinkPreview | null { + const candidate = trimUrlCandidate(href); + if (isEntityLink(candidate)) { + return parseBuzzEntityPreview(candidate); + } + let parsed: URL; try { - const candidate = trimUrlCandidate(href); parsed = new URL( /^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`, ); @@ -432,6 +544,7 @@ export function parseSupportedLinkPreview( } return ( + parseBuzzGitLink(parsed, activeRelayOrigin ?? null) ?? parseGithubLink(parsed) ?? parseLinearIssue(parsed) ?? parseGoogleDriveLink(parsed) ?? @@ -442,16 +555,23 @@ export function parseSupportedLinkPreview( export function isSupportedLinkAutolinkLabel( label: string, preview: SupportedLinkPreview, + activeRelayOrigin?: string | null, ): boolean { - return parseSupportedLinkPreview(label)?.href === preview.href; + return ( + parseSupportedLinkPreview(label, activeRelayOrigin)?.href === preview.href + ); } function titleFromMarkdownLabel( label: string, preview: SupportedLinkPreview, + activeRelayOrigin: string | null, ): string | null { const title = label.replace(/\s+/g, " ").trim(); - if (!title || isSupportedLinkAutolinkLabel(title, preview)) { + if ( + !title || + isSupportedLinkAutolinkLabel(title, preview, activeRelayOrigin) + ) { return null; } return title; @@ -474,6 +594,7 @@ type LinkPreviewCandidate = { /** Extract supported link previews from message text, preserving first-seen order. */ export function extractSupportedLinkPreviews( content: string, + activeRelayOrigin?: string | null, ): SupportedLinkPreview[] { const previews: SupportedLinkPreview[] = []; const seen = new Set(); @@ -506,8 +627,9 @@ export function extractSupportedLinkPreviews( candidates.sort((a, b) => a.index - b.index || a.order - b.order); + const relayOrigin = activeRelayOrigin ?? null; for (const candidate of candidates) { - const preview = parseSupportedLinkPreview(candidate.href); + const preview = parseSupportedLinkPreview(candidate.href, relayOrigin); if (!preview || seen.has(preview.href)) continue; seen.add(preview.href); @@ -515,7 +637,7 @@ export function extractSupportedLinkPreviews( withTitle( preview, candidate.label - ? titleFromMarkdownLabel(candidate.label, preview) + ? titleFromMarkdownLabel(candidate.label, preview, relayOrigin) : null, ), ); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 9773c33945..d1e3d27b35 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -1,8 +1,17 @@ import * as React from "react"; import { invokeTauri } from "@/shared/api/tauri"; +import { relayClient } from "@/shared/api/relayClient"; +import { + KIND_GIT_ISSUE, + KIND_GIT_PULL_REQUEST, +} from "@/shared/constants/kinds"; -import type { SupportedLinkPreview } from "./linkPreview"; +import { parseEntityLink } from "./entityLink"; +import { + buzzEntityFallbackTitle, + type SupportedLinkPreview, +} from "./linkPreview"; const GOOGLE_FALLBACK_TITLES = new Set([ "Drive file", @@ -13,33 +22,126 @@ const GOOGLE_FALLBACK_TITLES = new Set([ ]); const titleCache = new Map | string | null>(); +/** + * Generation counter incremented on every `resetLinkPreviewTitleCache` call. + * Each in-flight promise captures the generation at creation time and only + * writes back if no reset has happened since — preventing a resolved promise + * from a previous community from repopulating stale entries into the fresh + * cache of a new community. + */ +let cacheGeneration = 0; + +/** + * Buzz entity titles come from relay events, so they are community-scoped — + * wired into `resetCommunityState()` (see useCommunityInit.ts) to avoid + * leaking titles across community switches. + */ +export function resetLinkPreviewTitleCache(): void { + cacheGeneration += 1; + titleCache.clear(); +} + +/** + * Returns the current cache generation counter. Used in tests to verify that + * `resetLinkPreviewTitleCache` increments the generation so stale in-flight + * promises cannot seed the new cache. + * + * @internal test-only export + */ +export function getLinkPreviewCacheGeneration(): number { + return cacheGeneration; +} function fetchLinkPreviewTitle(href: string): Promise { return invokeTauri("fetch_link_preview_title", { href }); } -function shouldResolveTitle(preview: SupportedLinkPreview): boolean { +/** + * Resolve a Buzz PR/issue card title from the relay event's `subject` tag + * (first content line as fallback — the same precedence the projects views + * use). + * + * Security: the fetched event's canonical `a` tag must equal + * `30617::` from the link before we adopt its title. Without this + * check, a crafted link could pair the real title of a legitimate PR with an + * unrelated repository destination. + */ +async function fetchBuzzEntityTitle(href: string): Promise { + const parsed = parseEntityLink(href); + if (!parsed.ok || parsed.value.type === "repo") return null; + + 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) return null; + + // Verify the event belongs to the claimed repository coordinate. + const aTag = event.tags.find( + (tag) => tag[0] === "a" && tag[1] === expectedCoordinate, + ); + if (!aTag) return null; + + const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; + return subject || event.content.split("\n")[0] || null; +} + +/** + * Returns true when the preview's current title is still the auto-generated + * fallback and a relay lookup should be attempted to replace it. Returns false + * once the user has applied a markdown label (`[My label](link)`) so that the + * label wins over any cached relay title. + * + * Exported for unit testing of the label-must-win invariant. + */ +export function shouldResolveTitle(preview: SupportedLinkPreview): boolean { + if (preview.kind === "buzz-pull-request" || preview.kind === "buzz-issue") { + // A markdown-label override replaces the fallback title and must win + // over the relay lookup. + const parsed = parseEntityLink(preview.href); + return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value); + } + return ( preview.kind.startsWith("google-") && GOOGLE_FALLBACK_TITLES.has(preview.title) ); } -function cacheTitle(href: string): Promise { - const cached = titleCache.get(href); +function resolveTitle(preview: SupportedLinkPreview): Promise { + return preview.href.startsWith("buzz://") + ? fetchBuzzEntityTitle(preview.href) + : fetchLinkPreviewTitle(preview.href); +} + +function cacheTitle(preview: SupportedLinkPreview): Promise { + const cached = titleCache.get(preview.href); if (cached instanceof Promise) return cached; if (cached !== undefined) return Promise.resolve(cached); - const promise = fetchLinkPreviewTitle(href) + const generation = cacheGeneration; + const promise = resolveTitle(preview) .then((title) => { - titleCache.set(href, title); + // Only write back if no community switch has happened since we started. + if (cacheGeneration === generation) { + titleCache.set(preview.href, title); + } return title; }) .catch(() => { - titleCache.set(href, null); + if (cacheGeneration === generation) { + titleCache.set(preview.href, null); + } return null; }); - titleCache.set(href, promise); + titleCache.set(preview.href, promise); return promise; } @@ -66,7 +168,7 @@ export function useResolvedLinkPreviews( continue; } - void cacheTitle(preview.href).then((title) => { + void cacheTitle(preview).then((title) => { if (cancelled || !title) return; setResolvedTitles((current) => current[preview.href] === title @@ -85,7 +187,14 @@ export function useResolvedLinkPreviews( () => previews.map((preview) => { const title = resolvedTitles[preview.href]; - return title ? { ...preview, title } : preview; + // Only apply a relay-resolved title while the preview still has the + // fallback title (i.e. no markdown-label override). If the user edits + // a bare link into `[My label](same-link)`, `shouldResolveTitle` + // returns false and the label wins — the cached relay title is not + // applied to prevent it from silently overriding the explicit label. + return title && shouldResolveTitle(preview) + ? { ...preview, title } + : preview; }), [previews, resolvedTitles], ); diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 5d6b80767a..ad848a42ba 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -2,6 +2,7 @@ import { ExternalLink } from "lucide-react"; import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; +import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; import { Attachment, AttachmentActions, @@ -91,6 +92,10 @@ function GoogleSlidesLogo({ className }: { className?: string }) { function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { switch (preview.kind) { + case "buzz-issue": + case "buzz-pull-request": + case "buzz-repository": + return ; case "github-issue": case "github-pull-request": case "github-repository": @@ -111,9 +116,16 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { export function LinkPreviewAttachment({ className, + onOpen, preview, }: { className?: string; + /** + * In-app navigation handler for links the OS cannot open (e.g. `buzz://` + * entity deep links). When set, the card renders a button trigger instead + * of an external anchor. + */ + onOpen?: () => void; preview: SupportedLinkPreview; }) { return ( @@ -141,18 +153,29 @@ export function LinkPreviewAttachment({ className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100" /> - - Open {preview.provider} {preview.typeLabel}: {preview.title} - - + + ) : ( + + + + Open {preview.provider} {preview.typeLabel}: {preview.title} + + + + )} ); } diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index aa3e02984d..08168c1051 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -521,9 +521,10 @@ test("rehypeImageGallery: leaves a single trailing image in the text flow", () = // Regression test: react-markdown's `defaultUrlTransform` strips unknown // schemes (returns `""`) before our `a` component override can see them, -// which would break copy → paste → click for `buzz://message?…` links -// end-to-end. We pass a custom `urlTransform` that delegates to the -// default for `buzz://message` and legacy `buzz://message` hrefs. +// which would break copy → paste → click for `buzz://message?…` links and +// `buzz://pr|issue|repo?…` entity links end-to-end. We pass a custom +// `urlTransform` (`buzzDeepLinkUrlTransform`) that preserves valid Buzz +// deep links and delegates everything else to `defaultUrlTransform`. // // This test renders real `` with the production transform // and asserts the link href survives to the rendered DOM. Mirrors the @@ -534,12 +535,18 @@ import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; +import { parseEntityLink } from "../lib/entityLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; -function messageLinkUrlTransform(value, key) { - if (key === "href" && isMessageLink(value)) { - return value; - } +const OWNER_HEX = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; +const EVENT_HEX = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +function buzzDeepLinkUrlTransform(value, key) { + if (key !== "href") return defaultUrlTransform(value); + if (isMessageLink(value)) return value; + if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } @@ -547,7 +554,7 @@ function renderMarkdown(content) { return renderToStaticMarkup( React.createElement( ReactMarkdown, - { urlTransform: messageLinkUrlTransform }, + { urlTransform: buzzDeepLinkUrlTransform }, content, ), ); @@ -592,7 +599,7 @@ test("messageLinkUrlTransform: preserves legacy buzz://message href", () => { assert.match(html, /href="buzz:\/\/message\?channel=abc&(?:amp;)?id=xyz"/); }); -test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", () => { +test("messageLinkUrlTransform: leaves non-entity buzz:// schemes to default", () => { // `buzz://connect?relay=…` is handled by a different code path (Tauri // single-instance). The markdown renderer should let it pass through // defaultUrlTransform (which strips it) since it's not clickable in-app. @@ -602,6 +609,127 @@ test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", ( assert.match(html, /href=""/); }); +test("buzzDeepLinkUrlTransform: preserves buzz://pr entity link href", () => { + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`[My PR](${prLink})`); + // The href must survive — our transform preserves valid entity links. + assert.match(html, /href="buzz:\/\/pr\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: preserves buzz://pr autolink href", () => { + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`<${prLink}>`); + assert.match(html, /href="buzz:\/\/pr\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: preserves buzz://issue entity link href", () => { + const issueLink = `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`[Issue title](${issueLink})`); + assert.match(html, /href="buzz:\/\/issue\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: preserves buzz://repo entity link href", () => { + const repoLink = `buzz://repo?owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`[My repo](${repoLink})`); + assert.match(html, /href="buzz:\/\/repo\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () => { + // Strict parser rejects unknown params — transform falls back to default sanitizer. + const html = renderMarkdown( + `[link](buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world&extra=ignored)`, + ); + assert.match(html, /href=""/); +}); + +// ── renderEntityLinkAnchor: clone-URL anchor origin gating ─────────────────── +// +// `renderEntityLinkAnchor` now accepts `relayOrigin` and passes it to +// `parseSupportedLinkPreview`. A clone URL is only treated as an in-app entity +// link when the URL origin exactly matches the active relay origin. This covers +// the inline anchor click path (not just card extraction). + +import { renderEntityLinkAnchor } from "../ui/markdown/entityLinks.tsx"; + +const CLONE_URL = `https://relay.example/git/${OWNER_HEX}/my-repo`; + +test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => { + // Origin matches active relay — anchor should navigate in-app (non-null). + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "my-repo"), + href: CLONE_URL, + onOpenEntityLink: () => {}, + relayOrigin: "https://relay.example", + }); + assert.notEqual( + el, + null, + "matching-origin clone URL must produce an entity anchor", + ); + const html = renderToStaticMarkup(el); + // The entity anchor must be rendered — it carries the original href (for display) + // and navigates in-app via onClick. Verify the anchor is present with the cursor style. + assert.match( + html, + /cursor-pointer/, + "entity anchor must have the in-app navigation cursor style", + ); +}); + +test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => { + // Origin does NOT match active relay — must fall through to ExternalLinkAnchor. + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "my-repo"), + href: CLONE_URL, + onOpenEntityLink: () => {}, + relayOrigin: "https://evil.example", + }); + assert.equal( + el, + null, + "lookalike-origin clone URL must return null so the anchor falls through to external", + ); +}); + +test("renderEntityLinkAnchor_noRelayOrigin_cloneUrlReturnsNull", () => { + // No known relay origin — must fail closed, not guess. + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "my-repo"), + href: CLONE_URL, + onOpenEntityLink: () => {}, + relayOrigin: null, + }); + assert.equal( + el, + null, + "clone URL without a relay origin must return null (fail closed)", + ); +}); + +test("renderEntityLinkAnchor_directEntityLink_returnsAnchorRegardlessOfOrigin", () => { + // A direct buzz://pr link always resolves in-app — it does not require origin. + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "My PR"), + href: prLink, + onOpenEntityLink: () => {}, + relayOrigin: null, + }); + assert.notEqual( + el, + null, + "direct buzz://pr link must produce an entity anchor regardless of origin", + ); +}); + test("remarkSpoilers: block delimiter spoilers expose a block prop to React", () => { let spoilerProps; renderToStaticMarkup( diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..414b44a966 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -9,7 +9,6 @@ import { ZoomOut, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -23,13 +22,13 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; -import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { extractSupportedLinkPreviews, parseSupportedLinkPreview, } from "@/shared/lib/linkPreview"; import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; @@ -59,6 +58,12 @@ import { MarkdownCodeBlock, SyntaxHighlightedCode, } from "./markdown/CodeBlock"; +import { + renderEntityLinkAnchor, + useEntityCardOpenHandlers, + useOpenEntityLink, +} from "./markdown/entityLinks"; +import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor"; import { FileCard } from "./markdown/FileCard"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; import { MarkdownInput } from "./markdown/MarkdownInput"; @@ -108,7 +113,6 @@ import { visibleImageGalleryForTrigger, } from "./markdown/imageLightbox"; import { MarkdownTable } from "./markdown/MarkdownTable"; -import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip"; import { ProgressiveImage } from "./markdown/ProgressiveImage"; import { MessageLinkPill } from "./markdown/MessageLinkPill"; import { renderCachedMarkdown } from "./markdown/nodeCache"; @@ -1272,85 +1276,6 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) { ); } -/** - * An external `[text](href)` link with a custom right-click menu. - * - * Buzz renders inside a native webview whose default context menu has no - * useful link actions, so a plain right-click on a link is a no-op. This adds - * an in-app menu with "Open link" (via the OS opener, matching the anchor's - * left-click `target="_blank"` behavior) and "Copy link" (the real href, not - * the masked display text). - */ -function ExternalLinkAnchor({ - anchorProps, - children, - href, - isLinearLink, - label, -}: { - anchorProps: React.ComponentPropsWithoutRef<"a">; - children: React.ReactNode; - href: string | undefined; - isLinearLink: boolean; - label: string; -}) { - const [menu, setMenu] = React.useState(null); - const closeMenu = React.useCallback(() => setMenu(null), []); - useDismissMediaContextMenu(Boolean(menu), closeMenu); - - const anchor = ( - { - if (!href) return; - event.preventDefault(); - setMenu({ x: event.clientX, y: event.clientY }); - }} - rel="noreferrer" - target="_blank" - > - {children} - - ); - - return ( - <> - - {anchor} - - {menu && href ? ( - { - closeMenu(); - void openUrl(href).catch(() => { - toast.error("Failed to open link"); - }); - }, - }, - { - label: "Copy link", - onSelect: () => { - closeMenu(); - copyTextToClipboard(href, "Link copied to clipboard"); - }, - }, - ]} - position={menu} - /> - ) : null} - - ); -} - function createMarkdownComponents( interactive = true, mediaInset = false, @@ -1366,8 +1291,10 @@ function createMarkdownComponents( const { channels, imetaByUrl, + onOpenEntityLink, onOpenMessageLink, onImportSnapshotFromUrl, + relayOrigin, snapshotSharedBy, } = useMarkdownRuntime(); if (!interactive) { @@ -1465,7 +1392,20 @@ function createMarkdownComponents( // anchor (renders as a normal external link). } - const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null; + // `buzz://pr|issue|repo?…` entity links navigate in-app; malformed ones + // fall through to the default anchor. + const entityAnchor = renderEntityLinkAnchor({ + anchorProps: props, + children, + href, + onOpenEntityLink, + relayOrigin, + }); + if (entityAnchor) return entityAnchor; + + const supportedLinkPreview = href + ? parseSupportedLinkPreview(href, relayOrigin) + : null; const isLinearLink = supportedLinkPreview?.kind === "linear-issue"; return ( @@ -1799,7 +1739,7 @@ function createMarkdownComponents( * four instances ever exist. Module-stable maps mean cached markdown element * trees (see ./markdown/nodeCache.ts) never embed per-mount closures. */ -const MARKDOWN_COMPONENT_SCHEMA_VERSION = "4"; +const MARKDOWN_COMPONENT_SCHEMA_VERSION = "5"; const markdownComponentsByVariant = new Map(); type MarkdownComponentSet = { components: Components; variant: string }; @@ -1852,6 +1792,7 @@ function MarkdownInner({ }, [goChannel], ); + const onOpenEntityLink = useOpenEntityLink(); const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { // Always route through `goChannel` with `messageId` set: the channel @@ -1868,9 +1809,11 @@ function MarkdownInner({ }, [goChannel], ); + const relayOrigin = useRelayOrigin(); const linkPreviews = React.useMemo( - () => (interactive ? extractSupportedLinkPreviews(content) : []), - [content, interactive], + () => + interactive ? extractSupportedLinkPreviews(content, relayOrigin) : [], + [content, interactive, relayOrigin], ); const configNudge = React.useMemo( () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), @@ -1883,7 +1826,9 @@ function MarkdownInner({ imetaByUrl, mentionPubkeysByName, onOpenChannel, + onOpenEntityLink, onOpenMessageLink, + relayOrigin, snapshotSharedBy, onImportSnapshotFromUrl: ( fileBytes: number[], @@ -1900,7 +1845,9 @@ function MarkdownInner({ imetaByUrl, mentionPubkeysByName, onOpenChannel, + onOpenEntityLink, onOpenMessageLink, + relayOrigin, snapshotSharedBy, goAgents, ], @@ -1922,6 +1869,10 @@ function MarkdownInner({ } const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews); + const entityCardOpenHandlers = useEntityCardOpenHandlers( + resolvedLinkPreviews, + onOpenEntityLink, + ); // When a config-nudge suppresses the prose (selectProseOrNudge returns // null), skip the parse entirely — it would be thrown away unrendered. @@ -1977,7 +1928,11 @@ function MarkdownInner({ data-link-preview-list="" > {resolvedLinkPreviews.map((preview) => ( - + ))} ) : null} diff --git a/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx b/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx new file mode 100644 index 0000000000..00f19fb8de --- /dev/null +++ b/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { toast } from "sonner"; + +import { cn } from "@/shared/lib/cn"; +import { copyTextToClipboard } from "@/shared/lib/clipboard"; + +import { MaskedLinkTooltip } from "./MaskedLinkTooltip"; +import { + MediaContextMenu, + type MediaContextMenuPosition, + useDismissMediaContextMenu, +} from "./MediaContextMenu"; + +/** + * An external `[text](href)` link with a custom right-click menu. + * + * Buzz renders inside a native webview whose default context menu has no + * useful link actions, so a plain right-click on a link is a no-op. This adds + * an in-app menu with "Open link" (via the OS opener, matching the anchor's + * left-click `target="_blank"` behavior) and "Copy link" (the real href, not + * the masked display text). + */ +export function ExternalLinkAnchor({ + anchorProps, + children, + href, + isLinearLink, + label, +}: { + anchorProps: React.ComponentPropsWithoutRef<"a">; + children: React.ReactNode; + href: string | undefined; + isLinearLink: boolean; + label: string; +}) { + const [menu, setMenu] = React.useState(null); + const closeMenu = React.useCallback(() => setMenu(null), []); + useDismissMediaContextMenu(Boolean(menu), closeMenu); + + const anchor = ( + { + if (!href) return; + event.preventDefault(); + setMenu({ x: event.clientX, y: event.clientY }); + }} + rel="noreferrer" + target="_blank" + > + {children} + + ); + + return ( + <> + + {anchor} + + {menu && href ? ( + { + closeMenu(); + void openUrl(href).catch(() => { + toast.error("Failed to open link"); + }); + }, + }, + { + label: "Copy link", + onSelect: () => { + closeMenu(); + copyTextToClipboard(href, "Link copied to clipboard"); + }, + }, + ]} + position={menu} + /> + ) : null} + + ); +} diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx new file mode 100644 index 0000000000..b215110b86 --- /dev/null +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -0,0 +1,112 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + entityLinkProjectRouteId, + isEntityLink, + parseEntityLink, + type ParsedEntityLink, +} from "@/shared/lib/entityLink"; +import { + parseSupportedLinkPreview, + type SupportedLinkPreview, +} from "@/shared/lib/linkPreview"; + +/** + * Navigate to the project detail view for a `buzz://pr|issue|repo` link. + * The link's (owner, d) coordinate is exactly the `/projects/$projectId` + * route id, so no read-model resolution is needed. + */ +export function useOpenEntityLink(): (link: ParsedEntityLink) => void { + const { goProject } = useAppNavigation(); + return React.useCallback( + (link: ParsedEntityLink) => { + void goProject(entityLinkProjectRouteId(link), { + ...(link.type === "pr" ? { pullRequestId: link.id } : {}), + ...(link.type === "issue" ? { issueId: link.id } : {}), + }); + }, + [goProject], + ); +} + +/** + * In-app open handlers for `buzz://` entity preview cards, keyed by href. + * External cards get no handler and keep their OS-opened anchor. + */ +export function useEntityCardOpenHandlers( + previews: SupportedLinkPreview[], + onOpenEntityLink: (link: ParsedEntityLink) => void, +): Map void> { + return React.useMemo(() => { + const handlers = new Map void>(); + for (const preview of previews) { + if (!isEntityLink(preview.href)) continue; + const parsed = parseEntityLink(preview.href); + if (parsed.ok) { + handlers.set(preview.href, () => onOpenEntityLink(parsed.value)); + } + } + return handlers; + }, [onOpenEntityLink, previews]); +} + +/** + * Resolve an anchor href to a canonical `buzz://` entity link, accepting + * both the deep-link scheme directly and HTTPS relay clone URLs (which the + * preview parser normalizes onto `buzz://repo` only when the URL origin + * matches the active relay origin). + */ +function resolveEntityHref( + href: string, + relayOrigin: string | null, +): string | null { + if (isEntityLink(href)) return href; + if (!/^https?:\/\//i.test(href)) return null; + + const preview = parseSupportedLinkPreview(href, relayOrigin); + return preview && isEntityLink(preview.href) ? preview.href : null; +} + +/** + * Render an inline anchor for a Buzz entity link (`buzz://pr|issue|repo` or + * an HTTPS relay clone URL whose origin matches the active relay) that + * navigates in-app instead of handing the URL to the OS. Returns null when + * the href is not a valid entity link so the caller can fall through to its + * default anchor. + */ +export function renderEntityLinkAnchor({ + anchorProps, + children, + href, + onOpenEntityLink, + relayOrigin, +}: { + anchorProps: React.ComponentPropsWithoutRef<"a">; + children: React.ReactNode; + href: string | undefined; + onOpenEntityLink: (link: ParsedEntityLink) => void; + relayOrigin: string | null; +}): React.ReactElement | null { + if (!href) return null; + + const canonicalHref = resolveEntityHref(href, relayOrigin); + if (!canonicalHref) return null; + + const parsed = parseEntityLink(canonicalHref); + if (!parsed.ok) return null; + + return ( + { + event.preventDefault(); + onOpenEntityLink(parsed.value); + }} + > + {children} + + ); +} diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 97cbe212d3..5853f8943e 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -13,7 +13,7 @@ import remarkCustomEmoji, { import remarkMentions from "@/shared/lib/remarkMentions"; import remarkSpoilers from "@/shared/lib/remarkSpoilers"; -import { messageLinkUrlTransform } from "./utils"; +import { buzzDeepLinkUrlTransform } from "./utils"; /** * Parsed-markdown element cache. @@ -105,7 +105,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable ] as any[], rehypePlugins, - urlTransform: messageLinkUrlTransform, + urlTransform: buzzDeepLinkUrlTransform, }); } diff --git a/desktop/src/shared/ui/markdown/runtimeContext.ts b/desktop/src/shared/ui/markdown/runtimeContext.ts index a067125a39..2a1d914ce5 100644 --- a/desktop/src/shared/ui/markdown/runtimeContext.ts +++ b/desktop/src/shared/ui/markdown/runtimeContext.ts @@ -15,7 +15,9 @@ import type { MarkdownRuntime } from "./types"; const INERT_MARKDOWN_RUNTIME: MarkdownRuntime = { channels: [], onOpenChannel: () => {}, + onOpenEntityLink: () => {}, onOpenMessageLink: () => {}, + relayOrigin: null, }; export const MarkdownRuntimeContext = React.createContext( diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 63551024d2..f736922f93 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -1,4 +1,5 @@ import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; +import type { ParsedEntityLink } from "@/shared/lib/entityLink"; import type { Channel } from "@/shared/api/types"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { VideoReviewContext } from "../VideoPlayer"; @@ -31,7 +32,15 @@ export type MarkdownRuntime = { imetaByUrl?: ImetaLookup; mentionPubkeysByName?: Record; onOpenChannel: (channelId: string) => void; + /** Navigate to a Buzz git entity (`buzz://pr|issue|repo` deep link). */ + onOpenEntityLink: (link: ParsedEntityLink) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; + /** + * The resolved relay origin (e.g. `https://buzz.block.builderlab.xyz`), + * or `null` when not yet resolved. Used by the anchor component to + * validate that clone-URL rewrites point to the active relay only. + */ + relayOrigin: string | null; /** Display name of the message author sharing an agent snapshot. */ snapshotSharedBy?: string; /** diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index db9629921e..a35e60cadc 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { defaultUrlTransform } from "react-markdown"; import { isMessageLink } from "@/features/messages/lib/messageLink"; +import { parseEntityLink } from "@/shared/lib/entityLink"; export function useStableArray(arr: T[]): T[] { const ref = React.useRef(arr); @@ -166,18 +167,34 @@ export function isInsideHiddenSpoiler(element: Element): boolean { } /** - * `urlTransform` for `` that preserves `buzz://message?…` - * links. The default transform strips unknown schemes (returns `""`) before - * the `a` component override can see them, which would break copy → paste → - * click end-to-end. Everything else delegates to `defaultUrlTransform`. + * `urlTransform` for `` that preserves `buzz://` deep links + * used by Buzz — both `buzz://message?…` links and `buzz://pr|issue|repo?…` + * entity links. The default transform strips unknown schemes (returns `""`) + * before the `a` component override can see them, which would break copy → + * paste → click end-to-end. + * + * Policy: + * - `buzz://message` hrefs — preserved unconditionally (handled by the + * message-link pill renderer). + * - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink` + * succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs. + * - Everything else delegates to `defaultUrlTransform`. */ -export function messageLinkUrlTransform(value: string, key: string): string { - if (key === "href" && isMessageLink(value)) { - return value; - } +export function buzzDeepLinkUrlTransform(value: string, key: string): string { + if (key !== "href") return defaultUrlTransform(value); + if (isMessageLink(value)) return value; + if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } +/** + * @deprecated Preserved for external callers; use `buzzDeepLinkUrlTransform` + * which also handles `buzz://pr|issue|repo` entity links. + */ +export function messageLinkUrlTransform(value: string, key: string): string { + return buzzDeepLinkUrlTransform(value, key); +} + export function getReactNodeText(node: React.ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md new file mode 100644 index 0000000000..df32037898 --- /dev/null +++ b/docs/buzz-entity-links.md @@ -0,0 +1,266 @@ +# Buzz Entity Links + +Status: **partially implemented**. Done on this branch: + +- Slice 0 — HTTPS relay git clone URLs (`{relay-origin}/git//`) + render as Buzz repository preview cards in chat + (`desktop/src/shared/lib/linkPreview.ts`). +- Slice 1 — `buzz://pr|issue|repo` deep links: `entityLink.ts` + builders/parser, preview cards with relay title enrichment, in-timeline + click navigation to `/projects/$projectId`. +- Slice 3 (create-command part) — `crates/buzz-cli/src/links.rs`, `link` + output field on `pr open` / `issues create` / `repos create`, base prompt + guidance, cross-language golden-format tests. + +Still unimplemented: OS-level deep links (slice 2), `link` on get commands, +the `buzz://project` scheme (waiting on NIP-MP landing), and the follow-ups +in slice 4. + +## Problem + +When a message contains a GitHub URL, the desktop client renders a rich +preview card ("GitHub · PR block/buzz #4020") below the message. Those cards +are produced entirely client-side by URL parsing in +`desktop/src/shared/lib/linkPreview.ts` and rendered by +`desktop/src/shared/ui/link-preview-attachment.tsx`. + +Buzz-hosted entities have no equivalent. There is **no link format at all** +for a Buzz repository, project, pull request, or issue: + +- The only rich deep link today is `buzz://message?channel=…&id=…` + (`desktop/src/features/messages/lib/messageLink.ts`), rendered as an inline + pill via `remarkMessageLinks.ts` + `MessageLinkPill.tsx`. +- OS-level deep links (`desktop/src-tauri/src/deep_link.rs`, + `desktop/src/shared/deep-link.ts`) support `connect`, `join`, + `add-community`, `message`, and `nostr-bind` — no git entities. +- `buzz pr open` / `buzz issues create` return raw event ids; there is no URL + in their output and no guidance in the agent base prompt + (`crates/buzz-acp/src/base_prompt.md`) for referencing Buzz work items in + chat. Agents can only say "PR up" with a hex id. +- The relay-served web client only has `/repos/$repoId`; no PR/issue pages. + +So an agent that opens a PR on a Buzz-hosted repository cannot produce +anything clickable, while the same agent opening a GitHub PR gets a card for +free. + +## Goals + +1. A canonical, shareable link format for Buzz repositories, projects, pull + requests, and issues. +2. Rich preview cards in the desktop message timeline for those links, with + parity to (and better data than) the GitHub cards — titles come from the + actual Nostr events, not URL text. +3. Clicking a link navigates in-app to the existing project detail views. +4. CLI output includes the link so agents (and the base prompt) can emit it + when announcing work. + +## Non-goals (v1) + +- Web (browser) pages for PRs/issues — the web client has no such views yet, + so links are app-only, same as `buzz://message` today. +- Cross-community links. Like `buzz://message`, links are interpreted against + the community the message was received in. A `relay=` query parameter is + reserved for a future cross-community version but not emitted or consumed. +- Generic OpenGraph unfurling for arbitrary URLs — that is the separate + `proto/rich-link-previews` prototype and stays orthogonal. +- Mobile rendering. Mobile should degrade gracefully (plain link) in v1; + pill/card parity is a follow-up. + +## Link format + +Extend the existing `buzz://` scheme, mirroring `buzz://message`: + +``` +buzz://repo?owner=&d= +buzz://project?owner=&d= +buzz://pr?id=&owner=&d= +buzz://issue?id=&owner=&d= +``` + +- `owner` is the 64-char lowercase hex pubkey of the repository/project + announcement author (the NIP-34 / NIP-MP coordinate owner). +- `d` is the addressable `d`-tag. For `repo`/`project` links the + (`owner`, `d`) pair is the full `30617::` / + `30621::` coordinate. +- For `pr`/`issue` links, `id` identifies the kind `1618` / `1621` event; + `owner` + `d` are the routing coordinate that lets the client navigate + (and render a fallback card) without an event lookup. **v1 decision:** the + implemented parser requires all three parameters — the CLI always emits + them, and accepting hint-less links would force an event lookup before any + navigation. A future revision can relax this without breaking existing + links. + +Validation rules match the existing codebase: `owner` and `id` are +`/^[a-f0-9]{64}$/`; `d` follows addressable d-tag rules already enforced in +`projectModels.ts` / `buzz-sdk`. + +### HTTPS URLs + +Agents naturally paste HTTPS clone URLs +(`{relay-origin}/git//`) when announcing work, so those are +recognized **first** — implemented on this branch. Detection keys on the +path shape (`/git/` + 64-hex pubkey segment) rather than a host allow-list, +since relay hosts differ per community. The preview href is normalized to +the canonical `buzz://repo?owner=…&d=…` deep link (the raw transport URL is +not a browsable page), so clone-URL cards and inline clone-URL anchors get +the same in-app click navigation as explicit entity links, and both +spellings of the same repository dedupe to one card. + +PRs, issues, and projects have no HTTPS page to link to (the web client has +no such routes), which is why they use the `buzz://` scheme above: it is +community-relative by construction, matches the established `buzz://message` +precedent, and requires no new relay surface. If web views land later, the +desktop can additionally recognize those `{relay-origin}/…` URLs with the +same card treatment. + +## Rendering in chat (desktop) + +Two presentations, consistent with how GitHub links and message links behave +today: + +1. **Autolinked bare URL** (`` or bare in text): render an + **attachment card** below the message in the existing `AttachmentGroup`, + exactly like GitHub cards. Provider label `Buzz`, type label + `PR` / `issue` / `repo` / `project`. +2. **Explicitly labeled markdown link** (`[fix the tooltip](buzz://pr?…)`): + keep the author's label inline (same rule as + `resolveMessageLinkRenderTarget` in `messageLink.ts`), still clickable. + +### Card content and enrichment + +Unlike GitHub (title derived from URL path only), Buzz entities live on the +same relay, so the card can show real data: + +| Entity | Title source | Fallback | +|---------|-------------------------------------------|---------------------| +| PR | `subject` tag of the kind `1618` event | `PR ` | +| Issue | `subject` tag of the kind `1621` event | `issue ` | +| Repo | `name` tag of the kind `30617` event | `d`-tag | +| Project | `name` tag of the kind `30621` event | `d`-tag | + +Enrichment is a single relay query by event id (PR/issue) or coordinate +(repo/project) through the existing `relayClient`, cached per event id. +Kind filters must always be included in the query (relay p-gate). Cards +render immediately with the fallback title and upgrade in place when the +lookup resolves — same progressive pattern as +`useResolvedLinkPreviews.ts` uses for Google titles. + +Open/merged/closed status chips (from kind `1630`–`1633` status events) are +a nice-to-have and explicitly deferred to a follow-up. + +### New module + +`desktop/src/shared/lib/entityLink.ts` (placed in `shared/lib` rather than +the projects feature so `linkPreview.ts` — also `shared/lib` — can import +it without a feature→shared boundary violation): + +- `buildRepoLink`, `buildPullRequestLink`, `buildIssueLink` + (`buildProjectLink` deferred with the `project` scheme) +- `parseEntityLink(url): EntityLinkParseResult` (discriminated union, same + shape as `parseMessageLink`) +- `isEntityLink(href)` cheap pre-check for the markdown renderer + +Detection: extend `extractSupportedLinkPreviews` in `linkPreview.ts` with a +`buzz://` pattern (new `SupportedLinkPreviewKind` members +`buzz-pull-request`, `buzz-issue`, `buzz-repository`, `buzz-project`), or — +if mixing schemes into the URL regex is awkward — a parallel extractor +composed in `markdown.tsx`. Code blocks / spoiler / image-link masking rules +are shared either way, and the existing `MAX_PREVIEWS` cap applies across +both sources. + +## Click handling and OS deep links + +**In-timeline click** *(implemented)*: navigate via +`useAppNavigation.goProject()`. The `/projects/$projectId` route id is the +canonical `30617::` coordinate (see `entityLinkProjectRouteId` in +`shared/lib/entityLink.ts`). Route resolution on the `feat/multi-repository-projects` +branch (#4671) resolves this coordinate to the correct project and repository +regardless of container grouping — **#4671 must merge before #4695** to avoid +unresolved routes at runtime: + +- `pr` / `issue` → `/projects/30617::?pullRequestId=` (or `issueId`). +- `repo` → `/projects/30617::`. + +If resolution fails (entity not visible in this community), show the same +kind of toast fallback used for unresolvable message links. + +**OS-level**: register `repo` / `project` / `pr` / `issue` hosts in +`desktop/src-tauri/src/deep_link.rs` and dispatch to a new listener hook +(sibling to `useMessageDeepLinks.ts`). This makes links pasted outside Buzz +(e.g. in a terminal or another app) open the desktop app correctly. + +## CLI (`buzz-cli`) + +Add a `link` field to the JSON output of the write commands that create +linkable entities: + +- `buzz pr open` → `{ event_id, accepted, message, link }` +- `buzz issues create` → same +- `buzz repos create` → link built from owner pubkey + `d`-tag +- `buzz projects create` → same + +The builder lives in one Rust helper (e.g. `crates/buzz-cli/src/links.rs`) +so the format has exactly one definition on the Rust side; the TypeScript +`entityLink.ts` is its mirror and both are covered by shared-format tests +(golden strings asserted on both sides, like the NIP-MP fixture pattern). + +`buzz pr get` / `buzz issues get` / `buzz repos get` also include `link` in +their output so agents can link to existing entities, not just ones they +just created. + +## Agent guidance + +One addition to `crates/buzz-acp/src/base_prompt.md`, next to the existing +`--channel` rule for PR opens: + +> When you announce a pull request, issue, repository, or project in a +> channel message, include the `link` value from the command output as a +> bare URL on its own line so it renders as a preview card. + +No persona changes needed — the base prompt applies to all managed agents. + +## Interaction with existing work + +- **`proto/rich-link-previews`** (generic OpenGraph cards): orthogonal. + Entity links never hit the network beyond a relay event query; no overlap + in code paths except the shared `AttachmentGroup` rendering slot. +- **`feat/multi-repository-projects` (NIP-MP)**: independent. Entity links + reference single repositories/PRs/issues by coordinate/event id; the + PR→project resolution step simply uses whatever project read models exist + on `main` at implementation time. + +## Implementation plan (suggested PR slices) + +0. **HTTPS clone-URL repo cards** *(done, this branch)* — recognize relay + `/git//` URLs in `linkPreview.ts`, `Buzz` provider card + with the `BuzzMark` logo, href normalized to the `buzz://repo` deep link + for in-app navigation. +1. **Link core + cards** *(done, this branch)* — `entityLink.ts`, detection + in `linkPreview.ts`, `Buzz` card variant in + `link-preview-attachment.tsx`, in-timeline click navigation, relay title + enrichment (with `resetLinkPreviewTitleCache()` wired into + `resetCommunityState()`). Unit tests (`entityLink.test.mjs`, extended + `linkPreview.test.mjs`). +2. **OS deep links** — `deep_link.rs` + listener hook + `deep-link.ts` + parity tests. +3. **CLI + agent prompt** *(create commands done, this branch)* — `links.rs` + helper, `link` output field on `pr open` / `issues create` / + `repos create`, base prompt paragraph, cross-language golden-format test. + Still open: `link` on the get commands. +4. **Follow-ups (separate)** — status chips on PR/issue cards, mobile + pill/card rendering, web PR/issue routes + HTTPS link recognition, + cross-community `relay=` parameter. + +## Security considerations + +- All identifiers are validated before use (`owner`/`id` strict hex-64, + `d`-tag charset rules). Parse failures render the raw text as a plain, + non-clickable string — never an anchor with an unvalidated href. +- Title enrichment queries go through the already-authenticated + `relayClient` with explicit `kinds` filters; no new HTTP surface and no + outbound fetches to third parties. +- Card titles come from event tags authored by arbitrary users; they must be + rendered as text (existing card components already do this — verify no + `dangerouslySetInnerHTML` in the new variant). +- Deep links arriving from the OS are untrusted input; the new listener must + apply the same validation as the in-timeline parser before navigating.