mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw
Will Pfleger
parent
4c665aeac3
commit
a1d78f2959
@@ -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()`.**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod agent_management;
|
||||
mod client;
|
||||
mod commands;
|
||||
mod error;
|
||||
mod links;
|
||||
mod validate;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user