mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
**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**  **Editing an actual thread reply uses the thread composer**  --------- 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>
452 lines
12 KiB
JavaScript
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;
|
|
}
|
|
});
|