Rename web app to Buzz (#959)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-10 14:07:02 -07:00
committed by GitHub
parent 510009c11d
commit c5a54dcc39
25 changed files with 146 additions and 95 deletions

View File

@@ -176,7 +176,7 @@ or invoke with the full path.
### Deep Links ### Deep Links
`sprout://message?channel=<uuid>&id=<hex>` links reference a specific message `buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
thread. To read the linked thread: thread. To read the linked thread:
```bash ```bash

View File

@@ -11,7 +11,7 @@ FROM --platform=linux/amd64 node:24-bookworm-slim AS web-builder
WORKDIR /build WORKDIR /build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY web/ web/ COPY web/ web/
RUN corepack enable && pnpm install --frozen-lockfile --filter sprout-web RUN corepack enable && pnpm install --frozen-lockfile --filter buzz-web
RUN pnpm -C web build RUN pnpm -C web build
# ── Runtime stage ─────────────────────────────────────────── # ── Runtime stage ───────────────────────────────────────────

View File

@@ -246,7 +246,7 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Parse the query string of a `sprout://message?…` URL into the JSON /// Parse the query string of a `buzz://message?…` URL into the JSON
/// payload emitted on `deep-link-message`. Returns `None` when a required /// payload emitted on `deep-link-message`. Returns `None` when a required
/// param (`channel`, `id`) is missing or empty — mirroring the validation /// param (`channel`, `id`) is missing or empty — mirroring the validation
/// policy of the `connect` arm so the frontend never sees a half-formed /// policy of the `connect` arm so the frontend never sees a half-formed
@@ -278,10 +278,10 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
})) }))
} }
/// Handle an incoming `sprout://` deep link URL. /// Handle an incoming `buzz://` deep link URL, with `sprout://` accepted as a legacy alias.
/// ///
/// Currently supports: /// Currently supports:
/// - `sprout://connect?relay=<ws(s)://...>` — emits `deep-link-connect` to the frontend /// - `buzz://connect?relay=<ws(s)://...>` — emits `deep-link-connect` to the frontend
fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
let url = match Url::parse(url_str) { let url = match Url::parse(url_str) {
Ok(u) => u, Ok(u) => u,
@@ -291,8 +291,8 @@ fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
} }
}; };
if url.scheme() != "sprout" { if url.scheme() != "sprout" && url.scheme() != "buzz" {
eprintln!("sprout-desktop: ignoring non-sprout deep link: {url_str}"); eprintln!("sprout-desktop: ignoring unsupported deep link scheme: {url_str}");
return; return;
} }
@@ -324,7 +324,7 @@ fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
let _ = app.emit("deep-link-connect", relay_url); let _ = app.emit("deep-link-connect", relay_url);
} }
Some("message") => { Some("message") => {
// `sprout://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]` // `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
// //
// Validation policy mirrors the `connect` arm: parse what we // Validation policy mirrors the `connect` arm: parse what we
// need, refuse to emit anything if a required param is missing // need, refuse to emit anything if a required param is missing
@@ -373,7 +373,7 @@ pub fn run() {
} }
// Forward any deep link URLs from the duplicate launch. // Forward any deep link URLs from the duplicate launch.
for arg in &argv { for arg in &argv {
if arg.starts_with("sprout://") { if arg.starts_with("sprout://") || arg.starts_with("buzz://") {
handle_deep_link_url(app, arg); handle_deep_link_url(app, arg);
} }
} }
@@ -869,6 +869,14 @@ mod tests {
assert!(payload["threadRootId"].is_null()); assert!(payload["threadRootId"].is_null());
} }
#[test]
fn parse_message_deep_link_accepts_buzz_scheme() {
let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap();
let payload = parse_message_deep_link(&url).expect("required params present");
assert_eq!(payload["channelId"], "abc");
assert_eq!(payload["messageId"], "xyz");
}
#[test] #[test]
fn parse_message_deep_link_includes_thread_root() { fn parse_message_deep_link_includes_thread_root() {
let url = Url::parse("sprout://message?channel=abc&id=xyz&thread=root1").unwrap(); let url = Url::parse("sprout://message?channel=abc&id=xyz&thread=root1").unwrap();

View File

@@ -43,7 +43,7 @@
}, },
"deep-link": { "deep-link": {
"desktop": { "desktop": {
"schemes": ["sprout"] "schemes": ["sprout", "buzz"]
} }
} }
}, },

View File

