mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
**Category:** improvement **User Impact:** Buzz channel, message, repository, pull request, and issue links now open reliably and display recognizable context in the desktop app. **Problem:** Buzz links could appear as raw or ambiguous URLs, and navigation links received during startup or community transitions could be dropped before the UI was ready. Repository and issue shares in particular required hover context to understand at a glance. **Solution:** Queue desktop channel/message navigation until the UI is ready, then render bare Buzz permalinks as icon-prefixed chips with concise entity context while preserving user-authored Markdown labels as ordinary links. <details> <summary>File changes</summary> **desktop/src-tauri/src/deep_link.rs** Adds validated channel-link parsing and a deduplicated, acknowledged queue so navigation survives frontend startup. **desktop/src-tauri/src/lib.rs** Registers the pending-navigation state and commands with the desktop application. **desktop/src/features/communities/useCommunityInit.ts** Resets queued navigation safely across community boundaries without leaking stale destinations. **desktop/src/features/messages/lib/channelLink.test.mjs** Covers valid, malformed, and canonical channel permalink forms. **desktop/src/features/messages/lib/channelLink.ts** Defines strict parsing and detection for `buzz://channel/<uuid>` links. **desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs** Extends composer-node coverage for normalized Buzz link content. **desktop/src/features/messages/lib/composerMessageLinkNode.ts** Keeps composer link-node handling aligned with the expanded Buzz link surface. **desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs** Verifies bare channel URLs become renderable deep-link nodes without touching code. **desktop/src/features/messages/lib/remarkChannelDeepLinks.ts** Transforms eligible bare channel links into dedicated Markdown nodes. **desktop/src/features/messages/lib/remarkEntityLinks.test.mjs** Covers bare repository, pull-request, and issue detection and code-span exclusions. **desktop/src/features/messages/lib/remarkEntityLinks.ts** Adds dedicated Markdown nodes for bare Buzz project entities. **desktop/src/shared/deep-link.test.mjs** Exercises queued navigation, acknowledgement, serialization, and community-switch behavior. **desktop/src/shared/deep-link.ts** Serializes pending deep-link drains and acknowledges destinations only after successful navigation. **desktop/src/shared/styles/globals/markdown.css** Aligns permalink icon geometry and spacing with agent mention chips. **desktop/src/shared/ui/markdown.test.mjs** Adds integration coverage for every permalink chip, authored labels, fallbacks, icons, and static rendering. **desktop/src/shared/ui/markdown.tsx** Routes channel and entity nodes through the shared presentation path while preserving authored link text. **desktop/src/shared/ui/markdown/BuzzLinkChip.tsx** Introduces the shared interactive/static permalink chip and authored-label inline-link components. **desktop/src/shared/ui/markdown/ChannelDeepLink.tsx** Renders channel shares and references with Hash icons, names, and shortened-ID fallbacks. **desktop/src/shared/ui/markdown/MessageLinkPill.tsx** Renders ordinary message shares with message icons and channel/message context while retaining sent-from-thread behavior. **desktop/src/shared/ui/markdown/entityLinks.tsx** Maps repositories, pull requests, and issues to Projects-aligned icons and contextual labels. **desktop/src/shared/ui/markdown/nodeCache.ts** Includes entity-link rendering in cached Markdown node handling. **desktop/src/shared/ui/markdown/utils.ts** Allows validated channel links through the Buzz URL transform. **desktop/src/shared/useMessageDeepLinks.ts** Drains queued navigation links safely and clears them during teardown. **desktop/src/testing/e2eBridge.ts** Extends the mock bridge with pending-navigation command behavior. **desktop/tests/e2e/community-rail.spec.ts** Verifies queued links do not cross community boundaries. **desktop/tests/e2e/navigation.spec.ts** Covers channel/message deep-link navigation during startup and active sessions. **desktop/tests/helpers/bridge.ts** Adds reusable deep-link mock state and acknowledgement helpers. </details> ## Reproduction steps 1. Run the desktop app and open a channel containing bare `buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and `buzz://issue` URLs. 2. Confirm each bare URL renders as one cohesive chip with a type icon, a useful name or shortened identifier, and no duplicated channel `#` character. 3. Add an authored Markdown link such as `[design discussion](buzz://issue?...)` and confirm the supplied label remains an ordinary link rather than becoming a chip. 4. Select channel and message links and confirm they navigate correctly in warm and cold-start states. ## Screenshots / demos Houston dark theme with custom purple accent (`#a855f7`), captured from rebased visual implementation `ad411cc06`; current head `0aafa144f` only adjusts E2E expectations for the visible mention-label behavior shown here. **Composer — channel, message, repository, pull request, and issue pills**  **Message list — channel, message, repository, pull request, and issue pills**  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
400 lines
10 KiB
JavaScript
400 lines
10 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { afterEach, test } from "node:test";
|
|
|
|
const ipcHandlers = new Map();
|
|
let nextCallbackId = 1;
|
|
const callbacks = new Map();
|
|
|
|
const tauriInternals = {
|
|
invoke: (cmd, args) => {
|
|
const handler = ipcHandlers.get(cmd);
|
|
if (handler) return Promise.resolve(handler(args));
|
|
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
|
|
},
|
|
transformCallback: (callback) => {
|
|
const id = nextCallbackId++;
|
|
callbacks.set(id, callback);
|
|
return id;
|
|
},
|
|
};
|
|
globalThis.window = {
|
|
__TAURI_INTERNALS__: tauriInternals,
|
|
__TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} },
|
|
};
|
|
globalThis.__TAURI_INTERNALS__ = tauriInternals;
|
|
|
|
const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } =
|
|
await import("@/shared/deep-link.ts");
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function settle() {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
afterEach(() => {
|
|
ipcHandlers.clear();
|
|
callbacks.clear();
|
|
});
|
|
|
|
test("listener teardown leaves an unaccepted FIFO item for the next mount", async () => {
|
|
const queue = [
|
|
{
|
|
id: "first",
|
|
kind: "channel",
|
|
channelId: "channel-1",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
},
|
|
{
|
|
id: "second",
|
|
kind: "message",
|
|
channelId: "channel-2",
|
|
messageId: "message-2",
|
|
threadRootId: "root-2",
|
|
},
|
|
];
|
|
const firstAcknowledge = deferred();
|
|
const acknowledged = [];
|
|
let unlistenCount = 0;
|
|
|
|
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
|
|
ipcHandlers.set("plugin:event|unlisten", () => {
|
|
unlistenCount += 1;
|
|
});
|
|
ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null);
|
|
ipcHandlers.set(
|
|
"acknowledge_pending_navigation_deep_link",
|
|
async ({ id }) => {
|
|
if (id === "first") await firstAcknowledge.promise;
|
|
assert.equal(queue[0]?.id, id);
|
|
acknowledged.push(id);
|
|
queue.shift();
|
|
return true;
|
|
},
|
|
);
|
|
|
|
let firstMountActive = true;
|
|
const firstOpened = [];
|
|
const firstUnlisten = await listenForNavigationDeepLinks(
|
|
(payload) => {
|
|
if (!firstMountActive) return false;
|
|
firstOpened.push(payload.channelId);
|
|
return true;
|
|
},
|
|
(payload) => {
|
|
if (!firstMountActive) return false;
|
|
firstOpened.push(payload.messageId);
|
|
return true;
|
|
},
|
|
);
|
|
await settle();
|
|
assert.deepEqual(firstOpened, ["channel-1"]);
|
|
|
|
firstMountActive = false;
|
|
firstUnlisten();
|
|
firstAcknowledge.resolve();
|
|
await settle();
|
|
|
|
assert.deepEqual(acknowledged, ["first"]);
|
|
assert.equal(queue[0]?.id, "second");
|
|
|
|
const secondOpened = [];
|
|
const secondUnlisten = await listenForNavigationDeepLinks(
|
|
(payload) => {
|
|
secondOpened.push(payload.channelId);
|
|
return true;
|
|
},
|
|
(payload) => {
|
|
secondOpened.push(payload.messageId);
|
|
return true;
|
|
},
|
|
);
|
|
await settle();
|
|
|
|
assert.deepEqual(secondOpened, ["message-2"]);
|
|
assert.deepEqual(acknowledged, ["first", "second"]);
|
|
assert.equal(queue.length, 0);
|
|
secondUnlisten();
|
|
assert.equal(unlistenCount, 4);
|
|
});
|
|
|
|
test("concurrent listener remount does not take or acknowledge the in-flight head twice", async () => {
|
|
const queue = [
|
|
{
|
|
id: "in-flight",
|
|
kind: "channel",
|
|
channelId: "channel-1",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
},
|
|
];
|
|
const acknowledgeGate = deferred();
|
|
const opened = [];
|
|
const acknowledged = [];
|
|
|
|
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
|
|
ipcHandlers.set("plugin:event|unlisten", () => {});
|
|
ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null);
|
|
ipcHandlers.set(
|
|
"acknowledge_pending_navigation_deep_link",
|
|
async ({ id }) => {
|
|
acknowledged.push(id);
|
|
await acknowledgeGate.promise;
|
|
assert.equal(queue[0]?.id, id);
|
|
queue.shift();
|
|
return true;
|
|
},
|
|
);
|
|
|
|
const firstUnlisten = await listenForNavigationDeepLinks(
|
|
(payload) => {
|
|
opened.push(`first:${payload.channelId}`);
|
|
return true;
|
|
},
|
|
() => true,
|
|
);
|
|
await settle();
|
|
assert.deepEqual(opened, ["first:channel-1"]);
|
|
assert.deepEqual(acknowledged, ["in-flight"]);
|
|
|
|
firstUnlisten();
|
|
const secondUnlisten = await listenForNavigationDeepLinks(
|
|
(payload) => {
|
|
opened.push(`second:${payload.channelId}`);
|
|
return true;
|
|
},
|
|
() => true,
|
|
);
|
|
await settle();
|
|
|
|
assert.deepEqual(opened, ["first:channel-1"]);
|
|
assert.deepEqual(acknowledged, ["in-flight"]);
|
|
|
|
acknowledgeGate.resolve();
|
|
await settle();
|
|
await settle();
|
|
|
|
assert.deepEqual(opened, ["first:channel-1"]);
|
|
assert.deepEqual(acknowledged, ["in-flight"]);
|
|
assert.equal(queue.length, 0);
|
|
secondUnlisten();
|
|
});
|
|
|
|
test("community reset prevents an in-flight route from acknowledging", async () => {
|
|
const pending = {
|
|
id: "old-community",
|
|
kind: "channel",
|
|
channelId: "channel-1",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
};
|
|
const routeGate = deferred();
|
|
let acknowledgeCount = 0;
|
|
let clearCount = 0;
|
|
|
|
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
|
|
ipcHandlers.set("plugin:event|unlisten", () => {});
|
|
ipcHandlers.set("clear_pending_navigation_deep_links", () => {
|
|
clearCount += 1;
|
|
});
|
|
ipcHandlers.set("take_pending_navigation_deep_link", () => pending);
|
|
ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => {
|
|
acknowledgeCount += 1;
|
|
return true;
|
|
});
|
|
|
|
const unlisten = await listenForNavigationDeepLinks(
|
|
async () => {
|
|
await routeGate.promise;
|
|
return true;
|
|
},
|
|
() => true,
|
|
);
|
|
await settle();
|
|
|
|
await resetNavigationDeepLinkDrain();
|
|
routeGate.resolve();
|
|
await settle();
|
|
|
|
assert.equal(clearCount, 1);
|
|
assert.equal(acknowledgeCount, 0);
|
|
unlisten();
|
|
});
|
|
|
|
test("community reset after take does not route the stale item", async () => {
|
|
const takeGate = deferred();
|
|
const opened = [];
|
|
|
|
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
|
|
ipcHandlers.set("plugin:event|unlisten", () => {});
|
|
ipcHandlers.set("clear_pending_navigation_deep_links", () => {});
|
|
ipcHandlers.set("take_pending_navigation_deep_link", async () => {
|
|
await takeGate.promise;
|
|
return {
|
|
id: "old-community",
|
|
kind: "channel",
|
|
channelId: "channel-1",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
};
|
|
});
|
|
ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => true);
|
|
|
|
const unlisten = await listenForNavigationDeepLinks(
|
|
(payload) => {
|
|
opened.push(payload.channelId);
|
|
return true;
|
|
},
|
|
() => true,
|
|
);
|
|
await settle();
|
|
|
|
await resetNavigationDeepLinkDrain();
|
|
takeGate.resolve();
|
|
await settle();
|
|
|
|
assert.deepEqual(opened, []);
|
|
unlisten();
|
|
});
|
|
|
|
test("community reset stops the stale drain before taking another item", async () => {
|
|
const queue = [
|
|
{
|
|
id: "first",
|
|
kind: "channel",
|
|
channelId: "channel-1",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
},
|
|
{
|
|
id: "second",
|
|
kind: "channel",
|
|
channelId: "channel-2",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
},
|
|
];
|
|
const opened = [];
|
|
let takeCount = 0;
|
|
|
|
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
|
|
ipcHandlers.set("plugin:event|unlisten", () => {});
|
|
ipcHandlers.set("clear_pending_navigation_deep_links", () => {
|
|
queue.length = 0;
|
|
});
|
|
ipcHandlers.set("take_pending_navigation_deep_link", () => {
|
|
takeCount += 1;
|
|
return queue[0] ?? null;
|
|
});
|
|
ipcHandlers.set(
|
|
"acknowledge_pending_navigation_deep_link",
|
|
async ({ id }) => {
|
|
assert.equal(queue[0]?.id, id);
|
|
queue.shift();
|
|
await resetNavigationDeepLinkDrain();
|
|
return true;
|
|
},
|
|
);
|
|
|
|
const unlisten = await listenForNavigationDeepLinks(
|
|
(payload) => {
|
|
opened.push(payload.channelId);
|
|
return true;
|
|
},
|
|
() => true,
|
|
);
|
|
await settle();
|
|
await settle();
|
|
|
|
assert.deepEqual(opened, ["channel-1"]);
|
|
assert.equal(takeCount, 1);
|
|
unlisten();
|
|
});
|
|
|
|
test("failed community clear quarantines stale navigation from the next listener", async () => {
|
|
const stale = {
|
|
id: "old-community",
|
|
kind: "channel",
|
|
channelId: "old-channel",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
};
|
|
const opened = [];
|
|
let takeCount = 0;
|
|
|
|
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
|
|
ipcHandlers.set("plugin:event|unlisten", () => {});
|
|
ipcHandlers.set("clear_pending_navigation_deep_links", () => {
|
|
throw new Error("clear failed");
|
|
});
|
|
ipcHandlers.set("take_pending_navigation_deep_link", () => {
|
|
takeCount += 1;
|
|
return stale;
|
|
});
|
|
|
|
await assert.rejects(resetNavigationDeepLinkDrain(), /clear failed/);
|
|
const unlisten = await listenForNavigationDeepLinks(
|
|
(payload) => {
|
|
opened.push(payload.channelId);
|
|
return true;
|
|
},
|
|
() => true,
|
|
);
|
|
await settle();
|
|
|
|
assert.equal(takeCount, 0);
|
|
assert.deepEqual(opened, []);
|
|
unlisten();
|
|
|
|
// Restore the module-level gate for later tests, just as a successful retry
|
|
// does in the application.
|
|
ipcHandlers.set("clear_pending_navigation_deep_links", () => {});
|
|
await resetNavigationDeepLinkDrain();
|
|
});
|
|
|
|
test("rejected navigation remains queued and is not acknowledged", async () => {
|
|
const pending = {
|
|
id: "retry-me",
|
|
kind: "channel",
|
|
channelId: "channel-1",
|
|
messageId: null,
|
|
threadRootId: null,
|
|
};
|
|
let acknowledgeCount = 0;
|
|
const warnings = [];
|
|
const originalWarn = console.warn;
|
|
|
|
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
|
|
ipcHandlers.set("plugin:event|unlisten", () => {});
|
|
ipcHandlers.set("take_pending_navigation_deep_link", () => pending);
|
|
ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => {
|
|
acknowledgeCount += 1;
|
|
return true;
|
|
});
|
|
console.warn = (...args) => warnings.push(args);
|
|
|
|
try {
|
|
const unlisten = await listenForNavigationDeepLinks(
|
|
async () => {
|
|
throw new Error("route failed");
|
|
},
|
|
async () => true,
|
|
);
|
|
await settle();
|
|
|
|
assert.equal(acknowledgeCount, 0);
|
|
assert.equal(warnings.length, 1);
|
|
assert.match(String(warnings[0][1]), /route failed/);
|
|
unlisten();
|
|
} finally {
|
|
console.warn = originalWarn;
|
|
}
|
|
});
|