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.

![Forum invitation with Invite and
Cancel](https://raw.githubusercontent.com/block/buzz/6481bd088d661975672addcc834231df6ac88705/pr-7125--forum-invite.png)

*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:
Logan Johnson
2026-09-04 13:33:30 -04:00
committed by GitHub
parent d595806fc3
commit e7e29937a1
13 changed files with 1781 additions and 45 deletions

View File

@@ -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",

View 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");
});
}

View File

@@ -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}
</>

View File

@@ -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). */

View File

@@ -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..."

View File

@@ -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) => {

View 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);
};
}, []);
}

View 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,
},
};
}

View File

@@ -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}
</>

View File

@@ -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

View File

@@ -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[]>(
[],
);

View 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);
});
}

View 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.