From e7e29937a145aca7a3c7f5436b07e32c6c20087f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 13:33:30 -0400 Subject: [PATCH] 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 Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + .../forum/ui/ForumComposer.lifecycle.test.mjs | 575 ++++++++++++++++ .../src/features/forum/ui/ForumComposer.tsx | 180 ++++- .../features/forum/ui/ForumComposer.types.ts | 2 + .../features/forum/ui/ForumThreadPanel.tsx | 3 + desktop/src/features/forum/ui/ForumView.tsx | 3 + .../forum/ui/useForumDraftRecovery.ts | 94 +++ .../forum/ui/useForumMentionPreparation.ts | 186 +++++ .../features/messages/ui/MessageComposer.tsx | 14 +- .../messages/ui/NonMemberMentionDialog.tsx | 50 +- .../messages/ui/useDraftPersistSnapshot.ts | 26 +- .../tests/e2e/forum-agent-invitation.spec.ts | 640 ++++++++++++++++++ docs/forum-agent-invitation.md | 52 ++ 13 files changed, 1781 insertions(+), 45 deletions(-) create mode 100644 desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs create mode 100644 desktop/src/features/forum/ui/useForumDraftRecovery.ts create mode 100644 desktop/src/features/forum/ui/useForumMentionPreparation.ts create mode 100644 desktop/tests/e2e/forum-agent-invitation.spec.ts create mode 100644 docs/forum-agent-invitation.md diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 1304e66ba3..aad2580dad 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs new file mode 100644 index 0000000000..65e2912ce7 --- /dev/null +++ b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs @@ -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("", { + 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"); + }); +} diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index fd661cc60e..724487501e 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -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 ( + + ); +} + +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()); + 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({ )} + { + if (mountedRef.current && !isSubmissionPendingRef.current) + richText.focus(); + }} + /> {!isSubmissionPending && linkEditor.card} {!isSubmissionPending && linkEditor.dialog} diff --git a/desktop/src/features/forum/ui/ForumComposer.types.ts b/desktop/src/features/forum/ui/ForumComposer.types.ts index bd3d9d2ef7..8638b64c4c 100644 --- a/desktop/src/features/forum/ui/ForumComposer.types.ts +++ b/desktop/src/features/forum/ui/ForumComposer.types.ts @@ -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). */ diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 38848ad7e0..e4b3a4eb75 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -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({ setIsComposerOpen(false)} onSubmit={async (content, mentionPubkeys, mediaTags) => { diff --git a/desktop/src/features/forum/ui/useForumDraftRecovery.ts b/desktop/src/features/forum/ui/useForumDraftRecovery.ts new file mode 100644 index 0000000000..75a15c0488 --- /dev/null +++ b/desktop/src/features/forum/ui/useForumDraftRecovery.ts @@ -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); + }; + }, []); +} diff --git a/desktop/src/features/forum/ui/useForumMentionPreparation.ts b/desktop/src/features/forum/ui/useForumMentionPreparation.ts new file mode 100644 index 0000000000..09a7392a82 --- /dev/null +++ b/desktop/src/features/forum/ui/useForumMentionPreparation.ts @@ -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(null); + const [error, setError] = React.useState(null); + const [isInviting, setIsInviting] = React.useState(false); + const pendingRef = React.useRef(null); + const invitingRef = React.useRef(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((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, + }, + }; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 60610b82d5..6286ebfb56 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -991,7 +991,19 @@ function MessageComposerImpl({ - + { + if ( + effectiveDraftKeyRef.current === effectiveDraftKey && + richText.editor && + !richText.editor.isDestroyed && + richText.editor.view.dom.isConnected + ) { + richText.focus(); + } + }} + /> {linkEditor.card} {linkEditor.dialog} diff --git a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx index c72686a642..568b751616 100644 --- a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx +++ b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx @@ -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(null); + const restoreFocusRef = React.useRef(onRestoreFocus); return ( { @@ -40,7 +47,20 @@ export function NonMemberMentionDialog({ }} open={open} > - + { + 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?.()); + }} + > Mention people outside this channel? @@ -48,25 +68,37 @@ export function NonMemberMentionDialog({ {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} {error ? ( -

+

{error}

) : null} {canInvite ? (