diff --git a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs index 91c8b01157..ec6770829c 100644 --- a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs +++ b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs @@ -141,9 +141,10 @@ async function setup(options = {}) { }, "@/features/channels/hooks": { useAddChannelMembersMutation: () => ({ - mutateAsync: async () => { - calls.push(["add"]); + mutateAsync: async (input) => { + calls.push(["add", input]); if (control.add) await control.add.promise; + if (control.onAdd) control.onAdd(input); return { errors: [] }; }, }), @@ -1215,3 +1216,52 @@ for (const main of [false, true]) assert.ok(sends[0].flat(2).includes(HUMAN)); assert.ok(sends[0].flat(2).includes(KEY)); }); + +// A forum signs only current members, so a person outside the channel must +// be invited first, as a member. Cancel keeps the draft and sends nothing. +for (const choice of ["invite", "cancel"]) { + test(`mounted forum asks before mentioning a person outside the channel: ${choice}`, async () => { + const s = await setup({ realMentions: true }); + s.control.cachedMembers = [ + { pubkey: HUMAN, role: "member", isAgent: false, displayName: "Pat" }, + ]; + s.control.liveMembers = [PAT]; + s.navigate("b"); + await s.open(); + await s.choose("Pat"); + const text = s.text; + // Pat leaves the cached roster too, so the composer knows to ask. + s.control.cachedMembers = []; + s.control.liveMembers = []; + s.navigate("a"); + s.navigate("b"); + await act(async () => {}); + s.control.onAdd = ({ pubkeys }) => { + s.control.liveMembers = pubkeys.includes(HUMAN) ? [PAT] : []; + }; + await s.submit(); + assert.equal(s.prompt.open, true); + assert.deepEqual([...s.prompt.names], ["Pat"]); + if (choice === "invite") await s.invite(); + else await s.dismiss(); + await act(async () => {}); + const sends = s.calls.filter(([name]) => name === "send"); + if (choice === "cancel") { + assert.equal(sends.length, 0); + assert.equal(s.text.trim(), text.trim(), "the draft is kept"); + return; + } + assert.deepEqual( + s.calls + .filter(([name]) => name === "add") + .map(([, { channelId, pubkeys, role }]) => ({ + channelId, + pubkeys: [...pubkeys], + role, + })), + [{ channelId: "forum", pubkeys: [HUMAN], role: "member" }], + ); + assert.equal(sends.length, 1); + assert.ok(sends[0].flat(2).includes(HUMAN), "the invited person is tagged"); + }); +} diff --git a/desktop/src/features/forum/ui/useForumMentionPreparation.ts b/desktop/src/features/forum/ui/useForumMentionPreparation.ts index 3289a5e806..1866f8d828 100644 --- a/desktop/src/features/forum/ui/useForumMentionPreparation.ts +++ b/desktop/src/features/forum/ui/useForumMentionPreparation.ts @@ -11,6 +11,7 @@ type PendingInvite = { pubkeys: string[]; nonMemberPubkeys: string[]; intendedAgentPubkeys: string[]; + agentPubkeys: ReadonlySet; resolve: (invited: boolean) => void; }; @@ -72,15 +73,16 @@ export function useForumMentionPreparation( .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. + // Publication signs only current members, so a mentioned nonmember must + // be invited first. People join as members and relay-only agents use + // the same bot add as chat. Local managed-agent lifecycle and + // channel-less/notes surfaces are not part of this adapter. const nonMemberPubkeys = capturedChannelId && channelType === "forum" && mentions.hasResolvedMembers ? [...new Set(pubkeys.map(normalizePubkey))].filter( (pubkey) => - agentPubkeys.has(pubkey) && !mentions.isManagedAgentPubkey(pubkey) && !mentions.memberPubkeys.has(pubkey), ) @@ -92,6 +94,7 @@ export function useForumMentionPreparation( pubkeys, nonMemberPubkeys, intendedAgentPubkeys, + agentPubkeys, resolve, }; pendingRef.current = draft; @@ -140,15 +143,21 @@ export function useForumMentionPreparation( 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; + for (const role of ["member", "bot"] as const) { + const pubkeys = draft.nonMemberPubkeys.filter( + (pubkey) => draft.agentPubkeys.has(pubkey) === (role === "bot"), + ); + if (pubkeys.length === 0) continue; + const result = await addMembers.mutateAsync({ + channelId: draft.channelId, + pubkeys, + role, + }); + if (!isCurrent()) return; + if (result.errors.length > 0) { + setError(result.errors.map((failure) => failure.error).join("; ")); + return; + } } pendingRef.current = null; setPending(null); diff --git a/desktop/tests/e2e/mention-clipboard.spec.ts b/desktop/tests/e2e/mention-clipboard.spec.ts index 19cd599c08..81083e11c9 100644 --- a/desktop/tests/e2e/mention-clipboard.spec.ts +++ b/desktop/tests/e2e/mention-clipboard.spec.ts @@ -796,7 +796,11 @@ test("forum post and reply selection copies carry the mention", async ({ await expect(input).toHaveText(FORUM_REPLY_BODY); await expect(input.locator(".mention-chip")).toHaveText("John Smith"); + // John is not in the forum: a forum signs only members, so it asks first. await page.getByTestId("send-message").click(); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toContainText("John Smith"); + await dialog.getByRole("button", { name: "Invite", exact: true }).click(); await expect(input).toHaveText(""); await expect .poll(() => readSentMentionPubkeys(page, FORUM_REPLY_BODY))