fix(desktop): ask before mentioning a person outside a forum

A forum now signs only current members, so a person outside the forum
gets the same Invite or Cancel prompt that relay agents already get.
Invite adds people as members and agents as bots, then publishes.
Cancel keeps the draft. Before this, the publish roster check refused the
mention with no way to add the person.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-02 16:34:26 -04:00
committed by Larry
parent ec2417bebe
commit d77f81034f
3 changed files with 77 additions and 14 deletions

View File

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

View File

@@ -11,6 +11,7 @@ type PendingInvite = {
pubkeys: string[];
nonMemberPubkeys: string[];
intendedAgentPubkeys: string[];
agentPubkeys: ReadonlySet<string>;
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);

View File

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