mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): support channel message path links (#5889)
## Summary - accept `buzz://channel/<uuid>/<64-hex-event-id>` as a compatibility message deep link - activate the desktop window and route path-form message links through the existing durable message-navigation queue - support the same path form when rendered or pasted inside Buzz, while canonicalizing composer output to `buzz://message?...` - retain the existing one-segment channel-link behavior and reject malformed event IDs or extra segments ## Context Buzz Desktop 0.5.11 has no native `channel` route. The recently merged channel-link handling on main recognizes `buzz://channel/<uuid>`, but rejects the externally shared `<channel>/<event-id>` form before window activation. On macOS that presents as Buzz taking the menu bar while its window neither foregrounds nor navigates. ## Test plan - `cargo test --manifest-path desktop/src-tauri/Cargo.toml parse_channel_deep_link` - focused channel-link, composer-link, and markdown unit tests - `pnpm typecheck` - mandatory pre-push hook: desktop checks, full desktop unit tests, and Tauri/Rust checks Installed-app external-open behavior requires a build containing this change; 0.5.11 cannot exercise it because that release predates native channel-link handling. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -192,16 +192,32 @@ fn activate_main_window(app: &tauri::AppHandle) {
|
||||
}
|
||||
|
||||
fn parse_channel_deep_link(url: &Url) -> Option<serde_json::Value> {
|
||||
if url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() {
|
||||
if url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut segments = url.path_segments()?;
|
||||
let channel_id = segments.next()?;
|
||||
let message_id = segments.next();
|
||||
if segments.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string();
|
||||
Some(serde_json::json!({ "channelId": channel_id }))
|
||||
if message_id.is_some_and(|value| {
|
||||
value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
Some(match message_id {
|
||||
Some(message_id) => serde_json::json!({
|
||||
"channelId": channel_id,
|
||||
"messageId": message_id.to_ascii_lowercase(),
|
||||
}),
|
||||
None => serde_json::json!({ "channelId": channel_id }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the query string of a `buzz://message?…` URL into the JSON
|
||||
@@ -456,8 +472,13 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
|
||||
return;
|
||||
};
|
||||
activate_main_window(app);
|
||||
queue_navigation_deep_link(app, "channel", &payload);
|
||||
let _ = app.emit("deep-link-channel", payload);
|
||||
if payload["messageId"].is_string() {
|
||||
queue_navigation_deep_link(app, "message", &payload);
|
||||
let _ = app.emit("deep-link-message", payload);
|
||||
} else {
|
||||
queue_navigation_deep_link(app, "channel", &payload);
|
||||
let _ = app.emit("deep-link-channel", payload);
|
||||
}
|
||||
}
|
||||
Some("message") => {
|
||||
// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
|
||||
@@ -689,6 +710,18 @@ mod tests {
|
||||
assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_channel_deep_link_accepts_message_path() {
|
||||
let message_id = "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567";
|
||||
let url = Url::parse(&format!(
|
||||
"buzz://channel/a372f080-5961-4535-b1a3-edffface377d/{message_id}"
|
||||
))
|
||||
.unwrap();
|
||||
let payload = parse_channel_deep_link(&url).unwrap();
|
||||
assert_eq!(payload["channelId"], "a372f080-5961-4535-b1a3-edffface377d");
|
||||
assert_eq!(payload["messageId"], message_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() {
|
||||
for (raw, expected) in [
|
||||
@@ -712,8 +745,13 @@ mod tests {
|
||||
"buzz://channel",
|
||||
"buzz://channel/",
|
||||
"buzz://channel/one/two",
|
||||
"buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/not-hex",
|
||||
"buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra",
|
||||
"buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/",
|
||||
"buzz://channel/one?extra=true",
|
||||
"buzz://channel/one#fragment",
|
||||
"buzz://:pass@channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"buzz://channel/not-a-uuid",
|
||||
"buzz://channel/%2F",
|
||||
"buzz://channel/%00",
|
||||
|
||||
@@ -3,12 +3,23 @@ import test from "node:test";
|
||||
|
||||
import { isChannelLink, parseChannelLink } from "./channelLink.ts";
|
||||
|
||||
const CHANNEL_ID = "580ca78b-9dae-46f3-8854-bd671853ba32";
|
||||
const MESSAGE_ID =
|
||||
"8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
test("parseChannelLink accepts the canonical channel path", () => {
|
||||
assert.deepEqual(parseChannelLink(`buzz://channel/${CHANNEL_ID}`), {
|
||||
ok: true,
|
||||
value: { channelId: CHANNEL_ID },
|
||||
});
|
||||
});
|
||||
|
||||
test("parseChannelLink accepts a channel message path", () => {
|
||||
assert.deepEqual(
|
||||
parseChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"),
|
||||
parseChannelLink(`buzz://channel/${CHANNEL_ID}/${MESSAGE_ID}`),
|
||||
{
|
||||
ok: true,
|
||||
value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" },
|
||||
value: { channelId: CHANNEL_ID, messageId: MESSAGE_ID },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -35,6 +46,10 @@ test("parseChannelLink rejects malformed channel links", () => {
|
||||
"buzz://channel",
|
||||
"buzz://channel/",
|
||||
"buzz://channel/one/two",
|
||||
`buzz://channel/${CHANNEL_ID}/not-hex`,
|
||||
`buzz://channel/${CHANNEL_ID}/${"a".repeat(63)}`,
|
||||
`buzz://channel/${CHANNEL_ID}/${MESSAGE_ID}/extra`,
|
||||
`buzz://channel/${CHANNEL_ID}/`,
|
||||
"buzz://channel/one?extra=true",
|
||||
"buzz://channel/one#fragment",
|
||||
"https://channel/one",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/** `buzz://channel/<uuid>` link encoding and parsing. */
|
||||
/** `buzz://channel/<uuid>[/<event-id>]` link encoding and parsing. */
|
||||
|
||||
const CHANNEL_LINK_SCHEME = "buzz:";
|
||||
const CHANNEL_LINK_HOST = "channel";
|
||||
const CHANNEL_UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
||||
const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/iu;
|
||||
|
||||
export type ParsedChannelLink = { channelId: string };
|
||||
export type ParsedChannelLink = {
|
||||
channelId: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export type ChannelLinkParseResult =
|
||||
| { ok: true; value: ParsedChannelLink }
|
||||
@@ -34,20 +38,31 @@ export function parseChannelLink(url: string): ChannelLinkParseResult {
|
||||
if (parsed.search || parsed.hash || parsed.username || parsed.password) {
|
||||
return { ok: false, reason: "unexpected-components" };
|
||||
}
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
if (segments.length !== 1) {
|
||||
const segments = parsed.pathname.split("/").slice(1);
|
||||
if (segments.length < 1 || segments.length > 2 || segments.includes("")) {
|
||||
return { ok: false, reason: "missing-or-extra-channel" };
|
||||
}
|
||||
let channelId: string;
|
||||
let messageId: string | null = null;
|
||||
try {
|
||||
channelId = decodeURIComponent(segments[0]);
|
||||
messageId = segments[1] ? decodeURIComponent(segments[1]) : null;
|
||||
} catch {
|
||||
return { ok: false, reason: "invalid-channel-encoding" };
|
||||
}
|
||||
if (!CHANNEL_UUID_PATTERN.test(channelId)) {
|
||||
return { ok: false, reason: "invalid-channel-uuid" };
|
||||
}
|
||||
return { ok: true, value: { channelId: channelId.toLowerCase() } };
|
||||
if (messageId !== null && !EVENT_ID_PATTERN.test(messageId)) {
|
||||
return { ok: false, reason: "invalid-message-id" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
channelId: channelId.toLowerCase(),
|
||||
...(messageId ? { messageId: messageId.toLowerCase() } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isChannelLink(href: string | undefined | null): boolean {
|
||||
|
||||
@@ -15,6 +15,8 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const MESSAGE_ID = "root-event";
|
||||
const HREF = `buzz://message?channel=${CHANNEL_ID}&id=${MESSAGE_ID}`;
|
||||
const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`;
|
||||
const CHANNEL_MESSAGE_ID = "a".repeat(64);
|
||||
const CHANNEL_MESSAGE_HREF = `buzz://channel/${CHANNEL_ID}/${CHANNEL_MESSAGE_ID}`;
|
||||
const OWNER = "a".repeat(64);
|
||||
const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`;
|
||||
const ISSUE_ID = "b".repeat(64);
|
||||
@@ -47,6 +49,15 @@ test("resolves channel and entity links as composer chips", () => {
|
||||
),
|
||||
{ channelName: "general", href: CHANNEL_HREF },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveComposerMessageLinkAttributes(CHANNEL_MESSAGE_HREF, (channelId) =>
|
||||
channelId === CHANNEL_ID ? "general" : undefined,
|
||||
),
|
||||
{
|
||||
channelName: "general",
|
||||
href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveComposerMessageLinkAttributes(REPO_HREF, () => undefined),
|
||||
{ channelName: "", href: REPO_HREF },
|
||||
|
||||
@@ -66,7 +66,12 @@ export function resolveComposerMessageLinkAttributes(
|
||||
if (channel.ok) {
|
||||
return {
|
||||
channelName: resolveChannelName(channel.value.channelId) ?? "",
|
||||
href: buildChannelLink(channel.value.channelId),
|
||||
href: channel.value.messageId
|
||||
? buildMessageLink({
|
||||
channelId: channel.value.channelId,
|
||||
messageId: channel.value.messageId,
|
||||
})
|
||||
: buildChannelLink(channel.value.channelId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1063,9 +1063,11 @@ test("nudgeGuard_noSentinel_proseRenderedCardAbsent", () => {
|
||||
test("bare Buzz permalinks render cohesive icon-prefixed chips", () => {
|
||||
const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32";
|
||||
const messageLink = `buzz://message?channel=${channelId}&id=${EVENT_HEX}`;
|
||||
const compatibilityMessageLink = `buzz://channel/${channelId}/${EVENT_HEX}`;
|
||||
const channelLink = `buzz://channel/${channelId}`;
|
||||
const links = [
|
||||
messageLink,
|
||||
compatibilityMessageLink,
|
||||
channelLink,
|
||||
`buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`,
|
||||
`buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`,
|
||||
@@ -1092,9 +1094,11 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => {
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 5);
|
||||
assert.match(html, /inline-chip-icon-message/);
|
||||
assert.match(html, />engineering · c3b589fa</);
|
||||
assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 6);
|
||||
assert.equal((html.match(/inline-chip-icon-message/g) ?? []).length, 2);
|
||||
assert.equal((html.match(/>engineering · c3b589fa</g) ?? []).length, 2);
|
||||
assert.equal((html.match(/data-message-link=""/g) ?? []).length, 2);
|
||||
assert.equal((html.match(/data-channel-deep-link=""/g) ?? []).length, 1);
|
||||
assert.match(html, /inline-chip-icon-channel/);
|
||||
assert.match(html, />engineering</);
|
||||
assert.match(html, /inline-chip-icon-pr/);
|
||||
@@ -1108,6 +1112,7 @@ test("authored Buzz permalink labels remain ordinary links", () => {
|
||||
const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32";
|
||||
const links = [
|
||||
`[the message](buzz://message?channel=${channelId}&id=${EVENT_HEX})`,
|
||||
`[the compatibility message](buzz://channel/${channelId}/${EVENT_HEX})`,
|
||||
`[**design discussion**](buzz://channel/${channelId})`,
|
||||
`[the issue](buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world)`,
|
||||
];
|
||||
@@ -1134,11 +1139,13 @@ test("authored Buzz permalink labels remain ordinary links", () => {
|
||||
|
||||
assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 0);
|
||||
assert.match(html, />the message</);
|
||||
assert.match(html, />the compatibility message</);
|
||||
assert.match(html, /aria-label="Open message: the compatibility message"/);
|
||||
assert.match(html, />design discussion</);
|
||||
assert.match(html, /aria-label="Open channel: design discussion"/);
|
||||
assert.doesNotMatch(html, /\[object Object\]/);
|
||||
assert.match(html, />the issue</);
|
||||
assert.equal((html.match(/underline-offset-4/g) ?? []).length, 3);
|
||||
assert.equal((html.match(/underline-offset-4/g) ?? []).length, 4);
|
||||
});
|
||||
|
||||
test("bare Buzz permalinks shorten unavailable channel identifiers", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/features/messages/lib/channelLink";
|
||||
|
||||
import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip";
|
||||
import { MessageLinkPill } from "./MessageLinkPill";
|
||||
import { useMarkdownRuntime } from "./runtimeContext";
|
||||
import { getReactNodeText } from "./utils";
|
||||
|
||||
@@ -24,24 +25,46 @@ export function ChannelDeepLinkAnchor({
|
||||
href,
|
||||
interactive,
|
||||
}: React.ComponentPropsWithoutRef<"a"> & { interactive: boolean }) {
|
||||
const { channels, onOpenChannel } = useMarkdownRuntime();
|
||||
const { channels, onOpenChannel, onOpenMessageLink } = useMarkdownRuntime();
|
||||
if (!href) return <>{children}</>;
|
||||
const parsed = parseChannelLink(href);
|
||||
if (!parsed.ok) return <>{children}</>;
|
||||
const messageLink = parsed.value.messageId
|
||||
? {
|
||||
channelId: parsed.value.channelId,
|
||||
messageId: parsed.value.messageId,
|
||||
threadRootId: null,
|
||||
}
|
||||
: null;
|
||||
const openLink = () =>
|
||||
messageLink
|
||||
? onOpenMessageLink(messageLink)
|
||||
: onOpenChannel(parsed.value.channelId);
|
||||
const authoredLabel = getReactNodeText(children);
|
||||
if (authoredLabel !== href) {
|
||||
return (
|
||||
<BuzzInlineLink
|
||||
href={href}
|
||||
title={href}
|
||||
aria-label={`Open channel: ${authoredLabel}`}
|
||||
aria-label={`${messageLink ? "Open message" : "Open channel"}: ${authoredLabel}`}
|
||||
interactive={interactive}
|
||||
onOpenLink={() => onOpenChannel(parsed.value.channelId)}
|
||||
onOpenLink={openLink}
|
||||
>
|
||||
{children}
|
||||
</BuzzInlineLink>
|
||||
);
|
||||
}
|
||||
if (messageLink) {
|
||||
return (
|
||||
<MessageLinkPill
|
||||
channels={channels}
|
||||
href={href}
|
||||
interactive={interactive}
|
||||
link={messageLink}
|
||||
onOpenMessageLink={onOpenMessageLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const label = channelPermalinkLabel(channels, parsed.value.channelId);
|
||||
return (
|
||||
<BuzzLinkChip
|
||||
@@ -64,10 +87,28 @@ export function MarkdownChannelDeepLink({
|
||||
children?: React.ReactNode;
|
||||
interactive: boolean;
|
||||
}) {
|
||||
const { channels, onOpenChannel } = useMarkdownRuntime();
|
||||
const { channels, onOpenChannel, onOpenMessageLink } = useMarkdownRuntime();
|
||||
const href = String(children ?? "");
|
||||
const parsed = parseChannelLink(href);
|
||||
if (!parsed.ok) return <span data-channel-deep-link="">{href}</span>;
|
||||
const messageLink = parsed.value.messageId
|
||||
? {
|
||||
channelId: parsed.value.channelId,
|
||||
messageId: parsed.value.messageId,
|
||||
threadRootId: null,
|
||||
}
|
||||
: null;
|
||||
if (messageLink) {
|
||||
return (
|
||||
<MessageLinkPill
|
||||
channels={channels}
|
||||
href={href}
|
||||
interactive={interactive}
|
||||
link={messageLink}
|
||||
onOpenMessageLink={onOpenMessageLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const label = channelPermalinkLabel(channels, parsed.value.channelId);
|
||||
return (
|
||||
<BuzzLinkChip
|
||||
|
||||
Reference in New Issue
Block a user