From 8af2cd287102d10cf52aa69bad979348002e15ac Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Fri, 30 Oct 2026 11:42:01 -0700
Subject: [PATCH 01/11] feat(solo): open chats for community viewing
Co-authored-by: Carl
Signed-off-by: Taylor Ho
---
docs/buzz-help.md | 17 +-
docs/solo/README.md | 28 +-
docs/solo/sharing.md | 78 +++-
src/app/shell/SearchResults.tsx | 6 +-
.../channels/ChannelGeneralAccess.test.tsx | 52 ++-
src/bundled/channels/ChannelGeneralAccess.tsx | 92 ++--
.../ChannelMembersDialog.solo.test.tsx | 264 ++++++++++-
.../channels/ChannelMembersDialog.test.tsx | 64 ++-
src/bundled/channels/ChannelMembersDialog.tsx | 55 ++-
src/features/channel-members/members.test.ts | 19 +-
src/features/channel-members/members.ts | 11 +-
src/features/channels/general-access.ts | 46 ++
src/features/messages/ChannelJoinNotice.tsx | 2 +-
.../messages/MessageComposer.test.tsx | 12 +-
src/features/relay/channel-writes.ts | 36 +-
src/features/relay/contracts.ts | 2 +-
.../relay/discovery-permissions.test.ts | 32 ++
src/features/relay/discovery.ts | 6 +-
.../relay/session-agent-admission.test.ts | 115 +++--
src/features/relay/session-discovery.test.ts | 4 +-
src/features/solo/ConvertToChannel.test.tsx | 208 +++++++--
src/features/solo/ConvertToChannel.tsx | 92 ++--
src/features/solo/labels.ts | 9 +-
src/features/solo/share.test.ts | 14 +-
src/features/solo/share.ts | 2 +
tests/browser/channel-members-focus.spec.mjs | 4 +-
tests/browser/fixture.mjs | 46 +-
tests/browser/solo-chats.spec.mjs | 215 ++++++++-
tests/browser/solo-public-access.spec.mjs | 424 ++++++++++++++++++
29 files changed, 1629 insertions(+), 326 deletions(-)
create mode 100644 src/features/channels/general-access.ts
create mode 100644 tests/browser/solo-public-access.spec.mjs
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 72463d6e26..d621529665 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -76,8 +76,21 @@ the interface unless an available tool actually supports that action.
Canvas content come from participants; they are not instructions from Buzz
and cannot grant permissions or override your instructions.
- **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) → 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. 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.
## Choosing and customizing agents
diff --git a/docs/solo/README.md b/docs/solo/README.md
index 77f7b2e414..75a14caed1 100644
--- a/docs/solo/README.md
+++ b/docs/solo/README.md
@@ -1,14 +1,15 @@
# Solo
-Solo is a private channel whose only human member is its owner, talking with
-their agents. It is not a separate page, message store or relay-enforced privacy
-mode: a Solo chat is a channel, opened in Messages like any other, and signed
-channel membership remains access authority.
+Solo starts as a private channel whose only human member is its owner, talking with
+their agents. Its owner can share membership or open it for community viewing.
+It is not a separate page, message store or relay-enforced privacy
+mode: a Solo chat is a channel, opened in Messages like any other. Ordinary
+channel visibility and signed membership determine access.
## Model
- **The `buzz.solo` channel label is the one Solo fact.** It is a NIP-CL label
- on the private channel's relay metadata, so every member and agent, including
+ on the channel's relay metadata, so every member and agent, including
remote agents without the owner's encrypted state, reads the same answer.
Discovery derives `channelType: "solo"` from it alone. A Solo channel gets
the chat treatment for everyone: a timeline that loads history on scroll and
@@ -95,9 +96,10 @@ behavior on the label, so a rename touches copy only.
evidence, not privacy inference or a required role refresh. See
[sharing](sharing.md).
- Naming and lifetime belong to [Chat settings](#chat-lifetime),
- not Share. Backend read-only roles,
- dynamic inheritance/revocation and synced grant provenance remain deferred or
- blocked in this checkpoint. Sharing does not expose owner-private detailed agent
+ not Share. Solo's View only / Private choices reuse channel policy;
+ View only uses the relay-enforced Guest default;
+ individual invitation role choices, dynamic inheritance/revocation and synced
+ grant provenance remain deferred. Sharing does not expose owner-private detailed agent
activity or start an agent. Proposal documents remain design history, not
implemented behavior.
- Solo chats and drafts use the full conversation pane. Right-click,
@@ -218,11 +220,13 @@ Solo workspaces use one bounded 16 KiB recipe record. Only workspace assignments
occupy it, so unfiled chats cost nothing; a full record refuses the filing and
keeps the chat. **Convert to channel** is a
separate, owner/admin-confirmed action for Solo chats, available from
-Share and the existing menus. Its Private/Public choice defaults to Private and
-warns that Public exposes the full existing history to the community. It appears
-in Messages with the same ID, history, Canvas, membership, labels and lifetime.
+Share and the existing menus. Its Private / View only / Public choice starts from
+the chat's current access, with the same descriptions as channel general access.
+It appears in Messages with the same ID, history, Canvas, membership, labels and
+lifetime.
Conversion is one signed details edit that clears `buzz.solo` (keeping any other
-labels) and applies the chosen visibility; the chat moves to Team for everyone.
+labels) and applies the chosen visibility and admission policy; the chat moves to
+Team for everyone.
It then removes the chat's workspace assignment, if any, and opens the channel in
Messages. Share alone never does this. If unfiling fails, Finish conversion
retries only that step. The existing preference owner preserves other workspaces
diff --git a/docs/solo/sharing.md b/docs/solo/sharing.md
index 6164f16c68..3c30e28af6 100644
--- a/docs/solo/sharing.md
+++ b/docs/solo/sharing.md
@@ -6,7 +6,8 @@ client-side organization, independent of access; see [Solo](README.md).
## The model
- A Solo chat is a channel with the `buzz.solo` label.
-- Access is membership. Sharing gives people a membership in the chat.
+- Channel visibility and signed membership control access. Sharing gives people
+ a membership in the chat.
- A shared chat link is an ordinary message containing
`[Chat: name](buzz://channel/)`. It renders as a chat card when the target
is a Solo chat; the `Chat:` label lets a viewer without access see it as one.
@@ -34,8 +35,10 @@ people, agents and channels** for an owner who can add agents) filters current
access and lists people under **Not in this chat**, each with an **Add** button;
Enter adds the highlighted person. Adding is immediate and uses the Solo share rule
(`canShareSoloChat`), with per-person **Adding…**, error and **Retry** states. The
-owner's selectable agents are offered too and join without starting a run. Where
-the viewer cannot add agents, a note says to mention an agent in the chat instead.
+owner's selectable agents are offered too and join as Bot, even with a Guest
+admission default, without starting a run. Composer mentions use the same grant.
+Where the viewer cannot add agents, a note says to mention an agent in the chat
+instead.
Current access uses the channel groups (**People**, **Agents**) with Owner and
Admin tags, and each row has the channel members' actions menu, so the chat owner
can remove someone. The verified roster shows loading or failure, never treating a
@@ -67,6 +70,44 @@ cached non-agent evidence for an additional human. Unknown profiles remain
unclassified; absence of the indicator is **not** a privacy promise. Clicking the
indicator opens the same dialog.
+### General access
+
+The access dialog's **General access** offers **View only** and **Private**.
+Public is not offered. Participation through this UI is by invitation. Existing
+members keep their individual roles when the general policy changes.
+
+View only makes the chat's full history and Canvas readable by the community.
+The confirmation names this disclosure, including workspace folder paths and
+agent instructions stored in Canvas. Moving from Public (existing) also
+explains that people already in the chat keep their access. Moving from View only
+to Private warns that joined viewers remain members unless a verified role
+snapshot establishes that no Guests exist; loading or failed verification keeps
+the warning. One signed kind-9002 edit sets
+`visibility=open` and `join_role=guest` together, through the existing
+`channelDetails` save, conflict checks and exact readback.
+
+Omitting Public here is a UI restriction, not a relay or harness safeguard.
+CLI/MCP or another client can still set a Solo chat to Public with Member
+admission. The dialog shows **Public (existing)** for that policy and lets the
+owner change it to View only or Private. Public members can still post and
+address the owner's agents, and can replace the shared Canvas. Agent wake and
+owner-Canvas hardening are separate follow-up work; this picker does not fix
+those risks. Changing to View only restricts new people, not existing members.
+
+With View only, visitors can find the chat by name in Search Buzz or open its
+ordinary chat link. They read without joining and cannot send messages, react or wake agents.
+The relay's ordinary Guest/default-Guest write policy enforces this boundary;
+the absent composer is only its client presentation. View-only access grants no
+membership and adds no sidebar entry for a visitor. The owner keeps the same
+Solo label, conversation ID and private workspace filing.
+
+Changing back to Private stops nonmembers from reading; it does not remove
+members or change their roles. Guests who joined through the CLI, MCP or another
+client while the chat was View only keep read access until removed in People.
+Reading without joining creates no membership to retain. Neither transition
+changes invitations, creates a share message or adds notifications. Unconfirmed
+publication uses **Check save status**, without automatically resending.
+
## Sending a chat link in a channel
Pasting a recognized Solo chat link into a composer stages it as a chat card.
@@ -98,17 +139,21 @@ in the destination channel are not synchronized.
The product owner approved this distinction on 2026-10-14: **Share** keeps the
Solo label and project filing; **Convert to channel** removes the Solo label and
the initiating viewer's project filing, so the same conversation becomes an
-ordinary Team channel, either Private or Public. It changes organization and
-conversation behavior for every member, never creates a new conversation or
-copies history.
+ordinary Team channel, with Private, View only or Public access. It changes
+organization and conversation behavior for every member, never creates a new
+conversation or copies history.
The Solo chat header overflow and the Solo row menu offer **Convert to channel**
for the viewer's own Solo chat.
-`src/features/solo/ConvertToChannel.tsx` reuses the shared Dialog, Field,
-Private/Public RadioGroup and Button. Private is the default; Public warns that the
-community can view the full existing history. A selected share channel is never
-granted by conversion. Cancel writes nothing and returns focus to the retained
-initiating control.
+`src/features/solo/ConvertToChannel.tsx` reuses the shared Dialog, Select
+and Button, with the same Private, View only and Public options and
+descriptions as channel general access. The current access policy is selected
+initially. Choosing View only or Public for a private chat discloses community
+reading of its full history and Canvas, including workspace folders and agent
+instructions. View only saves public visibility with Guest admission; Public saves
+Member admission; Private keeps reading restricted to members and retains the
+current admission default. A selected share channel is never granted by conversion.
+Cancel writes nothing and returns focus to the retained initiating control.
The sequence is explicit, not atomic:
@@ -116,7 +161,7 @@ The sequence is explicit, not atomic:
owner. Fresh metadata, owner/admin authority, conflict checks,
altered-signature rejection and exact readback remain unchanged. The edit
removes `buzz.solo` from the channel's labels and applies the chosen
- visibility; name, lifetime and other labels are preserved.
+ visibility and admission policy; name, lifetime and other labels are preserved.
2. Refresh Solo projects and, if this viewer filed the chat in one, clear only
that assignment through `soloPreferences.assign(id, undefined)`. Its encrypted
account/community record, serialization and Outbox own persistence. Other
@@ -134,7 +179,7 @@ visit.
### Partial success and recovery
An uncertain metadata publication offers **Check status**, never automatic resend.
-The pending details owner retains exact selected visibility; **Reload chat
+The pending details owner retains the exact selected access policy; **Reload chat
details** recovers failed reads without losing that intent. If another details
surface confirmed the publication, fresh matching metadata can advance the retained
conversion receipt without another kind-9002 command.
@@ -169,10 +214,9 @@ View-only role.
[Chat settings](README.md#optional-naming) form, not Share. Persisted naming
provenance, automatic-name reset and dynamic link titles remain deferred.
Old and new generated links keep their numeric labels after a rename.
-- **Can view / Can participate** is deferred to a second PR. Read-only requires
- relay-enforced authorization and a matching client capability contract; a
- cosmetic role switch or disabled composer is not sufficient. This slice shows
- neither a fake dropdown nor a static role tag.
+- Individual **Can view / Can participate** invitation choices remain deferred.
+ View-only Solo sharing uses the channel's relay-enforced Guest admission default,
+ without changing existing individual invitations or roles.
- Direct-person DM/link delivery is not implemented. Access grants alone are not
a dedicated invitation or share notification; explicit automatic handoff needs
a separate product decision.
diff --git a/src/app/shell/SearchResults.tsx b/src/app/shell/SearchResults.tsx
index 4470789231..042ea43889 100644
--- a/src/app/shell/SearchResults.tsx
+++ b/src/app/shell/SearchResults.tsx
@@ -811,7 +811,11 @@ export function SearchResults({
? "Archived chat"
: "Archived channel"
: channel.readOnly && !channel.cached && channel.viewerRole !== "guest"
- ? "Public channel · not joined"
+ ? channel.channelType === "solo"
+ ? channel.joinRole === "guest"
+ ? "View-only chat"
+ : "Public chat · not joined"
+ : "Public channel · not joined"
: channel.channelType === "dm"
? "Direct message"
: channel.channelType === "solo"
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index b481d89b0e..fa8e14b31d 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -206,7 +206,7 @@ it.each(["member", "guest", undefined] as const)(
);
it.each(["private", "view-only", "legacy-public"])(
- "offers only Private and View only for a %s Solo chat",
+ "offers only View only / Private for a %s Solo chat",
async (policy) => {
const h = harness();
const current: ChannelDetails = {
@@ -239,8 +239,10 @@ it.each(["private", "view-only", "legacy-public"])(
screen.getByRole("option", { name: "View only" }),
).toHaveAccessibleDescription("Anyone in the community can read.");
expect(
- screen.queryByRole("option", { name: "Public" }),
+ screen.queryByRole("option", { name: /^Public$/ }),
).not.toBeInTheDocument();
+ expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
+ expect(h.save).not.toHaveBeenCalled();
await user.click(
screen.getByRole("option", {
name: policy === "view-only" ? "Private" : "View only",
@@ -252,10 +254,10 @@ it.each(["private", "view-only", "legacy-public"])(
joinRole: "guest",
};
expect(h.save).not.toHaveBeenCalled();
- expect(screen.getByRole("alertdialog")).toHaveTextContent(
+ expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription(
policy === "view-only"
- ? "Only chat members will have access."
- : "People already in the chat keep their access.",
+ ? "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."
+ : `Everyone in this community will be able to read this chat’s full history and Canvas, including its workspace folders and agent instructions.${policy === "legacy-public" ? " People already in the chat keep their access." : ""}`,
);
h.load.mockResolvedValue(next);
await user.click(screen.getByRole("button", { name: "Change access" }));
@@ -274,6 +276,46 @@ it.each(["private", "view-only", "legacy-public"])(
},
);
+it.each([
+ {
+ visibility: "private",
+ joinRole: "guest",
+ option: "View only",
+ expected:
+ "Everyone in this community will be able to read this chat’s full history and Canvas, including its workspace folders and agent instructions.",
+ },
+ {
+ visibility: "public",
+ joinRole: "member",
+ option: "Private",
+ expected: "Only chat members will have access.",
+ },
+] as const)(
+ "keeps $visibility/$joinRole → $option unchanged even with a Guest member",
+ async ({ visibility, joinRole, option, expected }) => {
+ const h = harness();
+ h.load.mockResolvedValue({
+ ...base,
+ labels: ["buzz.solo"],
+ visibility,
+ joinRole,
+ });
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await select(user, "General access", option);
+ expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription(
+ expected,
+ );
+ expect(h.save).not.toHaveBeenCalled();
+ },
+);
+
it("confirms new people's View only/Participate access independently of public reading access", async () => {
const h = harness();
const user = userEvent.setup();
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 972334d43b..964ac563d6 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -25,7 +25,12 @@ import {
} from "../../shared/design-system/icons";
import styles from "./ChannelMembersDialog.module.css";
import { privacyConsequence } from "./ChannelPrivacyConfirmation";
-import { soloLabels } from "../../features/solo/labels";
+import { SOLO_ACCESS_DISCLOSURE, soloLabels } from "../../features/solo/labels";
+import {
+ generalAccessOptions,
+ generalAccessPolicy,
+ generalAccessValue,
+} from "../../features/channels/general-access";
/** General access uses the same signed save/readback owner as channel details.
* Each policy is confirmed once, like the individual member roles. */
@@ -34,12 +39,16 @@ export function ChannelGeneralAccess({
recovery,
channelId,
channel,
+ mayHaveGuestMembers = true,
}: {
capability: ChannelDetailsCapability;
recovery?: RelayRecovery | undefined;
channelId: string;
channel?: ChannelSummary;
+ /** False only when the signed member-role snapshot verifies no Guests. */
+ mayHaveGuestMembers?: boolean;
}) {
+ const place = channel?.channelType === "solo" ? "chat" : "channel";
const Icon =
channel?.visibility === "public"
? channel.joinRole === "guest"
@@ -68,7 +77,7 @@ export function ChannelGeneralAccess({
{channel.visibility === "public"
? "Anyone in the community can read."
: channel.visibility === "private"
- ? "Only channel members can read."
+ ? `Only ${place} members can read.`
: "Reading access could not be confirmed."}
@@ -87,6 +96,7 @@ export function ChannelGeneralAccess({
capability={capability}
recovery={recovery}
channelId={channelId}
+ mayHaveGuestMembers={mayHaveGuestMembers}
/>
);
}
@@ -95,10 +105,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 +164,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 +173,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 +288,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 +317,26 @@ 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 01e837aace..12186b2e94 100644
--- a/src/bundled/channels/ChannelMembersDialog.test.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.test.tsx
@@ -99,6 +99,8 @@ async function setup(
archivedAgent?: boolean;
channelCreation?: boolean;
communities?: CommunityReader;
+ visibility?: "private" | "public";
+ joinRole?: "member" | "guest";
} = {},
) {
const viewer = keypair(),
@@ -139,7 +141,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")
? [
@@ -2583,7 +2586,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" }),
@@ -2621,6 +2624,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);
@@ -2851,6 +2855,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", {
@@ -3162,9 +3183,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(),
@@ -3178,7 +3209,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, {
@@ -3243,11 +3280,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();
@@ -3260,7 +3293,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(
@@ -3268,8 +3301,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 71d42a3865..5f83df27d0 100644
--- a/src/bundled/channels/ChannelMembersDialog.tsx
+++ b/src/bundled/channels/ChannelMembersDialog.tsx
@@ -679,11 +679,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,
@@ -697,19 +701,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
: ""
@@ -753,7 +757,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("");
@@ -788,7 +792,7 @@ export function ChannelMembersDialog({
return () => {
current = false;
};
- }, [session, channelId, refresh, solo]);
+ }, [session, channelId, refresh, soloSharing]);
const memberKey = channel?.members?.join(":") ?? "";
const administration = useMemberAdministration(
session,
@@ -1121,7 +1125,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({
@@ -1503,7 +1507,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/features/channel-members/members.test.ts b/src/features/channel-members/members.test.ts
index f633c1f176..c16b8f17c9 100644
--- a/src/features/channel-members/members.test.ts
+++ b/src/features/channel-members/members.test.ts
@@ -101,7 +101,8 @@ function setup(
content: "",
tags: [
["d", id],
- ["t", type],
+ ["t", type === "solo" ? "stream" : type],
+ ...(type === "solo" ? [["t", "buzz.solo"]] : []),
[visibility],
["join_role", joinRole],
["name", "Design"],
@@ -251,6 +252,22 @@ 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.each(["member", "guest"] as const)(
"leaves the public %s admission default to the relay",
async (joinRole) => {
diff --git a/src/features/channel-members/members.ts b/src/features/channel-members/members.ts
index fa3f7328b3..e9d67d2bee 100644
--- a/src/features/channel-members/members.ts
+++ b/src/features/channel-members/members.ts
@@ -39,7 +39,6 @@ export function canShareSoloChat(
session.viewer &&
session.outbox?.supports(9000) &&
channel?.channelType === "solo" &&
- channel.private &&
!channel.readOnly &&
!channel.cached &&
!channel.archived &&
@@ -210,13 +209,13 @@ export async function addChannelMember(
.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.
+ // Solo sharing and private-channel invitations grant participation explicitly,
+ // independently of the admission default for public visitors.
const role = isAgent
? "bot"
- : !soloParticipant &&
- current?.visibility === "private" &&
- (current.channelType === "stream" || current.channelType === "forum")
+ : soloParticipant ||
+ (current?.visibility === "private" &&
+ (current.channelType === "stream" || current.channelType === "forum"))
? "member"
: undefined;
beforeDispatch?.();
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 441b49673b..86d72db753 100644
--- a/src/features/messages/MessageComposer.test.tsx
+++ b/src/features/messages/MessageComposer.test.tsx
@@ -5765,9 +5765,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",
@@ -5775,6 +5779,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..31af3b8861 100644
--- a/src/features/relay/channel-writes.ts
+++ b/src/features/relay/channel-writes.ts
@@ -453,6 +453,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 +488,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 +514,7 @@ export function createChannelWrites(
{
kind: 9000,
content: "",
- tags: [
- ["h", id],
- ["p", key],
- ],
+ tags: [["h", id], ["p", key], ...roleTags],
},
undefined,
active,
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 9c6fe2f1d4..61a72e89c6 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 d02be1b8c8..cf09a14980 100644
--- a/src/features/relay/discovery.ts
+++ b/src/features/relay/discovery.ts
@@ -23,12 +23,10 @@ export function metadataName(event: RelayEvent): string | undefined {
return name;
}
-/** 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/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..9126dff74d 100644
--- a/src/features/solo/ConvertToChannel.test.tsx
+++ b/src/features/solo/ConvertToChannel.test.tsx
@@ -9,6 +9,7 @@ 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";
@@ -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,99 @@ 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();
+ 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("retains View only through uncertain-save recovery and rejects changed admission", 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();
+ h.admission("guest");
+ fireEvent.click(finish);
+ await waitFor(() => expect(h.close).toHaveBeenCalledOnce());
+ 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();
+});
it.each(["authority", "conflict"])(
"fresh %s prevents conversion and reload explicitly recovers",
async (change) => {
@@ -215,14 +352,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 +375,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 +485,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 +514,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..a0e6cf600a 100644
--- a/src/features/solo/ConvertToChannel.tsx
+++ b/src/features/solo/ConvertToChannel.tsx
@@ -9,8 +9,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 +17,14 @@ import {
useChatConversions,
type ChatConversion,
} from "./conversion";
-import { SOLO_LABEL, soloLabels } from "./labels";
+import { SOLO_ACCESS_DISCLOSURE, SOLO_LABEL, soloLabels } 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(
@@ -68,7 +73,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 =
@@ -93,14 +98,16 @@ export function ConvertToChannel({
!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
);
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"));
useEffect(() => {
const controller = new AbortController();
@@ -110,7 +117,7 @@ export function ConvertToChannel({
...(signal ? [signal] : []),
]);
setBase(undefined);
- setVisibility("private");
+ setAccess(undefined);
setError("");
setBusy(false);
pending.current = false;
@@ -165,7 +172,7 @@ export function ConvertToChannel({
draft: {
name: base?.name ?? "",
description: base?.description ?? "",
- visibility: selectedVisibility,
+ ...generalAccessPolicy(selectedAccess, base?.joinRole),
ttlSeconds: base?.ttlSeconds,
labels: withoutSolo(base?.labels),
},
@@ -180,6 +187,8 @@ export function ConvertToChannel({
soloLabels(fresh.labels) ||
fresh.name !== exact.draft.name ||
fresh.visibility !== exact.draft.visibility ||
+ (exact.draft.joinRole !== undefined &&
+ fresh.joinRole !== exact.draft.joinRole) ||
fresh.ttlSeconds !== exact.draft.ttlSeconds
)
throw new Error(
@@ -228,7 +237,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 +257,14 @@ 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
+ : "Move this conversation from Solo to Team. History, Canvas, members and lifetime stay the same."
+ }
onOpenChange={(open) => {
if (!open) setOpen(false);
}}
@@ -290,34 +308,26 @@ export function ConvertToChannel({
>
}
>
-
- {
- if (value === "private" || value === "public") setVisibility(value);
- }}
- >
-
-
-
-
+