From 7455ba4c8ece5181795ae907cc98b6ecf1c23acd Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:53:19 -0800 Subject: [PATCH] feat(members): add several people to a chat in one write Squashes the reviewed branch (c5454d69d..e4dab006f) for rebasing onto main: batch additions, a per-batch roster recheck, web signing, roster rereads after any dispatch attempt, batch settlement once everyone it names is settled, and leaving out anyone archived during the roster read. Rebased onto main's guest and Solo member roles: a batch groups people by the same role a single addition sends (an explicit Guest, Bot, or Member for Solo and private channels), web signing accepts Guest batches, Outbox Retry keeps a guest batch Guest and keeps single retries receipt-only, Retry never joins a batch, and a Solo chat's resolved no-role invitation stays with the single addition, which reports a Guest outcome. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- browser-host/channel-commands.mjs | 34 +- browser-host/channel-commands.test.mjs | 31 ++ browser-host/relay-broker-api.test.mjs | 36 +- docs/solo/sharing.md | 9 + src/bundled/channels/OutboxStatus.test.tsx | 60 +++- src/bundled/channels/OutboxStatus.tsx | 51 ++- src/features/channel-members/members.test.ts | 339 +++++++++++++++++- src/features/channel-members/members.ts | 299 +++++++++++++-- .../channel-members/operations.test.ts | 56 +++ src/features/channel-members/operations.ts | 43 ++- .../messages/MessageComposer.test.tsx | 91 +++++ src/features/messages/useBringAgents.tsx | 20 +- .../messages/useNonmemberMentions.tsx | 14 +- src/features/relay/channel-writes.ts | 2 + src/features/relay/session.ts | 32 +- src/features/solo/share.test.ts | 73 +++- src/features/solo/share.ts | 22 +- 17 files changed, 1122 insertions(+), 90 deletions(-) diff --git a/browser-host/channel-commands.mjs b/browser-host/channel-commands.mjs index 973f874f1e..1748cce9ca 100644 --- a/browser-host/channel-commands.mjs +++ b/browser-host/channel-commands.mjs @@ -22,7 +22,7 @@ export function validChannelCommand(event) { if (clientId.length !== 2 || !uuid.test(clientId[1])) return false; tags = tags.slice(0, -1); } - const [h, p] = tags; + const [h] = tags; if (h?.length !== 2 || h[0] !== "h" || !uuid.test(h[1])) return false; if (event.kind === 9007) { const expected = ["h", "name", "visibility", "channel_type"]; @@ -85,19 +85,29 @@ export function validChannelCommand(event) { String(Number(ttlValue)) === ttlValue)) ); } - // An invitation may name the participant role, or the read-only guest role - // Bestie joins private channels with. It never names a role above member. - if (event.kind === 9000) + if (event.kind === 9000) { + // block/buzz#8575: up to 100 distinct `p` tags. An invitation may name + // the participant role, or the read-only guest role Bestie joins private + // channels with. Several people may share the Bot role a single agent + // enrollment already signs; one agent keeps that exact enrollment shape. + // Never an elevation. + const role = tags.at(-1)?.[0] === "role" ? tags.at(-1) : undefined; + const people = tags.slice(1, role ? -1 : undefined); return ( event.content === "" && - (tags.length === 2 || - (tags.length === 3 && - tags[2].length === 2 && - tags[2][0] === "role" && - (tags[2][1] === "member" || tags[2][1] === "guest"))) && - p?.length === 2 && - p[0] === "p" && - /^[0-9a-f]{64}$/.test(p[1]) + people.length >= 1 && + people.length <= 100 && + people.every( + (tag) => + tag.length === 2 && tag[0] === "p" && /^[0-9a-f]{64}$/.test(tag[1]), + ) && + new Set(people.map(([, key]) => key)).size === people.length && + (!role || + (role.length === 2 && + (role[1] === "member" || + role[1] === "guest" || + (role[1] === "bot" && people.length > 1)))) ); + } return false; } diff --git a/browser-host/channel-commands.test.mjs b/browser-host/channel-commands.test.mjs index f9f6993a0d..806cd2daa8 100644 --- a/browser-host/channel-commands.test.mjs +++ b/browser-host/channel-commands.test.mjs @@ -131,6 +131,37 @@ it("accepts one trailing outbox identifier for invitations and rejects ambiguous expect(validChannelCommand(invite(tags))).toBe(false); }); +it("signs a batch invitation of up to 100 distinct people without elevating roles", () => { + const invite = (tags) => ({ kind: 9000, created_at: 1, content: "", tags }); + const h = ["h", id], + client = ["client-id", id]; + const people = (count) => + Array.from({ length: count }, (_, index) => [ + "p", + index.toString(16).padStart(64, "0"), + ]); + for (const role of [ + [], + [["role", "member"]], + [["role", "guest"]], + [["role", "bot"]], + ]) + expect( + validChannelCommand(invite([h, ...people(100), ...role, client])), + ).toBe(true); + for (const tags of [ + [h, ...people(101)], + [h, ...people(2), people(1)[0]], + [h, ...people(2), ["role", "admin"]], + [h, ...people(2), ["role", "owner"]], + [h, ["role", "member"]], + [h, people(1)[0], ["role", "bot"]], + [h, people(2)[0], ["role", "member"], people(2)[1]], + [h, ["p", "a".repeat(64), "admin"], ...people(1)], + ]) + expect(validChannelCommand(invite(tags))).toBe(false); +}); + it("allows bounded ordinary stream creation and rejects malformed TTL", () => { const create = { kind: 9007, diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 1d833c28c2..706dd7324a 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -1806,14 +1806,40 @@ test("member and guest additions work without channel creation, while role eleva expect(h.publications[2].tags).toEqual( expect.arrayContaining([["role", "guest"]]), ); - for (const role of ["owner", "admin"]) { - const denied = await h.post("sign", { + // block/buzz#8575: one addition may name several people. + const people = [ + ["p", "b".repeat(64)], + ["p", "c".repeat(64)], + ]; + for (const role of [ + [], + [["role", "member"]], + [["role", "guest"]], + [["role", "bot"]], + ]) { + const batchId = owner.outbox.send({ kind: 9000, content: "", - created_at: 1700000000, - tags: [...tags, ["role", role]], + tags: [...tags, ...people, ...role], }); - expect(denied.status).toBe(400); + await vi.waitFor(() => + expect( + owner.local.snapshot().find((row) => row.event.id === batchId) + ?.delivery, + ).toBe("accepted"), + ); + } + expect(h.publications).toHaveLength(7); + for (const role of ["owner", "admin"]) { + for (const named of [tags, [...tags, ...people]]) { + const denied = await h.post("sign", { + kind: 9000, + content: "", + created_at: 1700000000, + tags: [...named, ["role", role]], + }); + expect(denied.status).toBe(400); + } } } finally { owner?.dispose(); diff --git a/docs/solo/sharing.md b/docs/solo/sharing.md index da0f6725e9..f55e8d1a23 100644 --- a/docs/solo/sharing.md +++ b/docs/solo/sharing.md @@ -65,6 +65,15 @@ grant fails, the dialog shows the error and **Retry share**; a retry reads the channel again and grants only whoever is still missing. Grants already confirmed stay. With no channel selected, **Done** closes without writes. +People added together (a share, a team, mentioned nonmembers or brought agents) +go in one kind 9000 per role, at most 100 each, and the roster is read once after. +A relay without batch support adds only the first person an event names, so the +roster decides who was added; anyone else, or anyone with an earlier addition +still pending, gets the single addition, which reports their own failure. A batch +is never resent, and stays in Outbox until everyone it names is in the roster or +has their own addition; its **Retry** checks each person again. See the +relay's kind 9000 entry in block/buzz `NOSTR.md`. + The sidebar's Shared mark is status only: it uses the verified current roster and cached non-agent evidence for an additional human. Unknown profiles remain unclassified; absence of the indicator is **not** a privacy promise. Clicking the diff --git a/src/bundled/channels/OutboxStatus.test.tsx b/src/bundled/channels/OutboxStatus.test.tsx index 04c17b89fa..e5f59f188b 100644 --- a/src/bundled/channels/OutboxStatus.test.tsx +++ b/src/bundled/channels/OutboxStatus.test.tsx @@ -44,11 +44,13 @@ const invitation = { function show(item: OutgoingEvent) { const retry = vi.fn(); const add = vi.fn(async () => {}); + const acknowledge = vi.fn(async () => {}); const items = [item]; const outbox = { snapshot: () => items, subscribe: () => () => {}, retry, + acknowledge, dismiss: vi.fn(), } as unknown as Outbox; render( @@ -59,7 +61,7 @@ function show(item: OutgoingEvent) { />, ); fireEvent.click(screen.getByText("Outbox · 1 items")); - return { retry, add }; + return { retry, add, acknowledge }; } it("does not offer generic retry for guarded invitations from either profile or composer", () => { @@ -177,6 +179,62 @@ it("retries a guest addition as a guest", async () => { ); }); +it("retries a guest batch per person as guests, each as a new addition", async () => { + const { add } = show({ + ...invitation, + event: { + ...invitation.event, + tags: [ + ["h", "channel"], + ["p", "one"], + ["p", "two"], + ["role", "guest"], + ], + }, + recovery: { key: "member-add:batch:operation", value: "1" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => + expect(add.mock.calls).toEqual([ + ["channel", "one", { role: "guest" }], + ["channel", "two", { role: "guest" }], + ]), + ); +}); + +it.each(["failed", "accepted"] as const)( + "retries a %s batch addition per person, never resending it", + async (delivery) => { + const { retry, add, acknowledge } = show({ + ...invitation, + delivery, + event: { + ...invitation.event, + tags: [ + ["h", "channel"], + ["p", "one"], + ["p", "two"], + ["role", "member"], + ], + }, + recovery: { key: "member-add:batch:operation", value: "1" }, + }); + expect(screen.getByText(/Add 2 people/)).toBeVisible(); + add.mockImplementation(async (...args: unknown[]) => { + if (args[1] === "two") throw new Error("Could not add two"); + }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText(/Could not add two/)).toBeVisible(); + expect(add.mock.calls).toEqual([ + ["channel", "one", {}], + ["channel", "two", {}], + ]); + // Additions settle the batch once everyone is; Retry never clears it. + expect(acknowledge).not.toHaveBeenCalled(); + expect(retry).not.toHaveBeenCalled(); + }, +); + it.each( (["failed", "unknown", "admitted"] as const).flatMap((failure) => [false, true].map((restart) => ({ failure, restart })), diff --git a/src/bundled/channels/OutboxStatus.tsx b/src/bundled/channels/OutboxStatus.tsx index ce75595e4e..18cd71354a 100644 --- a/src/bundled/channels/OutboxStatus.tsx +++ b/src/bundled/channels/OutboxStatus.tsx @@ -6,6 +6,15 @@ import { RelayTimings } from "./RelayTimings"; import { PRODUCT_FEEDBACK_KIND } from "../../features/relay/product-feedback"; import type { Outbox } from "../../features/relay/outbox"; import type { RelaySession } from "../../features/relay/session"; +import { isBatchAddition } from "../../features/channel-members/members"; + +/** A batch names everyone its Retry re-adds. */ +function additionLabel(tags: readonly (readonly string[])[]) { + const people = tags.filter(([tag]) => tag === "p"); + return people.length > 1 + ? `Add ${people.length} people` + : `Add agent ${people[0]?.[1]?.slice(0, 12) ?? ""}`; +} /** Delivery belongs to the session, including messages from other pages or a previous launch. */ export function OutboxStatus({ @@ -39,7 +48,7 @@ export function OutboxStatus({
  • {item.event.kind === 9000 - ? `Add agent ${item.event.tags.find(([tag]) => tag === "p")?.[1]?.slice(0, 12) ?? ""}` + ? additionLabel(item.event.tags) : item.event.content.slice(0, 100)}{" "} ·{" "} { @@ -54,7 +63,10 @@ export function OutboxStatus({ {" "} {!isWorkflowOperation(item.event) && !item.guarded && - (item.delivery === "failed" || item.delivery === "unknown") && ( + (item.delivery === "failed" || + item.delivery === "unknown" || + // A kept batch still names someone unsettled. + (isBatchAddition(item) && item.delivery !== "sending")) && (