Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ or invoke with the full path.

### 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:

```bash
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ FROM --platform=linux/amd64 node:24-bookworm-slim AS web-builder
WORKDIR /build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
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

# ── Runtime stage ───────────────────────────────────────────
Expand Down
22 changes: 15 additions & 7 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
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
/// param (`channel`, `id`) is missing or empty — mirroring the validation
/// policy of the `connect` arm so the frontend never sees a half-formed
Expand Down Expand Up @@ -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:
/// - `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) {
let url = match Url::parse(url_str) {
Ok(u) => u,
Expand All @@ -291,8 +291,8 @@ fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
}
};

if url.scheme() != "sprout" {
eprintln!("sprout-desktop: ignoring non-sprout deep link: {url_str}");
if url.scheme() != "sprout" && url.scheme() != "buzz" {
eprintln!("sprout-desktop: ignoring unsupported deep link scheme: {url_str}");
return;
}

Expand Down Expand Up @@ -324,7 +324,7 @@ fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
let _ = app.emit("deep-link-connect", relay_url);
}
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
// need, refuse to emit anything if a required param is missing
Expand Down Expand Up @@ -373,7 +373,7 @@ pub fn run() {
}
// Forward any deep link URLs from the duplicate launch.
for arg in &argv {
if arg.starts_with("sprout://") {
if arg.starts_with("sprout://") || arg.starts_with("buzz://") {
handle_deep_link_url(app, arg);
}
}
Expand Down Expand Up @@ -869,6 +869,14 @@ mod tests {
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]
fn parse_message_deep_link_includes_thread_root() {
let url = Url::parse("sprout://message?channel=abc&id=xyz&thread=root1").unwrap();
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
},
"deep-link": {
"desktop": {
"schemes": ["sprout"]
"schemes": ["sprout", "buzz"]
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ export function AppShell() {
unreadChannelIds.size,
]);

// Dispatch `sprout://message` deep links into the router.
// Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks();

React.useEffect(() => {
Expand Down
35 changes: 26 additions & 9 deletions desktop/src/features/messages/lib/messageLink.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const THREAD =

test("buildMessageLink → parseMessageLink round-trips without thread", () => {
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);
assert.equal(parsed.ok, true);
Expand Down Expand Up @@ -52,37 +52,37 @@ test("buildMessageLink treats null/empty thread as absent", () => {
messageId: MESSAGE,
threadRootId: "",
});
assert.equal(a, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`);
assert.equal(b, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`);
assert.equal(a, `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`);
assert.equal(b, `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`);
});

test("buildMessageLink rejects missing required params", () => {
assert.throws(() => buildMessageLink({ channelId: "", messageId: MESSAGE }));
assert.throws(() => buildMessageLink({ channelId: CHANNEL, messageId: "" }));
});

test("parseMessageLink rejects non-sprout schemes", () => {
test("parseMessageLink rejects unsupported schemes", () => {
const r = parseMessageLink(
`https://example.com/?channel=${CHANNEL}&id=${MESSAGE}`,
);
assert.equal(r.ok, false);
assert.equal(r.ok === false && r.reason, "wrong-scheme");
});

test("parseMessageLink rejects sprout:// with wrong host", () => {
const r = parseMessageLink(`sprout://connect?relay=wss://example.com`);
test("parseMessageLink rejects buzz:// with wrong host", () => {
const r = parseMessageLink(`buzz://connect?relay=wss://example.com`);
assert.equal(r.ok, false);
assert.equal(r.ok === false && r.reason, "wrong-host");
});

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 && r.reason, "missing-channel");
});

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 && r.reason, "missing-id");
});
Expand All @@ -93,11 +93,28 @@ test("parseMessageLink rejects malformed URL strings", () => {
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(
isMessageLink(`sprout://message?channel=${CHANNEL}&id=${MESSAGE}`),
true,
);
assert.equal(isMessageLink("buzz://connect?relay=wss://x"), false);
assert.equal(isMessageLink("sprout://connect?relay=wss://x"), false);
assert.equal(isMessageLink("https://example.com"), false);
assert.equal(isMessageLink(undefined), false);
Expand Down
35 changes: 22 additions & 13 deletions desktop/src/features/messages/lib/messageLink.ts
Original file line number Diff line number Diff line change
@@ -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>]`
*
* Mirrors the existing `sprout://connect?relay=…` scheme already registered
* in `tauri.conf.json` and handled in `desktop/src-tauri/src/lib.rs`.
* Format: `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
*/

const MESSAGE_LINK_SCHEME = "buzz:";
const LEGACY_MESSAGE_LINK_SCHEME = "sprout:";
const MESSAGE_LINK_HOST = "message";

export type MessageLinkInput = {
Expand Down Expand Up @@ -35,7 +34,7 @@ export type MessageLinkParseResult =
| { 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
* the result of `getThreadReference(tags).rootId` without extra null checks.
Expand All @@ -54,12 +53,14 @@ export function buildMessageLink(input: MessageLinkInput): string {
if (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
* callers can render a fallback (e.g. a plain link) without throwing.
* Parse a `buzz://message?…` URL. Legacy `sprout://message?…` links are
* 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 {
let parsed: URL;
Expand All @@ -69,10 +70,13 @@ export function parseMessageLink(url: string): MessageLinkParseResult {
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" };
}
// `new URL("sprout://message?…")` puts "message" in `hostname`.
// `new URL("buzz://message?…")` puts "message" in `hostname`.
if (parsed.hostname !== MESSAGE_LINK_HOST) {
return { ok: false, reason: "wrong-host" };
}
Expand All @@ -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.
*/
export function isMessageLink(href: string | undefined | null): boolean {
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"
);
}
11 changes: 6 additions & 5 deletions desktop/src/features/messages/lib/remarkMessageLinks.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/**
* Remark plugin that detects bare `sprout://message?…` URLs in text nodes and
* replaces each with a custom `message-link` HAST element. The `markdown.tsx`
* Remark plugin that detects bare `buzz://message?…` URLs in text nodes and
* 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)
* instead of the raw 100-char URL.
*
* Why this plugin exists: `remark-gfm`'s autolinker only covers `http(s)://`
* and `www.`. Custom schemes like `sprout://` only reach the `<a>` component
* override when the user wrote an explicit `[label](sprout://…)` link.
* and `www.`. Custom schemes like `buzz://` only reach the `<a>` component
* override when the user wrote an explicit `[label](buzz://…)` link.
*
* Mirrors `remarkChannelLinks` / `remarkMentions` — same factory, same HAST
* shape — so the rendering layer treats all three uniformly. Trailing
Expand All @@ -19,7 +20,7 @@
// --experimental-strip-types`. `tsconfig.json` enables `allowImportingTsExtensions`.
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 = /[.,;:!?]+$/;

function trimMessageLinkMatch(matchText: string) {
Expand Down
9 changes: 5 additions & 4 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,11 @@ export function useRichTextEditor({
openOnClick: false,
autolink: true,
linkOnPaste: true,
// Allow `sprout://` (used by Copy-link-to-message + sprout://connect)
// through TipTap's URL sanitiser. http(s) and mailto are accepted by
// default; non-listed protocols are stripped on paste/typed input.
protocols: ["sprout"],
// Allow Buzz message links through TipTap's URL sanitiser. Keep the
// legacy Sprout protocol so already-copied links survive paste.
// http(s) and mailto are accepted by default; non-listed protocols are
// stripped on paste/typed input.
protocols: ["buzz", "sprout"],
HTMLAttributes: {
class: "text-primary underline underline-offset-4 cursor-pointer",
},
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/shared/deep-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
export type MessageDeepLinkPayload = {
Expand All @@ -26,11 +26,11 @@ export type MessageDeepLinkPayload = {
/**
* 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
* 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
* `RouterProvider` in the component tree.
*/
Expand Down
Loading