@@ -524,7 +524,7 @@ export function AppShell() {
unreadChannelIds.size, unreadChannelIds.size,
]); ]);
// Dispatch `sprout://message` deep links into the router. // Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks(); useMessageDeepLinks();
React.useEffect(() => { React.useEffect(() => {

View File

@@ -15,7 +15,7 @@ const THREAD =
test("buildMessageLink → parseMessageLink round-trips without thread", () => { test("buildMessageLink → parseMessageLink round-trips without thread", () => {
const url = buildMessageLink({ channelId: CHANNEL, messageId: MESSAGE }); const url = buildMessageLink({ channelId: CHANNEL, messageId: MESSAGE });
assert.equal(url, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`); assert.equal(url, `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`);
const parsed = parseMessageLink(url); const parsed = parseMessageLink(url);
assert.equal(parsed.ok, true); assert.equal(parsed.ok, true);
@@ -52,8 +52,8 @@ test("buildMessageLink treats null/empty thread as absent", () => {
messageId: MESSAGE, messageId: MESSAGE,
threadRootId: "", threadRootId: "",
}); });
assert.equal(a, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`); assert.equal(a, `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`);
assert.equal(b, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`); assert.equal(b, `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`);
}); });
test("buildMessageLink rejects missing required params", () => { test("buildMessageLink rejects missing required params", () => {
@@ -61,7 +61,7 @@ test("buildMessageLink rejects missing required params", () => {
assert.throws(() => buildMessageLink({ channelId: CHANNEL, messageId: "" })); assert.throws(() => buildMessageLink({ channelId: CHANNEL, messageId: "" }));
}); });
test("parseMessageLink rejects non-sprout schemes", () => { test("parseMessageLink rejects unsupported schemes", () => {
const r = parseMessageLink( const r = parseMessageLink(
`https://example.com/?channel=${CHANNEL}&id=${MESSAGE}`, `https://example.com/?channel=${CHANNEL}&id=${MESSAGE}`,
); );
@@ -69,20 +69,20 @@ test("parseMessageLink rejects non-sprout schemes", () => {
assert.equal(r.ok === false && r.reason, "wrong-scheme"); assert.equal(r.ok === false && r.reason, "wrong-scheme");
}); });
test("parseMessageLink rejects sprout:// with wrong host", () => { test("parseMessageLink rejects buzz:// with wrong host", () => {
const r = parseMessageLink(`sprout://connect?relay=wss://example.com`); const r = parseMessageLink(`buzz://connect?relay=wss://example.com`);
assert.equal(r.ok, false); assert.equal(r.ok, false);
assert.equal(r.ok === false && r.reason, "wrong-host"); assert.equal(r.ok === false && r.reason, "wrong-host");
}); });
test("parseMessageLink rejects missing channel", () => { test("parseMessageLink rejects missing channel", () => {
const r = parseMessageLink(`sprout://message?id=${MESSAGE}`); const r = parseMessageLink(`buzz://message?id=${MESSAGE}`);
assert.equal(r.ok, false); assert.equal(r.ok, false);
assert.equal(r.ok === false && r.reason, "missing-channel"); assert.equal(r.ok === false && r.reason, "missing-channel");
}); });
test("parseMessageLink rejects missing id", () => { test("parseMessageLink rejects missing id", () => {
const r = parseMessageLink(`sprout://message?channel=${CHANNEL}`); const r = parseMessageLink(`buzz://message?channel=${CHANNEL}`);
assert.equal(r.ok, false); assert.equal(r.ok, false);
assert.equal(r.ok === false && r.reason, "missing-id"); assert.equal(r.ok === false && r.reason, "missing-id");
}); });
@@ -93,11 +93,28 @@ test("parseMessageLink rejects malformed URL strings", () => {
assert.equal(r.ok === false && r.reason, "invalid-url"); assert.equal(r.ok === false && r.reason, "invalid-url");
}); });
test("isMessageLink matches only sprout://message", () => { test("parseMessageLink accepts legacy sprout://message links", () => {
const r = parseMessageLink(
`sprout://message?channel=${CHANNEL}&id=${MESSAGE}`,
);
assert.equal(r.ok, true);
assert.deepEqual(r.ok && r.value, {
channelId: CHANNEL,
messageId: MESSAGE,
threadRootId: null,
});
});
test("isMessageLink matches buzz://message and legacy sprout://message", () => {
assert.equal(
isMessageLink(`buzz://message?channel=${CHANNEL}&id=${MESSAGE}`),
true,
);
assert.equal( assert.equal(
isMessageLink(`sprout://message?channel=${CHANNEL}&id=${MESSAGE}`), isMessageLink(`sprout://message?channel=${CHANNEL}&id=${MESSAGE}`),
true, true,
); );
assert.equal(isMessageLink("buzz://connect?relay=wss://x"), false);
assert.equal(isMessageLink("sprout://connect?relay=wss://x"), false); assert.equal(isMessageLink("sprout://connect?relay=wss://x"), false);
assert.equal(isMessageLink("https://example.com"), false); assert.equal(isMessageLink("https://example.com"), false);
assert.equal(isMessageLink(undefined), false); assert.equal(isMessageLink(undefined), false);

View File

@@ -1,12 +1,11 @@
/** /**
* `sprout://message` link encoding for "Copy link" / deep-link-to-message. * `buzz://message` link encoding for "Copy link" / deep-link-to-message.
* *
* Format: `sprout://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]` * Format: `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
*
* Mirrors the existing `sprout://connect?relay=…` scheme already registered
* in `tauri.conf.json` and handled in `desktop/src-tauri/src/lib.rs`.
*/ */
const MESSAGE_LINK_SCHEME = "buzz:";
const LEGACY_MESSAGE_LINK_SCHEME = "sprout:";
const MESSAGE_LINK_HOST = "message"; const MESSAGE_LINK_HOST = "message";
export type MessageLinkInput = { export type MessageLinkInput = {
@@ -35,7 +34,7 @@ export type MessageLinkParseResult =
| { ok: false; reason: string }; | { ok: false; reason: string };
/** /**
* Build a `sprout://message` URL for a given channel + message. * Build a `buzz://message` URL for a given channel + message.
* *
* Empty `threadRootId` is treated as "no thread" so callers can pass through * Empty `threadRootId` is treated as "no thread" so callers can pass through
* the result of `getThreadReference(tags).rootId` without extra null checks. * the result of `getThreadReference(tags).rootId` without extra null checks.
@@ -54,12 +53,14 @@ export function buildMessageLink(input: MessageLinkInput): string {
if (input.threadRootId) { if (input.threadRootId) {
params.set("thread", input.threadRootId); params.set("thread", input.threadRootId);
} }
return `sprout://${MESSAGE_LINK_HOST}?${params.toString()}`; return `${MESSAGE_LINK_SCHEME}//${MESSAGE_LINK_HOST}?${params.toString()}`;
} }
/** /**
* Parse a `sprout://message?…` URL. Returns a discriminated result so * Parse a `buzz://message?…` URL. Legacy `sprout://message?…` links are
* callers can render a fallback (e.g. a plain link) without throwing. * accepted so already-copied message links keep working during the rename.
* Returns a discriminated result so callers can render a fallback (e.g. a
* plain link) without throwing.
*/ */
export function parseMessageLink(url: string): MessageLinkParseResult { export function parseMessageLink(url: string): MessageLinkParseResult {
let parsed: URL; let parsed: URL;
@@ -69,10 +70,13 @@ export function parseMessageLink(url: string): MessageLinkParseResult {
return { ok: false, reason: "invalid-url" }; return { ok: false, reason: "invalid-url" };
} }
if (parsed.protocol !== "sprout:") { if (
parsed.protocol !== MESSAGE_LINK_SCHEME &&
parsed.protocol !== LEGACY_MESSAGE_LINK_SCHEME
) {
return { ok: false, reason: "wrong-scheme" }; return { ok: false, reason: "wrong-scheme" };
} }
// `new URL("sprout://message?…")` puts "message" in `hostname`. // `new URL("buzz://message?…")` puts "message" in `hostname`.
if (parsed.hostname !== MESSAGE_LINK_HOST) { if (parsed.hostname !== MESSAGE_LINK_HOST) {
return { ok: false, reason: "wrong-host" }; return { ok: false, reason: "wrong-host" };
} }
@@ -97,10 +101,15 @@ export function parseMessageLink(url: string): MessageLinkParseResult {
} }
/** /**
* Convenience: returns true if the given href is a `sprout://message` link. * Convenience: returns true if the given href is a supported message link.
* Cheap pre-check used by the markdown renderer before parsing. * Cheap pre-check used by the markdown renderer before parsing.
*/ */
export function isMessageLink(href: string | undefined | null): boolean { export function isMessageLink(href: string | undefined | null): boolean {
if (!href) return false; if (!href) return false;
return href.startsWith("sprout://message?") || href === "sprout://message"; return (
href.startsWith("buzz://message?") ||
href === "buzz://message" ||
href.startsWith("sprout://message?") ||
href === "sprout://message"
);
} }

View File

@@ -1,12 +1,13 @@
/** /**
* Remark plugin that detects bare `sprout://message?…` URLs in text nodes and * Remark plugin that detects bare `buzz://message?…` URLs in text nodes and
* replaces each with a custom `message-link` HAST element. The `markdown.tsx` * replaces each with a custom `message-link` HAST element. Legacy
* `sprout://message?…` URLs are accepted during the rename. The `markdown.tsx`
* components map renders that as an inline pill (channel name + click-to-open) * components map renders that as an inline pill (channel name + click-to-open)
* instead of the raw 100-char URL. * instead of the raw 100-char URL.
* *
* Why this plugin exists: `remark-gfm`'s autolinker only covers `http(s)://` * Why this plugin exists: `remark-gfm`'s autolinker only covers `http(s)://`
* and `www.`. Custom schemes like `sprout://` only reach the `<a>` component * and `www.`. Custom schemes like `buzz://` only reach the `<a>` component
* override when the user wrote an explicit `[label](sprout://…)` link. * override when the user wrote an explicit `[label](buzz://…)` link.
* *
* Mirrors `remarkChannelLinks` / `remarkMentions` — same factory, same HAST * Mirrors `remarkChannelLinks` / `remarkMentions` — same factory, same HAST
* shape — so the rendering layer treats all three uniformly. Trailing * shape — so the rendering layer treats all three uniformly. Trailing
@@ -19,7 +20,7 @@
// --experimental-strip-types`. `tsconfig.json` enables `allowImportingTsExtensions`. // --experimental-strip-types`. `tsconfig.json` enables `allowImportingTsExtensions`.
import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts";
const MESSAGE_URL_PATTERN = /sprout:\/\/message\?[^\s<>"')\]]+/g; const MESSAGE_URL_PATTERN = /(?:buzz|sprout):\/\/message\?[^\s<>"')\]]+/g;
const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
function trimMessageLinkMatch(matchText: string) { function trimMessageLinkMatch(matchText: string) {

View File

@@ -304,10 +304,11 @@ export function useRichTextEditor({
openOnClick: false, openOnClick: false,
autolink: true, autolink: true,
linkOnPaste: true, linkOnPaste: true,
// Allow `sprout://` (used by Copy-link-to-message + sprout://connect) // Allow Buzz message links through TipTap's URL sanitiser. Keep the
// through TipTap's URL sanitiser. http(s) and mailto are accepted by // legacy Sprout protocol so already-copied links survive paste.
// default; non-listed protocols are stripped on paste/typed input. // http(s) and mailto are accepted by default; non-listed protocols are
protocols: ["sprout"], // stripped on paste/typed input.
protocols: ["buzz", "sprout"],
HTMLAttributes: { HTMLAttributes: {
class: "text-primary underline underline-offset-4 cursor-pointer", class: "text-primary underline underline-offset-4 cursor-pointer",
}, },

View File

@@ -14,7 +14,7 @@ export interface DeepLinkDeps {
} }
/** /**
* Payload emitted by the Rust deep-link handler for `sprout://message?…`. * Payload emitted by the Rust deep-link handler for `buzz://message?…`.
* Field names match the JSON shape produced in `desktop/src-tauri/src/lib.rs`. * Field names match the JSON shape produced in `desktop/src-tauri/src/lib.rs`.
*/ */
export type MessageDeepLinkPayload = { export type MessageDeepLinkPayload = {
@@ -26,11 +26,11 @@ export type MessageDeepLinkPayload = {
/** /**
* Register listeners for deep-link events emitted by the Rust backend. * Register listeners for deep-link events emitted by the Rust backend.
* *
* When a `sprout://connect?relay=<url>` link is opened, the handler * When a `buzz://connect?relay=<url>` link is opened, the handler
* adds a workspace for the relay (deduplicating by URL) and switches * adds a workspace for the relay (deduplicating by URL) and switches
* to it. Returns an unlisten function to tear down all listeners. * to it. Returns an unlisten function to tear down all listeners.
* *
* `sprout://message?…` is handled separately by `listenForMessageDeepLinks`, * `buzz://message?…` is handled separately by `listenForMessageDeepLinks`,
* because it needs to dispatch into the router which only exists below the * because it needs to dispatch into the router which only exists below the
* `RouterProvider` in the component tree. * `RouterProvider` in the component tree.
*/ */

View File

@@ -428,12 +428,12 @@ test("rehypeImageGallery: mixed content paragraph is not image-only", () => {
assert.equal(tree.children.length, 3); assert.equal(tree.children.length, 3);
}); });
// ── messageLinkUrlTransform: sprout:// link preservation ────────────── // ── messageLinkUrlTransform: buzz:// link preservation ──────────────
// Regression test: react-markdown's `defaultUrlTransform` strips unknown // Regression test: react-markdown's `defaultUrlTransform` strips unknown
// schemes (returns `""`) before our `a` component override can see them, // schemes (returns `""`) before our `a` component override can see them,
// which would break copy → paste → click for `sprout://message?…` links // which would break copy → paste → click for `buzz://message?…` links
// end-to-end. We pass a custom `urlTransform` that delegates to the // end-to-end. We pass a custom `urlTransform` that delegates to the
// default for everything except `sprout://message` hrefs. // default for `buzz://message` and legacy `sprout://message` hrefs.
// //
// This test renders real `<ReactMarkdown>` with the production transform // This test renders real `<ReactMarkdown>` with the production transform
// and asserts the link href survives to the rendered DOM. Mirrors the // and asserts the link href survives to the rendered DOM. Mirrors the
@@ -462,19 +462,19 @@ function renderMarkdown(content) {
); );
} }
test("messageLinkUrlTransform: preserves sprout://message href", () => { test("messageLinkUrlTransform: preserves buzz://message href", () => {
const html = renderMarkdown( const html = renderMarkdown(
"Click [here](sprout://message?channel=abc&id=xyz)", "Click [here](buzz://message?channel=abc&id=xyz)",
); );
// HTML-encoded `&` in attributes is fine — the browser decodes back to `&`. // HTML-encoded `&` in attributes is fine — the browser decodes back to `&`.
assert.match(html, /href="sprout:\/\/message\?channel=abc&(?:amp;)?id=xyz"/); assert.match(html, /href="buzz:\/\/message\?channel=abc&(?:amp;)?id=xyz"/);
}); });
test("messageLinkUrlTransform: preserves sprout://message href with thread", () => { test("messageLinkUrlTransform: preserves buzz://message href with thread", () => {
const html = renderMarkdown( const html = renderMarkdown(
"[link](sprout://message?channel=c1&id=m1&thread=t1)", "[link](buzz://message?channel=c1&id=m1&thread=t1)",
); );
assert.match(html, /href="sprout:\/\/message\?[^"]*thread=t1"/); assert.match(html, /href="buzz:\/\/message\?[^"]*thread=t1"/);
}); });
test("messageLinkUrlTransform: still strips javascript: scheme", () => { test("messageLinkUrlTransform: still strips javascript: scheme", () => {
@@ -489,18 +489,25 @@ test("messageLinkUrlTransform: passes http(s) through unchanged", () => {
assert.match(html, /href="https:\/\/example\.com\/path"/); assert.match(html, /href="https:\/\/example\.com\/path"/);
}); });
test("messageLinkUrlTransform: leaves non-message sprout:// schemes to default", () => { test("messageLinkUrlTransform: preserves legacy sprout://message href", () => {
// `sprout://connect?relay=…` is handled by a different code path (Tauri const html = renderMarkdown(
"Click [here](sprout://message?channel=abc&id=xyz)",
);
assert.match(html, /href="sprout:\/\/message\?channel=abc&(?:amp;)?id=xyz"/);
});
test("messageLinkUrlTransform: leaves non-message 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 // single-instance). The markdown renderer should let it pass through
// defaultUrlTransform (which strips it) since it's not clickable in-app. // defaultUrlTransform (which strips it) since it's not clickable in-app.
const html = renderMarkdown( const html = renderMarkdown(
"[connect](sprout://connect?relay=wss://relay.example)", "[connect](buzz://connect?relay=wss://relay.example)",
); );
assert.match(html, /href=""/); assert.match(html, /href=""/);
}); });
// ── remarkMessageLinks: bare-URL → message-link node ────────────────── // ── remarkMessageLinks: bare-URL → message-link node ──────────────────
// `remark-gfm`'s autolinker only covers http(s)://, so bare `sprout://message` // `remark-gfm`'s autolinker only covers http(s)://, so bare `buzz://message`
// URLs in plain text never reach any rendering path without this plugin. // URLs in plain text never reach any rendering path without this plugin.
// The plugin emits a custom `message-link` HAST element which markdown.tsx // The plugin emits a custom `message-link` HAST element which markdown.tsx
// renders as an inline pill. Tests operate on the mdast tree directly — // renders as an inline pill. Tests operate on the mdast tree directly —
@@ -521,18 +528,26 @@ function text(value) {
return { type: "text", value }; return { type: "text", value };
} }
test("remarkMessageLinks: bare sprout://message URL is replaced", () => { test("remarkMessageLinks: bare buzz://message URL is replaced", () => {
const tree = runPlugin(paragraph(text("buzz://message?channel=c&id=m")));
const para = tree.children[0];
assert.equal(para.children.length, 1);
assert.equal(para.children[0].type, "message-link");
assert.equal(para.children[0].value, "buzz://message?channel=c&id=m");
assert.equal(para.children[0].data.hName, "message-link");
});
test("remarkMessageLinks: legacy bare sprout://message URL is replaced", () => {
const tree = runPlugin(paragraph(text("sprout://message?channel=c&id=m"))); const tree = runPlugin(paragraph(text("sprout://message?channel=c&id=m")));
const para = tree.children[0]; const para = tree.children[0];
assert.equal(para.children.length, 1); assert.equal(para.children.length, 1);
assert.equal(para.children[0].type, "message-link"); assert.equal(para.children[0].type, "message-link");
assert.equal(para.children[0].value, "sprout://message?channel=c&id=m"); assert.equal(para.children[0].value, "sprout://message?channel=c&id=m");
assert.equal(para.children[0].data.hName, "message-link");
}); });
test("remarkMessageLinks: mid-sentence URL splits surrounding text", () => { test("remarkMessageLinks: mid-sentence URL splits surrounding text", () => {
const tree = runPlugin( const tree = runPlugin(
paragraph(text("see sprout://message?channel=c&id=m here")), paragraph(text("see buzz://message?channel=c&id=m here")),
); );
const kids = tree.children[0].children; const kids = tree.children[0].children;
assert.equal(kids.length, 3); assert.equal(kids.length, 3);
@@ -547,28 +562,28 @@ test("remarkMessageLinks: two URLs in one text node both replaced", () => {
const tree = runPlugin( const tree = runPlugin(
paragraph( paragraph(
text( text(
"first sprout://message?channel=a&id=1 then sprout://message?channel=b&id=2 done", "first buzz://message?channel=a&id=1 then buzz://message?channel=b&id=2 done",
), ),
), ),
); );
const kids = tree.children[0].children; const kids = tree.children[0].children;
const links = kids.filter((c) => c.type === "message-link"); const links = kids.filter((c) => c.type === "message-link");
assert.equal(links.length, 2); assert.equal(links.length, 2);
assert.equal(links[0].value, "sprout://message?channel=a&id=1"); assert.equal(links[0].value, "buzz://message?channel=a&id=1");
assert.equal(links[1].value, "sprout://message?channel=b&id=2"); assert.equal(links[1].value, "buzz://message?channel=b&id=2");
}); });
test("remarkMessageLinks: trailing sentence punctuation stays outside URL", () => { test("remarkMessageLinks: trailing sentence punctuation stays outside URL", () => {
for (const punctuation of [".", ",", ";", ":", "!", "?"]) { for (const punctuation of [".", ",", ";", ":", "!", "?"]) {
const tree = runPlugin( const tree = runPlugin(
paragraph(text(`see sprout://message?channel=c&id=m${punctuation}`)), paragraph(text(`see buzz://message?channel=c&id=m${punctuation}`)),
); );
const kids = tree.children[0].children; const kids = tree.children[0].children;
assert.equal(kids.length, 3, punctuation); assert.equal(kids.length, 3, punctuation);
assert.equal(kids[0].value, "see ", punctuation); assert.equal(kids[0].value, "see ", punctuation);
assert.equal(kids[1].type, "message-link", punctuation); assert.equal(kids[1].type, "message-link", punctuation);
assert.equal(kids[1].value, "sprout://message?channel=c&id=m", punctuation); assert.equal(kids[1].value, "buzz://message?channel=c&id=m", punctuation);
assert.equal(kids[2].type, "text", punctuation); assert.equal(kids[2].type, "text", punctuation);
assert.equal(kids[2].value, punctuation, punctuation); assert.equal(kids[2].value, punctuation, punctuation);
} }
@@ -576,20 +591,20 @@ test("remarkMessageLinks: trailing sentence punctuation stays outside URL", () =
test("remarkMessageLinks: URL inside parens keeps closing paren outside", () => { test("remarkMessageLinks: URL inside parens keeps closing paren outside", () => {
const tree = runPlugin( const tree = runPlugin(
paragraph(text("see (sprout://message?channel=c&id=m) for details")), paragraph(text("see (buzz://message?channel=c&id=m) for details")),
); );
const kids = tree.children[0].children; const kids = tree.children[0].children;
assert.equal(kids.length, 3); assert.equal(kids.length, 3);
assert.equal(kids[0].value, "see ("); assert.equal(kids[0].value, "see (");
assert.equal(kids[1].type, "message-link"); assert.equal(kids[1].type, "message-link");
assert.equal(kids[1].value, "sprout://message?channel=c&id=m"); assert.equal(kids[1].value, "buzz://message?channel=c&id=m");
assert.equal(kids[2].type, "text"); assert.equal(kids[2].type, "text");
assert.equal(kids[2].value, ") for details"); assert.equal(kids[2].value, ") for details");
}); });
test("remarkMessageLinks: URL without trailing punctuation matches end-to-end", () => { test("remarkMessageLinks: URL without trailing punctuation matches end-to-end", () => {
const value = "sprout://message?channel=c&id=m"; const value = "buzz://message?channel=c&id=m";
const tree = runPlugin(paragraph(text(value))); const tree = runPlugin(paragraph(text(value)));
const kids = tree.children[0].children; const kids = tree.children[0].children;
@@ -598,8 +613,8 @@ test("remarkMessageLinks: URL without trailing punctuation matches end-to-end",
assert.equal(kids[0].value, value); assert.equal(kids[0].value, value);
}); });
test("remarkMessageLinks: non-message sprout:// URLs are not matched", () => { test("remarkMessageLinks: non-message buzz:// URLs are not matched", () => {
const original = "sprout://connect?relay=wss://x.example"; const original = "buzz://connect?relay=wss://x.example";
const tree = runPlugin(paragraph(text(original))); const tree = runPlugin(paragraph(text(original)));
const kids = tree.children[0].children; const kids = tree.children[0].children;
assert.equal(kids.length, 1); assert.equal(kids.length, 1);
@@ -618,7 +633,7 @@ test("remarkMessageLinks: text inside inlineCode is left alone", () => {
{ {
type: "paragraph", type: "paragraph",
children: [ children: [
{ type: "inlineCode", value: "sprout://message?channel=c&id=m" }, { type: "inlineCode", value: "buzz://message?channel=c&id=m" },
], ],
}, },
], ],
@@ -627,5 +642,5 @@ test("remarkMessageLinks: text inside inlineCode is left alone", () => {
const kids = tree.children[0].children; const kids = tree.children[0].children;
assert.equal(kids.length, 1); assert.equal(kids.length, 1);
assert.equal(kids[0].type, "inlineCode"); assert.equal(kids[0].type, "inlineCode");
assert.equal(kids[0].value, "sprout://message?channel=c&id=m"); assert.equal(kids[0].value, "buzz://message?channel=c&id=m");
}); });

View File

@@ -120,7 +120,7 @@ function useStableArray<T>(arr: T[]): T[] {
} }
/** /**
* `urlTransform` for `<ReactMarkdown>` that preserves `sprout://message?…` * `urlTransform` for `<ReactMarkdown>` that preserves `buzz://message?…`
* links. The default transform strips unknown schemes (returns `""`) before * links. The default transform strips unknown schemes (returns `""`) before
* the `a` component override can see them, which would break copy → paste → * the `a` component override can see them, which would break copy → paste →
* click end-to-end. Everything else delegates to `defaultUrlTransform`. * click end-to-end. Everything else delegates to `defaultUrlTransform`.
@@ -709,7 +709,7 @@ function createMarkdownComponents(
); );
} }
// Intercept `sprout://message?channel=…&id=…` links so a click navigates // Intercept `buzz://message?channel=…&id=…` links so a click navigates
// in-app instead of opening the URL in the OS browser. http(s) links // in-app instead of opening the URL in the OS browser. http(s) links
// continue to use the existing target="_blank" behavior. // continue to use the existing target="_blank" behavior.
if (isMessageLink(href)) { if (isMessageLink(href)) {
@@ -730,7 +730,7 @@ function createMarkdownComponents(
</a> </a>
); );
} }
// Malformed sprout://message link — fall through to the default // Malformed message deep link — fall through to the default
// anchor (renders as a normal external link). // anchor (renders as a normal external link).
} }
return ( return (
@@ -991,7 +991,7 @@ function createMarkdownComponents(
const href = String(children ?? ""); const href = String(children ?? "");
const parsed = parseMessageLink(href); const parsed = parseMessageLink(href);
if (!parsed.ok) { if (!parsed.ok) {
// Malformed `sprout://message?…` — render the raw URL as plain text // Malformed `buzz://message?…` — render the raw URL as plain text
// rather than a misleading clickable pill. // rather than a misleading clickable pill.
return <span data-message-link="">{href}</span>; return <span data-message-link="">{href}</span>;
} }

View File

@@ -4,7 +4,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { listenForMessageDeepLinks } from "@/shared/deep-link"; import { listenForMessageDeepLinks } from "@/shared/deep-link";
/** /**
* Subscribe to `sprout://message` deep links emitted by the Tauri backend * Subscribe to `buzz://message` deep links emitted by the Tauri backend
* and route them through the app's navigation helpers. * and route them through the app's navigation helpers.
* *
* Lives in a hook (not inline in `AppShell`) so it can be unit-tested * Lives in a hook (not inline in `AppShell`) so it can be unit-tested
@@ -12,7 +12,7 @@ import { listenForMessageDeepLinks } from "@/shared/deep-link";
* *
* Mirrors the cold-start race handling of the `connect` listener in * Mirrors the cold-start race handling of the `connect` listener in
* `App.tsx`: late-arriving payloads from a fresh launch are picked up the * `App.tsx`: late-arriving payloads from a fresh launch are picked up the
* first time the listener mounts. Routing matches the in-app sprout:// * first time the listener mounts. Routing matches the in-app buzz://
* handler in `markdown.tsx`: always `goChannel` with `messageId` and let * handler in `markdown.tsx`: always `goChannel` with `messageId` and let
* the channel route's existing scroll-into-view + getEventById backfill * the channel route's existing scroll-into-view + getEventById backfill
* resolve the target (works for both stream replies and forum threads). * resolve the target (works for both stream replies and forum threads).

View File

@@ -1,5 +1,5 @@
{ {
"name": "sprout-workspace", "name": "buzz-workspace",
"private": true, "private": true,
"packageManager": "pnpm@11.4.0", "packageManager": "pnpm@11.4.0",
"scripts": { "scripts": {

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sprout</title> <title>Buzz</title>
</head> </head>
<body> <body>

View File

@@ -1,5 +1,5 @@
{ {
"name": "sprout-web", "name": "buzz-web",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@@ -13,7 +13,7 @@ function RootLayout() {
to="/" to="/"
className="text-sm font-semibold tracking-tight text-foreground" className="text-sm font-semibold tracking-tight text-foreground"
> >
Sprout Buzz
</Link> </Link>
<ThemeToggle /> <ThemeToggle />
</header> </header>

View File

@@ -28,7 +28,7 @@ import { relayHttpBaseUrl } from "@/shared/lib/relay-url";
/** Get a repo-specific LightningFS instance backed by IndexedDB. */ /** Get a repo-specific LightningFS instance backed by IndexedDB. */
export function getFs(owner: string, repoName: string): LightningFS { export function getFs(owner: string, repoName: string): LightningFS {
return new LightningFS(`sprout-git-${owner}-${repoName}`); return new LightningFS(`buzz-git-${owner}-${repoName}`);
} }
/** Working directory inside the virtual FS. */ /** Working directory inside the virtual FS. */

View File

@@ -4,13 +4,13 @@ import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
export function ConnectButton({ className }: { className?: string }) { export function ConnectButton({ className }: { className?: string }) {
const deepLink = `sprout://connect?relay=${encodeURIComponent(relayWsUrl())}`; const deepLink = `buzz://connect?relay=${encodeURIComponent(relayWsUrl())}`;
return ( return (
<Button asChild className={className}> <Button asChild className={className}>
<a href={deepLink}> <a href={deepLink}>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
Open in Sprout Open in Buzz
</a> </a>
</Button> </Button>
); );

View File

@@ -24,7 +24,7 @@ export function OrgSidebar({ repos }: { repos: Repo[] }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Open in Sprout */} {/* Open in Buzz */}
<ConnectButton className="w-full" /> <ConnectButton className="w-full" />
{/* People section */} {/* People section */}

View File

@@ -323,7 +323,7 @@ export function RepoDetailPage() {
{/* Sidebar */} {/* Sidebar */}
<aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block"> <aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block">
<div className="space-y-6"> <div className="space-y-6">
{/* Open in Sprout */} {/* Open in Buzz */}
<ConnectButton className="w-full" /> <ConnectButton className="w-full" />
{/* People */} {/* People */}

View File

@@ -1,4 +1,4 @@
import { BookMarked, GitBranch, Sprout } from "lucide-react"; import { BookMarked, GitBranch, Hexagon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -45,14 +45,14 @@ function RelayEmptyState() {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center px-4 text-center"> <div className="flex flex-1 flex-col items-center justify-center px-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10"> <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Sprout className="h-8 w-8 text-primary" /> <Hexagon className="h-8 w-8 text-primary" />
</div> </div>
<h1 className="mt-6 text-2xl font-semibold tracking-tight"> <h1 className="mt-6 text-2xl font-semibold tracking-tight">
This relay is empty This relay is empty
</h1> </h1>
<p className="mt-2 max-w-md text-sm leading-relaxed text-muted-foreground"> <p className="mt-2 max-w-md text-sm leading-relaxed text-muted-foreground">
Repositories pushed to this relay will show up here. Open this relay in Repositories pushed to this relay will show up here. Open this relay in
the Sprout desktop app to start pushing code. the Buzz desktop app to start pushing code.
</p> </p>
<ConnectButton className="mt-6" /> <ConnectButton className="mt-6" />
</div> </div>

View File

@@ -82,7 +82,7 @@ export function queryEvents(
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
// Wait briefly for an AUTH challenge before sending REQ. // Wait briefly for an AUTH challenge before sending REQ.
// Sprout relays always send AUTH, but other relays may not. // Buzz relays always send AUTH, but other relays may not.
setTimeout(() => sendReq(), 100); setTimeout(() => sendReq(), 100);
}); });

View File

@@ -15,7 +15,7 @@ type ThemeContextValue = {
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
}; };
const STORAGE_KEY = "sprout-web-theme"; const STORAGE_KEY = "buzz-web-theme";
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

View File

@@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("home page loads with Sprout heading", async ({ page }) => { test("home page loads with Buzz heading", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.locator("header")).toContainText("Sprout"); await expect(page.locator("header")).toContainText("Buzz");
}); });
test("home page shows repositories section", async ({ page }) => { test("home page shows repositories section", async ({ page }) => {