Files
buzz/desktop/src/shared/deep-link.test.mjs
Taylor Ho 4bf80978f5 fix(messages): route edits to the owning composer (#6575)
**Category:** fix
**User Impact:** Editing a channel message now opens in the main
composer, while editing a thread reply stays in the thread composer,
with focus ready for typing.

**Problem:** When a thread was open, Buzz treated its root message as
thread-owned and opened edits in the thread composer. Menu-driven edits
also lacked regression coverage for immediate focus.

**Solution:** Carry the message's semantic root/reply classification
into the edit target, route only actual replies to the thread composer,
and use the menu primitive's selection event for a reliable handoff.
End-to-end tests cover placement and focus for both paths.

<details>
<summary>File changes</summary>

**desktop/src/features/channels/ui/ChannelPane.tsx**
Routes edit targets by semantic thread ownership rather than membership
in the open thread panel.

**desktop/src/features/channels/ui/ChannelPane.types.ts**
Uses the shared composer edit-target type so routing metadata stays
attached to the target.

**desktop/src/features/messages/lib/draftMentionRefs.ts**
Classifies each edit target as a root or true thread reply from its
event tags.

**desktop/src/features/messages/lib/draftMentionRefs.test.mjs**
Covers semantic ownership for root and reply edit targets.

**desktop/src/features/messages/ui/MessageActionBar.tsx**
Handles Edit through the dropdown menu's selection event so focus
restoration and edit startup share the intended lifecycle.

**desktop/src/features/messages/ui/MessageComposer.types.ts**
Adds semantic thread ownership to the edit-target contract.

**desktop/tests/e2e/messaging.spec.ts**
Verifies root edits use and focus the main composer, while reply edits
use and focus the thread composer.

</details>

### Reproduction Steps

1. Send a channel message and open its thread.
2. From the thread panel, edit the root message; confirm its content
loads in the main composer and the editor is focused.
3. Send a reply in that thread.
4. Edit the reply; confirm its content loads in the thread composer and
the editor is focused.


### Screenshots

**Editing a channel-root message uses the main composer**

![Channel-root message editing in the main composer, dark theme with
purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-root-main-dark.png)

**Editing an actual thread reply uses the thread composer**

![Thread reply editing in the thread composer, dark theme with purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-reply-thread-dark.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-24 13:06:00 -07:00

452 lines
12 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("refused navigation stays at the FIFO head and retries with one acknowledgement", async () => {
const pending = {
id: "retry-me",
kind: "message",
channelId: "channel-1",
messageId: "message-1",
threadRootId: "root-1",
};
const queue = [pending];
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", ({ id }) => {
assert.equal(queue[0]?.id, id);
acknowledged.push(id);
queue.shift();
return true;
});
const firstUnlisten = await listenForNavigationDeepLinks(
() => true,
(payload) => {
opened.push(`refused:${payload.messageId}`);
return false;
},
);
await settle();
assert.deepEqual(opened, ["refused:message-1"]);
assert.equal(queue[0], pending);
assert.deepEqual(acknowledged, []);
firstUnlisten();
const secondUnlisten = await listenForNavigationDeepLinks(
() => true,
(payload) => {
opened.push(`accepted:${payload.messageId}`);
return true;
},
);
await settle();
await settle();
assert.deepEqual(opened, ["refused:message-1", "accepted:message-1"]);
assert.deepEqual(acknowledged, ["retry-me"]);
assert.equal(queue.length, 0);
secondUnlisten();
});
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;
}
});