diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index b3ac9dd387..c1da4487d9 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -158,8 +158,31 @@ that agent in Buzz; they do not notify the external app through this connection.
the same key as another shortcut, Settings → Shortcuts marks it "Also used
by"; change one of them to choose which fires.
- **Sharing a Solo chat** grants access to its conversation history, not only
- the next message. Copying its link alone does not grant access. Check the
- intended people and destination before changing access.
+ the next message. Its access dialog offers **View only** for community reading
+ or **Private** for people added to the chat. Public is not offered in
+ this UI; a Public policy set through another client appears as
+ **Public (existing)**. This is not protocol enforcement. View-only visitors
+ cannot post; existing members keep their roles. Returning to Private warns
+ about retained viewers unless verified roles show no Guests, including while
+ roles load or fail to load; remove them in People to end their access. Public
+ (existing) → Private also warns that joined members retain their access.
+ Public (existing) → View only confirms that existing
+ members keep their access. Agents the owner adds through
+ access search or a composer mention receive Bot access so they can reply,
+ including with a Guest admission default. Conversion to a channel offers
+ Private, View only and Public, starts from the chat's current access, and saves
+ the selected policy. Choosing Private during conversion also warns that people
+ who joined keep their access until removed in People. Opening a private chat to the community warns that its full
+ history and Canvas, including workspace folders and agent instructions, become
+ readable. View only retains Guest admission. Copying its link alone does not grant access. Check the intended people
+ and destination before changing access. If an older saved invitation granted
+ view-only access, remove that person in People and invite them again for
+ participation. For an older unresolved invitation, use **Retry** in Outbox
+ or add the person again if the receipt is already Seen. After its 15-minute
+ delivery window, Buzz verifies the original command and current membership
+ before allowing a fresh participation invitation; failed checks keep the original.
+ If the original invitation was applied and the person was later removed, Retry
+ clears the old row without re-adding them. Use **Add** in People to invite them again.
- **Sidebar width**: drag the sidebar's right edge, or focus its resize handle
and use the Left/Right arrow keys. Double-click the handle to reset the width.
Messages and plugin pages share the saved width within the same community
diff --git a/docs/identity.md b/docs/identity.md
index a76859d803..232034e740 100644
--- a/docs/identity.md
+++ b/docs/identity.md
@@ -1012,8 +1012,10 @@ message/event writes use this identity. [Join recovery](communities.md#packaged-
records public progress before remote changes. The existing durable outbox retains
uncertain delivery across restart; retry uses the same signed event with fresh HTTP
authentication. Events older than 15 minutes get a strong ID readback instead of
-being republished or silently re-dated. Missing/failed readback retains uncertainty;
-the user must inspect the conversation before explicitly sending a new message.
+being republished or silently re-dated. For ordinary messages, missing/failed
+readback retains uncertainty; inspect the conversation before explicitly sending
+another. Expired no-role Solo invitations have a narrower verified retirement
+path through [Outbox Retry](solo/sharing.md).
Protected media and uploads are native, because the webview cannot attach Blossom
(kind 24242) authentication itself: ``, `
@@ -87,6 +100,7 @@ export function ChannelGeneralAccess({
capability={capability}
recovery={recovery}
channelId={channelId}
+ mayHaveGuestMembers={mayHaveGuestMembers}
/>
);
}
@@ -95,10 +109,12 @@ function ChannelGeneralAccessEditor({
capability,
recovery,
channelId,
+ mayHaveGuestMembers,
}: {
capability: ChannelDetailsCapability;
recovery: RelayRecovery | undefined;
channelId: string;
+ mayHaveGuestMembers: boolean;
}) {
const attempt = useSyncExternalStore(capability.subscribe, () =>
capability.snapshot(channelId),
@@ -152,13 +168,7 @@ function ChannelGeneralAccessEditor({
const locked = loading || pending || !!attempt || !base?.canEdit;
const solo = soloLabels(base?.labels);
const place = solo ? "chat" : "channel";
- const access = !base
- ? ""
- : base.visibility === "private"
- ? "private"
- : base.joinRole === "guest"
- ? "view-only"
- : "public";
+ const access = base ? generalAccessValue(base) : "";
const Icon =
access === "public"
? GlobeIcon
@@ -167,23 +177,16 @@ function ChannelGeneralAccessEditor({
: LockIcon;
function choose(value: string) {
if (locked || !base) return;
- if (
- value === access ||
- !["private", "public", "view-only"].includes(value) ||
- (solo && value === "public")
- )
- return;
+ const option = Object.values(generalAccessOptions).find(
+ (option) => option.value === value,
+ );
+ if (value === access || !option || (solo && value === "public")) return;
origin.current =
section.current?.querySelector('[role="combobox"]') ?? null;
setError("");
- // Private preserves even an older Guest admission default. Public choices
- // set both fields explicitly so the label always describes the saved policy.
setChoice({
...base,
- visibility: value === "private" ? "private" : "public",
- ...(value === "private"
- ? {}
- : { joinRole: value === "view-only" ? "guest" : "member" }),
+ ...generalAccessPolicy(option.value, base.joinRole),
});
}
async function apply(check = false) {
@@ -289,33 +292,22 @@ function ChannelGeneralAccessEditor({
{
label: "",
options: [
- ...(!solo
- ? [
- {
- value: "public",
- label: "Public",
- description:
- "Anyone in the community can participate.",
- leading: ,
- },
- ]
- : []),
{
- value: "view-only",
- label: "View only",
- description: "Anyone in the community can read.",
+ ...generalAccessOptions.public,
+ leading: ,
+ },
+ {
+ ...generalAccessOptions["view-only"],
leading: ,
},
- ],
+ ].filter((option) => !solo || option.value !== "public"),
},
{
label: "",
separator: true,
options: [
{
- value: "private",
- label: "Private",
- description: "Only people you add can access.",
+ ...generalAccessOptions.private,
leading: ,
},
],
@@ -329,14 +321,23 @@ function ChannelGeneralAccessEditor({
{
localStorage.clear();
for (const owner of owners.splice(0)) owner.dispose();
});
-function fixture(direct = false) {
+function fixture(
+ direct = false,
+ access?: { visibility: "private" | "public"; joinRole: "member" | "guest" },
+) {
const viewer = keypair(),
relay = keypair(),
aria = keypair(),
@@ -118,7 +121,8 @@ function fixture(direct = false) {
metadata(relay, source, "Work", clock, [
["t", "stream"],
["t", SOLO_LABEL],
- ["private"],
+ [access?.visibility ?? "private"],
+ ...(access ? [["join_role", access.joinRole]] : []),
// The owner's own Solo chat, or one shared with them as a guest.
["P", direct ? viewer.pubkey : relay.pubkey],
]),
@@ -138,6 +142,8 @@ function fixture(direct = false) {
let failDetails = false;
let failRoster = false;
let holdRoster: Promise | undefined;
+ let holdRoles: Promise | undefined;
+ const roles = new Map();
let loseDetailsResponse = false;
const projects = (assignments: Record) => ({
status: "ready" as const,
@@ -171,6 +177,9 @@ function fixture(direct = false) {
const previous = metadataEvents.get(source)?.tags ?? [];
const ttl = value("ttl") ?? previous.find(([tag]) => tag === "ttl")?.[1];
const visibility = value("visibility");
+ const joinRole =
+ value("join_role") ??
+ previous.find(([tag]) => tag === "join_role")?.[1];
const written = event.tags.flatMap(([tag, label]) =>
tag === "t" ? [label ?? ""] : [],
);
@@ -201,6 +210,7 @@ function fixture(direct = false) {
"",
],
...(ttl && ttl !== "0" ? [["ttl", ttl]] : []),
+ ...(joinRole ? [["join_role", joinRole]] : []),
]),
);
if (loseDetailsResponse) throw new Error("Lost settings response");
@@ -232,6 +242,8 @@ function fixture(direct = false) {
scope: "https://community.test",
media: () => undefined,
query: async (filters) => {
+ if (filters.some((filter) => filter.consistency === "strong"))
+ await holdRoles;
if (
filters.some(
(filter) => filter.kinds?.length === 1 && filter.kinds[0] === 39002,
@@ -270,15 +282,16 @@ function fixture(direct = false) {
"p",
key,
"",
- key === viewer.pubkey
- ? "owner"
- : profiles.some(
- (event) =>
- event.pubkey === key &&
- JSON.parse(event.content).is_agent,
- )
- ? "bot"
- : "member",
+ roles.get(key) ??
+ (key === viewer.pubkey
+ ? "owner"
+ : profiles.some(
+ (event) =>
+ event.pubkey === key &&
+ JSON.parse(event.content).is_agent,
+ )
+ ? "bot"
+ : "member"),
]),
],
}),
@@ -349,6 +362,10 @@ function fixture(direct = false) {
profile(key, { name, ...(agent ? { is_agent: true } : {}) }),
);
},
+ holdRoles(value: Promise | undefined) {
+ holdRoles = value;
+ },
+ roles,
viewer,
aria,
dev,
@@ -409,6 +426,219 @@ function fixture(direct = false) {
};
}
+it("shows Public (existing) without offering Public and changes its admission to Guest", async () => {
+ const t = fixture(true, { visibility: "public", joinRole: "member" });
+ await t.ready();
+ t.mount();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "Access" }));
+ const audience = await screen.findByRole("combobox", {
+ name: "General access",
+ });
+ await waitFor(() => expect(audience).toBeEnabled());
+ expect(audience).toHaveTextContent("Public (existing)");
+ await user.click(audience);
+ expect(
+ (await screen.findAllByRole("option")).map((option) =>
+ option.getAttribute("aria-label"),
+ ),
+ ).toEqual(["View only", "Private"]);
+ await user.click(screen.getByRole("option", { name: "View only" }));
+ expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription(
+ "Everyone in this community will be able to read this chat’s full history and Canvas, including its workspace folders and agent instructions. People already in the chat keep their access.",
+ );
+ expect(t.published).toEqual([]);
+ await user.click(screen.getByRole("button", { name: "Change access" }));
+ await waitFor(() => expect(audience).toBeEnabled());
+ expect(audience).toHaveTextContent("View only");
+ expect(t.published).toHaveLength(1);
+ expect(t.published[0]).toMatchObject({ kind: 9002 });
+ expect(t.published[0]?.tags).toContainEqual(["join_role", "guest"]);
+ expect(t.channel()).toMatchObject({
+ channelType: "solo",
+ visibility: "public",
+ joinRole: "guest",
+ labels: [SOLO_LABEL],
+ });
+ expect(t.members.get(source)).toEqual([t.viewer.pubkey]);
+ expect(t.placement).not.toHaveBeenCalled();
+});
+
+it("opens a Solo chat for view-only visitors and makes it private again without conversion or grants", async () => {
+ const t = fixture(true);
+ await t.ready();
+ t.mount();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "Access" }));
+ const audience = await screen.findByRole("combobox", {
+ name: "General access",
+ });
+ await waitFor(() => expect(audience).toBeEnabled());
+ expect(audience).toHaveTextContent("Private");
+ await user.click(audience);
+ expect(
+ (await screen.findAllByRole("option")).map((option) =>
+ option.getAttribute("aria-label"),
+ ),
+ ).toEqual(["View only", "Private"]);
+ await user.click(await screen.findByRole("option", { name: "View only" }));
+ const confirmation = screen.getByRole("alertdialog", {
+ name: "Change general access?",
+ });
+ expect(confirmation).toHaveAccessibleDescription(
+ "Everyone in this community will be able to read this chat’s full history and Canvas, including its workspace folders and agent instructions.",
+ );
+ expect(
+ screen.queryByRole("combobox", { name: "New people can" }),
+ ).not.toBeInTheDocument();
+ await user.keyboard("{Escape}");
+ await waitFor(() => expect(audience).toHaveFocus());
+ expect(t.published).toEqual([]);
+ await user.click(audience);
+ await user.click(await screen.findByRole("option", { name: "View only" }));
+ await user.click(screen.getByRole("button", { name: "Change access" }));
+ await waitFor(() => expect(audience).toBeEnabled());
+ expect(audience).toHaveTextContent("View only");
+ expect(t.published).toHaveLength(1);
+ expect(t.published[0]).toMatchObject({ kind: 9002 });
+ expect(t.published[0]?.tags).toContainEqual(["visibility", "open"]);
+ expect(t.published[0]?.tags).toContainEqual(["join_role", "guest"]);
+ expect(t.channel()).toMatchObject({
+ channelType: "solo",
+ visibility: "public",
+ joinRole: "guest",
+ labels: [SOLO_LABEL],
+ });
+ expect(
+ screen.queryByRole("combobox", { name: "New people can" }),
+ ).not.toBeInTheDocument();
+ expect(t.session.soloPreferences.snapshot().data?.assignments).toEqual({
+ [source]: "g",
+ });
+ expect(t.placement).not.toHaveBeenCalled();
+ expect(t.members.get(source)).toEqual([t.viewer.pubkey]);
+ await user.click(audience);
+ await user.click(await screen.findByRole("option", { name: "Private" }));
+ await user.click(screen.getByRole("button", { name: "Change access" }));
+ await waitFor(() => expect(audience).toBeEnabled());
+ expect(audience).toHaveTextContent("Private");
+ expect(t.channel()).toMatchObject({
+ channelType: "solo",
+ visibility: "private",
+ joinRole: "guest",
+ labels: [SOLO_LABEL],
+ });
+ expect(t.published.map(({ kind }) => kind)).toEqual([9002, 9002]);
+ expect(t.published[1]?.tags).toContainEqual(["visibility", "private"]);
+ expect(t.placement).not.toHaveBeenCalled();
+});
+
+it.each(["guest", "member"] as const)(
+ "warns about retained viewers only with a verified %s in a View-only chat",
+ async (role) => {
+ const t = fixture(true, { visibility: "public", joinRole: "guest" });
+ t.setSourceMembers([t.viewer.pubkey, t.aria.pubkey]);
+ t.roles.set(t.aria.pubkey, role);
+ await t.ready();
+ t.mount();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "Access" }));
+ const audience = await screen.findByRole("combobox", {
+ name: "General access",
+ });
+ await waitFor(() => expect(audience).toBeEnabled());
+ await waitFor(() =>
+ expect(t.session.memberAdministration.snapshot(source).status).toBe(
+ "ready",
+ ),
+ );
+ await user.click(audience);
+ await user.click(await screen.findByRole("option", { name: "Private" }));
+ expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription(
+ `Only chat members will have access.${role === "guest" ? " People who joined while it was View only are members and keep reading until you remove them in People." : ""}`,
+ );
+ expect(t.published).toEqual([]);
+ await user.click(screen.getByRole("button", { name: "Change access" }));
+ await waitFor(() => expect(audience).toBeEnabled());
+ expect(audience).toHaveTextContent("Private");
+ expect(t.published.map(({ kind }) => kind)).toEqual([9002]);
+ expect(t.members.get(source)).toEqual([t.viewer.pubkey, t.aria.pubkey]);
+ expect(
+ t.session.memberAdministration.snapshot(source).authority.roles[
+ t.aria.pubkey
+ ],
+ ).toBe(role);
+ },
+);
+
+it.each(["guest", "member"] as const)(
+ "keeps the retained-viewer warning while previous %s roles are loading or unavailable",
+ async (role) => {
+ const t = fixture(true, { visibility: "public", joinRole: "guest" });
+ t.setSourceMembers([t.viewer.pubkey, t.aria.pubkey]);
+ t.roles.set(t.aria.pubkey, role);
+ await t.ready();
+ t.mount();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "Access" }));
+ const audience = await screen.findByRole("combobox", {
+ name: "General access",
+ });
+ await waitFor(() => expect(audience).toBeEnabled());
+ await waitFor(() =>
+ expect(t.session.memberAdministration.snapshot(source).status).toBe(
+ "ready",
+ ),
+ );
+ let release!: () => void;
+ let reject!: (reason: Error) => void;
+ t.holdRoles(
+ new Promise((resolve, fail) => {
+ release = resolve;
+ reject = fail;
+ }),
+ );
+ let refreshing!: Promise;
+ act(() => {
+ refreshing = t.session.memberAdministration.refresh(source);
+ });
+ try {
+ await waitFor(() =>
+ expect(t.session.memberAdministration.snapshot(source).status).toBe(
+ "loading",
+ ),
+ );
+ expect(
+ t.session.memberAdministration.snapshot(source).authority.roles[
+ t.aria.pubkey
+ ],
+ ).toBe(role);
+ await user.click(audience);
+ await user.click(await screen.findByRole("option", { name: "Private" }));
+ expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription(
+ "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in People.",
+ );
+ await act(async () => {
+ reject(new Error("Roles unavailable"));
+ await refreshing;
+ });
+ expect(t.session.memberAdministration.snapshot(source).status).toBe(
+ "error",
+ );
+ expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription(
+ "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in People.",
+ );
+ await user.keyboard("{Escape}");
+ expect(t.published).toEqual([]);
+ } finally {
+ release();
+ await act(async () => {
+ await refreshing;
+ });
+ }
+ },
+);
+
const search = (dialog: HTMLElement) =>
within(dialog).getByRole("searchbox", { name: "Search people and channels" });
async function chooseDestination(
@@ -618,7 +848,17 @@ it("shows loading and failed current access with refresh, never an empty roster"
);
await user.click(refresh);
await within(access).findByRole("region", { name: "People" });
- expect(within(access).queryByRole("alert")).toBeNull();
+ // General access has its own failed metadata read and explicit recovery.
+ const recovery = within(access).getByRole("group", {
+ name: "General access recovery",
+ });
+ expect(within(recovery).getByRole("alert")).toHaveTextContent(
+ "Roster unavailable",
+ );
+ await user.click(
+ within(recovery).getByRole("button", { name: "Reload access" }),
+ );
+ await waitFor(() => expect(within(access).queryByRole("alert")).toBeNull());
t.failRoster(true);
await chooseDestination(user, dialog);
const selectedChannel = within(dialog).getByRole("region", {
diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx
index 1deceea58e..fb4139b40d 100644
--- a/src/bundled/channels/ChannelMembersDialog.test.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.test.tsx
@@ -110,6 +110,8 @@ async function setup(
page: number,
) => Promise;
communities?: CommunityReader;
+ visibility?: "private" | "public";
+ joinRole?: "member" | "guest";
} = {},
) {
const viewer = keypair(),
@@ -157,7 +159,8 @@ async function setup(
tags: [
["d", id],
["t", type.includes("solo") ? "stream" : type],
- ["private"],
+ [options.visibility ?? "private"],
+ ...(options.joinRole ? [["join_role", options.joinRole]] : []),
["name", "Design"],
...(type.includes("solo")
? [
@@ -2659,7 +2662,7 @@ it("lets the Solo owner add an agent without sending a message", async () => {
"Add people, agents or a channel",
);
expect(screen.queryByText(/Join this channel/)).not.toBeInTheDocument();
- expect(screen.getByText(/Adding an agent won’t start a run/)).toBeVisible();
+ expect(input).not.toHaveAttribute("aria-describedby");
expect(screen.getByRole("dialog")).not.toHaveAccessibleDescription();
expect(
screen.queryByRole("region", { name: "Not in this chat" }),
@@ -2697,6 +2700,7 @@ it("lets the Solo owner add an agent without sending a message", async () => {
).toEqual([
["h", "11111111-1111-4111-8111-111111111111"],
["p", t.agent.pubkey],
+ ["role", "bot"],
]);
await t.user.click(screen.getByRole("button", { name: "Close" }));
await t.user.click(t.trigger);
@@ -2927,6 +2931,23 @@ it.each([false, true])(
},
);
+it.each(["private", "public"] as const)(
+ "adds the Solo owner's agent as Bot in a %s chat with a Guest default",
+ async (visibility) => {
+ const t = await setup("solo", false, true, undefined, undefined, {
+ visibility,
+ joinRole: "guest",
+ });
+ await t.user.type(screen.getByRole("searchbox"), "Fixture agent");
+ await t.user.click(
+ await screen.findByRole("button", { name: /Add Fixture agent/ }),
+ );
+ await screen.findByText("Fixture agent added.");
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "bot"]);
+ },
+);
+
it("routes a Solo owner's person and agent additions through their own paths", async () => {
const t = await setup("solo", false, true);
const input = screen.getByRole("searchbox", {
@@ -3238,9 +3259,19 @@ it.each(["public", "private"] as const)(
},
);
-it.each([false, true])(
- "explains reading and contribution access without joining or editing as a Guest %s",
- async (guest) => {
+it.each([
+ { solo: false, guest: false, joinRole: "guest" },
+ { solo: false, guest: true, joinRole: "guest" },
+ { solo: true, guest: false, joinRole: "guest" },
+ { solo: true, guest: true, joinRole: "guest" },
+ { solo: true, guest: false, joinRole: "member" },
+] as const)(
+ "reads preview rosters without joining or editing (Solo: $solo, Guest: $guest, admission: $joinRole)",
+ async ({ solo, guest, joinRole }) => {
+ const participation =
+ guest || joinRole === "guest"
+ ? "Viewing only. Ask an owner or admin to participate."
+ : "New people can participate. People already in the channel keep their access.";
const id = "11111111-1111-4111-8111-111111111111";
const viewer = keypair(),
relay = keypair(),
@@ -3254,7 +3285,13 @@ it.each([false, true])(
["t", "stream"],
["public"],
["name", "Reading room"],
- ["join_role", "guest"],
+ ["join_role", joinRole],
+ ...(solo
+ ? [
+ ["t", SOLO_LABEL],
+ ["P", owner.pubkey],
+ ]
+ : []),
],
}),
signed(relay, {
@@ -3319,11 +3356,7 @@ it.each([false, true])(
,
);
await user.click(screen.getByRole("button", { name: "Access" }));
- expect(
- await screen.findByText(
- "Viewing only. Ask an owner or admin to participate.",
- ),
- ).toBeVisible();
+ expect(await screen.findByText(participation)).toBeVisible();
expect(
screen.queryByText("You have read-only access to this channel."),
).not.toBeInTheDocument();
@@ -3336,7 +3369,7 @@ it.each([false, true])(
).toHaveTextContent("Anyone in the community can read.");
expect(
screen.getByRole("region", { name: "General access" }),
- ).toHaveTextContent("Viewing only. Ask an owner or admin to participate.");
+ ).toHaveTextContent(participation);
// Finish the roster read before checking that the dialog cannot invite or mutate.
await vi.waitFor(() =>
expect(
@@ -3344,8 +3377,13 @@ it.each([false, true])(
).toHaveAttribute("data-refreshing", "false"),
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.getByRole("region", { name: "People" })).toHaveTextContent(
+ "Channel owner",
+ );
expect(
- screen.getByRole("searchbox", { name: "Search people and agents" }),
+ screen.getByRole("searchbox", {
+ name: solo ? "Search people and channels" : "Search people and agents",
+ }),
).toHaveAttribute("placeholder", "Search members");
expect(load).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx
index 145455ddd3..56852c06b8 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -681,11 +681,15 @@ export function ChannelMembersDialog({
session.channels.get?.(channelId);
const solo = channel?.channelType === "solo";
const place = solo ? "chat" : "channel";
+ const readOnlyAccess =
+ channel?.viewerRole === "guest" ||
+ (channel?.readOnly && channel.joinRole === "guest");
+ const soloSharing = solo && !channel?.readOnly && !readOnlyAccess;
const share = useSoloShare({
session,
navigator,
channelId,
- enabled: solo,
+ enabled: soloSharing,
signal,
query,
setQuery,
@@ -699,19 +703,19 @@ export function ChannelMembersDialog({
session.channelWrites.available;
const canAddPeople = solo ? share.eligible : canAddMembers(session, channel);
const canAdd = canAddPeople || canAddSoloAgents;
- const readOnlyAccess =
- !solo &&
- (channel?.viewerRole === "guest" ||
- (channel?.readOnly && channel.joinRole === "guest"));
const generalAccess =
channel &&
- (channel.channelType === "stream" || channel.channelType === "forum") &&
+ (solo ||
+ channel.channelType === "stream" ||
+ channel.channelType === "forum") &&
!channel.cached &&
!channel.archived &&
(channel.readOnly || session.channelDetails.available);
- // Solo access is verified by the sharing roster read; channels read 39002.
- const rosterBusy = solo ? share.rosterState === "loading" : rosterReading;
- const rosterError = solo
+ // Sharing verifies joined Solo rosters; read-only previews use ordinary 39002 reads.
+ const rosterBusy = soloSharing
+ ? share.rosterState === "loading"
+ : rosterReading;
+ const rosterError = soloSharing
? share.rosterState === "error"
? share.rosterError
: ""
@@ -755,7 +759,7 @@ export function ChannelMembersDialog({
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly retries the roster read.
useEffect(() => {
void session.archives.ensure();
- if (solo) return;
+ if (soloSharing) return;
let current = true;
setRosterReading(true);
setRosterReadError("");
@@ -790,7 +794,7 @@ export function ChannelMembersDialog({
return () => {
current = false;
};
- }, [session, channelId, refresh, solo]);
+ }, [session, channelId, refresh, soloSharing]);
const memberKey = channel?.members?.join(":") ?? "";
const largeRoster = (channel?.members?.length ?? 0) > MEMBER_PAGE_SIZE;
const administration = useMemberAdministration(
@@ -1241,7 +1245,7 @@ export function ChannelMembersDialog({
const refreshMembers = () => {
if (refreshing || mutationPending) return;
setChangeError(undefined);
- if (solo) share.reloadRoster();
+ if (soloSharing) share.reloadRoster();
else setRosterReading(true);
setRefresh((value) => value + 1);
setInvitationPage({
@@ -1660,7 +1664,6 @@ export function ChannelMembersDialog({
- {canAddSoloAgents
- ? "People and agents with access can read the full history and participate in new messages. Adding an agent won’t start a run."
- : "People with access can read the full history and participate in new messages."}
-
- )}
{(administrationError ||
rosterError ||
nameError ||
diff --git a/src/bundled/channels/OutboxStatus.test.tsx b/src/bundled/channels/OutboxStatus.test.tsx
index 4f96184a9f..04c17b89fa 100644
--- a/src/bundled/channels/OutboxStatus.test.tsx
+++ b/src/bundled/channels/OutboxStatus.test.tsx
@@ -105,10 +105,32 @@ it("does not show private feedback body or generic retry in the channel outbox",
expect(retry).not.toHaveBeenCalled();
});
-it("preserves generic retry for legacy unguarded invitations", () => {
- const { retry } = show(invitation);
+it("routes an unadopted no-role invitation through member verification", async () => {
+ const { retry, add } = show({
+ ...invitation,
+ delivery: "unknown",
+ event: {
+ ...invitation.event,
+ tags: [
+ ["h", "channel"],
+ ["p", "person"],
+ ],
+ },
+ });
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
- expect(retry).toHaveBeenCalledWith("invite");
+ await waitFor(() =>
+ expect(add).toHaveBeenCalledExactlyOnceWith("channel", "person", {
+ retry: true,
+ }),
+ );
+ expect(retry).not.toHaveBeenCalled();
+});
+it("keeps a malformed no-role invitation out of generic replay", async () => {
+ const { retry, add } = show(invitation);
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("Reopen Members");
+ expect(retry).not.toHaveBeenCalled();
+ expect(add).not.toHaveBeenCalled();
});
it("routes an ordinary addition retry through fresh membership checks", async () => {
@@ -126,7 +148,9 @@ it("routes an ordinary addition retry through fresh membership checks", async ()
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() =>
- expect(add).toHaveBeenCalledExactlyOnceWith("channel", "person"),
+ expect(add).toHaveBeenCalledExactlyOnceWith("channel", "person", {
+ retry: true,
+ }),
);
expect(retry).not.toHaveBeenCalled();
});
@@ -148,6 +172,7 @@ it("retries a guest addition as a guest", async () => {
await waitFor(() =>
expect(add).toHaveBeenCalledExactlyOnceWith("channel", "person", {
role: "guest",
+ retry: true,
}),
);
});
diff --git a/src/bundled/channels/OutboxStatus.tsx b/src/bundled/channels/OutboxStatus.tsx
index 8b8f4e13b7..ce75595e4e 100644
--- a/src/bundled/channels/OutboxStatus.tsx
+++ b/src/bundled/channels/OutboxStatus.tsx
@@ -61,7 +61,8 @@ export function OutboxStatus({
try {
if (
item.event.kind === 9000 &&
- item.recovery?.key.startsWith("member-add:")
+ (item.recovery?.key.startsWith("member-add:") ||
+ !item.event.tags.some(([tag]) => tag === "role"))
) {
const channelId = item.event.tags.find(
([tag]) => tag === "h",
@@ -79,11 +80,10 @@ export function OutboxStatus({
const guest = item.event.tags.some(
([tag, value]) => tag === "role" && value === "guest",
);
- await (guest
- ? memberAdditions.add(channelId, pubkey, {
- role: "guest",
- })
- : memberAdditions.add(channelId, pubkey));
+ await memberAdditions.add(channelId, pubkey, {
+ ...(guest ? { role: "guest" } : {}),
+ retry: true,
+ });
} else outbox.retry(item.event.id);
setError(undefined);
} catch (reason) {
diff --git a/src/features/channel-members/members.test.ts b/src/features/channel-members/members.test.ts
index c34d941308..e97368f3a9 100644
--- a/src/features/channel-members/members.test.ts
+++ b/src/features/channel-members/members.test.ts
@@ -37,6 +37,7 @@ it.each([
);
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
+ vi.useRealTimers();
});
function setup(
type = "stream",
@@ -58,12 +59,21 @@ function setup(
let agent = false;
let echo = false;
let failRoster = false;
+ let rolesAvailable = true;
+ let failInvitationRead = false;
+ let recordedInvitation: RelayEvent | undefined;
+ let onInvitationRead: (() => void) | undefined;
+ const invitationReads: import("../relay/events").ReadFilter[][] = [];
let failSave = false;
let failAcknowledgement = false;
+ let roleGate: { entered(): void; held: Promise } | undefined;
const published: RelayEvent[] = [];
const publish = vi.fn(async (event: RelayEvent) => {
if (fail) throw unknown ? new Error(fail) : new PublishRejected(fail);
- if (apply) members = [...new Set([...members, person.pubkey])];
+ if (apply) {
+ members = [...new Set([...members, person.pubkey])];
+ personRole = event.tags.find(([tag]) => tag === "role")?.[1] ?? joinRole;
+ }
clock++;
published.push(event);
if (loseReceipt) throw new Error("Receipt lost after membership applied");
@@ -80,6 +90,12 @@ function setup(
definitions: [],
identities: agent ? [{ pubkey: person.pubkey, name: "Agent" }] : [],
}),
+ memberAdministration: {
+ sign: async (template) => signed(viewer, template),
+ publish: async (event) => {
+ await publish(event);
+ },
+ },
writer: {
kinds: [9, 9000, 9007],
sign: async (template) => signed(viewer, template),
@@ -88,9 +104,25 @@ function setup(
},
},
query: async (filters) => {
+ if (filters.some((filter) => filter.kinds?.includes(9000))) {
+ invitationReads.push([...filters]);
+ onInvitationRead?.();
+ if (failInvitationRead)
+ throw new Error("Invitation read unavailable");
+ return recordedInvitation ? [recordedInvitation] : [];
+ }
+ if (
+ roleGate &&
+ filters.some((filter) => filter.kinds?.includes(39001))
+ ) {
+ const gate = roleGate;
+ roleGate = undefined;
+ gate.entered();
+ await gate.held;
+ }
if (
failRoster &&
- published.length &&
+ (published.length || recordedInvitation) &&
filters.some((filter) => filter.kinds?.includes(39002))
)
throw new Error("Roster unavailable");
@@ -101,12 +133,29 @@ function setup(
content: "",
tags: [
["d", id],
- ["t", type],
+ ["t", type === "solo" ? "stream" : type],
+ ...(type === "solo" ? [["t", "buzz.solo"]] : []),
[visibility],
["join_role", joinRole],
["name", "Design"],
],
}),
+ ...(rolesAvailable
+ ? [
+ signed(relay, {
+ kind: 39001,
+ content: "",
+ created_at: clock,
+ tags: [
+ ["d", id],
+ ...(personRole === "admin" &&
+ members.includes(person.pubkey)
+ ? [["p", person.pubkey, "admin"]]
+ : []),
+ ],
+ }),
+ ]
+ : []),
personRole
? signed(relay, {
kind: 39002,
@@ -179,6 +228,31 @@ function setup(
setEcho: () => {
echo = true;
},
+ invitationReads,
+ setInvitationReadFailure: (value: boolean) => {
+ failInvitationRead = value;
+ },
+ recordOriginal: () => {
+ recordedInvitation = records[0]?.signed;
+ },
+ onInvitationRead: (run: () => void) => {
+ onInvitationRead = run;
+ },
+ holdRoleRead: () => {
+ let release!: () => void;
+ let entered!: () => void;
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ const started = new Promise((resolve) => {
+ entered = resolve;
+ });
+ roleGate = { entered, held };
+ return { release, started };
+ },
+ setRolesAvailable: (value: boolean) => {
+ rolesAvailable = value;
+ },
setRosterFailure: (value: boolean) => {
failRoster = value;
},
@@ -311,6 +385,47 @@ it("adds a participant despite a private channel's saved Guest default", async (
await t.managedAdd();
expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "member"]);
});
+it.each(["private", "public"] as const)(
+ "keeps Solo invitations participatory with a %s chat's Guest visitor default",
+ async (visibility) => {
+ const t = setup("solo", visibility, "guest");
+ await t.ready();
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual([
+ "role",
+ "member",
+ ]);
+ expect(t.session.channels.list().channels[0]?.members).toContain(
+ t.person.pubkey,
+ );
+ },
+);
+it("keeps a saved Solo Guest invitation separate from participation across restart", async () => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const guest = () =>
+ t.session.memberAdditions.add(t.id, t.person.pubkey, { role: "guest" });
+ t.setFail("Not committed");
+ await expect(guest()).rejects.toThrow(/Not committed/);
+ const original = t.records()[0]?.event.id;
+ expect(t.records()[0]?.event.tags).toContainEqual(["role", "guest"]);
+ t.setFail("");
+ await expect(t.managedAdd()).rejects.toThrow(/another role/);
+ t.restart();
+ await t.ready();
+ await expect(t.managedAdd()).rejects.toThrow(/another role/);
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.records()).toHaveLength(1);
+ expect(t.records()[0]).toMatchObject({ event: { id: original } });
+ expect(t.records()[0]?.acknowledged).toBeFalsy();
+ await guest();
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ const retried = t.publish.mock.calls[1]?.[0];
+ expect(retried?.id).toBe(original);
+ expect(retried?.tags).toContainEqual(["role", "guest"]);
+ expect(retried?.tags).not.toContainEqual(["role", "member"]);
+});
it.each(["member", "guest"] as const)(
"leaves the public %s admission default to the relay",
async (joinRole) => {
@@ -810,3 +925,375 @@ it.each([false, true])(
]);
},
);
+
+async function savedNoRoleInvitation(
+ t: ReturnType,
+ uncertain = true,
+) {
+ t.setFail("Old invitation result", uncertain);
+ const outbox = t.session.outbox;
+ if (!outbox) throw new Error("Expected membership outbox");
+ const original = outbox.send({
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", t.id],
+ ["p", t.person.pubkey],
+ ],
+ });
+ await vi.waitFor(() =>
+ expect(t.records()[0]?.delivery).toBe(uncertain ? "unknown" : "failed"),
+ );
+ t.legacy();
+ t.restart();
+ await t.ready();
+ t.setFail("");
+ return original;
+}
+it("does not replay a legacy Solo invitation that could now admit a Guest", async () => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ await expect(t.managedAdd()).rejects.toThrow(/15-minute delivery window/i);
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.records()[0]).toMatchObject({
+ event: { id: original },
+ delivery: "unknown",
+ });
+ expect(t.records()[0]?.acknowledged).toBeUndefined();
+ await expect(t.session.outbox?.dismiss(original)).rejects.toThrow(/Confirm/);
+ t.restart();
+ await t.ready();
+ await expect(t.managedAdd()).rejects.toThrow(/15-minute delivery window/i);
+ expect(t.publish).toHaveBeenCalledOnce();
+});
+it.each(["member", "admin"])(
+ "confirms a recovered no-role invitation only after signed %s participation",
+ async (role) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ t.confirmPerson(role);
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.records()[0]).toMatchObject({
+ event: { id: original },
+ acknowledged: true,
+ });
+ },
+);
+it("reports a recovered Guest and permits a fresh Member invite only after explicit removal", async () => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ t.confirmPerson("guest");
+ await expect(t.managedAdd()).rejects.toThrow(
+ /remove them in People.*invite them again/i,
+ );
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.records()[0]).toMatchObject({
+ event: { id: original },
+ acknowledged: true,
+ });
+ t.restart();
+ await t.ready();
+ await expect(t.managedAdd()).rejects.toThrow(/remove them in People/i);
+ expect(t.publish).toHaveBeenCalledOnce();
+ t.removePerson();
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ expect(t.publish.mock.calls[1]?.[0].id).not.toBe(original);
+ expect(t.publish.mock.calls[1]?.[0].tags).toContainEqual(["role", "member"]);
+});
+
+it("preserves a legacy invitation while signed roles are unavailable", async () => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ await savedNoRoleInvitation(t);
+ t.confirmPerson("guest");
+ t.setRolesAvailable(false);
+ await expect(t.managedAdd()).rejects.toThrow(
+ /access could not be confirmed/i,
+ );
+ expect(t.records()[0]?.acknowledged).toBeUndefined();
+ expect(t.publish).toHaveBeenCalledOnce();
+ t.setRolesAvailable(true);
+ await expect(t.managedAdd()).rejects.toThrow(/remove them in People/i);
+ expect(t.records()[0]?.acknowledged).toBe(true);
+});
+it("replaces a definitively failed no-role invitation only after explicit Outbox dismissal", async () => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t, false);
+ await expect(t.managedAdd()).rejects.toThrow(/15-minute delivery window/i);
+ expect(t.publish).toHaveBeenCalledOnce();
+ await t.session.outbox?.dismiss(original);
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ expect(t.publish.mock.calls[1]?.[0].id).not.toBe(original);
+ expect(t.publish.mock.calls[1]?.[0].tags).toContainEqual(["role", "member"]);
+});
+
+it("does not confirm a legacy invitation from stale roles during another member operation", async () => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ await savedNoRoleInvitation(t);
+ t.confirmPerson("member");
+ await t.session.memberAdministration.refresh(t.id);
+ const gate = t.holdRoleRead();
+ const other = t.session.memberAdministration
+ .run(t.id, {
+ pubkey: t.person.pubkey,
+ expectedRole: "member",
+ role: "guest",
+ })
+ .catch(() => {});
+ try {
+ await gate.started;
+ expect(t.session.memberAdministration.snapshot(t.id)).toMatchObject({
+ status: "ready",
+ operation: { status: "pending" },
+ });
+ await expect(t.managedAdd()).rejects.toThrow(
+ /access could not be confirmed/i,
+ );
+ expect(t.records()[0]?.acknowledged).toBeUndefined();
+ expect(t.publish).toHaveBeenCalledOnce();
+ } finally {
+ gate.release();
+ await other;
+ }
+ await t.managedAdd();
+ expect(t.records()[0]?.acknowledged).toBe(true);
+ expect(t.publish).toHaveBeenCalledOnce();
+});
+
+it.each([false, true])(
+ "retires an expired unapplied legacy invitation before a fresh Member grant (Retry=%s)",
+ async (retry) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ const created = t.records()[0]?.event.created_at;
+ if (created === undefined) throw new Error("Expected saved invitation");
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime((created + 901) * 1000);
+ await t.session.memberAdditions.add(
+ t.id,
+ t.person.pubkey,
+ retry ? { retry: true } : {},
+ );
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ expect(t.publish.mock.calls[1]?.[0].id).not.toBe(original);
+ expect(t.publish.mock.calls[1]?.[0].tags).toContainEqual([
+ "role",
+ "member",
+ ]);
+ expect(
+ t.records().find((item) => item.event.id === original),
+ ).toMatchObject({
+ acknowledged: true,
+ });
+ t.restart();
+ await t.ready();
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ },
+);
+
+function afterInvitationWindow(t: ReturnType, seconds = 901) {
+ const created = t.records()[0]?.event.created_at;
+ if (created === undefined) throw new Error("Expected saved invitation");
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime((created + seconds) * 1000);
+}
+it.each([false, true])(
+ "Outbox Retry retires a recorded removal without granting, then Add invites once (restart=%s)",
+ async (restart) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ t.recordOriginal();
+ afterInvitationWindow(t);
+ if (restart) {
+ await t.session.read([{ kinds: [9000], ids: [original], limit: 1 }], {
+ fresh: true,
+ });
+ t.restart();
+ await t.ready();
+ }
+ await t.session.memberAdditions.add(t.id, t.person.pubkey, { retry: true });
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.records()).toHaveLength(1);
+ expect(t.records()[0]).toMatchObject({
+ event: { id: original },
+ acknowledged: true,
+ });
+ expect(t.session.channels.list().channels[0]?.members).not.toContain(
+ t.person.pubkey,
+ );
+ if (restart) {
+ t.restart();
+ await t.ready();
+ }
+ await t.session.memberAdditions.add(t.id, t.person.pubkey, { retry: true });
+ expect(t.publish).toHaveBeenCalledOnce();
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ expect(t.publish.mock.calls[1]?.[0].id).not.toBe(original);
+ expect(t.publish.mock.calls[1]?.[0].tags).toContainEqual([
+ "role",
+ "member",
+ ]);
+ },
+);
+it.each(["read", "roster", "storage"])(
+ "Outbox Retry preserves recorded recovery on %s failure",
+ async (failure) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ await savedNoRoleInvitation(t);
+ t.recordOriginal();
+ afterInvitationWindow(t);
+ if (failure === "read") t.setInvitationReadFailure(true);
+ else if (failure === "roster")
+ t.onInvitationRead(() => t.setRosterFailure(true));
+ else t.setAcknowledgementFailure(true);
+ const retry = () =>
+ t.session.memberAdditions.add(t.id, t.person.pubkey, { retry: true });
+ await expect(retry()).rejects.toThrow();
+ expect(t.records()[0]?.acknowledged).toBeUndefined();
+ expect(t.publish).toHaveBeenCalledOnce();
+ t.onInvitationRead(() => {});
+ t.setRosterFailure(false);
+ t.setInvitationReadFailure(false);
+ t.setAcknowledgementFailure(false);
+ t.restart();
+ await t.ready();
+ await retry();
+ expect(t.records()[0]?.acknowledged).toBe(true);
+ expect(t.publish).toHaveBeenCalledOnce();
+ },
+);
+it.each([false, true])(
+ "reconciles a recorded then removed invitation after restart=%s",
+ async (restart) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ t.confirmPerson("guest");
+ t.removePerson();
+ t.recordOriginal();
+ afterInvitationWindow(t);
+ if (restart) {
+ await t.session.read([{ kinds: [9000], ids: [original], limit: 1 }], {
+ fresh: true,
+ });
+ t.restart();
+ await t.ready();
+ }
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ expect(t.publish.mock.calls[1]?.[0].id).not.toBe(original);
+ expect(t.publish.mock.calls[1]?.[0].tags).toContainEqual([
+ "role",
+ "member",
+ ]);
+ expect(
+ t.records().find((item) => item.event.id === original),
+ ).toMatchObject({ acknowledged: true, delivery: "seen" });
+ t.restart();
+ await t.ready();
+ await t.managedAdd();
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ },
+);
+it.each(["read", "roster", "storage"])(
+ "retains recorded recovery after %s failure and recovers on a later Add",
+ async (failure) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ t.recordOriginal();
+ afterInvitationWindow(t);
+ await t.session.read([{ kinds: [9000], ids: [original], limit: 1 }], {
+ fresh: true,
+ });
+ if (failure === "read") t.setInvitationReadFailure(true);
+ else if (failure === "roster")
+ t.onInvitationRead(() => t.setRosterFailure(true));
+ else t.setAcknowledgementFailure(true);
+ await expect(t.managedAdd()).rejects.toThrow();
+ expect(t.records()[0]?.acknowledged).toBeUndefined();
+ expect(t.publish).toHaveBeenCalledOnce();
+ t.onInvitationRead(() => {});
+ t.setRosterFailure(false);
+ t.setInvitationReadFailure(false);
+ t.setAcknowledgementFailure(false);
+ t.restart();
+ await t.ready();
+ await t.managedAdd();
+ expect(
+ t.records().find((item) => item.event.id === original)?.acknowledged,
+ ).toBe(true);
+ expect(t.publish).toHaveBeenCalledTimes(2);
+ },
+);
+it.each(["read failure", "storage failure"])(
+ "preserves expired legacy recovery after %s",
+ async (failure) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ afterInvitationWindow(t);
+ if (failure === "read failure") t.setInvitationReadFailure(true);
+ if (failure === "storage failure") t.setAcknowledgementFailure(true);
+ await expect(t.managedAdd()).rejects.toThrow();
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.records()[0]).toMatchObject({
+ event: { id: original },
+ delivery: "unknown",
+ });
+ expect(t.records()[0]?.acknowledged).toBeUndefined();
+ t.restart();
+ await t.ready();
+ await expect(t.managedAdd()).rejects.toThrow();
+ expect(t.publish).toHaveBeenCalledOnce();
+ },
+);
+it("does not replace at the inclusive expiry boundary and confirms strong ID absence afterward", async () => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ const original = await savedNoRoleInvitation(t);
+ afterInvitationWindow(t, 900);
+ await expect(t.managedAdd()).rejects.toThrow(/15-minute/);
+ expect(t.invitationReads).toHaveLength(0);
+ afterInvitationWindow(t);
+ await t.managedAdd();
+ expect(t.invitationReads).toEqual([
+ [
+ {
+ kinds: [9000],
+ ids: [original],
+ authors: [t.viewer.pubkey],
+ limit: 1,
+ consistency: "strong",
+ },
+ ],
+ ]);
+ expect(t.publish).toHaveBeenCalledTimes(2);
+});
+it.each([false, true])(
+ "rechecks membership instead of overwriting a joined Guest when recorded=%s",
+ async (recorded) => {
+ const t = setup("solo", "public", "guest");
+ await t.ready();
+ await savedNoRoleInvitation(t);
+ afterInvitationWindow(t);
+ if (recorded) t.recordOriginal();
+ t.onInvitationRead(() => t.confirmPerson("guest"));
+ await expect(t.managedAdd()).rejects.toThrow(/remove them in People/);
+ expect(t.publish).toHaveBeenCalledOnce();
+ expect(t.records()[0]?.acknowledged).toBe(true);
+ },
+);
diff --git a/src/features/channel-members/members.ts b/src/features/channel-members/members.ts
index 147737316b..b6a950826a 100644
--- a/src/features/channel-members/members.ts
+++ b/src/features/channel-members/members.ts
@@ -2,6 +2,7 @@ import { archiveHides } from "../relay/identity-archives";
import type { RelaySession } from "../relay/session";
import type { ChannelSummary } from "../relay/contracts";
import type { LocalEvents } from "../relay/outbox";
+import type { RelayEvent } from "../relay/events";
const keyPattern = /^[0-9a-f]{64}$/;
@@ -39,7 +40,6 @@ export function canShareSoloChat(
session.viewer &&
session.outbox?.supports(9000) &&
channel?.channelType === "solo" &&
- channel.private &&
!channel.readOnly &&
!channel.cached &&
!channel.archived &&
@@ -87,6 +87,8 @@ export type MemberAdditionIntent = {
dismissed?: boolean;
/** Adds the person as a guest, who can read but not post. */
role?: "guest";
+ /** Outbox recovery is not an explicit request to re-invite a removed member. */
+ retry?: true;
};
/** Explicit single-person intent. Membership truth and pending writes stay session-owned. */
@@ -101,7 +103,7 @@ export async function addChannelMember(
allowSoloAgents = false,
/** Runs synchronously just before a new or retried addition is dispatched. */
beforeDispatch?: () => void,
-) {
+): Promise {
if (!keyPattern.test(pubkey))
throw new Error("Choose a valid person or agent.");
const channel = () =>
@@ -135,29 +137,33 @@ export async function addChannelMember(
const fresh =
intent.dismissed ||
(intent.confirmed && !channel()?.members?.includes(pubkey));
+ const matchesTarget = (event: Pick) =>
+ event.kind === 9000 &&
+ event.tags.some(([tag, value]) => tag === "h" && value === channelId) &&
+ event.tags.some(([tag, value]) => tag === "p" && value === pubkey);
+ const noRole = (event: Pick) =>
+ !event.tags.some(([tag]) => tag === "role");
+ const saved = journal.snapshot();
const pending = fresh
? undefined
- : journal
- .snapshot()
- .find(({ event, acknowledged }) =>
- intent.id
- ? event.id === intent.id
- : !acknowledged &&
- event.kind === 9000 &&
- event.tags.some(
- ([tag, value]) => tag === "h" && value === channelId,
- ) &&
- event.tags.some(
- ([tag, value]) => tag === "p" && value === pubkey,
- ),
- );
- // A saved addition finishes only with the role this one asks for: a guest
- // add never sends an earlier Bot or Member grant, nor the reverse. The
- // receipt stays, and no second write competes with it.
+ : (saved.find(({ event, acknowledged }) =>
+ intent.id
+ ? event.id === intent.id
+ : !acknowledged && matchesTarget(event),
+ ) ??
+ // Keep a resolved legacy Guest outcome actionable after restart.
+ (soloParticipant && channel()?.members?.includes(pubkey)
+ ? saved.find(
+ ({ event, acknowledged }) =>
+ acknowledged && matchesTarget(event) && noRole(event),
+ )
+ : undefined));
+ // A saved addition finishes only with the role this one asks for.
const savedRole = pending?.event.tags.find(([tag]) => tag === "role")?.[1];
const sameRole = intent.role
? savedRole === intent.role
: savedRole === undefined || savedRole === "bot" || savedRole === "member";
+
const recovery = { key: `member-add:${channelId}:${pubkey}`, value: "1" };
// A saved share may finish its exact Bot grant; it cannot authorize a new one.
const recoveringSoloAgent =
@@ -184,17 +190,114 @@ export async function addChannelMember(
);
};
check();
+ // A completed or missing receipt is not permission for Retry to create a grant.
+ if (intent.retry && !pending) return;
+ const confirmAddition = async (id: string) => {
+ intent.id = id;
+ intent.dismissed = false;
+ intent.confirmed = false;
+ await waitForAddition(journal, id, signal);
+ check();
+ await session.channelWrites.refreshMembership(channelId);
+ check();
+ if (!channel()?.members?.includes(pubkey))
+ throw new Error(
+ "Addition is not confirmed in the member list yet. Retry to check again.",
+ );
+ await outbox.acknowledge(id);
+ check();
+ intent.confirmed = true;
+ };
if (pending?.guarded && !channel()?.members?.includes(pubkey))
throw new Error(
"Retry this invitation from the agent’s profile so its eligibility can be checked.",
);
+ if (soloParticipant && pending && noRole(pending.event))
+ intent.id = pending.event.id;
if (pending && !sameRole && !channel()?.members?.includes(pubkey))
throw new Error(
"An addition of this member with another role is still in Outbox. Retry it there to finish it, or remove it once it shows Not sent, then try again.",
);
+
if (pending && !pending.acknowledged)
await outbox.recover(pending.event.id, recovery);
check();
+ if (soloParticipant && pending && noRole(pending.event)) {
+ if (!channel()?.members?.includes(pubkey)) {
+ let replacement: string | undefined;
+ let recorded = false;
+ await outbox.acknowledge(
+ pending.event.id,
+ async (event) => {
+ check();
+ recorded = await session.channelWrites.additionRecorded(
+ event,
+ signal,
+ );
+ // Finish the fresh membership check while the original is recoverable.
+ await session.channelWrites.refreshMembership(channelId);
+ check();
+ return recorded ? "recorded" : undefined;
+ },
+ {
+ input: () => {
+ check();
+ if (
+ channel()?.members?.includes(pubkey) ||
+ (intent.retry && recorded)
+ )
+ return undefined;
+ beforeDispatch?.();
+ return invitation();
+ },
+ queued: (id) => {
+ replacement = id;
+ },
+ },
+ );
+ if (replacement) return confirmAddition(replacement);
+ check();
+ intent.confirmed = true;
+ // Retiring a recorded command must not fall through to a new grant.
+ if (intent.retry && recorded && !channel()?.members?.includes(pubkey))
+ return;
+ // A joined target still needs role reconciliation; never overwrite a Guest.
+ return addChannelMember(
+ session,
+ channelId,
+ pubkey,
+ signal,
+ intent,
+ receipts,
+ soloParticipant,
+ allowSoloAgents,
+ beforeDispatch,
+ );
+ }
+ await session.memberAdministration.refresh(channelId);
+ check();
+ const verified = session.memberAdministration.snapshot(channelId);
+ const role = verified.authority.roles[pubkey];
+ if (
+ verified.status !== "ready" ||
+ verified.operation?.status === "pending" ||
+ !role ||
+ role === "unknown"
+ )
+ throw new Error(
+ "Current access could not be confirmed. Refresh members and roles before trying again.",
+ );
+ // Membership resolves the original command, even when its grant was Guest.
+ // Do not promote a newer role or replay the old command after removal.
+ if (role === "guest") {
+ await outbox.acknowledge(pending.event.id);
+ check();
+ intent.confirmed = true;
+ throw new Error(
+ "This earlier invitation granted view-only access. To grant participation, remove them in People and invite them again.",
+ );
+ }
+ }
if (channel()?.members?.includes(pubkey)) {
if (!intent.id && pending) intent.id = pending.event.id;
if (pending) await outbox.acknowledge(pending.event.id);
@@ -213,52 +316,40 @@ export async function addChannelMember(
throw new Error(
"This addition expired. Remove its Not sent item in Outbox, then try again.",
);
- const isAgent =
- session.agentChoices
- .snapshot()
- .identities.some((agent) => agent.pubkey === pubkey) ||
- session.profiles.snapshot().get(pubkey)?.isAgent;
- const current = channel();
- // Private channel additions grant participation explicitly, even if an older
- // saved admission default is Guest. Solo and system admission stay separate.
- const role = intent.role
- ? intent.role
- : isAgent
- ? "bot"
- : !soloParticipant &&
- current?.visibility === "private" &&
- (current.channelType === "stream" || current.channelType === "forum")
- ? "member"
- : undefined;
+
beforeDispatch?.();
- const id =
- pending?.event.id ??
- outbox.send(
- {
- kind: 9000,
- content: "",
- tags: [
- ["h", channelId],
- ["p", pubkey],
- ...(role ? [["role", role]] : []),
- ],
- },
- recovery,
- );
+ const id = pending?.event.id ?? outbox.send(invitation(), recovery);
if (pending?.delivery === "failed" || pending?.delivery === "unknown")
outbox.retry(id);
- intent.id = id;
- intent.dismissed = false;
- intent.confirmed = false;
- await waitForAddition(journal, id, signal);
- check();
- await session.channelWrites.refreshMembership(channelId);
- check();
- if (!channel()?.members?.includes(pubkey))
- throw new Error(
- "Addition is not confirmed in the member list yet. Retry to check again.",
- );
- await outbox.acknowledge(id);
- check();
- intent.confirmed = true;
+ return confirmAddition(id);
+
+ function invitation() {
+ const isAgent =
+ session.agentChoices
+ .snapshot()
+ .identities.some((agent) => agent.pubkey === pubkey) ||
+ session.profiles.snapshot().get(pubkey)?.isAgent;
+ const current = channel();
+ // Solo sharing and private-channel invitations grant participation explicitly,
+ // independently of the admission default for public visitors.
+ const role = intent.role
+ ? intent.role
+ : isAgent
+ ? "bot"
+ : soloParticipant ||
+ (current?.visibility === "private" &&
+ (current.channelType === "stream" ||
+ current.channelType === "forum"))
+ ? "member"
+ : undefined;
+ return {
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", channelId],
+ ["p", pubkey],
+ ...(role ? [["role", role]] : []),
+ ],
+ };
+ }
}
diff --git a/src/features/channel-members/operations.test.ts b/src/features/channel-members/operations.test.ts
index 3fc3ea8b74..ff914e0b3f 100644
--- a/src/features/channel-members/operations.test.ts
+++ b/src/features/channel-members/operations.test.ts
@@ -85,3 +85,31 @@ it("joins an addition under way only when it asks for the same role", async () =
expect(add).toHaveBeenCalledOnce();
expect(roles).toEqual([undefined]);
});
+
+it("keeps Retry separate from a concurrent explicit Add and clears its flag afterward", async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const add = vi.fn(
+ async (_channel: string, _person: string, intent: { retry?: true }) => {
+ if (add.mock.calls.length === 1) {
+ expect(intent).toMatchObject({ retry: true });
+ await gate;
+ } else expect(intent.retry).toBeUndefined();
+ },
+ );
+ const owner = createMemberAdditions(new AbortController().signal, add);
+ const retry = owner.add("channel", "person", { retry: true });
+ try {
+ expect(owner.add("channel", "person", { retry: true })).toBe(retry);
+ const explicit = owner.add("channel", "person");
+ expect(explicit).not.toBe(retry);
+ await expect(explicit).rejects.toThrow(/already.*running/);
+ } finally {
+ release();
+ await retry;
+ }
+ await owner.add("channel", "person");
+ expect(add).toHaveBeenCalledTimes(2);
+});
diff --git a/src/features/channel-members/operations.ts b/src/features/channel-members/operations.ts
index 810e873b73..ec0a8295d0 100644
--- a/src/features/channel-members/operations.ts
+++ b/src/features/channel-members/operations.ts
@@ -54,7 +54,7 @@ export function createMemberAdditions(
add(
channelId: string,
pubkey: string,
- options: { role?: "guest" } = {},
+ options: { role?: "guest"; retry?: true } = {},
): Promise {
if (signal.aborted)
return Promise.reject(new Error("The community connection closed."));
@@ -62,10 +62,17 @@ export function createMemberAdditions(
const existing = pending.get(key);
// An addition under way keeps the role it asked for; another role
// waits for it rather than joining it or racing it with a second write.
- if (existing)
- return intents.get(key)?.role === options.role
- ? existing
- : Promise.reject(new Error(differentRole));
+ if (existing) {
+ if (intents.get(key)?.role !== options.role)
+ return Promise.reject(new Error(differentRole));
+ if (intents.get(key)?.retry !== options.retry)
+ return Promise.reject(
+ new Error(
+ "A member operation is already running. Wait for it to finish, then try again.",
+ ),
+ );
+ return existing;
+ }
const matches = (item: Addition) =>
item.channelId === channelId && item.pubkey === pubkey;
const previous = snapshot.find(matches);
@@ -73,6 +80,8 @@ export function createMemberAdditions(
intents.set(key, intent);
if (options.role) intent.role = options.role;
else delete intent.role;
+ if (options.retry) intent.retry = true;
+ else delete intent.retry;
let confirmed = previous?.confirmed ?? false;
const update = (item?: Addition) => {
if (!signal.aborted)
diff --git a/src/features/channels/general-access.ts b/src/features/channels/general-access.ts
new file mode 100644
index 0000000000..ee137c4dc4
--- /dev/null
+++ b/src/features/channels/general-access.ts
@@ -0,0 +1,46 @@
+import type { ChannelDetailsDraft } from "../relay/channel-details-protocol";
+
+export const generalAccessOptions = {
+ private: {
+ value: "private",
+ label: "Private",
+ description: "Only people you add can access.",
+ },
+ "view-only": {
+ value: "view-only",
+ label: "View only",
+ description: "Anyone in the community can read.",
+ },
+ public: {
+ value: "public",
+ label: "Public",
+ description: "Anyone in the community can participate.",
+ },
+} as const;
+
+export type GeneralAccess = keyof typeof generalAccessOptions;
+type Policy = Pick;
+
+export function generalAccessValue(policy: Policy): GeneralAccess {
+ return policy.visibility === "private"
+ ? "private"
+ : policy.joinRole === "guest"
+ ? "view-only"
+ : "public";
+}
+
+/** Private retains the admission default; open policies state it explicitly. */
+export function generalAccessPolicy(
+ access: GeneralAccess,
+ joinRole: Policy["joinRole"],
+): Policy {
+ return {
+ visibility: access === "private" ? "private" : "public",
+ joinRole:
+ access === "private"
+ ? joinRole
+ : access === "view-only"
+ ? "guest"
+ : "member",
+ };
+}
diff --git a/src/features/messages/ChannelJoinNotice.tsx b/src/features/messages/ChannelJoinNotice.tsx
index 73b24239ee..203e9e3912 100644
--- a/src/features/messages/ChannelJoinNotice.tsx
+++ b/src/features/messages/ChannelJoinNotice.tsx
@@ -103,7 +103,7 @@ export function ChannelJoinNotice({
? `This ${place} is archived`
: readOnly
? joinRole === "guest"
- ? "This channel is read-only"
+ ? `This ${place} is read-only`
: "Read-only access"
: canJoin
? "Join channel to send messages"
diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx
index e0e9553eac..dadc731ee8 100644
--- a/src/features/messages/MessageComposer.test.tsx
+++ b/src/features/messages/MessageComposer.test.tsx
@@ -5766,9 +5766,13 @@ it.each([false, true])(
},
);
-it.each(["send", "unmount", "disabled", "denied"])(
- "waits for agent admission before saved Solo chat messages: %s",
- async (outcome) => {
+it.each(
+ ["send", "unmount", "disabled", "denied"].flatMap((outcome) =>
+ ["private", "public"].map((visibility) => ({ outcome, visibility })),
+ ),
+)(
+ "waits for agent admission before saved $visibility Solo chat messages with a Guest default: $outcome",
+ async ({ outcome, visibility }) => {
const view = mount();
const list = {
status: "ready",
@@ -5776,6 +5780,8 @@ it.each(["send", "unmount", "disabled", "denied"])(
{
id: "channel",
channelType: "solo",
+ visibility,
+ joinRole: "guest",
members: [] as string[],
},
],
diff --git a/src/features/relay/channel-writes.ts b/src/features/relay/channel-writes.ts
index 64ca55b6b7..2223d2de67 100644
--- a/src/features/relay/channel-writes.ts
+++ b/src/features/relay/channel-writes.ts
@@ -6,6 +6,7 @@ import type { Outbox } from "./outbox";
import { withdraw } from "./withdraw";
import type { ChannelQueries, ChannelSummary } from "./contracts";
import type { RelayReader } from "./reader";
+import type { EventData } from "./events";
export const DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -453,6 +454,8 @@ export function createChannelWrites(
if (active?.() === false)
throw new DOMException("Channel addition cancelled", "AbortError");
const original = await refreshMembership(id);
+ // Solo agents must be able to reply even with a saved Guest admission default.
+ const roleTags = original.channelType === "solo" ? [["role", "bot"]] : [];
if (active?.() === false)
throw new DOMException("Channel addition cancelled", "AbortError");
const unique = [...new Set(keys)].filter(
@@ -486,21 +489,22 @@ export function createChannelWrites(
if (!current?.members || current.archived)
throw new Error("Channel membership is unavailable.");
if (current.members.includes(key)) continue;
- const previous = (receipts ?? outbox)
- ?.snapshot()
- .find(
- (item) =>
- item.event.kind === 9000 &&
- !item.acknowledged &&
- !["accepted", "seen"].includes(item.delivery) &&
- item.event.tags.some(
- ([name, value]) => name === "h" && value === id,
- ) &&
- item.event.tags.some(
- ([name, value]) => name === "p" && value === key,
- ) &&
- !item.event.tags.some(([name]) => name === "role"),
+ const previous = (receipts ?? outbox)?.snapshot().find((item) => {
+ const roles = item.event.tags.filter(([name]) => name === "role");
+ return (
+ item.event.kind === 9000 &&
+ !item.acknowledged &&
+ !["accepted", "seen"].includes(item.delivery) &&
+ item.event.tags.some(
+ ([name, value]) => name === "h" && value === id,
+ ) &&
+ item.event.tags.some(
+ ([name, value]) => name === "p" && value === key,
+ ) &&
+ roles.length === roleTags.length &&
+ roles.every((tag) => tag.length === 2 && tag[1] === "bot")
);
+ });
if (previous && Date.now() / 1000 - previous.event.created_at >= 15 * 60)
throw new Error(
`This agent-add request expired. Open Outbox, remove the “Add agent ${key.slice(0, 12)}” item, then add the agent again. Check channel membership first if the request was unconfirmed.`,
@@ -511,10 +515,7 @@ export function createChannelWrites(
{
kind: 9000,
content: "",
- tags: [
- ["h", id],
- ["p", key],
- ],
+ tags: [["h", id], ["p", key], ...roleTags],
},
undefined,
active,
@@ -772,6 +773,25 @@ export function createChannelWrites(
false
);
},
+ /** Outbox fences local delivery and expiry; this read bypasses cached absence. */
+ async additionRecorded(event: EventData, caller: AbortSignal) {
+ const active = AbortSignal.any([signal, caller]);
+ active.throwIfAborted();
+ const events = await reader.read(
+ [
+ {
+ kinds: [9000],
+ ids: [event.id],
+ authors: [event.pubkey],
+ limit: 1,
+ consistency: "strong",
+ },
+ ],
+ { signal: active, fresh: true, priority: "foreground" },
+ );
+ active.throwIfAborted();
+ return events.some((recorded) => recorded.id === event.id);
+ },
async discardFailed(id: string) {
const source = writer();
if (
diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts
index 3dc4c9d1a0..4128a429b5 100644
--- a/src/features/relay/contracts.ts
+++ b/src/features/relay/contracts.ts
@@ -43,7 +43,7 @@ export type ChannelSummary = Readonly<{
hidden?: true;
/** Relay-authored metadata; absent while metadata is unavailable. */
/** `system` (NIP-CL): access control for app data; never in list().
- * `session`: a private stream with the Solo label (`SOLO_LABEL`). */
+ * `solo`: a stream with the Solo label (`SOLO_LABEL`), regardless of visibility. */
channelType?: "stream" | "forum" | "dm" | "solo" | "system";
/** Relay-authored channel visibility; private channels use restricted presentation. */
private?: true;
diff --git a/src/features/relay/discovery-permissions.test.ts b/src/features/relay/discovery-permissions.test.ts
index 6b1dc5b05f..e9768fa69d 100644
--- a/src/features/relay/discovery-permissions.test.ts
+++ b/src/features/relay/discovery-permissions.test.ts
@@ -2,6 +2,7 @@ import { expect, it } from "vitest";
import { DiscoveryState } from "./discovery";
import { eventDto } from "./events";
import { keypair, metadata, signed } from "./testing";
+import { SOLO_LABEL } from "../solo/labels";
const relay = keypair(),
viewer = keypair();
@@ -23,6 +24,37 @@ const details = (role: string, time: number) =>
["join_role", role],
]);
+it("keeps public Solo visitors read-only without membership and revokes their access when made private", () => {
+ const owner = keypair();
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ const chat = (visibility: "public" | "private", time: number) =>
+ metadata(relay, id, "Planning", time, [
+ [visibility],
+ ["t", "stream"],
+ ["t", SOLO_LABEL],
+ ["P", owner.pubkey],
+ ["join_role", "guest"],
+ ]);
+ state.accept(chat("private", 1));
+ expect(state.get(id)).toBeUndefined();
+ state.accept(chat("public", 2));
+ expect(state.get(id)).toMatchObject({
+ channelType: "solo",
+ readOnly: true,
+ joinRole: "guest",
+ creator: owner.pubkey,
+ });
+ expect(state.canAccess(id)).toBe(true);
+ expect(state.canWrite(id)).toBe(false);
+ expect(state.canParticipate(id)).toBe(false);
+ expect(state.channels()).toEqual([]);
+ state.accept(chat("private", 3));
+ expect(state.canAccess(id)).toBe(false);
+ expect(state.get(id)).toBeUndefined();
+ state.accept(chat("public", 2));
+ expect(state.canAccess(id)).toBe(false);
+});
+
it("keeps a guest joined and readable across admission-default changes until explicitly promoted", () => {
const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
state.accept(details("guest", 10));
diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts
index 5f462c1ea9..bcc5d90a62 100644
--- a/src/features/relay/discovery.ts
+++ b/src/features/relay/discovery.ts
@@ -26,12 +26,10 @@ export function metadataName(event: RelayEvent): string | undefined {
const noMembers: readonly string[] = Object.freeze([]);
-/** A Solo chat: a private stream carrying the Solo label. Never access. */
+/** A Solo chat: a stream carrying the Solo label. Never access. */
export function soloChannelMetadata(event: RelayEvent): boolean {
return (
- tag(event, "t") === "stream" &&
- event.tags.some(([name]) => name === "private") &&
- soloLabels(channelIdentity(event).labels)
+ tag(event, "t") === "stream" && soloLabels(channelIdentity(event).labels)
);
}
diff --git a/src/features/relay/outbox-recovery.test.ts b/src/features/relay/outbox-recovery.test.ts
index 5b0aa41fe0..d9a1572f05 100644
--- a/src/features/relay/outbox-recovery.test.ts
+++ b/src/features/relay/outbox-recovery.test.ts
@@ -13,6 +13,7 @@ const recovery = { key: "new-dm", value: '{"draft":"First message"}' };
const owners: ReturnType[] = [];
afterEach(() => {
for (const owner of owners.splice(0)) owner.dispose();
+ vi.useRealTimers();
});
function setup(
storage: OutboxStorage,
@@ -296,3 +297,269 @@ it("retires roster-confirmed member recovery without inventing a delivery receip
expect(h.publish).toHaveBeenCalledOnce();
expect(restored.publish).not.toHaveBeenCalled();
});
+
+function legacyAddition(age = 901) {
+ const event = signed(viewer, {
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "channel"],
+ ["p", "person"],
+ ],
+ created_at: Math.floor(Date.now() / 1000) - age,
+ });
+ const recovery = { key: "member-add:channel:person", value: "1" };
+ let records: readonly OutgoingEvent[] = [
+ { event, signed: event, delivery: "unknown", recovery },
+ ];
+ const store = {
+ load: () => structuredClone(records),
+ save: (next: readonly OutgoingEvent[]) => {
+ records = structuredClone(next);
+ },
+ };
+ return { event, recovery, store };
+}
+it("fences replay and replacement until an expired legacy invitation is durably resolved", async () => {
+ const { event, recovery, store } = legacyAddition();
+ const h = setup(store);
+ await h.outbox.ready();
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const verify = vi.fn(async () => {
+ await gate;
+ return undefined;
+ });
+ const retire = h.outbox.acknowledge(event.id, verify);
+ try {
+ await vi.waitFor(() => expect(verify).toHaveBeenCalledOnce());
+ h.outbox.retry(event.id);
+ expect(() =>
+ h.outbox.send(
+ {
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "channel"],
+ ["p", "person"],
+ ["role", "member"],
+ ],
+ },
+ recovery,
+ ),
+ ).toThrow(/Recover the earlier/);
+ expect(store.load()[0]?.acknowledged).toBeUndefined();
+ } finally {
+ release();
+ }
+ await retire;
+ expect(h.publish).not.toHaveBeenCalled();
+ expect(store.load()[0]).toMatchObject({
+ acknowledged: true,
+ delivery: "unknown",
+ });
+ expect(h.outbox.snapshot()).toEqual([]);
+});
+it("cannot retire a still-active delivery after its intake window expires", async () => {
+ const { event, store } = legacyAddition(0);
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const publish = vi.fn(async () => {
+ await gate;
+ });
+ const h = setup(store, publish);
+ await h.outbox.ready();
+ h.outbox.retry(event.id);
+ try {
+ await vi.waitFor(() => expect(publish).toHaveBeenCalledOnce());
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime((event.created_at + 901) * 1000);
+ const verify = vi.fn(async () => undefined);
+ await expect(h.outbox.acknowledge(event.id, verify)).rejects.toThrow(
+ /may still arrive/,
+ );
+ expect(verify).not.toHaveBeenCalled();
+ expect(store.load()[0]?.acknowledged).toBeUndefined();
+ } finally {
+ release();
+ }
+ await vi.waitFor(() =>
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("accepted"),
+ );
+});
+it("retains a legacy receipt when an echo arrives during negative verification", async () => {
+ const { event, store } = legacyAddition();
+ const h = setup(store);
+ await h.outbox.ready();
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const verify = vi.fn(async () => {
+ await gate;
+ return undefined;
+ });
+ const retire = h.outbox.acknowledge(event.id, verify);
+ const rejected = expect(retire).rejects.toThrow(/changed while checking/);
+ try {
+ await vi.waitFor(() => expect(verify).toHaveBeenCalledOnce());
+ h.observe([event]);
+ } finally {
+ release();
+ }
+ await rejected;
+ expect(h.outbox.snapshot()[0]).toMatchObject({
+ delivery: "seen",
+ recovery: { key: "member-add:channel:person" },
+ });
+ expect(store.load()[0]?.acknowledged).toBeUndefined();
+ expect(h.publish).not.toHaveBeenCalled();
+});
+
+it("atomically hands legacy recovery to a durable Member invitation before publication", async () => {
+ const { event, recovery, store } = legacyAddition();
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const save = vi.fn(async (records: readonly OutgoingEvent[]) => {
+ await gate;
+ store.save(records);
+ });
+ const h = setup({ ...store, save });
+ await h.outbox.ready();
+ const queued = vi.fn();
+ const handoff = h.outbox.acknowledge(event.id, async () => undefined, {
+ input: () => ({
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "channel"],
+ ["p", "person"],
+ ["role", "member"],
+ ],
+ }),
+ queued,
+ });
+ try {
+ await vi.waitFor(() => expect(save).toHaveBeenCalledOnce());
+ const transaction = save.mock.calls[0]?.[0];
+ expect(
+ transaction?.find((item) => item.event.id === event.id),
+ ).toMatchObject({ acknowledged: true, recovery: undefined });
+ expect(
+ transaction?.find((item) => item.event.id !== event.id),
+ ).toMatchObject({ recovery, delivery: "sending" });
+ expect(store.load()[0]?.acknowledged).toBeUndefined();
+ expect(h.outbox.snapshot().length).toBeGreaterThan(0);
+ expect(h.publish).not.toHaveBeenCalled();
+ expect(queued).not.toHaveBeenCalled();
+ } finally {
+ release();
+ }
+ await handoff;
+ expect(queued).toHaveBeenCalledOnce();
+ const replacement = queued.mock.calls[0]?.[0];
+ expect(
+ store.load().find((item) => item.event.id === replacement)?.recovery,
+ ).toEqual(recovery);
+ h.dispose();
+ const restored = setup(store);
+ await restored.outbox.ready();
+ expect(restored.outbox.snapshot()).toMatchObject([
+ { event: { id: replacement }, recovery },
+ ]);
+ expect(restored.publish).not.toHaveBeenCalled();
+ restored.outbox.retry(replacement);
+ await vi.waitFor(() => expect(restored.publish).toHaveBeenCalledOnce());
+ expect(restored.publish.mock.calls[0]?.[0].id).toBe(replacement);
+});
+
+it("keeps only the original recoverable intent when an atomic handoff cannot be saved", async () => {
+ const { event, store } = legacyAddition();
+ const h = setup({
+ ...store,
+ save: async () => {
+ throw new Error("Disk full");
+ },
+ });
+ await h.outbox.ready();
+ const queued = vi.fn();
+ await expect(
+ h.outbox.acknowledge(event.id, async () => undefined, {
+ input: () => ({
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "channel"],
+ ["p", "person"],
+ ["role", "member"],
+ ],
+ }),
+ queued,
+ }),
+ ).rejects.toThrow("Disk full");
+ expect(h.outbox.snapshot()).toMatchObject([
+ { event: { id: event.id }, recovery: { key: "member-add:channel:person" } },
+ ]);
+ expect(h.outbox.snapshot()).toHaveLength(1);
+ expect(store.load()[0]?.acknowledged).toBeUndefined();
+ expect(queued).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+});
+
+it("does not expose replacement intent to an earlier queued save", async () => {
+ const { event, recovery, store } = legacyAddition();
+ let release!: () => void;
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ const transactions: (readonly OutgoingEvent[])[] = [];
+ const save = vi.fn(async (records: readonly OutgoingEvent[]) => {
+ await held;
+ transactions.push(structuredClone(records));
+ store.save(records);
+ });
+ const h = setup({ ...store, save });
+ await h.outbox.ready();
+ const first = h.outbox.recover(event.id, recovery);
+ await vi.waitFor(() => expect(save).toHaveBeenCalledOnce());
+ const changed = vi.fn();
+ const stop = h.outbox.subscribe(changed);
+ const earlier = h.outbox.recover(event.id, recovery);
+ await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce());
+ const input = vi.fn(() => ({
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "channel"],
+ ["p", "person"],
+ ["role", "member"],
+ ],
+ }));
+ const queued = vi.fn();
+ const handoff = h.outbox.acknowledge(event.id, async () => undefined, {
+ input,
+ queued,
+ });
+ try {
+ await vi.waitFor(() => expect(input).toHaveBeenCalledOnce());
+ expect(h.outbox.snapshot()).toHaveLength(1);
+ } finally {
+ release();
+ stop();
+ }
+ await Promise.all([first, earlier, handoff]);
+ const replacement = queued.mock.calls[0]?.[0];
+ expect(replacement).toBeDefined();
+ for (const records of transactions) {
+ if (records.some((item) => item.event.id === replacement))
+ expect(
+ records.find((item) => item.event.id === event.id)?.acknowledged,
+ ).toBe(true);
+ }
+});
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index a2f2503bc9..b5ac9a4363 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -44,6 +44,14 @@ export type OutgoingEvent = Readonly<{
* device, being asked to wait, or going unanswered. */
refused?: true | undefined;
}>;
+type OutboxInput = Pick & {
+ /** NIP-AP: created_at of the newest observed head this event replaces. */
+ supersedes?: number;
+};
+type Replacement = {
+ input(): OutboxInput | undefined;
+ queued(id: string): void;
+};
export interface Outbox {
/** Outstanding operations only. Confirmed events live in the session's retained data. */
snapshot(): readonly OutgoingEvent[];
@@ -54,16 +62,18 @@ export interface Outbox {
supports(kind: number): boolean;
ready(): Promise;
send(
- input: Pick & {
- /** NIP-AP: created_at of the newest observed head this event replaces. */
- supersedes?: number;
- },
+ input: OutboxInput,
recovery?: OutboxRecovery,
active?: () => boolean,
): string;
/** Attach caller-owned recovery to an existing receipt before resuming it. */
recover(id: string, recovery: OutboxRecovery): Promise;
- acknowledge(id: string): Promise;
+ /** An expired no-role invitation may be resolved by fresh command/roster readback. */
+ acknowledge(
+ id: string,
+ verifyRetirement?: (event: EventData) => Promise<"recorded" | undefined>,
+ replacement?: Replacement,
+ ): Promise;
retry(id: string, active?: () => boolean): void;
dismiss(id: string): Promise;
/** Durable explicit dismissal, with delivery evidence at removal. */
@@ -250,7 +260,11 @@ export function createOutbox(
for (const listener of localListeners) notifyListener(listener);
for (const listener of listeners) notifyListener(listener);
};
- const persist = (id: string, action?: "acknowledge" | "dismiss") => {
+ const persist = (
+ id: string,
+ action?: "acknowledge" | "dismiss",
+ addition?: OutgoingEvent,
+ ) => {
const queued = profiling.start("outbox.queue", id);
const work = durable
.catch(() => {})
@@ -263,8 +277,8 @@ export function createOutbox(
queued();
// Hydration may have added restored intent while this commit waited.
// Commit current state so no intermediate transaction erases that intent.
- const records =
- action === "dismiss"
+ const records = [
+ ...(action === "dismiss"
? visible.filter((item) => item.event.id !== id)
: action === "acknowledge"
? visible.map((item) =>
@@ -276,15 +290,20 @@ export function createOutbox(
}
: item,
)
- : visible;
+ : visible),
+ ...(addition ? [addition] : []),
+ ];
const bytes =
action === "dismiss"
? byteSize(snapshot.filter((item) => item.event.id !== id))
- : pendingBytes;
+ : pendingBytes + (addition ? byteSize(addition) : 0);
return profiling.measureAsync("outbox.persist", id, async () => {
if (bytes > 2 * 1024 * 1024)
throw new Error("Outbox storage is full");
await storage.save(records);
+ // The replacement joins visible intent only with durable retirement.
+ // Other queued saves must never observe two unresolved recovery keys.
+ if (addition) snapshot = Object.freeze([...snapshot, addition]);
if (action === "dismiss") {
// Commit removal inside the serialized write, before a later save
// can capture state. Failed storage never exposes a temporary absence.
@@ -638,6 +657,147 @@ export function createOutbox(
set.delete(listener);
};
};
+ /** Persist replacement intent and retire its verified predecessor in one save. */
+ function enqueue(
+ { supersedes, ...input }: OutboxInput,
+ recovery?: OutboxRecovery,
+ active?: () => boolean,
+ replacing?: string,
+ ) {
+ if (active && !active())
+ throw new DOMException("Channel addition cancelled", "AbortError");
+ if (closed) throw abortError();
+ if (storageError) throw new Error(storageError);
+ const savedRecovery = recoveryValue(recovery);
+ if (
+ savedRecovery &&
+ (!hydrated ||
+ snapshot.some(
+ (item) =>
+ item.event.id !== replacing &&
+ item.recovery?.key === savedRecovery.key,
+ ))
+ )
+ throw new Error("Recover the earlier message before sending another");
+ if (
+ !Number.isInteger(input.kind) ||
+ input.kind < 0 ||
+ input.kind > 65535 ||
+ !outbox.supports(input.kind)
+ )
+ throw new Error("This relay connection cannot publish that event kind");
+ const catalog = isCatalogKind(input.kind);
+ if (
+ (input.kind === 9 && !input.content.trim()) ||
+ // NIP-AP admits larger public definitions than ordinary input.
+ (catalog
+ ? bytes(input.content) > MAX_CONTENT_BYTES ||
+ byteSize(input) > MAX_EVENT_BYTES
+ : byteSize(input) > OUTBOX_INPUT_MAX_BYTES)
+ )
+ throw new Error("Message is empty or too large");
+ if (snapshot.length >= MAX_PENDING)
+ throw new Error(
+ "Too many outstanding operations; resolve or dismiss a pending operation",
+ );
+ // Rendered messages carry send order within their second; the optimistic
+ // row and the signed event share this exact ms and created_at.
+ const channelId = channelRowKind(input.kind)
+ ? input.tags.find(([name]) => name === "h")?.[1]
+ : undefined;
+ const ms = channelId ? clock.next(channelId) : Date.now();
+ let createdAt = Math.floor(ms / 1000);
+ if (input.kind === 40003) {
+ if (!hydrated)
+ throw new Error("Message history is still loading. Try again.");
+ const target = input.tags.find(([name]) => name === "e")?.[1];
+ const channel = input.tags.find(([name]) => name === "h")?.[1];
+ // Edits use second precision on every client. Preserve retained local
+ // submission order without changing the protocol's event-ID tie-break.
+ for (const { event } of visible) {
+ if (
+ target &&
+ channel &&
+ event.kind === 40003 &&
+ event.pubkey === viewer &&
+ event.tags.some(([name, id]) => name === "e" && id === target) &&
+ event.tags.some(([name, id]) => name === "h" && id === channel)
+ )
+ createdAt = Math.max(createdAt, event.created_at + 1);
+ }
+ // Stay well inside the signer's 15-minute clock-skew allowance.
+ if (createdAt > Math.floor(ms / 1000) + 60)
+ throw new Error(
+ "Edits are arriving too quickly or your clock changed. Wait a moment and try again.",
+ );
+ }
+ if (catalog) {
+ // NIP-AP: max(now, head + 1) over the observed relay head and this
+ // device's retained writes, so a replacement can never lose or tie.
+ if (supersedes !== undefined)
+ createdAt = Math.max(createdAt, supersedes + 1);
+ const d = input.tags.find(([name]) => name === "d")?.[1];
+ for (const { event } of visible)
+ if (
+ event.kind === input.kind &&
+ event.pubkey === viewer &&
+ event.tags.some(([name, value]) => name === "d" && value === d)
+ )
+ createdAt = Math.max(createdAt, event.created_at + 1);
+ if (createdAt > Math.floor(ms / 1000) + 60)
+ throw new Error(
+ "Sharing is changing too quickly or your clock changed. Wait a moment and try again.",
+ );
+ }
+ const template = {
+ ...input,
+ pubkey: viewer,
+ created_at: createdAt,
+ tags: [
+ ...input.tags.map((tag) => [...tag]),
+ ["client-id", crypto.randomUUID()],
+ ...(channelId ? [["ms", String(ms % 1000)]] : []),
+ ],
+ };
+ const event = Object.freeze({
+ ...template,
+ tags: Object.freeze(
+ template.tags.map((tag) => Object.freeze(tag)),
+ ) as unknown as string[][],
+ id: getEventHash(template),
+ });
+ if (active) admissionGates.set(event.id, active);
+ captureSend(event);
+ const operation = Object.freeze({
+ event,
+ delivery: "sending" as const,
+ ...(savedRecovery ? { recovery: savedRecovery } : {}),
+ ...(active ? { guarded: true } : {}),
+ });
+ if (!replacing)
+ profiling.measure("send.local", event.id, () => {
+ snapshot = Object.freeze([...snapshot, operation]);
+ notify();
+ });
+ const intent = ready.then(() => {
+ if (storageError) throw new Error(storageError);
+ return persist(
+ replacing ?? event.id,
+ replacing ? "acknowledge" : undefined,
+ replacing ? operation : undefined,
+ );
+ });
+ void intent.catch(() => {
+ if (replacing) deliveryWork.delete(event.id);
+ });
+ if (replacing)
+ void intent.then(
+ () => schedule(event.id),
+ () => {},
+ );
+ else schedule(event.id, intent);
+ return { id: event.id, persisted: intent };
+ }
const outbox: Outbox = Object.freeze({
snapshot: () => finalSnapshot ?? snapshot,
subscribe: (listener: () => void) => subscribe(listeners, listener),
@@ -688,10 +848,64 @@ export function createOutbox(
notify();
await persist(id);
},
- async acknowledge(id: string) {
+ async acknowledge(
+ id: string,
+ verifyRetirement?: (event: EventData) => Promise<"recorded" | undefined>,
+ replacement?: Replacement,
+ ) {
await outbox.ready();
const item = visible.find((item) => item.event.id === id);
if (!item?.recovery) return;
+ if (verifyRetirement) {
+ const eligible = (recorded = false) => {
+ const current = find(id);
+ return (
+ !closed &&
+ !attempts.has(id) &&
+ current?.event.kind === 9000 &&
+ current.recovery?.key.startsWith("member-add:") &&
+ (current.delivery === "unknown" ||
+ current.delivery === "failed" ||
+ (recorded && current.delivery === "seen")) &&
+ current.event.content === "" &&
+ current.event.tags.every(
+ (tag) =>
+ tag.length === 2 &&
+ ["h", "p", "client-id", "ms"].includes(tag[0] ?? ""),
+ ) &&
+ current.event.tags.filter(([tag]) => tag === "h").length === 1 &&
+ current.event.tags.filter(([tag]) => tag === "p").length === 1 &&
+ current.event.created_at < Math.floor(Date.now() / 1000) - 15 * 60
+ );
+ };
+ // Seen may enter verification, but only fresh recorded-command evidence
+ // with caller-verified membership can retire it below.
+ if (dismissing.has(id) || !eligible(true))
+ throw new Error(
+ "This saved invitation may still arrive. Retry after its 15-minute delivery window ends.",
+ );
+ // Fence every local replay until verification and retirement are durable.
+ const work = Promise.resolve().then(async () => {
+ const result = await verifyRetirement(item.event);
+ if (!eligible(result === "recorded"))
+ throw new Error(
+ "The invitation changed while checking it. Refresh members and try again.",
+ );
+ const input = replacement?.input();
+ if (input) {
+ const next = enqueue(input, item.recovery, undefined, id);
+ await next.persisted;
+ replacement?.queued(next.id);
+ } else await persist(id, "acknowledge");
+ });
+ dismissing.set(id, work);
+ try {
+ await work;
+ } finally {
+ if (dismissing.get(id) === work) dismissing.delete(id);
+ }
+ return;
+ }
// Member callers verify a fresh signed roster even when the receipt was lost.
if (
!(
@@ -704,136 +918,11 @@ export function createOutbox(
throw new Error("Confirm delivery before completing this message");
await persist(id, "acknowledge");
},
- send(
- {
- supersedes,
- ...input
- }: Pick & {
- supersedes?: number;
- },
+ send: (
+ input: OutboxInput,
recovery?: OutboxRecovery,
active?: () => boolean,
- ) {
- if (active && !active())
- throw new DOMException("Channel addition cancelled", "AbortError");
- if (closed) throw abortError();
- if (storageError) throw new Error(storageError);
- const savedRecovery = recoveryValue(recovery);
- if (
- savedRecovery &&
- (!hydrated ||
- snapshot.some((item) => item.recovery?.key === savedRecovery.key))
- )
- throw new Error("Recover the earlier message before sending another");
- if (
- !Number.isInteger(input.kind) ||
- input.kind < 0 ||
- input.kind > 65535 ||
- !outbox.supports(input.kind)
- )
- throw new Error("This relay connection cannot publish that event kind");
- const catalog = isCatalogKind(input.kind);
- if (
- (input.kind === 9 && !input.content.trim()) ||
- // NIP-AP admits larger public definitions than ordinary input.
- (catalog
- ? bytes(input.content) > MAX_CONTENT_BYTES ||
- byteSize(input) > MAX_EVENT_BYTES
- : byteSize(input) > OUTBOX_INPUT_MAX_BYTES)
- )
- throw new Error("Message is empty or too large");
- if (snapshot.length >= MAX_PENDING)
- throw new Error(
- "Too many outstanding operations; resolve or dismiss a pending operation",
- );
- // Rendered messages carry send order within their second; the optimistic
- // row and the signed event share this exact ms and created_at.
- const channelId = channelRowKind(input.kind)
- ? input.tags.find(([name]) => name === "h")?.[1]
- : undefined;
- const ms = channelId ? clock.next(channelId) : Date.now();
- let createdAt = Math.floor(ms / 1000);
- if (input.kind === 40003) {
- if (!hydrated)
- throw new Error("Message history is still loading. Try again.");
- const target = input.tags.find(([name]) => name === "e")?.[1];
- const channel = input.tags.find(([name]) => name === "h")?.[1];
- // Edits use second precision on every client. Preserve retained local
- // submission order without changing the protocol's event-ID tie-break.
- for (const { event } of visible) {
- if (
- target &&
- channel &&
- event.kind === 40003 &&
- event.pubkey === viewer &&
- event.tags.some(([name, id]) => name === "e" && id === target) &&
- event.tags.some(([name, id]) => name === "h" && id === channel)
- )
- createdAt = Math.max(createdAt, event.created_at + 1);
- }
- // Stay well inside the signer's 15-minute clock-skew allowance.
- if (createdAt > Math.floor(ms / 1000) + 60)
- throw new Error(
- "Edits are arriving too quickly or your clock changed. Wait a moment and try again.",
- );
- }
- if (catalog) {
- // NIP-AP: max(now, head + 1) over the observed relay head and this
- // device's retained writes, so a replacement can never lose or tie.
- if (supersedes !== undefined)
- createdAt = Math.max(createdAt, supersedes + 1);
- const d = input.tags.find(([name]) => name === "d")?.[1];
- for (const { event } of visible)
- if (
- event.kind === input.kind &&
- event.pubkey === viewer &&
- event.tags.some(([name, value]) => name === "d" && value === d)
- )
- createdAt = Math.max(createdAt, event.created_at + 1);
- if (createdAt > Math.floor(ms / 1000) + 60)
- throw new Error(
- "Sharing is changing too quickly or your clock changed. Wait a moment and try again.",
- );
- }
- const template = {
- ...input,
- pubkey: viewer,
- created_at: createdAt,
- tags: [
- ...input.tags.map((tag) => [...tag]),
- ["client-id", crypto.randomUUID()],
- ...(channelId ? [["ms", String(ms % 1000)]] : []),
- ],
- };
- const event = Object.freeze({
- ...template,
- tags: Object.freeze(
- template.tags.map((tag) => Object.freeze(tag)),
- ) as unknown as string[][],
- id: getEventHash(template),
- });
- if (active) admissionGates.set(event.id, active);
- captureSend(event);
- profiling.measure("send.local", event.id, () => {
- snapshot = Object.freeze([
- ...snapshot,
- Object.freeze({
- event,
- delivery: "sending" as const,
- ...(savedRecovery ? { recovery: savedRecovery } : {}),
- ...(active ? { guarded: true } : {}),
- }),
- ]);
- notify();
- });
- const intent = ready.then(() => {
- if (storageError) throw new Error(storageError);
- return persist(event.id);
- });
- void intent.catch(() => {}); // Delivery reports persistence errors; disposal still saves queued intent.
- schedule(event.id, intent);
- return event.id;
- },
+ ) => enqueue(input, recovery, active).id,
retry(id: string, active?: () => boolean) {
const item = find(id);
if (item?.guarded && (active ?? admissionGates.get(id))?.() !== true)
diff --git a/src/features/relay/session-agent-admission.test.ts b/src/features/relay/session-agent-admission.test.ts
index 65c8436765..416919e95f 100644
--- a/src/features/relay/session-agent-admission.test.ts
+++ b/src/features/relay/session-agent-admission.test.ts
@@ -6,7 +6,7 @@ import { matchesEvent } from "./projection";
import type { RelayEvent } from "./events";
import { SOLO_LABEL } from "../solo/labels";
-function setup() {
+function setup(visibility: "private" | "public" = "private") {
const viewer = keypair(),
relay = keypair(),
agent = keypair();
@@ -102,7 +102,11 @@ function setup() {
tags: [["-"], ...archived.map((key) => ["p", key])],
}),
meta(parent, "stream"),
- meta(child, "stream", [["private"], ["t", SOLO_LABEL]]),
+ meta(child, "stream", [
+ [visibility],
+ ["t", SOLO_LABEL],
+ ["join_role", "guest"],
+ ]),
roster(relay, parent, members, clock),
roster(relay, child, childMembers, clock),
];
@@ -174,36 +178,83 @@ function setup() {
};
}
-it("adds an outside agent once and confirms its membership", async () => {
- const test = setup();
- try {
- await test.ready();
- await test.owner.session.channelWrites.addAgents(test.child, [
- test.agent,
- test.agent,
- ]);
- expect(test.publish).toHaveBeenCalledOnce();
- expect(test.publish.mock.calls[0]?.[0]).toMatchObject({
- kind: 9000,
- tags: expect.arrayContaining([
- ["h", test.child],
- ["p", test.agent],
- ]),
- });
- expect(
- test.publish.mock.calls[0]?.[0].tags.some(([name]) => name === "role"),
- ).toBe(false);
- expect(
- test.owner.session.channels
- .list()
- .channels.find((item) => item.id === test.child)?.members,
- ).toContain(test.agent);
- await test.owner.session.channelWrites.addAgents(test.child, [test.agent]);
- expect(test.publish).toHaveBeenCalledOnce();
- } finally {
- test.owner.dispose();
- }
-});
+it.each(["private", "public"] as const)(
+ "adds an outside agent as Bot with a Guest default in a %s Solo chat",
+ async (visibility) => {
+ const test = setup(visibility);
+ try {
+ await test.ready();
+ await test.owner.session.channelWrites.addAgents(test.child, [
+ test.agent,
+ test.agent,
+ ]);
+ expect(test.publish).toHaveBeenCalledOnce();
+ expect(test.publish.mock.calls[0]?.[0]).toMatchObject({
+ kind: 9000,
+ tags: expect.arrayContaining([
+ ["h", test.child],
+ ["p", test.agent],
+ ["role", "bot"],
+ ]),
+ });
+ expect(
+ test.owner.session.channels
+ .list()
+ .channels.find((item) => item.id === test.child)?.members,
+ ).toContain(test.agent);
+ await test.owner.session.channelWrites.addAgents(test.child, [
+ test.agent,
+ ]);
+ expect(test.publish).toHaveBeenCalledOnce();
+ } finally {
+ test.owner.dispose();
+ }
+ },
+);
+it.each([
+ { roleTags: [] },
+ { roleTags: [["role", "guest"]] },
+ { roleTags: [["role", "member"]] },
+ {
+ roleTags: [
+ ["role", "bot"],
+ ["role", "bot"],
+ ],
+ },
+])(
+ "does not reuse a Solo agent receipt with incompatible role tags $roleTags",
+ async ({ roleTags }) => {
+ const test = setup();
+ try {
+ await test.ready();
+ test.setDenied(true);
+ const outbox = test.owner.session.outbox;
+ if (!outbox) throw new Error("Missing fixture outbox");
+ const previous = outbox.send({
+ kind: 9000,
+ content: "",
+ tags: [["h", test.child], ["p", test.agent], ...roleTags],
+ });
+ await vi.waitFor(() =>
+ expect(
+ outbox.snapshot().find((item) => item.event.id === previous)
+ ?.delivery,
+ ).toBe("failed"),
+ );
+ test.setDenied(false);
+ await test.owner.session.channelWrites.addAgents(test.child, [
+ test.agent,
+ ]);
+ expect(test.publish.mock.lastCall?.[0].id).not.toBe(previous);
+ expect(test.publish.mock.lastCall?.[0].tags).toContainEqual([
+ "role",
+ "bot",
+ ]);
+ } finally {
+ test.owner.dispose();
+ }
+ },
+);
it("reports channel-specific uncertainty when an accepted addition lacks roster confirmation", async () => {
const test = setup();
try {
diff --git a/src/features/relay/session-discovery.test.ts b/src/features/relay/session-discovery.test.ts
index a9d093cecc..38ee1a83ab 100644
--- a/src/features/relay/session-discovery.test.ts
+++ b/src/features/relay/session-discovery.test.ts
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { DiscoveryState } from "./discovery";
import { keypair, roster, signed } from "./testing";
import { SOLO_LABEL } from "../solo/labels";
-it("recognizes only relay-authorized private streams with the Solo label", () => {
+it("recognizes relay-authorized streams with the Solo label independently of visibility", () => {
const viewer = keypair(),
relay = keypair();
const discovery = new DiscoveryState(viewer.pubkey, relay.pubkey);
@@ -28,7 +28,7 @@ it("recognizes only relay-authorized private streams with the Solo label", () =>
discovery.accept(metadata(viewer, true, 1));
expect(discovery.isSolo("work")).toBe(false);
discovery.accept(metadata(relay, false, 1));
- expect(discovery.isSolo("work")).toBe(false);
+ expect(discovery.isSolo("work")).toBe(true);
discovery.accept(metadata(relay, true, 2));
expect(discovery.channels()[0]).toMatchObject({
channelType: "solo",
diff --git a/src/features/solo/ConvertToChannel.test.tsx b/src/features/solo/ConvertToChannel.test.tsx
index a164ee20ca..ca55546a3d 100644
--- a/src/features/solo/ConvertToChannel.test.tsx
+++ b/src/features/solo/ConvertToChannel.test.tsx
@@ -9,12 +9,13 @@ import {
waitFor,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools";
import { createChannelDetails } from "../relay/channel-details";
import { createRelaySession } from "../relay/session";
import { PublishRejected } from "../relay/outbox";
import { SOLO_LABEL } from "./labels";
-import { chatConversion } from "./conversion";
+import { chatConversion, conversionLabel } from "./conversion";
import { ConvertToChannel, canConvertToChannel } from "./ConvertToChannel";
const id = "11111111-1111-4111-8111-111111111111";
@@ -27,11 +28,16 @@ afterEach(() => {
cleanup();
for (const close of dispose.splice(0)) close();
});
-function setup(solo = true) {
+function setup(
+ solo = true,
+ initialVisibility: "private" | "public" = "private",
+ initialJoinRole: "member" | "guest" | undefined = undefined,
+) {
let clock = 100,
role = "owner",
name = "Original",
- visibility = "private";
+ visibility = initialVisibility,
+ joinRole = initialJoinRole;
let labels = solo ? [SOLO_LABEL, "work"] : ["work"];
const record = (kind: number, tags: string[][]) =>
finalizeEvent(
@@ -45,6 +51,7 @@ function setup(solo = true) {
["t", "stream"],
...labels.map((label) => ["t", label]),
[visibility],
+ ...(joinRole ? [["join_role", joinRole]] : []),
["ttl", "86400"],
]);
const read = vi.fn(async () => [
@@ -62,6 +69,8 @@ function setup(solo = true) {
if (written.length) labels = written.filter(Boolean);
const choice = event.tags.find(([tag]) => tag === "visibility")?.[1];
if (choice) visibility = choice === "open" ? "public" : "private";
+ const admission = event.tags.find(([tag]) => tag === "join_role")?.[1];
+ if (admission === "member" || admission === "guest") joinRole = admission;
clock++;
});
const details = createChannelDetails({
@@ -133,31 +142,66 @@ function setup(solo = true) {
name = "Changed";
clock++;
},
- converted: (next = "private") => {
+ admission: (next: "member" | "guest") => {
+ joinRole = next;
+ clock++;
+ },
+ converted: (
+ next: "private" | "public" = "private",
+ admission: "member" | "guest" | undefined = next === "public"
+ ? "member"
+ : joinRole,
+ ) => {
labels = labels.filter((label) => label !== SOLO_LABEL);
visibility = next;
+ joinRole = admission;
clock++;
},
};
}
const confirm = () =>
screen.getByRole("button", { name: "Convert to channel" });
+const accessControl = () =>
+ screen.getByRole("combobox", { name: "General access" });
+async function selectAccess(label: string) {
+ const user = userEvent.setup();
+ await user.click(accessControl());
+ await user.click(await screen.findByRole("option", { name: label }));
+}
async function ready() {
await waitFor(() => expect(confirm()).toBeEnabled());
}
+it("preselects View only and preserves Guest admission when converting a view-only Solo chat", async () => {
+ const h = setup(true, "public", "guest");
+ await ready();
+ expect(accessControl()).toHaveTextContent("View only");
+ fireEvent.click(confirm());
+ await waitFor(() => expect(h.close).toHaveBeenCalledOnce());
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.sign.mock.calls[0]?.[0].tags).not.toContainEqual([
+ "visibility",
+ "private",
+ ]);
+ expect(await h.details.capability.load(id)).toMatchObject({
+ visibility: "public",
+ joinRole: "guest",
+ labels: ["work"],
+ ttlSeconds: 86400,
+ });
+});
it.each(["private", "public"])(
"converts through signed kind 9002 with explicit %s visibility and unchanged TTL",
async (visibility) => {
const h = setup();
await ready();
- expect(screen.getByRole("radio", { name: "Private" })).toBeChecked();
+ expect(accessControl()).toHaveTextContent("Private");
+ const user = userEvent.setup();
+ await user.click(accessControl());
expect(
- screen.getByRole("radio", { name: "Public" }),
- ).toHaveAccessibleDescription(
- "Everyone in this community will be able to view this channel’s full history.",
- );
- if (visibility === "public")
- fireEvent.click(screen.getByRole("radio", { name: "Public" }));
+ await screen.findByRole("option", { name: "Public" }),
+ ).toHaveAccessibleDescription("Anyone in the community can participate.");
+ await user.keyboard("{Escape}");
+ if (visibility === "public") await selectAccess("Public");
expect(h.sign).not.toHaveBeenCalled();
fireEvent.click(confirm());
await waitFor(() => expect(h.close).toHaveBeenCalledOnce());
@@ -187,6 +231,154 @@ it.each(["private", "public"])(
});
},
);
+
+const policies = [
+ { label: "Private", visibility: "private", joinRole: "guest" },
+ { label: "View only", visibility: "public", joinRole: "guest" },
+ { label: "Public", visibility: "public", joinRole: "member" },
+] as const;
+it.each(policies.flatMap((from) => policies.map((to) => ({ from, to }))))(
+ "converts $from.label → $to.label with the selected admission policy",
+ async ({ from, to }) => {
+ const h = setup(true, from.visibility, from.joinRole);
+ await ready();
+ expect(accessControl()).toHaveTextContent(from.label);
+ const user = userEvent.setup();
+ await user.click(accessControl());
+ expect(await screen.findAllByRole("option")).toHaveLength(3);
+ expect(
+ screen.getByRole("option", { name: "View only" }),
+ ).toHaveAccessibleDescription("Anyone in the community can read.");
+ await user.click(screen.getByRole("option", { name: to.label }));
+ const disclosure = screen.queryByText(
+ "Everyone in this community will be able to read this chat’s full history and Canvas, including its workspace folders and agent instructions.",
+ { exact: false },
+ );
+ if (from.visibility === "private" && to.visibility === "public") {
+ expect(disclosure).toBeVisible();
+ expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
+ disclosure?.textContent,
+ );
+ } else expect(disclosure).not.toBeInTheDocument();
+ if (from.visibility === "public" && to.visibility === "private") {
+ expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
+ from.joinRole === "guest"
+ ? "People who joined while it was View only are members and keep reading until you remove them in People."
+ : "People who already joined remain members and keep their access until you remove them in People.",
+ );
+ }
+ expect(h.publish).not.toHaveBeenCalled();
+ fireEvent.click(confirm());
+ await waitFor(() => expect(h.close).toHaveBeenCalledOnce());
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(await h.details.capability.load(id)).toMatchObject({
+ visibility: to.visibility,
+ joinRole: to.visibility === "private" ? from.joinRole : to.joinRole,
+ labels: ["work"],
+ ttlSeconds: 86400,
+ });
+ },
+);
+
+it("preselects Public when the open chat has the default Member admission", async () => {
+ setup(true, "public", undefined);
+ await ready();
+ expect(accessControl()).toHaveTextContent("Public");
+});
+
+it("retires superseded View-only recovery after Finish rejects changed admission and reload/remount", async () => {
+ const h = setup(true, "public", "member");
+ await ready();
+ await selectAccess("View only");
+ h.publish.mockRejectedValueOnce(new Error("lost acknowledgment"));
+ fireEvent.click(confirm());
+ await screen.findByRole("alert");
+ h.mounted.unmount();
+ h.converted("public", "guest");
+ await h.session.channelDetails.check(id);
+ render();
+ const finish = await screen.findByRole("button", {
+ name: "Finish conversion",
+ });
+ expect(accessControl()).toHaveTextContent("View only");
+ h.admission("member");
+ fireEvent.click(finish);
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "settings changed",
+ );
+ expect(h.unfile).not.toHaveBeenCalled();
+ expect(chatConversion(h.session, id)).toBeUndefined();
+ fireEvent.click(screen.getByRole("button", { name: "Reload chat details" }));
+ await screen.findByText("Only a Solo chat can be converted.");
+ expect(
+ screen.queryByRole("button", { name: "Finish conversion" }),
+ ).toBeNull();
+ expect(confirm()).toBeDisabled();
+ cleanup();
+ render();
+ await screen.findByText("Only a Solo chat can be converted.");
+ expect(
+ screen.queryByRole("button", { name: "Finish conversion" }),
+ ).toBeNull();
+ expect(conversionLabel(h.session, id)).toBe("Convert to channel");
+ expect(
+ canConvertToChannel(h.session, {
+ id,
+ name: "Original",
+ channelType: "stream",
+ }),
+ ).toBe(false);
+ expect(h.close).not.toHaveBeenCalled();
+ expect(h.unfile).not.toHaveBeenCalled();
+ expect(h.publish).toHaveBeenCalledOnce();
+});
+
+it("does not offer completion when the confirmed conversion has different admission", async () => {
+ const h = setup(true, "public", "guest");
+ await ready();
+ h.publish.mockRejectedValueOnce(new Error("lost acknowledgment"));
+ fireEvent.click(confirm());
+ await screen.findByRole("alert");
+ h.mounted.unmount();
+ h.converted("public", "guest");
+ await h.session.channelDetails.check(id);
+ h.admission("member");
+ render();
+ await screen.findByText("Only a Solo chat can be converted.");
+ expect(
+ screen.queryByRole("button", { name: "Finish conversion" }),
+ ).toBeNull();
+ expect(confirm()).toBeDisabled();
+ expect(h.unfile).not.toHaveBeenCalled();
+ expect(chatConversion(h.session, id)).toBeUndefined();
+});
+
+it("keeps Finish recovery after a transient read failure and completes without republishing", async () => {
+ const h = setup(true, "public", "guest");
+ await ready();
+ h.publish.mockRejectedValueOnce(new Error("lost acknowledgment"));
+ fireEvent.click(confirm());
+ await screen.findByRole("alert");
+ h.mounted.unmount();
+ h.converted("public", "guest");
+ await h.session.channelDetails.check(id);
+ render();
+ const finish = await screen.findByRole("button", {
+ name: "Finish conversion",
+ });
+ const receipt = chatConversion(h.session, id);
+ h.read.mockRejectedValueOnce(new Error("Details unavailable"));
+ fireEvent.click(finish);
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Details unavailable",
+ );
+ expect(chatConversion(h.session, id)).toBe(receipt);
+ await waitFor(() => expect(finish).toBeEnabled());
+ fireEvent.click(finish);
+ await waitFor(() => expect(h.close).toHaveBeenCalledOnce());
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.unfile).toHaveBeenCalledOnce();
+});
it.each(["authority", "conflict"])(
"fresh %s prevents conversion and reload explicitly recovers",
async (change) => {
@@ -215,14 +407,13 @@ it.each(["authority", "conflict"])(
it.each([
{ visibility: "private", failedRead: false },
{ visibility: "public", failedRead: true },
-])(
+] as const)(
"unknown $visibility conversion checks status without resending after remount, failed initial read=$failedRead",
async ({ visibility, failedRead }) => {
const h = setup();
h.publish.mockRejectedValueOnce(new Error("lost acknowledgement"));
await ready();
- if (visibility === "public")
- fireEvent.click(screen.getByRole("radio", { name: "Public" }));
+ if (visibility === "public") await selectAccess("Public");
fireEvent.click(confirm());
expect(await screen.findByRole("alert")).toHaveTextContent(
"may have been saved",
@@ -239,48 +430,24 @@ it.each([
expect(confirm()).toBeDisabled();
expect(screen.queryByRole("button", { name: "Check status" })).toBeNull();
expect(h.details.capability.snapshot(id)).toBe(pending);
- expect(
- screen.getByRole("radio", {
- name: visibility === "public" ? "Public" : "Private",
- }),
- ).toBeChecked();
- expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute(
- "aria-disabled",
- "true",
- );
- expect(screen.getByRole("radio", { name: "Private" })).toHaveAttribute(
- "aria-disabled",
- "true",
+ expect(accessControl()).toHaveTextContent(
+ visibility === "public" ? "Public" : "Private",
);
+ expect(accessControl()).toBeDisabled();
fireEvent.click(
screen.getByRole("button", { name: "Reload chat details" }),
);
}
const check = await screen.findByRole("button", { name: "Check status" });
expect(h.details.capability.snapshot(id)).toBe(pending);
- expect(
- screen.getByRole("radio", {
- name: visibility === "public" ? "Public" : "Private",
- }),
- ).toBeChecked();
- expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute(
- "aria-disabled",
- "true",
+ expect(accessControl()).toHaveTextContent(
+ visibility === "public" ? "Public" : "Private",
);
- expect(screen.getByRole("radio", { name: "Private" })).toHaveAttribute(
- "aria-disabled",
- "true",
+ expect(accessControl()).toBeDisabled();
+ fireEvent.keyDown(accessControl(), { key: "ArrowDown" });
+ expect(accessControl()).toHaveTextContent(
+ visibility === "public" ? "Public" : "Private",
);
- fireEvent.click(
- screen.getByRole("radio", {
- name: visibility === "public" ? "Private" : "Public",
- }),
- );
- expect(
- screen.getByRole("radio", {
- name: visibility === "public" ? "Public" : "Private",
- }),
- ).toBeChecked();
expect(h.sign).toHaveBeenCalledOnce();
expect(h.publish).toHaveBeenCalledOnce();
fireEvent.click(check);
@@ -373,7 +540,7 @@ it("retries only project unfiling after confirmed conversion and remount", async
const h = setup();
h.unfile.mockRejectedValueOnce(new Error("Projects save unavailable"));
await ready();
- fireEvent.click(screen.getByRole("radio", { name: "Public" }));
+ await selectAccess("Public");
fireEvent.click(confirm());
expect(await screen.findByRole("alert")).toHaveTextContent(
"Projects save unavailable",
@@ -402,7 +569,7 @@ it("retries only project unfiling after confirmed conversion and remount", async
"Channel converted. Finish removing it from its Solo workspace.",
),
).toBeVisible();
- expect(screen.getByRole("radio", { name: "Public" })).toBeChecked();
+ expect(accessControl()).toHaveTextContent("Public");
fireEvent.click(screen.getByRole("button", { name: "Finish conversion" }));
await waitFor(() => expect(h.convertedCallback).toHaveBeenCalledOnce());
expect(h.publish).toHaveBeenCalledOnce();
diff --git a/src/features/solo/ConvertToChannel.tsx b/src/features/solo/ConvertToChannel.tsx
index 006266211d..2885a6f88a 100644
--- a/src/features/solo/ConvertToChannel.tsx
+++ b/src/features/solo/ConvertToChannel.tsx
@@ -1,5 +1,11 @@
import { prepareSoloMove } from "../channel-navigation/solo-navigation";
-import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
import type { ComponentProps } from "react";
import type { RelaySession } from "../relay/session";
import type { ChannelSummary } from "../relay/contracts";
@@ -9,8 +15,7 @@ import {
} from "../relay/channel-details-protocol";
import { Dialog } from "../../shared/design-system/ui/Dialog";
import { Button } from "../../shared/design-system/ui/Button";
-import { Field } from "../../shared/design-system/ui/Field";
-import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup";
+import { Select } from "../../shared/design-system/ui/Select";
import { useToastNotification } from "../../shared/design-system/ui/Toast";
import {
chatConversion,
@@ -18,8 +23,19 @@ import {
useChatConversions,
type ChatConversion,
} from "./conversion";
-import { SOLO_LABEL, soloLabels } from "./labels";
+import {
+ SOLO_ACCESS_DISCLOSURE,
+ SOLO_LABEL,
+ soloLabels,
+ soloRetainedAccessWarning,
+} from "./labels";
import { applySharedChannelDetails } from "./share";
+import {
+ generalAccessOptions,
+ generalAccessPolicy,
+ generalAccessValue,
+ type GeneralAccess,
+} from "../channels/general-access";
/** Presentation gating only; fresh signed details authorize the actual write. */
export function canConvertToChannel(
@@ -40,6 +56,17 @@ export function canConvertToChannel(
const withoutSolo = (labels: readonly string[] | undefined) =>
(labels ?? []).filter((label) => label !== SOLO_LABEL);
+function matchesConversion(base: ChannelDetails, receipt: ChatConversion) {
+ return (
+ !soloLabels(base.labels) &&
+ base.name === receipt.draft.name &&
+ base.visibility === receipt.draft.visibility &&
+ (receipt.draft.joinRole === undefined ||
+ base.joinRole === receipt.draft.joinRole) &&
+ base.ttlSeconds === receipt.draft.ttlSeconds
+ );
+}
+
/** Clear the Solo label, drop the chat's project filing, then hand off navigation. */
export function ConvertToChannel({
session,
@@ -68,7 +95,7 @@ export function ConvertToChannel({
const [base, setBase] = useState();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
- const [visibility, setVisibility] = useState<"private" | "public">("private");
+ const [access, setAccess] = useState();
const lifetime = useRef(undefined);
const pending = useRef(false);
const conversionAttempt =
@@ -90,17 +117,32 @@ export function ConvertToChannel({
base &&
receipt &&
!attempt &&
- !soloLabels(base.labels) &&
- base.name === receipt.draft.name &&
- base.visibility === receipt.draft.visibility &&
- base.ttlSeconds === receipt.draft.ttlSeconds
+ matchesConversion(base, receipt)
);
const canFinish = converted || confirmedElsewhere;
- // Uncertain intent belongs to the details owner, including the chosen privacy.
- const selectedVisibility =
- receipt?.draft.visibility ??
- conversionAttempt?.draft.visibility ??
- visibility;
+ // Uncertain intent belongs to the details owner, including admission.
+ const savedDraft = receipt?.draft ?? conversionAttempt?.draft;
+ const selectedAccess = savedDraft
+ ? generalAccessValue(savedDraft)
+ : (access ?? (base ? generalAccessValue(base) : "private"));
+
+ const acceptDetails = useCallback(
+ (next: ChannelDetails) => {
+ setBase(next);
+ const saved = chatConversion(session, id);
+ // A fresh superseding edit retires only unconfirmed local orchestration.
+ // Durable uncertainty and a completed metadata phase retain their owners.
+ if (
+ saved &&
+ !saved.running &&
+ !saved.converted &&
+ !details.snapshot(id) &&
+ !matchesConversion(next, saved)
+ )
+ updateChatConversion(session, id, undefined);
+ },
+ [details, id, session],
+ );
useEffect(() => {
const controller = new AbortController();
@@ -110,21 +152,21 @@ export function ConvertToChannel({
...(signal ? [signal] : []),
]);
setBase(undefined);
- setVisibility("private");
+ setAccess(undefined);
setError("");
setBusy(false);
pending.current = false;
void details
.load(id, active)
.then((next) => {
- if (!active.aborted) setBase(next);
+ if (!active.aborted) acceptDetails(next);
})
.catch((reason) => {
if (!active.aborted)
setError(reason instanceof Error ? reason.message : String(reason));
});
return () => controller.abort();
- }, [details, id, signal]);
+ }, [details, id, signal, acceptDetails]);
const run = async (action: "convert" | "reload" | "check" | "finish") => {
const controller = lifetime.current;
@@ -147,7 +189,7 @@ export function ConvertToChannel({
try {
if (action === "reload") {
const next = await details.load(id, active);
- if (!active.aborted) setBase(next);
+ if (!active.aborted) acceptDetails(next);
} else {
if (chatConversion(session, id)?.running)
throw new Error("Conversion is already in progress in another view.");
@@ -165,7 +207,7 @@ export function ConvertToChannel({
draft: {
name: base?.name ?? "",
description: base?.description ?? "",
- visibility: selectedVisibility,
+ ...generalAccessPolicy(selectedAccess, base?.joinRole),
ttlSeconds: base?.ttlSeconds,
labels: withoutSolo(base?.labels),
},
@@ -176,15 +218,14 @@ export function ConvertToChannel({
if (action === "check") await details.check(id, active);
else if (action === "finish") {
const fresh = await details.load(id, active);
- if (
- soloLabels(fresh.labels) ||
- fresh.name !== exact.draft.name ||
- fresh.visibility !== exact.draft.visibility ||
- fresh.ttlSeconds !== exact.draft.ttlSeconds
- )
+ if (!matchesConversion(fresh, exact)) {
+ setBase(fresh);
+ if (!details.snapshot(id))
+ updateChatConversion(session, id, undefined);
throw new Error(
"Channel settings changed. Reload details before continuing.",
);
+ }
} else if (base)
await applySharedChannelDetails(
session,
@@ -228,7 +269,9 @@ export function ConvertToChannel({
updateChatConversion(
session,
id,
- exact.converted || details.snapshot(id) ? exact : undefined,
+ exact.converted || details.snapshot(id) || action === "finish"
+ ? exact
+ : undefined,
);
}
if (!controller.signal.aborted) {
@@ -246,7 +289,17 @@ export function ConvertToChannel({
if (completed.current && !signal?.aborted) onConverted?.();
}}
title="Convert to channel"
- description="Move this conversation from Solo to Team. History, Canvas, members and lifetime stay the same."
+ size="compact"
+ headerGap="compact"
+ footerGap="compact"
+ description={
+ base?.visibility === "private" && selectedAccess !== "private"
+ ? SOLO_ACCESS_DISCLOSURE
+ : (base &&
+ selectedAccess === "private" &&
+ soloRetainedAccessWarning(base)) ||
+ "Move this conversation from Solo to Team. History, Canvas, members and lifetime stay the same."
+ }
onOpenChange={(open) => {
if (!open) setOpen(false);
}}
@@ -290,34 +343,26 @@ export function ConvertToChannel({
>
}
>
-
- {
- if (value === "private" || value === "public") setVisibility(value);
- }}
- >
-
-
-
-
+