mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): invite owned agents from standalone forums (#7125)
## Summary In Desktop's standalone Forums, selecting an owned agent from another device could leave a post or reply unsendable if the agent had not joined the forum. This adds **Invite / Cancel** to the send flow so you can resolve membership without leaving your draft. - **Invite** checks response policy and your permission to add members, adds the agent to the forum, waits for refreshed membership, then rechecks authorization before posting to the original destination. Membership is forum-wide, not limited to one post. - **Cancel / Escape** keeps the text, attachments and selected recipients for retry. Unlike chat's **Do nothing / Send anyway**, this dialog has no reference-only send choice. Invite is disabled while pending; Cancel remains available. - Leaving the source post/reply cancels its pending invitation, even if you return. Errors remain visible, focus returns to the initiating editor when appropriate, and late completion cannot resume a cancelled post or interfere with a newer attempt. - A rejected send restores text, uploaded media and exact selected recipients to the source draft only if no newer edit, deletion, upload intent or send supersedes it. Clipboard verification settles before recipient capture, with stale edits/navigation fenced out. ### Related issue Targets `main` after #7124 merged. This PR reuses its publication checks and draft protection; the five forum commits have been replayed unchanged onto the merged parent. Owned-agent discovery (#7122) is already merged. Split from #7114. Forum creation/templates, channel-less Notes and local-agent management are unchanged. Duplicate-name binding from #7133 is already merged and retained by this stack. Inviting does not start a remote agent or promise that it is online or will reply. ### Testing Forum composer lifecycle tests, including clipboard-settlement cases, and TypeScript/changed-file formatting checks passed after the restack. Earlier invitation, focus and transport-recovery browser coverage is retained, not claimed as a fresh full browser run on this head. See [live CI](https://github.com/block/buzz/pull/7125/checks) for current-head results. Browser evidence uses mock IPC; no full local `just ci` pass or native/live-relay validation is claimed. To try it: open a forum post or reply, select an owned nonmember agent and send. Cancel, then retry without reselecting; Invite should add that agent before posting. Deny the add to check the visible error and retained draft. Navigate away/back during a pending invitation or rejected send; no stale publication or overwrite of a newer draft should occur.  *Earlier mock-browser capture, not current-head runtime proof. [Error and successful-post captures](https://github.com/block/buzz/pull/7125#issuecomment-5513883462); no before-state/native capture available.* **Limits:** cancellation cannot undo accepted membership changes or dispatched posts; authorization and publication are not atomic. Recovery is same-window, subject to browser storage limits, and is not a durable in-flight send journal: reload/crash can lose a pending snapshot. Cross-window coordination and in-flight upload custody are unchanged. The parent's legacy member-agent compatibility does not establish ownership for nonmember invitations. --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -82,6 +82,7 @@ export default defineConfig({
|
||||
"**/cloud-provenance.spec.ts",
|
||||
"**/mention-recipients.spec.ts",
|
||||
"**/remote-owned-mentions.spec.ts",
|
||||
"**/forum-agent-invitation.spec.ts",
|
||||
"**/team-mentions.spec.ts",
|
||||
"**/persistent-agent-audience.spec.ts",
|
||||
"**/relay-reconnect.spec.ts",
|
||||
|
||||
575
desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs
Normal file
575
desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs
Normal file
@@ -0,0 +1,575 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import * as React from "react";
|
||||
import ts from "typescript";
|
||||
import * as draftStore from "../../messages/lib/useDrafts.ts";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
before(() =>
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
window: dom.window,
|
||||
localStorage: dom.window.localStorage,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
}),
|
||||
);
|
||||
afterEach(async () => (await import("@testing-library/react")).cleanup());
|
||||
after(() => dom.window.close());
|
||||
const KEY = "b".repeat(64);
|
||||
const REFS = [{ displayName: "RemoteScout", pubkey: KEY, isAgent: true }];
|
||||
const MEDIA = [
|
||||
{
|
||||
url: "https://media.example/file.pdf",
|
||||
type: "application/pdf",
|
||||
filename: "file.pdf",
|
||||
sha256: "a".repeat(64),
|
||||
size: 123,
|
||||
uploaded: true,
|
||||
},
|
||||
];
|
||||
const TEXT = "@RemoteScout hello";
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function load(relative, stubs) {
|
||||
const source = fs.readFileSync(new URL(relative, import.meta.url), "utf8");
|
||||
const exports = {};
|
||||
vm.runInNewContext(
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.React,
|
||||
},
|
||||
}).outputText,
|
||||
{
|
||||
exports,
|
||||
Error,
|
||||
Set,
|
||||
Map,
|
||||
require: (name) => {
|
||||
assert.ok(name in stubs, `unmocked dependency: ${name}`);
|
||||
return stubs[name];
|
||||
},
|
||||
},
|
||||
);
|
||||
return exports;
|
||||
}
|
||||
async function setup(options = {}) {
|
||||
const { persistent = true } = options;
|
||||
const channelType = "channelType" in options ? options.channelType : "forum";
|
||||
const { act, render, fireEvent } = await import("@testing-library/react");
|
||||
draftStore.clearAllDrafts();
|
||||
localStorage.clear();
|
||||
draftStore.initDraftStore("forum-test", "wss://forum.test");
|
||||
const calls = [];
|
||||
const control = { add: null, publish: null, prepare: null };
|
||||
const noop = () => {};
|
||||
let editor, media, mentionState, prompt;
|
||||
const stubs = {
|
||||
react: React,
|
||||
sonner: { toast: { error: (value) => calls.push(["error", value]) } },
|
||||
"@tiptap/react": { EditorContent: () => null },
|
||||
"lucide-react": { ChevronDown: () => null },
|
||||
"@/shared/lib/cn": { cn: () => "" },
|
||||
"@/shared/lib/pubkey": {
|
||||
normalizePubkey: (s) => s.toLowerCase(),
|
||||
truncatePubkey: (s) => s,
|
||||
},
|
||||
"@/features/channels/hooks": {
|
||||
useAddChannelMembersMutation: () => ({
|
||||
mutateAsync: async () => {
|
||||
calls.push(["add"]);
|
||||
if (control.add) await control.add.promise;
|
||||
return { errors: [] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
"@/features/channels/useCanAddChannelMembers": {
|
||||
useCanAddChannelMembers: () => true,
|
||||
},
|
||||
"@/features/channels/lib/channelMemberAdmission": {
|
||||
PRIVATE_CHANNEL_ADD_DENIED_MESSAGE: "denied",
|
||||
},
|
||||
"@/features/messages/lib/useDrafts": draftStore,
|
||||
"@/features/messages/lib/stripImplicitAgentMentions": {
|
||||
stripImplicitAgentMentionPrefix: (s) => s,
|
||||
},
|
||||
"@/features/messages/lib/useComposerFocusOwnership": {
|
||||
useComposerFocusOwnership: () => true,
|
||||
},
|
||||
"@/features/messages/lib/useChannelLinks": {
|
||||
useChannelLinks: () => ({
|
||||
clearChannels: noop,
|
||||
updateChannelQuery: noop,
|
||||
}),
|
||||
},
|
||||
"@/features/messages/lib/mentionCodeContext": {
|
||||
isMentionCodeContext: () => false,
|
||||
},
|
||||
"@/features/messages/lib/normalizeMentionClipboard": {
|
||||
hasMentionClipboardHtml: () => false,
|
||||
},
|
||||
"@/features/messages/lib/mentionClipboardPaste": {
|
||||
handleMentionClipboardPaste: () => false,
|
||||
},
|
||||
"@/features/messages/lib/useLinkEditor": { useLinkEditor: () => ({}) },
|
||||
"./useCompactComposerInteractions": {
|
||||
useCompactComposerInteractions: () => ({ shouldIgnoreBlur: () => false }),
|
||||
},
|
||||
"@/features/messages/lib/useMentions": {
|
||||
useMentions: () => {
|
||||
const state = React.useRef({ refs: [] });
|
||||
mentionState = state.current;
|
||||
return {
|
||||
knownNames: {},
|
||||
settlePendingMentionBindings: async () => {
|
||||
if (control.settle) await control.settle.promise;
|
||||
},
|
||||
cancelMentionAutocomplete: noop,
|
||||
updateMentionQuery: noop,
|
||||
clearMentions: () => {
|
||||
state.current.refs = [];
|
||||
},
|
||||
restoreDraftMentionRefs: (refs) => {
|
||||
state.current.refs = [...refs];
|
||||
},
|
||||
getDraftMentionRefs: () => state.current.refs,
|
||||
extractMentionPubkeys: () =>
|
||||
state.current.refs.map((ref) => ref.pubkey),
|
||||
isAgentPubkey: () => true,
|
||||
isManagedAgentPubkey: () => false,
|
||||
hasResolvedMembers: true,
|
||||
memberPubkeys: new Set(),
|
||||
getMentionDisplayName: () => "RemoteScout",
|
||||
revalidateMentionPubkeys: async (keys, _channel, { phase }) => {
|
||||
calls.push([phase]);
|
||||
if (control[phase]) await control[phase].promise;
|
||||
return keys;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
"@/features/messages/lib/useMediaUpload": {
|
||||
useMediaUpload: () => {
|
||||
const [pendingImeta, setPendingImeta] = React.useState([]);
|
||||
const ref = React.useRef(pendingImeta);
|
||||
ref.current = pendingImeta;
|
||||
media = {
|
||||
pendingImeta,
|
||||
pendingImetaRef: ref,
|
||||
setPendingImeta,
|
||||
uploadState: { status: "idle" },
|
||||
};
|
||||
return media;
|
||||
},
|
||||
},
|
||||
"@/features/messages/lib/useRichTextEditor": {
|
||||
useRichTextEditor: (options) => {
|
||||
const state = React.useRef("");
|
||||
editor = {
|
||||
getMarkdown: () => state.current,
|
||||
setContent: (value) => {
|
||||
state.current = value;
|
||||
},
|
||||
clearContent: () => {
|
||||
state.current = "";
|
||||
},
|
||||
edit: (value) => {
|
||||
state.current = value;
|
||||
options.onUpdate({ text: value, cursor: value.length });
|
||||
},
|
||||
focus: noop,
|
||||
};
|
||||
return editor;
|
||||
},
|
||||
},
|
||||
"@/features/messages/lib/imetaMediaMarkdown": {
|
||||
buildOutgoingMessage: (content, imeta) => ({ content, mediaTags: imeta }),
|
||||
},
|
||||
"@/features/messages/ui/NonMemberMentionDialog": {
|
||||
NonMemberMentionDialog: (props) => {
|
||||
prompt = props;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const [path, names] of [
|
||||
["@/shared/ui/button", ["Button"]],
|
||||
[
|
||||
"@/shared/ui/dropdown-menu",
|
||||
[
|
||||
"DropdownMenu",
|
||||
"DropdownMenuContent",
|
||||
"DropdownMenuRadioGroup",
|
||||
"DropdownMenuRadioItem",
|
||||
"DropdownMenuTrigger",
|
||||
],
|
||||
],
|
||||
["@/features/messages/ui/ComposerAttachments", ["DropZoneOverlay"]],
|
||||
[
|
||||
"@/features/messages/ui/MessageComposerToolbar",
|
||||
["MessageComposerToolbar"],
|
||||
],
|
||||
["./ForumComposerAutocompletes", ["ForumComposerAutocompletes"]],
|
||||
["./ForumComposerCompactLayout", ["ForumComposerCompactLayout"]],
|
||||
["./ForumComposerMediaStatus", ["ForumComposerMediaStatus"]],
|
||||
])
|
||||
stubs[path] = Object.fromEntries(names.map((name) => [name, () => null]));
|
||||
stubs["@/features/messages/ui/useDraftPersistSnapshot"] = load(
|
||||
"../../messages/ui/useDraftPersistSnapshot.ts",
|
||||
stubs,
|
||||
);
|
||||
stubs["./useForumMentionPreparation"] = load(
|
||||
"./useForumMentionPreparation.ts",
|
||||
stubs,
|
||||
);
|
||||
stubs["./useForumDraftRecovery"] = load("./useForumDraftRecovery.ts", stubs);
|
||||
const { ForumComposer } = load("./ForumComposer.tsx", stubs);
|
||||
let source = "a";
|
||||
let transport;
|
||||
const props = () => ({
|
||||
channelId: channelType === "forum" ? "forum" : null,
|
||||
channelType,
|
||||
draftKey: persistent
|
||||
? `${options.keyPrefix ?? "thread"}:${source}`
|
||||
: undefined,
|
||||
placeholder: "Reply",
|
||||
onSubmit: async (...args) => {
|
||||
calls.push(["send", source, ...args]);
|
||||
if (transport) await transport.promise;
|
||||
},
|
||||
});
|
||||
const view = render(
|
||||
React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(ForumComposer, props()),
|
||||
),
|
||||
);
|
||||
const flush = () => act(async () => {});
|
||||
return {
|
||||
calls,
|
||||
control,
|
||||
get prompt() {
|
||||
return prompt;
|
||||
},
|
||||
get text() {
|
||||
return editor.getMarkdown();
|
||||
},
|
||||
get refs() {
|
||||
return mentionState.refs;
|
||||
},
|
||||
get imeta() {
|
||||
return media.pendingImeta;
|
||||
},
|
||||
set transport(value) {
|
||||
transport = value;
|
||||
},
|
||||
edit(text = TEXT, refs = REFS) {
|
||||
act(() => {
|
||||
mentionState.refs = refs;
|
||||
editor.edit(text);
|
||||
});
|
||||
},
|
||||
attach(value = MEDIA) {
|
||||
act(() => media.setPendingImeta(value));
|
||||
},
|
||||
deleteDraft() {
|
||||
act(() =>
|
||||
draftStore.deleteDraftEntry(
|
||||
`${options.keyPrefix ?? "thread"}:${source}`,
|
||||
),
|
||||
);
|
||||
},
|
||||
async submit() {
|
||||
act(() => fireEvent.submit(view.container.querySelector("form")));
|
||||
await flush();
|
||||
},
|
||||
async invite() {
|
||||
act(() => prompt.onInvite());
|
||||
await flush();
|
||||
},
|
||||
async dismiss() {
|
||||
act(() => prompt.onDismiss());
|
||||
await flush();
|
||||
},
|
||||
navigate(value) {
|
||||
source = value;
|
||||
view.rerender(
|
||||
React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(ForumComposer, props()),
|
||||
),
|
||||
);
|
||||
},
|
||||
async finish(gate, error) {
|
||||
await act(async () => (error ? gate.reject(error) : gate.resolve()));
|
||||
},
|
||||
unmount: view.unmount,
|
||||
};
|
||||
}
|
||||
for (const stage of ["prepare", "add", "publish"]) {
|
||||
test(`production composer ${stage}: source visit retains refs/media, no late publication or error`, async () => {
|
||||
const s = await setup();
|
||||
s.edit();
|
||||
s.attach();
|
||||
await s.submit();
|
||||
const gate = deferred();
|
||||
s.control[stage] = gate;
|
||||
await s.invite();
|
||||
s.navigate("b");
|
||||
assert.equal(s.text, "");
|
||||
s.edit("B draft", []);
|
||||
s.navigate("a");
|
||||
assert.equal(s.text, TEXT);
|
||||
assert.deepEqual(s.refs, REFS);
|
||||
assert.deepEqual(Array.from(s.imeta), MEDIA);
|
||||
s.edit("", []);
|
||||
s.navigate("b");
|
||||
await s.finish(gate, new Error("obsolete failure"));
|
||||
assert.equal(s.text, "B draft");
|
||||
assert.equal(
|
||||
s.calls.filter((c) => c[0] === "send" || c[0] === "error").length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
s.calls.filter((c) => c[0] === "add").length,
|
||||
stage === "prepare" ? 0 : 1,
|
||||
);
|
||||
s.navigate("a");
|
||||
assert.equal(s.text, "");
|
||||
assert.deepEqual(s.refs, []);
|
||||
});
|
||||
}
|
||||
test("cancel/retry owns its latch: old add cannot release or fail a new pending invitation", async () => {
|
||||
const s = await setup();
|
||||
s.edit();
|
||||
await s.submit();
|
||||
const old = deferred();
|
||||
s.control.add = old;
|
||||
await s.invite();
|
||||
await s.dismiss();
|
||||
await s.submit();
|
||||
const current = deferred();
|
||||
s.control.add = current;
|
||||
await s.invite();
|
||||
await s.finish(old, new Error("old error"));
|
||||
assert.equal(s.prompt.isInvitePending, true);
|
||||
assert.equal(s.prompt.error, null);
|
||||
await s.finish(current);
|
||||
assert.equal(s.calls.filter((c) => c[0] === "send").length, 1);
|
||||
assert.equal(s.text, "");
|
||||
});
|
||||
for (const channelType of [null, undefined]) {
|
||||
test(`shared channel-less consumer ${channelType}: failed transport retains content/media/refs for exact retry`, async () => {
|
||||
const s = await setup({ channelType, persistent: false });
|
||||
s.edit();
|
||||
s.attach();
|
||||
const transport = deferred();
|
||||
s.transport = transport;
|
||||
await s.submit();
|
||||
assert.equal(s.prompt.open, false);
|
||||
assert.equal(s.text, "");
|
||||
await s.finish(transport, new Error("transport failed"));
|
||||
assert.equal(s.text, TEXT);
|
||||
assert.deepEqual(s.refs, REFS);
|
||||
assert.deepEqual(Array.from(s.imeta), MEDIA);
|
||||
s.transport = null;
|
||||
await s.submit();
|
||||
assert.equal(s.text, "");
|
||||
assert.equal(s.calls.filter((c) => c[0] === "add").length, 0);
|
||||
assert.deepEqual(
|
||||
Array.from(s.calls.filter((c) => c[0] === "send").at(-1)[3]),
|
||||
[KEY],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("dismiss during final validation invalidates publication but retains the source draft", async () => {
|
||||
const s = await setup();
|
||||
s.edit();
|
||||
s.attach();
|
||||
await s.submit();
|
||||
const gate = deferred();
|
||||
s.control.publish = gate;
|
||||
await s.invite();
|
||||
assert.equal(s.prompt.open, false);
|
||||
await s.dismiss();
|
||||
await s.finish(gate);
|
||||
assert.equal(s.text, TEXT);
|
||||
assert.deepEqual(s.refs, REFS);
|
||||
assert.deepEqual(Array.from(s.imeta), MEDIA);
|
||||
assert.equal(s.calls.filter((c) => c[0] === "send").length, 0);
|
||||
});
|
||||
|
||||
test("late transport failure never restores into a later source visit", async () => {
|
||||
const s = await setup();
|
||||
s.edit();
|
||||
await s.submit();
|
||||
const transport = deferred();
|
||||
s.transport = transport;
|
||||
await s.invite();
|
||||
assert.equal(s.text, "");
|
||||
s.navigate("b");
|
||||
s.edit("B stays", []);
|
||||
s.navigate("a");
|
||||
s.edit("new A intent", []);
|
||||
await s.finish(transport, new Error("old transport failed"));
|
||||
assert.equal(s.text, "new A intent");
|
||||
assert.deepEqual(s.refs, []);
|
||||
assert.equal(s.calls.filter((c) => c[0] === "error").length, 0);
|
||||
});
|
||||
|
||||
for (const keyPrefix of ["thread", "forum-post"]) {
|
||||
for (const invited of [false, true]) {
|
||||
test(`${keyPrefix} failed transport A-B-A without new intent restores exact payload (${invited ? "invited" : "ordinary"})`, async () => {
|
||||
const s = await setup({ keyPrefix });
|
||||
s.edit(invited ? TEXT : "original A body", invited ? REFS : []);
|
||||
s.attach();
|
||||
const transport = deferred();
|
||||
s.transport = transport;
|
||||
await s.submit();
|
||||
if (invited) await s.invite();
|
||||
assert.equal(s.text, "");
|
||||
s.navigate("b");
|
||||
s.edit("B stays", []);
|
||||
s.navigate("a");
|
||||
assert.equal(s.text, "");
|
||||
await s.finish(transport, new Error("relay unavailable"));
|
||||
const text = invited ? TEXT : "original A body";
|
||||
assert.equal(s.text, text);
|
||||
assert.deepEqual(Array.from(s.refs), invited ? REFS : []);
|
||||
assert.deepEqual(Array.from(s.imeta), MEDIA);
|
||||
const saved = draftStore.loadDraftEntry(`${keyPrefix}:a`);
|
||||
assert.equal(saved.content, text);
|
||||
assert.deepEqual(Array.from(saved.mentionRefs), invited ? REFS : []);
|
||||
assert.deepEqual(Array.from(saved.pendingImeta), MEDIA);
|
||||
assert.equal(saved.channelId, "forum");
|
||||
assert.equal(s.calls.filter((c) => c[0] === "send").length, 1);
|
||||
s.navigate("b");
|
||||
assert.equal(s.text, "B stays");
|
||||
s.navigate("a");
|
||||
assert.equal(s.text, text);
|
||||
assert.deepEqual(Array.from(s.imeta), MEDIA);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const intent of [
|
||||
"text-clear",
|
||||
"attachment",
|
||||
"attachment-remove",
|
||||
"delete",
|
||||
"refs",
|
||||
"new-send",
|
||||
"scope-reset",
|
||||
]) {
|
||||
test(`late failed transport cannot overwrite newer ${intent}`, async () => {
|
||||
const s = await setup();
|
||||
s.edit();
|
||||
s.attach();
|
||||
const old = deferred();
|
||||
s.transport = old;
|
||||
await s.submit();
|
||||
await s.invite();
|
||||
s.navigate("b");
|
||||
s.navigate("a");
|
||||
if (intent === "text-clear") {
|
||||
s.edit("replacement", []);
|
||||
s.edit("", []);
|
||||
}
|
||||
if (intent === "attachment" || intent === "attachment-remove") {
|
||||
s.attach([{ ...MEDIA[0], url: "https://media.example/new.pdf" }]);
|
||||
if (intent === "attachment-remove") s.attach([]);
|
||||
}
|
||||
if (intent === "delete") s.deleteDraft();
|
||||
if (intent === "refs")
|
||||
s.edit(TEXT, [{ ...REFS[0], pubkey: "c".repeat(64) }]);
|
||||
let current;
|
||||
if (intent === "new-send") {
|
||||
s.edit("next send", []);
|
||||
current = deferred();
|
||||
s.transport = current;
|
||||
await s.submit();
|
||||
}
|
||||
if (intent === "scope-reset") {
|
||||
draftStore.initDraftStore("other", "wss://other.test");
|
||||
draftStore.initDraftStore("forum-test", "wss://forum.test");
|
||||
}
|
||||
const text = s.text;
|
||||
const refs = [...s.refs];
|
||||
const imeta = [...s.imeta];
|
||||
const stored = draftStore.loadDraftEntry("thread:a");
|
||||
await s.finish(old, new Error("old transport failed"));
|
||||
assert.equal(s.text, text);
|
||||
assert.deepEqual(Array.from(s.refs), refs);
|
||||
assert.deepEqual(Array.from(s.imeta), imeta);
|
||||
assert.deepEqual(draftStore.loadDraftEntry("thread:a"), stored);
|
||||
if (current) {
|
||||
await s.submit(); // The older finally cannot release this attempt.
|
||||
assert.equal(s.calls.filter((c) => c[0] === "send").length, 2);
|
||||
await s.finish(current, new Error("new transport failed"));
|
||||
assert.equal(s.text, "next send");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const result of ["failure", "success"]) {
|
||||
test(`off-source transport ${result} never writes B or replays publication`, async () => {
|
||||
const s = await setup();
|
||||
s.edit();
|
||||
s.attach();
|
||||
const transport = deferred();
|
||||
s.transport = transport;
|
||||
await s.submit();
|
||||
await s.invite();
|
||||
s.navigate("b");
|
||||
s.edit("B untouched", []);
|
||||
await s.finish(
|
||||
transport,
|
||||
result === "failure" ? new Error("offline") : undefined,
|
||||
);
|
||||
assert.equal(s.text, "B untouched");
|
||||
s.navigate("a");
|
||||
assert.equal(s.text, result === "failure" ? TEXT : "");
|
||||
assert.deepEqual(Array.from(s.imeta), result === "failure" ? MEDIA : []);
|
||||
assert.equal(s.calls.filter((c) => c[0] === "send").length, 1);
|
||||
assert.equal(s.calls.filter((c) => c[0] === "add").length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of ["navigation", "return", "edit", "unmount"]) {
|
||||
test(`clipboard settlement after ${action} cannot prepare another forum draft`, async () => {
|
||||
const s = await setup();
|
||||
s.edit();
|
||||
const gate = deferred();
|
||||
s.control.settle = gate;
|
||||
await s.submit();
|
||||
if (action === "navigation" || action === "return") {
|
||||
s.navigate("b");
|
||||
s.edit("B draft", []);
|
||||
if (action === "return") s.navigate("a");
|
||||
}
|
||||
if (action === "edit") s.edit("new authored draft", []);
|
||||
if (action === "unmount") s.unmount();
|
||||
await s.finish(gate);
|
||||
assert.equal(s.calls.length, 0);
|
||||
assert.equal(s.prompt.open, false);
|
||||
if (action === "navigation") assert.equal(s.text, "B draft");
|
||||
if (action === "return") assert.equal(s.text, TEXT);
|
||||
if (action === "edit") assert.equal(s.text, "new authored draft");
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { EditorContent } from "@tiptap/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { claimDraftSend, useDrafts } from "@/features/messages/lib/useDrafts";
|
||||
import { useDraftPersistLifecycle } from "@/features/messages/ui/useDraftPersistSnapshot";
|
||||
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
|
||||
import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership";
|
||||
@@ -20,6 +22,7 @@ import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
|
||||
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
|
||||
import { NonMemberMentionDialog } from "@/features/messages/ui/NonMemberMentionDialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
@@ -34,8 +37,20 @@ import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes";
|
||||
import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout";
|
||||
import { ForumComposerMediaStatus } from "./ForumComposerMediaStatus";
|
||||
import { useCompactComposerInteractions } from "./useCompactComposerInteractions";
|
||||
import { useForumMentionPreparation } from "./useForumMentionPreparation";
|
||||
import { useForumDraftRecovery } from "./useForumDraftRecovery";
|
||||
|
||||
export function ForumComposer({
|
||||
export function ForumComposer(props: ForumComposerProps) {
|
||||
return (
|
||||
<ForumComposerVisit
|
||||
key={`${props.channelId ?? ""}:${props.draftKey ?? ""}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ForumComposerVisit({
|
||||
draftKey,
|
||||
channelId = null,
|
||||
channelType,
|
||||
members,
|
||||
@@ -52,6 +67,14 @@ export function ForumComposer({
|
||||
autocompleteBelow = false,
|
||||
profiles,
|
||||
}: ForumComposerProps) {
|
||||
const drafts = useDrafts();
|
||||
const mountedRef = React.useRef(false);
|
||||
React.useLayoutEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
const [content, setContent] = React.useState("");
|
||||
const contentRef = React.useRef(content);
|
||||
contentRef.current = content;
|
||||
@@ -73,8 +96,20 @@ export function ForumComposer({
|
||||
}, [compact]);
|
||||
|
||||
const mentions = useMentions(channelId, members, profiles, { channelType });
|
||||
const { prepareMentionPubkeys, nonMemberPromptProps } =
|
||||
useForumMentionPreparation(channelId, channelType, mentions);
|
||||
const channelLinks = useChannelLinks();
|
||||
const media = useMediaUpload();
|
||||
const expectedMediaRef = React.useRef(media.pendingImeta);
|
||||
const pendingMediaRestoreRef = React.useRef(false);
|
||||
const replacePendingImeta = React.useCallback(
|
||||
(imeta: typeof media.pendingImeta) => {
|
||||
expectedMediaRef.current = imeta;
|
||||
pendingMediaRestoreRef.current = true;
|
||||
media.setPendingImeta(imeta);
|
||||
},
|
||||
[media.setPendingImeta],
|
||||
);
|
||||
const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } =
|
||||
useCompactComposerInteractions({
|
||||
compact,
|
||||
@@ -130,12 +165,80 @@ export function ForumComposer({
|
||||
const markdown = richText.getMarkdown();
|
||||
setContent(markdown);
|
||||
contentRef.current = markdown;
|
||||
draftLifecycle.trackAuthoredContent(markdown);
|
||||
|
||||
mentions.updateMentionQuery(text, cursor);
|
||||
channelLinks.updateChannelQuery(text, cursor);
|
||||
},
|
||||
});
|
||||
|
||||
const spoileredUrlsRef = React.useRef(new Set<string>());
|
||||
const draftLifecycle = useDraftPersistLifecycle({
|
||||
effectiveDraftKey: draftKey,
|
||||
channelId,
|
||||
loadDraft: drafts.loadDraft,
|
||||
persistDraft: drafts.persistDraft,
|
||||
getMentionRefs: mentions.getDraftMentionRefs,
|
||||
restoreMentionRefs: mentions.restoreDraftMentionRefs,
|
||||
livePendingImeta: media.pendingImeta,
|
||||
setPendingImeta: replacePendingImeta,
|
||||
setContent: (value) => {
|
||||
contentRef.current = value;
|
||||
setContent(value);
|
||||
richText.setContent(value);
|
||||
},
|
||||
clearContent: () => {
|
||||
contentRef.current = "";
|
||||
setContent("");
|
||||
richText.clearContent();
|
||||
},
|
||||
setSpoileredAttachmentUrls: () => {},
|
||||
spoileredAttachmentUrlsRef: spoileredUrlsRef,
|
||||
syncComposerContentFromEditor: () => contentRef.current,
|
||||
});
|
||||
|
||||
// Completed media changes are authored intent too, including add -> remove.
|
||||
// Programmatic restoration/clear goes through replacePendingImeta instead.
|
||||
React.useLayoutEffect(() => {
|
||||
if (pendingMediaRestoreRef.current) {
|
||||
if (
|
||||
JSON.stringify(media.pendingImeta) !==
|
||||
JSON.stringify(expectedMediaRef.current)
|
||||
)
|
||||
return;
|
||||
pendingMediaRestoreRef.current = false;
|
||||
}
|
||||
if (
|
||||
JSON.stringify(expectedMediaRef.current) !==
|
||||
JSON.stringify(media.pendingImeta)
|
||||
) {
|
||||
expectedMediaRef.current = media.pendingImeta;
|
||||
draftLifecycle.trackAuthoredContent(contentRef.current);
|
||||
}
|
||||
}, [media.pendingImeta, draftLifecycle.trackAuthoredContent]);
|
||||
React.useLayoutEffect(() => {
|
||||
if (media.isUploading)
|
||||
draftLifecycle.trackAuthoredContent(contentRef.current);
|
||||
}, [media.isUploading, draftLifecycle.trackAuthoredContent]);
|
||||
const captureRecovery = useForumDraftRecovery({
|
||||
draftKey,
|
||||
channelId,
|
||||
getComposerRevision: draftLifecycle.getComposerRevision,
|
||||
isEmpty: () =>
|
||||
!contentRef.current &&
|
||||
media.pendingImetaRef.current.length === 0 &&
|
||||
!isUploadingRef.current,
|
||||
restore: (snapshot) => {
|
||||
draftLifecycle.runComposerUpdate(() => {
|
||||
setContent(snapshot.content);
|
||||
contentRef.current = snapshot.content;
|
||||
richText.setContent(snapshot.content);
|
||||
replacePendingImeta(snapshot.pendingImeta);
|
||||
mentions.restoreDraftMentionRefs(snapshot.mentionRefs);
|
||||
}, snapshot.pendingImeta);
|
||||
},
|
||||
});
|
||||
|
||||
const linkEditor = useLinkEditor(richText);
|
||||
onEditLinkRef.current = linkEditor.openFromClick;
|
||||
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
|
||||
@@ -236,6 +339,8 @@ export function ForumComposer({
|
||||
return;
|
||||
}
|
||||
|
||||
claimDraftSend(draftKey);
|
||||
const composerRevision = draftLifecycle.getComposerRevision();
|
||||
isSubmissionPendingRef.current = true;
|
||||
setIsSubmissionPending(true);
|
||||
mentions.cancelMentionAutocomplete();
|
||||
@@ -245,9 +350,17 @@ export function ForumComposer({
|
||||
// A pasted mention's identity check can still be in flight; extracting
|
||||
// first would publish the label with no `p` tag. Bounded internally.
|
||||
await mentions.settlePendingMentionBindings();
|
||||
const pubkeys = await mentions.revalidateMentionPubkeys(
|
||||
// This await precedes the preparation adapter's own visit fence.
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
draftLifecycle.getComposerRevision() !== composerRevision
|
||||
)
|
||||
return;
|
||||
const pubkeys = await prepareMentionPubkeys(
|
||||
mentions.extractMentionPubkeys(trimmed),
|
||||
trimmed,
|
||||
);
|
||||
if (pubkeys === null || !mountedRef.current) return;
|
||||
|
||||
// Reuse the shared send-path builder so forum/notes posts emit the same
|
||||
// body + imeta as chat: generic files become `[filename](url)` links with a
|
||||
@@ -258,50 +371,60 @@ export function ForumComposer({
|
||||
currentPendingImeta,
|
||||
);
|
||||
|
||||
// Save draft state so we can restore on failure.
|
||||
const savedContent = contentRef.current;
|
||||
const savedImeta = [...currentPendingImeta];
|
||||
|
||||
setContent("");
|
||||
contentRef.current = "";
|
||||
richText.clearContent();
|
||||
media.setPendingImeta([]);
|
||||
mentions.clearMentions();
|
||||
// Publication has been authorized for this visit. Preserve the exact
|
||||
// snapshot before the existing optimistic clear, including selected refs.
|
||||
const recoverDraft = captureRecovery({
|
||||
content: contentRef.current,
|
||||
pendingImeta: [...currentPendingImeta],
|
||||
mentionRefs: mentions.getDraftMentionRefs(contentRef.current),
|
||||
});
|
||||
draftLifecycle.runComposerUpdate(() => {
|
||||
setContent("");
|
||||
contentRef.current = "";
|
||||
richText.clearContent();
|
||||
replacePendingImeta([]);
|
||||
mentions.clearMentions();
|
||||
}, []);
|
||||
if (draftKey) drafts.clearDraft(draftKey);
|
||||
channelLinks.clearChannels();
|
||||
setIsEmojiPickerOpen(false);
|
||||
|
||||
try {
|
||||
await submitter(finalContent, pubkeys, mediaTags);
|
||||
if (!mountedRef.current) return;
|
||||
setSubmitMode("primary");
|
||||
if (compact) setIsCompactExpanded(false);
|
||||
} catch {
|
||||
setContent(savedContent);
|
||||
contentRef.current = savedContent;
|
||||
richText.setContent(savedContent);
|
||||
media.setPendingImeta(savedImeta);
|
||||
if (compact) setIsCompactExpanded(true);
|
||||
} catch (failure) {
|
||||
// Draft authority survives the visit; editor ownership does not.
|
||||
recoverDraft();
|
||||
throw failure;
|
||||
}
|
||||
} catch (error) {
|
||||
// Authorization and ambiguous-name failures must be visible, not a
|
||||
// silent no-op. This path has not cleared the draft or its selections.
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
// Authorization, ambiguous-name and transport failures remain visible
|
||||
// only in the originating visit; draft recovery is handled above.
|
||||
if (mountedRef.current)
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
isSubmissionPendingRef.current = false;
|
||||
setIsSubmissionPending(false);
|
||||
if (mountedRef.current) setIsSubmissionPending(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
compact,
|
||||
draftKey,
|
||||
drafts.clearDraft,
|
||||
draftLifecycle.runComposerUpdate,
|
||||
draftLifecycle.getComposerRevision,
|
||||
mentions.getDraftMentionRefs,
|
||||
captureRecovery,
|
||||
media.pendingImetaRef,
|
||||
media.setPendingImeta,
|
||||
replacePendingImeta,
|
||||
mentions.cancelMentionAutocomplete,
|
||||
mentions.extractMentionPubkeys,
|
||||
mentions.revalidateMentionPubkeys,
|
||||
mentions.settlePendingMentionBindings,
|
||||
prepareMentionPubkeys,
|
||||
mentions.clearMentions,
|
||||
channelLinks.clearChannels,
|
||||
richText.clearContent,
|
||||
richText.setContent,
|
||||
],
|
||||
);
|
||||
const submitSelectedMessage = React.useCallback(() => {
|
||||
@@ -649,6 +772,13 @@ export function ForumComposer({
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
<NonMemberMentionDialog
|
||||
{...nonMemberPromptProps}
|
||||
onRestoreFocus={() => {
|
||||
if (mountedRef.current && !isSubmissionPendingRef.current)
|
||||
richText.focus();
|
||||
}}
|
||||
/>
|
||||
{!isSubmissionPending && linkEditor.card}
|
||||
{!isSubmissionPending && linkEditor.dialog}
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { ChannelMember, ChannelType } from "@/shared/api/types";
|
||||
|
||||
export type ForumComposerProps = {
|
||||
channelId?: string | null;
|
||||
/** Persistent source identity; changing it starts a new composer visit. */
|
||||
draftKey?: string;
|
||||
/** Known channel type for channel-backed composers; omitted uses fail closed. */
|
||||
channelType?: ChannelType | null;
|
||||
/** Override mention source when no channel is available (e.g. Pulse). */
|
||||
|
||||
@@ -28,6 +28,7 @@ type ForumThreadPanelProps = {
|
||||
isLoading: boolean;
|
||||
isSendingReply: boolean;
|
||||
channelId: string;
|
||||
postId: string;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
onBack: () => void;
|
||||
@@ -146,6 +147,7 @@ export function ForumThreadPanel({
|
||||
isLoading,
|
||||
isSendingReply,
|
||||
channelId,
|
||||
postId,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
onBack,
|
||||
@@ -334,6 +336,7 @@ export function ForumThreadPanel({
|
||||
<ForumComposer
|
||||
channelId={channelId}
|
||||
channelType="forum"
|
||||
draftKey={`thread:${postId}`}
|
||||
isSending={isSendingReply}
|
||||
onSubmit={onReply}
|
||||
placeholder="Reply to this post..."
|
||||
|
||||
@@ -137,6 +137,8 @@ export function ForumView({
|
||||
|
||||
return (
|
||||
<ForumThreadPanel
|
||||
key={`${channel.id}:${selectedPostId}`}
|
||||
postId={selectedPostId}
|
||||
canDeletePost={canDeleteExpandedPost}
|
||||
currentPubkey={effectiveCurrentPubkey}
|
||||
isDeletingPost={deletePostMutation.isPending}
|
||||
@@ -176,6 +178,7 @@ export function ForumView({
|
||||
autocompleteBelow
|
||||
channelId={channel.id}
|
||||
channelType="forum"
|
||||
draftKey={`forum:${channel.id}`}
|
||||
isSending={createPostMutation.isPending}
|
||||
onCancel={() => setIsComposerOpen(false)}
|
||||
onSubmit={async (content, mentionPubkeys, mediaTags) => {
|
||||
|
||||
94
desktop/src/features/forum/ui/useForumDraftRecovery.ts
Normal file
94
desktop/src/features/forum/ui/useForumDraftRecovery.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import {
|
||||
type DraftMentionRef,
|
||||
loadDraftEntry,
|
||||
persistDraftEntry,
|
||||
subscribeToStore,
|
||||
} from "@/features/messages/lib/useDrafts";
|
||||
|
||||
type ForumDraftSnapshot = {
|
||||
content: string;
|
||||
pendingImeta: ImetaMedia[];
|
||||
mentionRefs: DraftMentionRef[];
|
||||
};
|
||||
|
||||
type Params = {
|
||||
draftKey: string | null | undefined;
|
||||
channelId: string | null;
|
||||
getComposerRevision: () => number;
|
||||
isEmpty: () => boolean;
|
||||
restore: (snapshot: ForumDraftSnapshot) => void;
|
||||
};
|
||||
|
||||
/** Source-key recovery survives a visit; only a pristine visit follows storage. */
|
||||
export function useForumDraftRecovery(params: Params) {
|
||||
const live = React.useRef(params);
|
||||
live.current = params;
|
||||
const mounted = React.useRef(false);
|
||||
const { draftKey, getComposerRevision } = params;
|
||||
React.useLayoutEffect(() => {
|
||||
mounted.current = true;
|
||||
const revision = getComposerRevision();
|
||||
let observed = draftKey ? loadDraftEntry(draftKey) : undefined;
|
||||
const unsubscribe = subscribeToStore(() => {
|
||||
if (!draftKey) return;
|
||||
const saved = loadDraftEntry(draftKey);
|
||||
if (saved === observed) return;
|
||||
observed = saved;
|
||||
// Local authored edits (including edit -> delete and attachment removal)
|
||||
// and new sends revoke this visit's right to adopt a background recovery.
|
||||
if (
|
||||
saved &&
|
||||
getComposerRevision() === revision &&
|
||||
live.current.isEmpty()
|
||||
) {
|
||||
live.current.restore({
|
||||
...saved,
|
||||
mentionRefs: saved.mentionRefs ?? [],
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [draftKey, getComposerRevision]);
|
||||
|
||||
return React.useCallback((snapshot: ForumDraftSnapshot) => {
|
||||
const source = live.current;
|
||||
const revision = source.getComposerRevision();
|
||||
return () => {
|
||||
if (source.getComposerRevision() !== revision) return;
|
||||
if (source.draftKey) {
|
||||
const existing = loadDraftEntry(source.draftKey);
|
||||
// Persistence is not authored intent. Still do not replace a different
|
||||
// stored payload from another owner, even if its revision is unchanged.
|
||||
if (
|
||||
existing &&
|
||||
(existing.content !== snapshot.content ||
|
||||
existing.channelId !== (source.channelId ?? source.draftKey) ||
|
||||
JSON.stringify(existing.pendingImeta) !==
|
||||
JSON.stringify(snapshot.pendingImeta) ||
|
||||
JSON.stringify(existing.mentionRefs ?? []) !==
|
||||
JSON.stringify(snapshot.mentionRefs) ||
|
||||
existing.spoileredAttachmentUrls.length > 0)
|
||||
)
|
||||
return;
|
||||
persistDraftEntry(
|
||||
source.draftKey,
|
||||
snapshot.content,
|
||||
source.channelId ?? source.draftKey,
|
||||
snapshot.pendingImeta,
|
||||
[],
|
||||
snapshot.mentionRefs,
|
||||
);
|
||||
}
|
||||
// Never call the departed editor. The current source visit above follows
|
||||
// durable recovery independently; a different thread never receives it.
|
||||
if (mounted.current && live.current.isEmpty())
|
||||
live.current.restore(snapshot);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
186
desktop/src/features/forum/ui/useForumMentionPreparation.ts
Normal file
186
desktop/src/features/forum/ui/useForumMentionPreparation.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import * as React from "react";
|
||||
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
|
||||
import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission";
|
||||
import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers";
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type PendingInvite = {
|
||||
channelId: string;
|
||||
pubkeys: string[];
|
||||
nonMemberPubkeys: string[];
|
||||
intendedAgentPubkeys: string[];
|
||||
resolve: (invited: boolean) => void;
|
||||
};
|
||||
|
||||
/** Adapt the normal mention Invite dialog and authorized add to standalone forums. */
|
||||
export function useForumMentionPreparation(
|
||||
channelId: string | null,
|
||||
channelType: ChannelType | null | undefined,
|
||||
mentions: UseMentionsResult,
|
||||
) {
|
||||
const addMembers = useAddChannelMembersMutation(channelId);
|
||||
const canInvite = useCanAddChannelMembers(channelId);
|
||||
const [pending, setPending] = React.useState<PendingInvite | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isInviting, setIsInviting] = React.useState(false);
|
||||
const pendingRef = React.useRef<PendingInvite | null>(null);
|
||||
const invitingRef = React.useRef<PendingInvite | null>(null);
|
||||
const attemptRef = React.useRef(0);
|
||||
const activeChannelRef = React.useRef(channelId);
|
||||
activeChannelRef.current = channelId;
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
const dismiss = React.useCallback(() => {
|
||||
const draft = pendingRef.current;
|
||||
attemptRef.current += 1;
|
||||
invitingRef.current = null;
|
||||
setIsInviting(false);
|
||||
pendingRef.current = null;
|
||||
setPending(null);
|
||||
setError(null);
|
||||
draft?.resolve(false);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
attemptRef.current += 1;
|
||||
pendingRef.current?.resolve(false);
|
||||
pendingRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (pendingRef.current?.channelId !== channelId) dismiss();
|
||||
}, [channelId, dismiss]);
|
||||
|
||||
const prepareMentionPubkeys = React.useCallback(
|
||||
async (pubkeys: string[], content: string) => {
|
||||
const attempt = ++attemptRef.current;
|
||||
const capturedChannelId = channelId;
|
||||
const isCurrent = () =>
|
||||
mountedRef.current &&
|
||||
activeChannelRef.current === capturedChannelId &&
|
||||
attemptRef.current === attempt;
|
||||
const intendedAgentPubkeys = [
|
||||
...pubkeys.filter(mentions.isAgentPubkey),
|
||||
...mentions
|
||||
.getDraftMentionRefs(content)
|
||||
.filter((ref) => ref.isAgent)
|
||||
.map((ref) => ref.pubkey),
|
||||
];
|
||||
const agentPubkeys = new Set(intendedAgentPubkeys.map(normalizePubkey));
|
||||
// Local managed-agent lifecycle and channel-less/notes surfaces are not
|
||||
// part of this adapter. Relay-only agents use the same bot add as chat.
|
||||
const nonMemberPubkeys =
|
||||
capturedChannelId &&
|
||||
channelType === "forum" &&
|
||||
mentions.hasResolvedMembers
|
||||
? [...new Set(pubkeys.map(normalizePubkey))].filter(
|
||||
(pubkey) =>
|
||||
agentPubkeys.has(pubkey) &&
|
||||
!mentions.isManagedAgentPubkey(pubkey) &&
|
||||
!mentions.memberPubkeys.has(pubkey),
|
||||
)
|
||||
: [];
|
||||
if (capturedChannelId && nonMemberPubkeys.length > 0) {
|
||||
const invited = await new Promise<boolean>((resolve) => {
|
||||
const draft = {
|
||||
channelId: capturedChannelId,
|
||||
pubkeys,
|
||||
nonMemberPubkeys,
|
||||
intendedAgentPubkeys,
|
||||
resolve,
|
||||
};
|
||||
pendingRef.current = draft;
|
||||
setError(null);
|
||||
setPending(draft);
|
||||
});
|
||||
if (!invited) return null;
|
||||
}
|
||||
if (!isCurrent()) return null;
|
||||
// The add mutation awaits membership invalidation. Publication still
|
||||
// requires a fresh authoritative directory/membership/policy read.
|
||||
try {
|
||||
const validated = await mentions.revalidateMentionPubkeys(
|
||||
pubkeys,
|
||||
capturedChannelId,
|
||||
{ phase: "publish", intendedAgentPubkeys },
|
||||
);
|
||||
return isCurrent() ? validated : null;
|
||||
} catch (failure) {
|
||||
if (!isCurrent()) return null;
|
||||
throw failure;
|
||||
}
|
||||
},
|
||||
[channelId, channelType, mentions],
|
||||
);
|
||||
|
||||
const invite = React.useCallback(async () => {
|
||||
const draft = pendingRef.current;
|
||||
if (!draft || invitingRef.current) return;
|
||||
if (!canInvite) {
|
||||
setError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const isCurrent = () =>
|
||||
mountedRef.current &&
|
||||
activeChannelRef.current === draft.channelId &&
|
||||
pendingRef.current === draft;
|
||||
invitingRef.current = draft;
|
||||
setIsInviting(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Preparation admits eligible owned nonmembers, not arbitrary targets.
|
||||
// Never use publication's membership gate before the authorized add.
|
||||
await mentions.revalidateMentionPubkeys(draft.pubkeys, draft.channelId, {
|
||||
phase: "prepare",
|
||||
intendedAgentPubkeys: draft.intendedAgentPubkeys,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
const result = await addMembers.mutateAsync({
|
||||
channelId: draft.channelId,
|
||||
pubkeys: draft.nonMemberPubkeys,
|
||||
role: "bot",
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
if (result.errors.length > 0) {
|
||||
setError(result.errors.map((failure) => failure.error).join("; "));
|
||||
return;
|
||||
}
|
||||
pendingRef.current = null;
|
||||
setPending(null);
|
||||
draft.resolve(true);
|
||||
} catch (failure) {
|
||||
if (isCurrent())
|
||||
setError(
|
||||
failure instanceof Error
|
||||
? failure.message
|
||||
: "Could not invite members.",
|
||||
);
|
||||
} finally {
|
||||
if (invitingRef.current === draft) {
|
||||
invitingRef.current = null;
|
||||
if (mountedRef.current) setIsInviting(false);
|
||||
}
|
||||
}
|
||||
}, [addMembers.mutateAsync, canInvite, mentions.revalidateMentionPubkeys]);
|
||||
|
||||
return {
|
||||
prepareMentionPubkeys,
|
||||
nonMemberPromptProps: {
|
||||
canInvite,
|
||||
error,
|
||||
isInvitePending: isInviting,
|
||||
names: (pending?.nonMemberPubkeys ?? []).map(
|
||||
(pubkey) =>
|
||||
mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey),
|
||||
),
|
||||
onDismiss: dismiss,
|
||||
onInvite: () => void invite(),
|
||||
open: pending !== null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -991,7 +991,19 @@ function MessageComposerImpl({
|
||||
</form>
|
||||
</div>
|
||||
</footer>
|
||||
<NonMemberMentionDialog {...mentionSendFlow.nonMemberPromptProps} />
|
||||
<NonMemberMentionDialog
|
||||
{...mentionSendFlow.nonMemberPromptProps}
|
||||
onRestoreFocus={() => {
|
||||
if (
|
||||
effectiveDraftKeyRef.current === effectiveDraftKey &&
|
||||
richText.editor &&
|
||||
!richText.editor.isDestroyed &&
|
||||
richText.editor.view.dom.isConnected
|
||||
) {
|
||||
richText.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{linkEditor.card}
|
||||
{linkEditor.dialog}
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
@@ -16,9 +17,12 @@ type NonMemberMentionDialogProps = {
|
||||
isInvitePending: boolean;
|
||||
names: string[];
|
||||
onDismiss: () => void;
|
||||
onDoNothing: () => void;
|
||||
/** Omit when publication requires the intended recipients to be invited. */
|
||||
onDoNothing?: () => void;
|
||||
onInvite: () => void;
|
||||
open: boolean;
|
||||
/** Restore the initiating editor when it still owns the visible draft. */
|
||||
onRestoreFocus?: () => void;
|
||||
};
|
||||
|
||||
export function NonMemberMentionDialog({
|
||||
@@ -30,7 +34,10 @@ export function NonMemberMentionDialog({
|
||||
onDoNothing,
|
||||
onInvite,
|
||||
open,
|
||||
onRestoreFocus,
|
||||
}: NonMemberMentionDialogProps) {
|
||||
const safeActionRef = React.useRef<HTMLButtonElement>(null);
|
||||
const restoreFocusRef = React.useRef(onRestoreFocus);
|
||||
return (
|
||||
<AlertDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
@@ -40,7 +47,20 @@ export function NonMemberMentionDialog({
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
restoreFocusRef.current = onRestoreFocus;
|
||||
safeActionRef.current?.focus();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreFocusRef.current) return;
|
||||
event.preventDefault();
|
||||
// Pending state/inert is released by the async cancellation continuation.
|
||||
// Wait for its React commit; the source checks that it is still visible.
|
||||
requestAnimationFrame(() => restoreFocusRef.current?.());
|
||||
}}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Mention people outside this channel?
|
||||
@@ -48,25 +68,37 @@ export function NonMemberMentionDialog({
|
||||
<AlertDialogDescription>
|
||||
{names.join(", ")} {names.length === 1 ? "is" : "are"} not in this
|
||||
channel.{" "}
|
||||
{canInvite
|
||||
? "Invite them to the channel, or send without inviting them."
|
||||
: `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`}
|
||||
{onDoNothing
|
||||
? canInvite
|
||||
? "Invite them to the channel, or send without inviting them."
|
||||
: `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`
|
||||
: canInvite
|
||||
? "Invite them to the channel, or cancel to keep your draft."
|
||||
: PRIVATE_CHANNEL_ADD_DENIED_MESSAGE}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error ? (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
disabled={isInvitePending}
|
||||
onClick={onDoNothing}
|
||||
ref={safeActionRef}
|
||||
disabled={Boolean(onDoNothing) && isInvitePending}
|
||||
onClick={onDoNothing ?? onDismiss}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{canInvite ? "Do nothing" : "Send anyway"}
|
||||
{onDoNothing
|
||||
? canInvite
|
||||
? "Do nothing"
|
||||
: "Send anyway"
|
||||
: "Cancel"}
|
||||
</Button>
|
||||
{canInvite ? (
|
||||
<Button
|
||||
|
||||
@@ -70,7 +70,7 @@ type UseDraftPersistLifecycleResult = {
|
||||
/** Shared key intent; later visits can revoke a captured continuation. */
|
||||
getComposerRevision: () => number;
|
||||
/** Optimistic send/recovery is not an authored edit or authoritative deletion. */
|
||||
runComposerUpdate: (update: () => void) => void;
|
||||
runComposerUpdate: (update: () => void, pendingImeta?: ImetaMedia[]) => void;
|
||||
/**
|
||||
* Record the latest authored editor content. Empty content is persisted
|
||||
* immediately and remains authoritative across composer remounts until a
|
||||
@@ -155,15 +155,21 @@ export function useDraftPersistLifecycle({
|
||||
const pendingImetaForPersistRef = React.useRef<ImetaMedia[]>([]);
|
||||
const emptyContentIsAuthoritativeRef = React.useRef(false);
|
||||
const isRestoringContentRef = React.useRef(false);
|
||||
const runComposerUpdate = React.useCallback((update: () => void) => {
|
||||
const wasRestoring = isRestoringContentRef.current;
|
||||
isRestoringContentRef.current = true;
|
||||
try {
|
||||
update();
|
||||
} finally {
|
||||
isRestoringContentRef.current = wasRestoring;
|
||||
}
|
||||
}, []);
|
||||
const runComposerUpdate = React.useCallback(
|
||||
(update: () => void, pendingImeta?: ImetaMedia[]) => {
|
||||
// Recovery may notify a mounted source immediately before it unmounts.
|
||||
// Snapshot media synchronously, just as the initial restore below does.
|
||||
if (pendingImeta) pendingImetaForPersistRef.current = pendingImeta;
|
||||
const wasRestoring = isRestoringContentRef.current;
|
||||
isRestoringContentRef.current = true;
|
||||
try {
|
||||
update();
|
||||
} finally {
|
||||
isRestoringContentRef.current = wasRestoring;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const restoredQueuedAttachmentsRef = React.useRef<QueuedMediaAttachment[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
640
desktop/tests/e2e/forum-agent-invitation.spec.ts
Normal file
640
desktop/tests/e2e/forum-agent-invitation.spec.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const OWNER = "deadbeef".repeat(8);
|
||||
const REMOTE = "ed".repeat(32);
|
||||
|
||||
async function install(
|
||||
page: Page,
|
||||
overrides: Parameters<typeof installMockBridge>[1] = {},
|
||||
) {
|
||||
await installMockBridge(page, {
|
||||
...overrides,
|
||||
ownerOnlyAccessBuild: true,
|
||||
managedAgents: [],
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
displayName: "RemoteScout",
|
||||
ownerPubkey: OWNER,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
name: "RemoteScout",
|
||||
ownerPubkey: OWNER,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [],
|
||||
channelNames: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
}
|
||||
async function select(page: Page) {
|
||||
await page.getByTestId("message-input").fill("@Remote");
|
||||
const row = page.getByTestId(`mention-suggestion-${REMOTE}`);
|
||||
await expect(row).toContainText("RemoteScout");
|
||||
await row.locator("button").first().click();
|
||||
await page.keyboard.type("hello");
|
||||
}
|
||||
async function sent(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
|
||||
.filter((event) => event.content === "@RemoteScout hello")
|
||||
.map((event) =>
|
||||
event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]),
|
||||
);
|
||||
if (signed.length) return signed;
|
||||
// New DMs deliberately use the acknowledged native HTTP command rather
|
||||
// than JS sign_event. Assert its exact outgoing recipients, not fake crypto.
|
||||
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).flatMap((call) => {
|
||||
const payload = call.payload as {
|
||||
content?: string;
|
||||
mentionPubkeys?: string[];
|
||||
};
|
||||
return call.command === "send_channel_message" &&
|
||||
payload.content === "@RemoteScout hello"
|
||||
? [payload.mentionPubkeys ?? []]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
}
|
||||
async function assertNoLocalLifecycle(page: Page) {
|
||||
const commands = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMANDS__ ?? [],
|
||||
);
|
||||
for (const command of [
|
||||
"start_managed_agent",
|
||||
"create_managed_agent",
|
||||
"attach_managed_agent",
|
||||
]) {
|
||||
expect(commands).not.toContain(command);
|
||||
}
|
||||
}
|
||||
const FORUM = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
|
||||
|
||||
async function openStandaloneForumInvite(page: Page) {
|
||||
await install(page);
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
||||
await page.getByRole("button", { name: "Start a new post..." }).click();
|
||||
await select(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toContainText("RemoteScout");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Invite", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Cancel", exact: true }),
|
||||
).toBeFocused();
|
||||
expect(await sent(page)).toEqual([]);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function forumAdds(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
||||
(call) => call.command === "add_channel_members",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test("standalone forum explicitly invites, refreshes membership, then sends exact p-tags", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openStandaloneForumInvite(page);
|
||||
expect(await forumAdds(page)).toEqual([]);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: "test-results/forum-invite.png" });
|
||||
// Unlike chat's explicit reference-only option, cancel must not publish.
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Do nothing|Send anyway/ }),
|
||||
).toHaveCount(0);
|
||||
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: "test-results/forum-sent.png" });
|
||||
expect(await forumAdds(page)).toEqual([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
channelId: FORUM,
|
||||
pubkeys: [REMOTE],
|
||||
role: "bot",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
const calls = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMAND_LOG__ ?? [],
|
||||
);
|
||||
const addIndex = calls.findIndex(
|
||||
(call) => call.command === "add_channel_members",
|
||||
);
|
||||
expect(calls.slice(0, addIndex)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
command: "revalidate_relay_agents",
|
||||
payload: expect.objectContaining({
|
||||
channelId: FORUM,
|
||||
pubkeys: [REMOTE],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const afterAdd = calls.slice(addIndex + 1);
|
||||
const refreshIndex = afterAdd.findIndex(
|
||||
(call) => call.command === "get_channel_members",
|
||||
);
|
||||
const finalCheckIndex = afterAdd.findIndex(
|
||||
(call) => call.command === "revalidate_relay_agents",
|
||||
);
|
||||
expect(refreshIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(finalCheckIndex).toBeGreaterThan(refreshIndex);
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
|
||||
test("standalone forum cancelled Invite keeps selected identity and draft without publishing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openStandaloneForumInvite(page);
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByTestId("message-input")).toBeFocused();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
await expect(page.getByTestId("message-input")).toHaveAttribute(
|
||||
"contenteditable",
|
||||
"true",
|
||||
);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
expect(await forumAdds(page)).toEqual([]);
|
||||
// Retry without selecting again must preserve the exact intended recipient.
|
||||
await page.getByTestId("send-message").click();
|
||||
await page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Invite", exact: true })
|
||||
.click();
|
||||
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
|
||||
});
|
||||
|
||||
for (const error of [
|
||||
"actor not authorized",
|
||||
"policy:nobody — this agent has disabled external channel additions",
|
||||
"relay unavailable during add",
|
||||
]) {
|
||||
test(`standalone forum denied/failed add preserves draft and publishes nothing: ${error}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openStandaloneForumInvite(page);
|
||||
await page.evaluate((error) => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.addChannelMembersErrors = [error];
|
||||
}, error);
|
||||
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toHaveText(error);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `test-results/forum-error-${error.split(" ")[0].replace(/[^a-zA-Z0-9_-]/g, "-")}.png`,
|
||||
});
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(page.getByTestId("message-input")).toHaveAttribute(
|
||||
"contenteditable",
|
||||
"true",
|
||||
);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
}
|
||||
|
||||
test("standalone forum selected identity revoked before Invite fails visibly without adding", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openStandaloneForumInvite(page);
|
||||
await page.evaluate((pubkey) => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
|
||||
}, REMOTE);
|
||||
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect(
|
||||
dialog.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
expect(await forumAdds(page)).toEqual([]);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test("standalone forum still requires final authorization after a successful add", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openStandaloneForumInvite(page);
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.addChannelMembersDelayMs = 1_000;
|
||||
});
|
||||
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect.poll(async () => (await forumAdds(page)).length).toBe(1);
|
||||
// Preparation already passed; revoke during the add, before final publish.
|
||||
await page.evaluate((pubkey) => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
|
||||
}, REMOTE);
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test("navigation during an outstanding forum Invite never publishes to either channel", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openStandaloneForumInvite(page);
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.addChannelMembersDelayMs = 800;
|
||||
});
|
||||
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect.poll(async () => (await forumAdds(page)).length).toBe(1);
|
||||
// Route navigation unmounts the composer even while its modal traps focus.
|
||||
await page.evaluate(() => {
|
||||
window.location.hash = "/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
});
|
||||
|
||||
// Hold the actual IPC boundary rather than rely on wall-clock sleeps.
|
||||
type ForumGateWindow = Window & {
|
||||
__TAURI_INTERNALS__: {
|
||||
invoke: (command: string, payload?: unknown) => Promise<unknown>;
|
||||
};
|
||||
forumGateEntered?: boolean;
|
||||
forumGateDone?: boolean;
|
||||
releaseForumGate?: () => void;
|
||||
};
|
||||
async function holdForumCommand(
|
||||
page: Page,
|
||||
heldCommand: string,
|
||||
skip = 0,
|
||||
failure?: string,
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ heldCommand, skip, failure }) => {
|
||||
const state = window as unknown as ForumGateWindow;
|
||||
const invoke = state.__TAURI_INTERNALS__.invoke;
|
||||
state.forumGateEntered = false;
|
||||
state.forumGateDone = false;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
state.releaseForumGate = resolve;
|
||||
});
|
||||
state.__TAURI_INTERNALS__.invoke = async (command, payload) => {
|
||||
if (command !== heldCommand || skip-- > 0)
|
||||
return invoke(command, payload);
|
||||
state.__TAURI_INTERNALS__.invoke = invoke;
|
||||
state.forumGateEntered = true;
|
||||
await gate;
|
||||
try {
|
||||
if (failure) throw new Error(failure);
|
||||
return await invoke(command, payload);
|
||||
} finally {
|
||||
state.forumGateDone = true;
|
||||
}
|
||||
};
|
||||
},
|
||||
{ heldCommand, skip, failure },
|
||||
);
|
||||
}
|
||||
async function waitForForumGate(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => (window as unknown as ForumGateWindow).forumGateEntered,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
async function releaseForumGate(page: Page) {
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as ForumGateWindow).releaseForumGate?.(),
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as ForumGateWindow).forumGateDone),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
for (const stage of ["add", "publish"] as const) {
|
||||
for (const replacement of [null, "new A draft", ""] as const) {
|
||||
test(`forum source visit ${stage}: A→B→A retains source draft; replacement=${replacement}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page, {
|
||||
deferredComposerUploads: true,
|
||||
uploadDescriptors: [
|
||||
{
|
||||
url: `https://mock.relay/media/${"f".repeat(64)}.pdf`,
|
||||
sha256: "f".repeat(64),
|
||||
size: 12345,
|
||||
type: "application/pdf",
|
||||
uploaded: Math.floor(Date.now() / 1000),
|
||||
filename: "source.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||||
channelName: "watercooler",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const roots = await page.evaluate(() =>
|
||||
["Source forum A", "Source forum B"].map(
|
||||
(content) =>
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "watercooler",
|
||||
content,
|
||||
kind: 45001,
|
||||
})?.id,
|
||||
),
|
||||
);
|
||||
expect(roots.every(Boolean)).toBe(true);
|
||||
const navigate = async (index: number) => {
|
||||
await page.evaluate(
|
||||
({ forum, id }) => {
|
||||
window.location.hash = `/channels/${forum}/posts/${id}`;
|
||||
},
|
||||
{ forum: FORUM, id: roots[index] },
|
||||
);
|
||||
await expect(
|
||||
page.getByText(index === 0 ? "Source forum A" : "Source forum B", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("message-input")).toHaveAttribute(
|
||||
"contenteditable",
|
||||
"true",
|
||||
);
|
||||
};
|
||||
// Cache both threads before the critical transition, so query loading
|
||||
// cannot accidentally supply the missing source-visit boundary.
|
||||
await navigate(1);
|
||||
await page.getByTestId("message-input").fill("B owns this draft");
|
||||
await navigate(0);
|
||||
await select(page);
|
||||
if (replacement === "new A draft") {
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Remove attachment" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await page.getByTestId("send-message").click();
|
||||
await holdForumCommand(
|
||||
page,
|
||||
stage === "add" ? "add_channel_members" : "revalidate_relay_agents",
|
||||
stage === "publish" ? 1 : 0,
|
||||
);
|
||||
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await waitForForumGate(page);
|
||||
await navigate(1);
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"B owns this draft",
|
||||
);
|
||||
await navigate(0);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
if (replacement === "new A draft")
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Remove attachment" }),
|
||||
).toBeVisible();
|
||||
if (replacement !== null)
|
||||
await page.getByTestId("message-input").fill(replacement);
|
||||
await navigate(1);
|
||||
await releaseForumGate(page);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter(
|
||||
(event) => event.kind === 45001 || event.kind === 45003,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"B owns this draft",
|
||||
);
|
||||
await navigate(0);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
replacement ?? "@RemoteScout hello",
|
||||
);
|
||||
if (replacement === null) {
|
||||
// No re-selection: a recovered selected identity must still route exactly.
|
||||
await page.getByTestId("send-message").click();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
if (await dialog.count())
|
||||
await dialog
|
||||
.getByRole("button", { name: "Invite", exact: true })
|
||||
.click();
|
||||
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
|
||||
}
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("forum Escape cancels a pending add, restores focus, and a late add cannot publish", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openStandaloneForumInvite(page);
|
||||
await holdForumCommand(page, "add_channel_members");
|
||||
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await waitForForumGate(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByTestId("message-input")).toBeFocused();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
await releaseForumGate(page);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test("shared chat dialog keeps reference-only action and restores composer focus after Escape", async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await select(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Do nothing", exact: true }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByTestId("message-input")).toBeFocused();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
await page.getByTestId("send-message").click();
|
||||
await dialog.getByRole("button", { name: "Do nothing", exact: true }).click();
|
||||
await expect.poll(() => sent(page)).toEqual([[]]);
|
||||
expect(await forumAdds(page)).toEqual([]);
|
||||
});
|
||||
|
||||
for (const replacement of [null, "new A draft", ""] as const) {
|
||||
test(`forum failed dispatched reply A→B→A recovers only without newer intent: ${replacement}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page, {
|
||||
deferredComposerUploads: true,
|
||||
uploadDescriptors: [
|
||||
{
|
||||
url: `https://mock.relay/media/${"f".repeat(64)}.pdf`,
|
||||
sha256: "f".repeat(64),
|
||||
size: 12345,
|
||||
type: "application/pdf",
|
||||
uploaded: Math.floor(Date.now() / 1000),
|
||||
filename: "source.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||||
channelName: "watercooler",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const roots = await page.evaluate(() =>
|
||||
["Transport A", "Transport B"].map(
|
||||
(content) =>
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "watercooler",
|
||||
content,
|
||||
kind: 45001,
|
||||
})?.id,
|
||||
),
|
||||
);
|
||||
expect(roots.every(Boolean)).toBe(true);
|
||||
const navigate = async (index: number) => {
|
||||
await page.evaluate(
|
||||
({ forum, id }) => {
|
||||
window.location.hash = `/channels/${forum}/posts/${id}`;
|
||||
},
|
||||
{ forum: FORUM, id: roots[index] },
|
||||
);
|
||||
await expect(
|
||||
page.getByText(index === 0 ? "Transport A" : "Transport B", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("message-input")).toHaveAttribute(
|
||||
"contenteditable",
|
||||
"true",
|
||||
);
|
||||
};
|
||||
await navigate(1);
|
||||
await page.getByTestId("message-input").fill("B owns this draft");
|
||||
await navigate(0);
|
||||
await select(page);
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Remove attachment" }),
|
||||
).toBeVisible();
|
||||
await holdForumCommand(
|
||||
page,
|
||||
"send_channel_message",
|
||||
0,
|
||||
"transport offline",
|
||||
);
|
||||
await page.getByTestId("send-message").click();
|
||||
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await waitForForumGate(page);
|
||||
await navigate(1);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"B owns this draft",
|
||||
);
|
||||
await navigate(0);
|
||||
await expect(page.getByTestId("message-input")).toHaveText("");
|
||||
if (replacement !== null) {
|
||||
// An actual edit -> delete is authoritative, not an unchanged empty editor.
|
||||
await page.getByTestId("message-input").fill("replacement intent");
|
||||
if (replacement === "") {
|
||||
await page.getByTestId("message-input").press("ControlOrMeta+a");
|
||||
await page.getByTestId("message-input").press("Backspace");
|
||||
} else {
|
||||
await page.getByTestId("message-input").fill(replacement);
|
||||
}
|
||||
await expect(page.getByTestId("message-input")).toHaveText(replacement);
|
||||
}
|
||||
await releaseForumGate(page);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
replacement ?? "@RemoteScout hello",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Remove attachment" }),
|
||||
).toHaveCount(replacement === null ? 1 : 0);
|
||||
await navigate(1);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"B owns this draft",
|
||||
);
|
||||
await navigate(0);
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
replacement ?? "@RemoteScout hello",
|
||||
);
|
||||
// Recovery only restores authorship: no automatic send, add, or signing replay.
|
||||
expect(await forumAdds(page)).toHaveLength(1);
|
||||
const replies = () =>
|
||||
page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
||||
(call) =>
|
||||
call.command === "send_channel_message" &&
|
||||
(call.payload as { kind?: number })?.kind === 45003,
|
||||
),
|
||||
);
|
||||
expect(await replies()).toHaveLength(0);
|
||||
if (replacement === null) {
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect.poll(replies).toHaveLength(1);
|
||||
expect((await replies())[0].payload).toEqual(
|
||||
expect.objectContaining({
|
||||
channelId: FORUM,
|
||||
mentionPubkeys: [REMOTE],
|
||||
content: expect.stringContaining("@RemoteScout hello"),
|
||||
mediaTags: expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
"imeta",
|
||||
`url https://mock.relay/media/${"f".repeat(64)}.pdf`,
|
||||
]),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
}
|
||||
52
docs/forum-agent-invitation.md
Normal file
52
docs/forum-agent-invitation.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Standalone forum agent invitation
|
||||
|
||||
The standalone forum composer reuses the remote mention preparation/publication
|
||||
contract (`docs/remote-mention-routing.md`). An eligible owned relay nonmember
|
||||
opens an explicit Invite/Cancel dialog. There is no implicit reference-only send.
|
||||
|
||||
Invite revalidates the captured exact recipients in the preparation phase,
|
||||
performs the existing authorized bot-member add, awaits membership invalidation,
|
||||
then performs a fresh publication-phase check against the captured destination.
|
||||
A successful add alone is never authorization to publish.
|
||||
|
||||
Cancel resolves the pending submission without clearing its text or selected
|
||||
identity. Retry can use the same exact key. Rejected adds and preparation errors
|
||||
remain visible inside the dialog; final authorization errors remain visible in
|
||||
the composer and retain its draft. Channel change/unmount cancels pending work,
|
||||
and completions must still belong to the mounted captured channel. An add already
|
||||
accepted by the relay is not rolled back on cancellation; no message is sent.
|
||||
|
||||
This adapter does not create/manage local agents or change notes/channel-less
|
||||
surfaces. Regression coverage in `forum-agent-invitation.spec.ts` covers exact
|
||||
p-tags, membership refresh before final authorization, cancel/retry, three add
|
||||
failures, policy revocation before Invite and during add, and navigation during
|
||||
an outstanding add. Browser fixtures are mock IPC; signed native ownership and
|
||||
membership validation live in PR6, not in the frontend bridge.
|
||||
|
||||
## Dispatched reply recovery
|
||||
|
||||
After authorized dispatch, transport rejection recovers the captured text,
|
||||
completed/uploaded media and exact selected mention refs under the source draft
|
||||
key. This extends the standalone-forum follow-up noted in the shared routing
|
||||
document: a cached A → B → A visit with no new intent no longer loses its reply.
|
||||
The departed editor is never called. A pristine, empty current source visit may
|
||||
adopt the recovered store entry; B cannot receive A's text or attachments.
|
||||
|
||||
The existing same-window draft authority fences recovery across visits. New text
|
||||
(including edit → delete), completed-media changes/upload intent, selected refs,
|
||||
explicit draft deletion, a new send or a community/identity reset revoke older
|
||||
recovery. A different existing stored payload is not overwritten. Optimistic
|
||||
clear and recovery are programmatic, not authored deletion; synchronous media
|
||||
snapshots protect cleanup immediately after recovery. No recovery path repeats
|
||||
publication, undoes an accepted add or releases another visit's pending send.
|
||||
|
||||
`ForumComposer.lifecycle.test.mjs` exercises ordinary and invited sends,
|
||||
source-key re-entry, newer intent/deletion/media/refs, scope reset and late
|
||||
completion. The browser spec additionally rejects deferred `send_channel_message`
|
||||
after cached A → B → A with uploaded media and verifies recovery versus newer
|
||||
text/deletion, B isolation and exact recipients on an explicit retry.
|
||||
|
||||
Recovery is not a durable in-flight send journal: renderer reload/crash while the
|
||||
transport is pending can still lose the in-memory snapshot. Local in-flight
|
||||
upload custody, cross-window coordination, native authorization and atomic
|
||||
relay publication are outside this correction.
|
||||
Reference in New Issue
Block a user