feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues (#4695)

## Summary

Gives Buzz-hosted git entities the same "GitHub-style" chat experience
GitHub links already get: rich preview cards, real titles, and
click-through — except clicks navigate **in-app** to the Projects view
instead of a browser.

- **Spec**: `docs/buzz-entity-links.md` — link scheme, slices, and
deferred work (`buzz://project`, OS deep links, web routes).
- **Canonical `buzz://` deep links**: new
`desktop/src/shared/lib/entityLink.ts` with builders + strict parser for
`buzz://pr?id=…&owner=…&d=…`, `buzz://issue?…`, and
`buzz://repo?owner=…&d=…`, mirrored by a Rust module
(`crates/buzz-cli/src/links.rs`) with a shared golden-format test so the
two implementations can't drift.
- **Preview cards**: `linkPreview.ts` recognizes `buzz://` entity links
*and* HTTPS relay clone URLs (`{origin}/git/<pubkey>/<repo>`, the shape
agents paste today). Both normalize onto the canonical `buzz://` href,
so the two spellings of a repo dedupe to one `Buzz`-provider card
(`BuzzMark` logo) rendered by `link-preview-attachment.tsx`.
- **Title enrichment**: PR/issue cards fetch the real subject from the
relay event (`subject` tag or first content line) via
`useResolvedLinkPreviews.ts`; the cache is community-scoped and reset in
`resetCommunityState()`.
- **In-app navigation**: clicking a card or inline anchor (including
HTTPS relay clone URLs whose origin matches the active relay) routes to
the canonical `30617:<owner>:<d>` coordinate via `goProject()`
(`markdown/entityLinks.tsx`). **Merge dependency: #4671 must merge
first** — route resolution for `30617:` coordinates is implemented on
that branch (`feat/multi-repository-projects`). Entity-link and
external-anchor logic were extracted out of `markdown.tsx` to stay under
the file-size ratchet.
- **Agent side**: `buzz pr open`, `buzz issues create`, and `buzz repos
create` now return a ready-made `link` field (omitted when the relay
returns `accepted: false`), and `base_prompt.md` instructs agents to
paste it verbatim when announcing work.

## Test plan

- [x] Desktop unit tests: pass, including new `entityLink.test.mjs` and
`linkPreview.test.mjs` coverage (golden formats, malformed-link
rejection, clone-URL/`buzz://` dedupe, origin-gated anchor behavior,
label-must-win invariant, cache epoch)
- [x] Rust: `cargo test -p buzz-cli` golden-format test +
accepted/rejected link guard assertions, clippy + fmt clean
- [x] Biome + `tsc --noEmit` clean; pre-push hooks
(desktop-tauri-checks, rust-tests, desktop-test) pass
- [ ] Manual: paste a relay clone URL and a `buzz://pr` link in a
channel — verify one card each, real PR title, and in-app navigation to
the Projects view

Related: [#4671](https://github.com/block/buzz/pull/4671)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
thomaspblock
2026-08-04 17:54:14 -04:00
committed by GitHub
co-authored by npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw Will Pfleger
parent 4c665aeac3
commit a1d78f2959
24 changed files with 1715 additions and 142 deletions
+1
View File
@@ -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()`.**
+3
View File
@@ -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 <group> --help` for full usage. For multiline message
When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` 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.
+31 -9
View File
@@ -1387,19 +1387,25 @@ pub fn extract_p_tags(event: &serde_json::Value) -> Vec<serde_json::Value> {
.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) {
+5 -1
View File
@@ -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(())
}
+5 -1
View File
@@ -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(())
}
+5 -1
View File
@@ -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(())
}
+1
View File
@@ -2,6 +2,7 @@ pub mod agent_management;
mod client;
mod commands;
mod error;
mod links;
mod validate;
use clap::{Parser, Subcommand};
+51
View File
@@ -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")
);
}
}
@@ -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 =
+135
View File
@@ -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" },
);
});
+194
View File
@@ -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=<owner-pubkey>&d=<repo-dtag>
* buzz://pr?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
* buzz://issue?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
*
* `owner` + `d` identify the NIP-34 repository coordinate
* (`30617:<owner>:<d>`); `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:<owner>:<dtag>`) 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 `<owner>:<dtag>` 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}`;
}
+317
View File
@@ -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",
);
});
+129 -7
View File
@@ -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>/<repo>`) 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/<owner-pubkey>/<repo>`,
* 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<string>();
@@ -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,
),
);
+119 -10
View File
@@ -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, Promise<string | null> | 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<string | null> {
return invokeTauri<string | null>("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:<owner>:<d>` 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<string | null> {
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<string | null> {
const cached = titleCache.get(href);
function resolveTitle(preview: SupportedLinkPreview): Promise<string | null> {
return preview.href.startsWith("buzz://")
? fetchBuzzEntityTitle(preview.href)
: fetchLinkPreviewTitle(preview.href);
}
function cacheTitle(preview: SupportedLinkPreview): Promise<string | null> {
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],
);
@@ -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 <BuzzMark className="h-4 w-4" />;
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"
/>
</AttachmentActions>
<AttachmentTrigger asChild>
<a
{onOpen ? (
<AttachmentTrigger
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
onClick={onOpen}
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</a>
</AttachmentTrigger>
</AttachmentTrigger>
) : (
<AttachmentTrigger asChild>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</a>
</AttachmentTrigger>
)}
</Attachment>
);
}
+137 -9
View File
@@ -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 `<ReactMarkdown>` 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(
+42 -87
View File
@@ -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<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);
const anchor = (
<a
{...anchorProps}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
onContextMenuCapture={(event) => {
if (!href) return;
event.preventDefault();
setMenu({ x: event.clientX, y: event.clientY });
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
return (
<>
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
{menu && href ? (
<MediaContextMenu
dataAttributes={["data-link-context-menu"]}
items={[
{
label: "Open link",
onSelect: () => {
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<string, MarkdownComponentSet>();
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) => (
<LinkPreviewAttachment key={preview.href} preview={preview} />
<LinkPreviewAttachment
key={preview.href}
onOpen={entityCardOpenHandlers.get(preview.href)}
preview={preview}
/>
))}
</AttachmentGroup>
) : null}
@@ -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<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);
const anchor = (
<a
{...anchorProps}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
onContextMenuCapture={(event) => {
if (!href) return;
event.preventDefault();
setMenu({ x: event.clientX, y: event.clientY });
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
return (
<>
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
{menu && href ? (
<MediaContextMenu
dataAttributes={["data-link-context-menu"]}
items={[
{
label: "Open link",
onSelect: () => {
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}
</>
);
}
@@ -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<string, () => void> {
return React.useMemo(() => {
const handlers = new Map<string, () => 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 (
<a
{...anchorProps}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
onClick={(event) => {
event.preventDefault();
onOpenEntityLink(parsed.value);
}}
>
{children}
</a>
);
}
+2 -2
View File
@@ -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,
});
}
@@ -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<MarkdownRuntime>(
+9
View File
@@ -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<string, string>;
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;
/**
+25 -8
View File
@@ -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<T>(arr: T[]): T[] {
const ref = React.useRef(arr);
@@ -166,16 +167,32 @@ export function isInsideHiddenSpoiler(element: Element): boolean {
}
/**
* `urlTransform` for `<ReactMarkdown>` 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 `<ReactMarkdown>` 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 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 {
if (key === "href" && isMessageLink(value)) {
return value;
}
return defaultUrlTransform(value);
return buzzDeepLinkUrlTransform(value, key);
}
export function getReactNodeText(node: React.ReactNode): string {
+266
View File
@@ -0,0 +1,266 @@
# Buzz Entity Links
Status: **partially implemented**. Done on this branch:
- Slice 0 — HTTPS relay git clone URLs (`{relay-origin}/git/<pubkey>/<repo>`)
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=<pubkey-hex>&d=<repo-dtag>
buzz://project?owner=<pubkey-hex>&d=<project-dtag>
buzz://pr?id=<event-id-hex>&owner=<pubkey-hex>&d=<repo-dtag>
buzz://issue?id=<event-id-hex>&owner=<pubkey-hex>&d=<repo-dtag>
```
- `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:<owner>:<d>` /
`30621:<owner>:<d>` 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/<pubkey>/<repo>`) 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** (`<buzz://pr?…>` 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 <id-prefix>` |
| Issue | `subject` tag of the kind `1621` event | `issue <id-prefix>` |
| 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:<owner>:<d>` 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:<owner>:<d>?pullRequestId=<id>` (or `issueId`).
- `repo``/projects/30617:<owner>:<d>`.
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/<pubkey>/<repo>` 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.