From 44a0b51c420b5dadc2b00b57d3bc52e7dbca9d26 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 27 Oct 2026 21:41:51 -0700 Subject: [PATCH 01/63] feat(channels): add admission roles and guest reading states Expose the relay admission default independently of existing participant roles, restore explicit guest assignment, and keep guest channel and thread views read-only. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/relay-broker-api.test.mjs | 17 ++- docs/channels.md | 28 +++-- src-tauri/src/relay/channel_writes.rs | 43 +++++++- .../channels/ChannelDetailsEditor.test.tsx | 54 +++++++++ src/bundled/channels/ChannelDetailsEditor.tsx | 30 +++++ .../channels/ChannelHeaderMenu.test.tsx | 21 ++++ src/bundled/channels/ChannelHeaderMenu.tsx | 2 +- src/bundled/channels/ChannelMembersDialog.tsx | 20 +++- src/bundled/channels/ChannelSettingsPanel.tsx | 11 ++ src/bundled/channels/ChannelsPage.tsx | 46 ++++---- .../channels/MemberAdministration.test.tsx | 25 ++++- src/bundled/channels/MemberAdministration.tsx | 3 +- src/features/messages/ChannelJoinNotice.tsx | 18 ++- src/features/messages/ChannelTimeline.tsx | 6 +- src/features/messages/ThreadPanel.test.tsx | 46 ++++++++ src/features/messages/ThreadPanel.tsx | 22 +++- .../relay/channel-details-protocol.ts | 44 +++++++- src/features/relay/channel-details.test.ts | 81 ++++++++++++++ src/features/relay/channel-details.ts | 1 + src/features/relay/contracts.ts | 6 +- .../relay/discovery-permissions.test.ts | 103 ++++++++++++++++++ src/features/relay/discovery.ts | 27 ++++- src/features/relay/store.ts | 8 +- tests/browser/channel-permissions.spec.mjs | 59 ++++++++++ 24 files changed, 662 insertions(+), 59 deletions(-) create mode 100644 src/features/relay/discovery-permissions.test.ts create mode 100644 tests/browser/channel-permissions.spec.mjs diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 1713ff1f31..f05f1f7008 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -3464,7 +3464,20 @@ test("details routes are narrow, require the live owner, and do not widen lifecy expect(signed).toMatchObject(change); await transport.channelDetails.publish(signed, signal); } + for (const role of ["member", "guest"]) { + const change = { + ...template, + tags: [...template.tags, ["join_role", role]], + }; + expect((await h.post("sign", change)).status).toBe(400); + expect((await h.post("channel-lifecycle-sign", change)).status).toBe(400); + const signed = await transport.channelDetails.sign(change, signal); + expect(signed).toMatchObject(change); + await transport.channelDetails.publish(signed, signal); + } for (const tags of [ + [...template.tags, ["join_role", "admin"]], + [...template.tags, ["join_role", "guest"], ["join_role", "member"]], [...template.tags.slice(0, 3), ["ttl", "+1"]], [...template.tags.slice(0, 3), ["ttl", "1\n"]], [...template.tags.slice(0, 3), ["ttl", "1"], ["ttl", ""]], @@ -3493,13 +3506,13 @@ test("details routes are narrow, require the live owner, and do not widen lifecy expect(reopened).toMatchObject(reopening); expect((await h.post("publish", reopened)).status).toBe(400); await transport.channelDetails.publish(reopened, signal); - expect(h.publications).toHaveLength(4); + expect(h.publications).toHaveLength(6); const foreign = finalizeEvent( structuredClone(template), new Uint8Array(32).fill(5), ); expect((await h.post("channel-details-publish", foreign)).status).toBe(400); - expect(h.publications).toHaveLength(4); + expect(h.publications).toHaveLength(6); } finally { live?.dispose(); await h.close(); diff --git a/docs/channels.md b/docs/channels.md index 1dfeb6a29c..e3ae5b8c5a 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -1005,18 +1005,30 @@ another non-owner member: | Target role | Change role | Remove from this channel | | --- | --- | --- | -| Admin / Member / Guest | Admin / Member, excluding the current role | Yes | -| Bot | Admin / Member, after explicit confirmation | Yes | +| Admin / Member / Guest | Admin / Member / Guest, excluding the current role | Yes | +| Bot | Admin / Member / Guest, after explicit confirmation | Yes | | Owner, self, unknown or inconsistent | No | No | Agent identity is independent of channel role. Explicitly promoting a Bot to Admin grants authority to that agent’s own public key and preserves its verified agent identity; the human owner’s roles do not confer authority. -The menu deliberately omits **Make guest** while Guest's permission contract is -unsettled: the inspected relay message path does not enforce the role's documented -read-only meaning, while Git push policy does distinguish Guest from Member. -Existing Guest roles remain available to the permission/confirmation flow and can be deliberately changed to Member or -Admin, or removed; they are never automatically converted. This is a menu-only -restriction, not a change to relay semantics or the broker's supported commands. +The menu includes **Make guest** through the existing deliberate confirmation +flow. Member and Guest labels show each participant's verified stored role; +changing the admission default never changes those roles. Agent Bot roles retain +their existing Member presentation. + +Channel Settings keeps **Visibility** separate from **New participant role**. +On relays that publish an explicit `join_role` in signed kind:39000 metadata, +owners/admins can select Member or Guest in Edit details and save through the +existing kind:9002 command. Unchanged or omitted defaults are not resent, and +success requires matching fresh signed metadata. A relay that omits or publishes +an unrecognized default gets no admission-role control. + +A Guest's signed roster role makes the channel read-only independently of that +default. Channel and thread history remain available, with a read-only notice +instead of a composer or Join button. A public preview of a Guest-default channel +offers joining with read-only access. Explicit promotion to Member restores the +composer; changing only `join_role` does not. Guests retain Leave, and the relay +remains the authority for every write. Current members may also remove their own verified non-owner agents, but cannot edit their roles or remove someone else's agent. The service verifies signed diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs index 2e84516b3a..d4e2a1f9de 100644 --- a/src-tauri/src/relay/channel_writes.rs +++ b/src-tauri/src/relay/channel_writes.rs @@ -19,7 +19,7 @@ fn common(event: &EventTemplate) -> bool { event.content.is_empty() && event.created_at <= i64::MAX as u64 } fn details(event: &EventTemplate) -> bool { - if event.kind != 9002 || !common(event) || !(3..=13).contains(&event.tags.len()) { + if event.kind != 9002 || !common(event) || !(3..=14).contains(&event.tags.len()) { return false; } let (Some(id), Some(name), Some(about)) = ( @@ -47,6 +47,12 @@ fn details(event: &EventTemplate) -> bool { index += 1; } // NIP-CL: trailing `t` tags replace the label set; one `["t", ""]` clears it. + if let Some(role) = tag(event, index, "join_role") { + if !matches!(role, "member" | "guest") { + return false; + } + index += 1; + } let labels = &event.tags[index..]; let labels_valid = details_labels(labels); uuid(id) @@ -568,6 +574,41 @@ mod boundary_tests { } } + #[test] + fn details_admit_only_member_and_guest_admission_defaults() { + let host = IdentityHost::fixture(); + for role in ["member", "guest"] { + let event = + serde_json::to_value(details_event(vec![vec!["join_role".into(), role.into()]])) + .unwrap(); + let parsed = validate("channel-details", &event, None).unwrap(); + assert!(validate("channel-lifecycle", &event, None).is_err()); + let signed = tauri::async_runtime::block_on(host.sign(parsed)).unwrap(); + assert!(validate("channel-details", &signed, signed["pubkey"].as_str()).is_ok()); + } + for extra in [ + vec![vec!["join_role", "owner"]], + vec![vec!["join_role", "admin"]], + vec![vec!["join_role", "bot"]], + vec![vec!["join_role", ""]], + vec![vec!["join_role"]], + vec![vec!["join_role", "guest", "extra"]], + vec![vec!["join_role", "member"], vec!["join_role", "guest"]], + vec![vec!["join_role", "guest"], vec!["ttl", "60"]], + ] { + let template = details_event( + extra + .iter() + .map(|tag| tag.iter().map(|s| (*s).into()).collect()) + .collect(), + ); + let event = serde_json::to_value(&template).unwrap(); + assert!(validate("channel-details", &event, None).is_err()); + let signed = tauri::async_runtime::block_on(host.sign(template)).unwrap(); + assert!(validate("channel-details", &signed, signed["pubkey"].as_str()).is_err()); + } + } + #[test] fn details_reject_huddle_markers_at_sign_and_publish_boundaries() { let mut event = details_event(vec![]); diff --git a/src/bundled/channels/ChannelDetailsEditor.test.tsx b/src/bundled/channels/ChannelDetailsEditor.test.tsx index 80c1b7a640..63d014d2d0 100644 --- a/src/bundled/channels/ChannelDetailsEditor.test.tsx +++ b/src/bundled/channels/ChannelDetailsEditor.test.tsx @@ -71,6 +71,60 @@ function harness() { }; return { capability, load, save, check, setAttempt }; } +it("edits the new participant role separately, preserving existing participants and canceling drafts", async () => { + const h = harness(); + h.load.mockResolvedValue({ ...base, joinRole: "member" }); + const user = userEvent.setup(); + render( + , + ); + await user.click(await screen.findByRole("button", { name: "Edit details" })); + const choice = screen.getByRole("combobox", { name: "New participant role" }); + expect(choice).toHaveTextContent("Member"); + expect(choice).toHaveAccessibleDescription( + /won’t change existing participants/, + ); + await user.click(choice); + await user.click(await screen.findByRole("option", { name: "Guest" })); + expect(h.save).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await user.click(screen.getByRole("button", { name: "Edit details" })); + expect( + screen.getByRole("combobox", { name: "New participant role" }), + ).toHaveTextContent("Member"); + await user.click( + screen.getByRole("combobox", { name: "New participant role" }), + ); + await user.click(await screen.findByRole("option", { name: "Guest" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(h.save).toHaveBeenCalledOnce()); + expect(h.save.mock.calls[0]).toEqual([ + expect.objectContaining({ joinRole: "member" }), + expect.objectContaining({ joinRole: "guest" }), + expect.any(AbortSignal), + ]); +}); + +it("does not offer an admission setting when the relay does not publish it", async () => { + const h = harness(); + const user = userEvent.setup(); + render( + , + ); + await user.click(await screen.findByRole("button", { name: "Edit details" })); + expect( + screen.queryByRole("combobox", { name: "New participant role" }), + ).not.toBeInTheDocument(); +}); + it("edits deliberately, cancels every field, and sends all fields only on Save", async () => { const h = harness(); const user = userEvent.setup(); diff --git a/src/bundled/channels/ChannelDetailsEditor.tsx b/src/bundled/channels/ChannelDetailsEditor.tsx index a565638a6a..5bf8b0469b 100644 --- a/src/bundled/channels/ChannelDetailsEditor.tsx +++ b/src/bundled/channels/ChannelDetailsEditor.tsx @@ -26,6 +26,7 @@ import { import { ChannelDurationField } from "./ChannelDurationField"; import { ChannelTextField } from "./ChannelTextField"; import { Switch } from "../../shared/design-system/ui/Switch"; +import { Select } from "../../shared/design-system/ui/Select"; import { DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS } from "../../features/relay/channel-writes"; import styles from "./Channels.module.css"; @@ -137,6 +138,10 @@ export function ChannelDetailsEditor({ old.base && old.draft.visibility !== old.base.visibility ? old.draft.visibility : base.visibility, + joinRole: + old.base && old.draft.joinRole !== old.base.joinRole + ? old.draft.joinRole + : base.joinRole, ttlSeconds: old.draft.ttlSeconds === old.baselineTtl ? base.ttlSeconds @@ -181,6 +186,7 @@ export function ChannelDetailsEditor({ (normalized.name !== view.base.name || normalized.description !== view.base.description || normalized.visibility !== view.base.visibility || + normalized.joinRole !== view.base.joinRole || normalized.ttlSeconds !== view.base.ttlSeconds); const temporaryTtl = draft?.ttlSeconds ?? @@ -224,6 +230,7 @@ export function ChannelDetailsEditor({ draft.name !== view.base.name || draft.description !== view.base.description || draft.visibility !== view.base.visibility || + draft.joinRole !== view.base.joinRole || draft.ttlSeconds !== view.base.ttlSeconds); const requestClose = () => { if (pending || busy.current) return; @@ -519,6 +526,29 @@ export function ChannelDetailsEditor({ }) } /> + {draft.joinRole !== undefined && ( + { - if (joinRole === "member" || joinRole === "guest") - patch({ draft: { ...draft, joinRole } }); - }} - /> - )} + { + const run = vi.fn(); + const started = vi.fn(); + render( + , + ); + expect( + screen.getByRole("region", { name: "Read-only channel" }), + ).toHaveTextContent("You have read-only access to this channel."); + expect(screen.queryByRole("button", { name: "Join" })).toBeNull(); + expect(run).not.toHaveBeenCalled(); + expect(started).not.toHaveBeenCalled(); + }, +); + function lifecycle( run: ChannelLifecycleCapability["run"], available = true, diff --git a/src/features/messages/ChannelJoinNotice.tsx b/src/features/messages/ChannelJoinNotice.tsx index 022aaa9b95..53c3849da5 100644 --- a/src/features/messages/ChannelJoinNotice.tsx +++ b/src/features/messages/ChannelJoinNotice.tsx @@ -4,7 +4,7 @@ import { ChannelLifecycleUnconfirmed } from "../relay/channel-lifecycle"; import { Button } from "../../shared/design-system/ui/Button"; import styles from "./Messages.module.css"; -/** A public preview offers Join; the membership roster, not this notice, enables the composer. +/** Member-default public previews offer Join; the roster alone enables the composer. * Confirmed membership can unmount this notice before its run settles, so the * page records the join intent when the run starts, not when it resolves. */ export function ChannelJoinNotice({ @@ -72,11 +72,12 @@ export function ChannelJoinNotice({ }, ); }; - const canJoin = !guest && joinable && !!lifecycle?.available; + const readOnly = guest || joinRole === "guest"; + const canJoin = !readOnly && joinable && !!lifecycle?.available; return (

- {guest + {readOnly ? "You have read-only access to this channel." : canJoin - ? joinRole === "guest" - ? "Join channel with read-only access" - : "Join channel to send messages" + ? "Join channel to send messages" : "You’re previewing this channel."}

{canJoin && ( diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx index 07a284faa6..b093937f61 100644 --- a/src/features/messages/ThreadPanel.test.tsx +++ b/src/features/messages/ThreadPanel.test.tsx @@ -1449,6 +1449,11 @@ it("passes readiness and direct Activity through to thread composition, and give it.each([ { channelType: "stream" as const, joinable: true }, { channelType: "forum" as const, joinable: true }, + { + channelType: "stream" as const, + joinRole: "guest" as const, + joinable: false, + }, { channelType: "stream" as const, archived: true as const, joinable: false }, { channelType: "solo" as const, joinable: false }, ])( @@ -1472,7 +1477,12 @@ it.each([ }); h.render(); expect( - screen.getByRole("region", { name: "Channel preview" }), + screen.getByRole("region", { + name: + metadata.joinRole === "guest" + ? "Read-only channel" + : "Channel preview", + }), ).toBeVisible(); expect(screen.queryByRole("region", { name: "Composer" })).toBeNull(); expect(!!screen.queryByRole("button", { name: "Join" })).toBe(joinable); diff --git a/src/features/relay/channel-details-protocol.ts b/src/features/relay/channel-details-protocol.ts index a576923c21..423d4c7f3c 100644 --- a/src/features/relay/channel-details-protocol.ts +++ b/src/features/relay/channel-details-protocol.ts @@ -226,17 +226,13 @@ export function channelTtl(event: RelayEvent): number | undefined { } } -/** Older channel metadata may omit this field; do not invent its current value. */ -export function channelJoinRole( - event: RelayEvent, -): "member" | "guest" | undefined { +/** Legacy absence means Member; malformed explicit defaults fail closed to Guest. */ +export function channelJoinRole(event: RelayEvent): "member" | "guest" { const tags = event.tags.filter(([key]) => key === "join_role"); - const value = tags[0]?.[1]; - return tags.length === 1 && - tags[0]?.length === 2 && - (value === "member" || value === "guest") - ? value - : undefined; + if (tags.length === 0) return "member"; + return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "member" + ? "member" + : "guest"; } /** A relay metadata TTL must be an unambiguous positive i32; only absence is ongoing. */ diff --git a/src/features/relay/channel-details.test.ts b/src/features/relay/channel-details.test.ts index 1e89b90ab7..9612de8f50 100644 --- a/src/features/relay/channel-details.test.ts +++ b/src/features/relay/channel-details.test.ts @@ -116,7 +116,7 @@ it.each([undefined, "member"] as const)( if (initialRole) h.set([withRole(initialRole), ...h.events().slice(1)]); const originalRoster = h.entry(2); const base = await h.owner.capability.load(id); - expect(base.joinRole).toBe(initialRole); + expect(base.joinRole).toBe(initialRole ?? "member"); const next = { ...base, joinRole: "guest" as const }; // A relay OK that ignores the new tag must not report success. h.publish.mockImplementationOnce(async () => {}); @@ -182,15 +182,15 @@ it("rejects malformed or elevated admission defaults at the host boundary", () = ).toThrow(); } const h = harness(); + expect(channelJoinRole(h.record(39000, []))).toBe("member"); for (const tags of [ - [], [["join_role", "owner"]], [ ["join_role", "member"], ["join_role", "guest"], ], ]) - expect(channelJoinRole(h.record(39000, tags))).toBeUndefined(); + expect(channelJoinRole(h.record(39000, tags))).toBe("guest"); }); it("saves only after two fresh authority checks and projects confirmed readback", async () => { diff --git a/src/features/relay/discovery-permissions.test.ts b/src/features/relay/discovery-permissions.test.ts index 5d76ddab2b..ad2b458572 100644 --- a/src/features/relay/discovery-permissions.test.ts +++ b/src/features/relay/discovery-permissions.test.ts @@ -84,6 +84,48 @@ it("does not turn a public nonmember or cached guest into a writer", () => { expect(state.canWrite(id)).toBe(false); }); +it.each(["guest", "member"])( + "restores only public reading after a %s leaves, requiring fresh metadata without restoring membership", + (role) => { + const state = new DiscoveryState(viewer.pubkey, relay.pubkey); + const publicMetadata = details("guest", 10); + const membership = roster(role, 10); + state.accept(publicMetadata); + state.accept(membership); + state.deny(id); + expect(state.canAccess(id)).toBe(false); + state.resume(id, publicMetadata); + expect(state.canAccess(id)).toBe(true); + expect(state.canParticipate(id)).toBe(false); + expect(state.canWrite(id)).toBe(false); + state.accept(membership); // Replaying the retained roster is not a promotion. + expect(state.canParticipate(id)).toBe(false); + state.accept( + metadata(relay, id, "Private", 20, [["private"], ["t", "stream"]]), + ); + expect(state.canAccess(id)).toBe(false); + state.resume(id, publicMetadata); // An older public snapshot cannot reopen it. + expect(state.canAccess(id)).toBe(false); + state.resume( + id, + metadata(viewer, id, "Forged", 30, [["public"], ["t", "stream"]]), + ); + expect(state.canAccess(id)).toBe(false); + const reopened = details("guest", 30); + state.accept(reopened); + state.resume(id, reopened); + expect(state.canAccess(id)).toBe(true); + expect(state.canParticipate(id)).toBe(false); + state.deny(id); + expect(state.canAccess(id)).toBe(false); + state.resume(id, reopened); + state.denyAll(); + expect(state.canAccess(id)).toBe(false); + state.accept(roster("member", 40)); + expect(state.canWrite(id)).toBe(true); + }, +); + it("does not grant writing through a contradictory duplicate guest entry", () => { const state = new DiscoveryState(viewer.pubkey, relay.pubkey); state.accept(details("member", 10)); diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts index 9a231328ee..89c854a807 100644 --- a/src/features/relay/discovery.ts +++ b/src/features/relay/discovery.ts @@ -46,6 +46,8 @@ export class DiscoveryState { private denied = new Set(); /** Denied only because a complete read omitted them; a later one can list them again. */ private absent = new Set(); + /** Fresh public resolution can restore reading while membership remains denied. */ + private confirmedPreviews = new Set(); private suspended = new Set(); private complete = false; private cached = new Set(); @@ -98,6 +100,8 @@ export class DiscoveryState { if (next === previous) return confirmed; const accessible = this.canAccess(id); map.set(id, next); + if (event.kind === 39000 && !this.open(id)) + this.confirmedPreviews.delete(id); if (event.kind === 39002) { this.absent.delete(id); if (cached) this.cached.add(id); @@ -149,6 +153,7 @@ export class DiscoveryState { } this.accessRevision++; this.suspended.delete(id); + this.confirmedPreviews.delete(id); this.denied.add(id); } /** A CLOSED preview is unreadable pending fresh evidence, not permanently denied. */ @@ -161,8 +166,21 @@ export class DiscoveryState { suspendedChannels(): string[] { return [...this.suspended]; } - resume(id: string): boolean { - return this.suspended.delete(id); + resume(id: string, metadata?: RelayEvent): boolean { + const resumed = this.suspended.delete(id); + if ( + this.denied.has(id) && + metadata?.kind === 39000 && + metadata.pubkey === this.relayAuthor && + tag(metadata, "d") === id && + openMetadata(metadata) && + newer(this.metadata.get(id), metadata) === metadata + ) { + const changed = !this.confirmedPreviews.has(id); + this.confirmedPreviews.add(id); + return resumed || changed; + } + return resumed; } /** A complete viewer-scoped roster read proves absence: rosters it omitted no longer include the viewer. */ rosterVersions(): ReadonlyMap { @@ -198,6 +216,7 @@ export class DiscoveryState { this.complete = true; this.starting.clear(); this.absent.clear(); + this.confirmedPreviews.clear(); for (const id of [...this.rosters.keys(), ...this.metadata.keys()]) this.denied.add(id); } @@ -212,7 +231,9 @@ export class DiscoveryState { /** Unknown is not denied until a complete roster or local snapshot closes that boundary. */ canAccess(id: string): boolean { if (this.undecided(id)) return true; - if (this.denied.has(id) || this.suspended.has(id)) return false; + if (this.suspended.has(id)) return false; + if (this.denied.has(id)) + return this.confirmedPreviews.has(id) && this.open(id); const roster = this.rosters.get(id); if (roster && hasTag(roster, "p", this.viewer)) return true; return ( @@ -229,6 +250,7 @@ export class DiscoveryState { const roster = this.rosters.get(id); return ( this.canAccess(id) && + !this.denied.has(id) && !this.cached.has(id) && (roster ? hasTag(roster, "p", this.viewer) diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 7578761d9e..7889fac5ae 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -1628,7 +1628,16 @@ export function createChannelStore( (event.kind === 39002 && hasTag(event, "p", transport.viewer))), ) ) - resumed = discovery.resume(id) || resumed; + resumed = + discovery.resume( + id, + events.find( + (event) => + event.kind === 39000 && + event.pubkey === transport.relayAuthor && + tag(event, "d") === id, + ), + ) || resumed; } for (const id of ids) checkedMetadata.add(id); // A bounded exact roster fills omissions from capped discovery. Apply grants diff --git a/tests/browser/channel-permissions.spec.mjs b/tests/browser/channel-permissions.spec.mjs index b3cd8c8a7f..63bf5dcb29 100644 --- a/tests/browser/channel-permissions.spec.mjs +++ b/tests/browser/channel-permissions.spec.mjs @@ -1,5 +1,6 @@ import { test, expect } from "./fixture.mjs"; import { openPage } from "./navigation.mjs"; +import { hoverMessage } from "./message-actions.mjs"; test.use({ productionBroker: true, @@ -8,6 +9,122 @@ test.use({ historyCounts: { alpha: 1, beta: 1 }, }); +const readerChannelId = "11111111-1111-4111-8111-111111111111"; +const permalink = (app, messageId) => + `${app.origin}/#buzz=${encodeURIComponent( + JSON.stringify({ + version: 1, + kind: "conversation", + channelId: readerChannelId, + messageId, + scope: { viewer: app.viewer, communityOrigin: "https://primary.example" }, + }), + )}`; + +async function readPermalink(page, app, messageId) { + await page.goto(permalink(app, messageId)); + const thread = page.getByRole("region", { + name: "Thread messages", + exact: true, + }); + const row = thread.locator(`[data-message-id="${messageId}"]`); + await expect(row).toBeVisible(); + await expect( + thread.locator(`[data-message-id="${app.exact.root.id}"]`), + ).toBeVisible(); + await expect( + thread.locator(`[data-message-id="${app.exact.replies[1].id}"]`), + ).toBeVisible(); + await expect( + page.getByRole("region", { name: "Read-only channel" }).last(), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Join", exact: true }), + ).toHaveCount(0); + await expect(page.locator('[contenteditable="true"]')).toHaveCount(0); + // Opening the actual message menu settles the deferred message actions. + await hoverMessage(row); + await row + .getByRole("button", { name: "More message actions", exact: true }) + .click(); + await expect(page.getByRole("menu")).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "React", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: /^(React|Add reaction)/ }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +function expectNotJoined(app) { + expect( + app + .startupCache() + .discovery.filter( + (event) => + event.kind === 39002 && + event.tags.some( + ([key, value]) => key === "d" && value === readerChannelId, + ), + ), + ).toEqual([]); + expect(app.report.lifecyclePublications ?? []).not.toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 9021 })]), + ); + expect(app.report.publications).toEqual([]); + expect(app.report.unexpected).toEqual([]); +} + +// Browser-only: cold-link resolution, surrounding thread history, deferred row +// actions, and a departed guest cross routing, discovery and publication owners. +test.describe("Guest-default permalinks", () => { + test.use({ guestPermalinks: true, exactMessages: "nested" }); + + test.describe("nonmember", () => { + test.use({ lifecycleRole: "nonmember" }); + test("old message and nested reply links read without joining", async ({ + page, + app, + }) => { + expectNotJoined(app); + await readPermalink(page, app, app.exact.root.id); + await readPermalink(page, app, app.exact.target.id); + expectNotJoined(app); + }); + }); + + test("a guest leaves and reopens both links as the same read-only preview", async ({ + page, + app, + }) => { + await readPermalink(page, app, app.exact.target.id); + await page + .getByRole("button", { name: "Channel actions", exact: true }) + .click(); + await page + .getByRole("menuitem", { name: "Leave channel", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { + name: "Leave channel: Lifecycle channel", + exact: true, + }); + await dialog + .getByRole("button", { name: "Leave channel", exact: true }) + .click(); + await expect + .poll(() => app.report.lifecyclePublications?.map((event) => event.kind)) + .toEqual([9022]); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole("dialog", { name: "Leaving channel…", exact: true }), + ).toHaveCount(0); + await readPermalink(page, app, app.exact.root.id); + await readPermalink(page, app, app.exact.target.id); + expectNotJoined(app); + }); +}); + // The real broker -> signed roster -> sidebar -> conversation wiring and // keyboard menu handoff are browser boundaries; role matrices stay in Vitest. test("a joined guest can read and leave without receiving a composer or Join prompt", async ({ diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 7bd0497f6e..cba613eef1 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -60,6 +60,7 @@ export const test = base.extend({ sortingSidebar: [false, { option: true }], initialSidebarSort: [{}, { option: true }], channelLifecycle: [false, { option: true }], + guestPermalinks: [false, { option: true }], lifecycleRole: ["owner", { option: true }], lifecycleOwnerAgent: [false, { option: true }], lifecycleVisibility: [{ archived: [], hidden: [] }, { option: true }], @@ -128,6 +129,7 @@ export const test = base.extend({ sortingSidebar, initialSidebarSort, channelLifecycle, + guestPermalinks, lifecycleRole, lifecycleOwnerAgent, lifecycleVisibility, @@ -321,6 +323,7 @@ export const test = base.extend({ }, ] : []; + const guestChannelId = guestPermalinks ? lifecycleRows[0].id : undefined; const archivedIds = new Set(lifecycleVisibility.archived); const hiddenDmIds = new Set(lifecycleVisibility.hidden); let lifecycleTime = 1700000001; @@ -329,7 +332,11 @@ export const test = base.extend({ ...channels, ...dmIds, ...sortingIds, - ...lifecycleRows.map((row) => row.id), + ...lifecycleRows + .filter( + (row) => row.id !== guestChannelId || lifecycleRole !== "nonmember", + ) + .map((row) => row.id), ]), ]; if (savedSidebar) { @@ -523,6 +530,20 @@ export const test = base.extend({ ), ]); const targetEvents = []; + // Enough newer roots to put the permalink outside the initial history page. + if (guestChannelId) + histories.set( + `primary/${guestChannelId}`, + Array.from({ length: 103 }, (_, i) => + sign( + 9, + [["h", guestChannelId]], + `Guest room history ${i}`, + peerKey, + 1700000000 + i, + ), + ), + ); let searchTarget; if (openSearch) { const root = sign( @@ -547,12 +568,13 @@ export const test = base.extend({ } let exact; if (exactMessages) { - const root = histories.get("primary/alpha")[2]; - const replies = Array.from({ length: 80 }, (_, i) => + const exactChannelId = guestChannelId ?? "alpha"; + const root = histories.get(`primary/${exactChannelId}`)[2]; + const replies = Array.from({ length: guestChannelId ? 3 : 80 }, (_, i) => sign( 9, [ - ["h", "alpha"], + ["h", exactChannelId], ["e", root.id, "", "reply"], ["p", getPublicKey(peerKey)], ], @@ -566,7 +588,7 @@ export const test = base.extend({ replies[replies.length - 1] = sign( 9, [ - ["h", "alpha"], + ["h", exactChannelId], ["e", root.id, "", "root"], ["e", replies.at(-2).id, "", "reply"], ["p", getPublicKey(peerKey)], @@ -1088,7 +1110,9 @@ export const test = base.extend({ viewer, "", lifecycleRows.some((row) => row.id === id) - ? lifecycleRole + ? lifecycleRole === "nonmember" + ? "member" + : lifecycleRole : "member", ], ...(ownSoloChannels.includes(id) @@ -1131,7 +1155,11 @@ export const test = base.extend({ ); if (filter.kinds?.includes(39000)) return [ - ...new Set([...rosterIds, ...(openSearch ? [OPEN_CHANNEL] : [])]), + ...new Set([ + ...rosterIds, + ...(openSearch ? [OPEN_CHANNEL] : []), + ...(guestChannelId ? [guestChannelId] : []), + ]), ] .filter((id) => !filter["#d"] || filter["#d"].includes(id)) .map( @@ -1159,6 +1187,7 @@ export const test = base.extend({ lifecycleRows.find((row) => row.id === id)?.type ?? (dmIds.includes(id) ? "dm" : "stream"), ], + ...(id === guestChannelId ? [["join_role", "guest"]] : []), ...(archivedIds.has(id) ? [["archived", "true"]] : []), // Ordinary channels are explicitly public; do not add a public // flag to private Solo chats or change the separate DM fixtures. From dbafdabf58419d39184713845205e19a0089aaa2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 27 Oct 2026 23:26:49 -0700 Subject: [PATCH 06/63] feat(channels): manage general access alongside people Move reading and new-participant access into the members dialog, with confirmed changes and existing signed save/readback recovery. Keep individual roles separate and leave Edit details focused on text and lifespan. Co-authored-by: Carl Signed-off-by: Taylor Ho --- docs/channels.md | 82 ++-- .../channels/ChannelDetailsEditor.test.tsx | 367 ++---------------- src/bundled/channels/ChannelDetailsEditor.tsx | 139 +------ .../channels/ChannelGeneralAccess.test.tsx | 239 ++++++++++++ src/bundled/channels/ChannelGeneralAccess.tsx | 229 +++++++++++ .../channels/ChannelMembersDialog.test.tsx | 83 ++++ src/bundled/channels/ChannelMembersDialog.tsx | 21 +- .../ChannelPrivacyConfirmation.test.tsx | 108 +++--- .../channels/ChannelPrivacyConfirmation.tsx | 12 +- .../channels/ChannelSettingsPanel.test.tsx | 3 +- src/bundled/channels/ChannelSettingsPanel.tsx | 56 +-- tests/browser/channel-header-menu.spec.mjs | 2 +- 12 files changed, 734 insertions(+), 607 deletions(-) create mode 100644 src/bundled/channels/ChannelGeneralAccess.test.tsx create mode 100644 src/bundled/channels/ChannelGeneralAccess.tsx diff --git a/docs/channels.md b/docs/channels.md index 1f316dd273..c5ea022e94 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -605,17 +605,15 @@ No dismissal saves, retries, replaces setup or confirms a destructive action. ## Editing channel details -Channel Settings shows the signed name, description and explicit visibility for -ordinary channels; missing visibility stays **Not available**, not implicitly -Public. The centered title opens the same editor, revealing a pencil with a short +Channel Settings shows the signed name and description for ordinary channels. The centered title opens the same editor, revealing a pencil with a short left-to-right fade on hover or keyboard focus (always visible on touch, no animation for reduced motion). The pencil follows the final text line; balanced side padding keeps the text centered and lets long titles wrap without clipping it. -Description and Visibility have small pencils immediately after their labels, +Description has a small pencil after its label, revealed with a short left-to-right fade on whole-row hover or keyboard focus (always visible on touch, no animation for reduced motion) without shifting layout; -each whole row opens the shared Dialog with one Name/Description/Duration/Private draft. -Opening Description focuses its textarea; the title and Visibility still focus Name. +the whole row opens the shared Dialog with one Name/Description/Duration draft. +Opening Description focuses its textarea; the title focuses Name. Only authorized editors get the interactive rows; other viewers retain plain metadata. Members has a right chevron and opens the existing member list. Channel ID has a small inline copy icon with the same hover/focus/touch behavior; selecting its row @@ -624,8 +622,9 @@ growing the row; hovering or focusing does not open a hint or replay old feedbac Clipboard failure shows an inline error and leaves the row available to retry. The redundant standalone Edit details button, header-menu Edit details entry and informational Channel type row are omitted. -Duration uses Create's Ongoing/Temporary cards, and Private uses the same -switch in the action row. These controls stage changes; neither publishes immediately. +Duration uses Create's Ongoing/Temporary cards. Access controls live in the members +dialog, separate from the text and lifespan draft. These details controls stage +changes; none publishes immediately. **Save changes** submits the draft together and is enabled only for valid, changed values. **Cancel** explicitly discards edits and closes without confirmation. Close, Escape and backdrop clicks close untouched forms immediately; changed drafts first show @@ -673,8 +672,8 @@ keyboard navigation switch immediately; hidden controls are inert and excluded from accessibility, with one complete accessible description per state. Steps are **1 day ↔ 7 days ↔ 2 weeks**, starting at seven days, with unavailable directions disabled. Switching to Ongoing and back retains the chosen -time within the draft. Private reuses the shared switch, left-aligned in each -footer with matching enabled/disabled label treatment. Both forms use a compact +time within the draft. Create keeps its Private switch in the footer; Edit details +leaves access management to the members dialog. Both forms use a compact 12px header-to-body gap and 16px field spacing. Write ownership stays with each workflow. @@ -687,7 +686,7 @@ Description on submission; Edit preserves its exact text and can explicitly clea Both dialogs opt into backdrop dismissal and guard Close, Escape and backdrop dismissal with an in-dialog discard confirmation when the draft differs. Comparison covers raw -text, staged privacy and effective duration. Reverting +text, effective duration and, for Create, staged privacy. Reverting all fields removes the warning. Pending operations still block dismissal; once settled, closing abandons any uncertain Create. Returning from a discard confirmation focuses Name and retains the draft. @@ -720,29 +719,25 @@ and membership (`39002`) for the exact channel, plus current session participati Only direct channel owners/admins may edit ordinary stream/forum channels. Cached, read-only, archived, DM and Solo chat views do not offer this editor. A local key, delegated agent role or community-admin status does not imply channel authority. -Public ↔ private is supported as a draft choice, never an immediate mutation. -In both Create and Edit, a Private toggle normally replaces the form with a confirmation -step in the same modal. The whole surface crossfades using `motion/react`: the form scales from -1 to 1.05 and blurs while confirmation sharpens from 0.95 to 1. Returning reverses -the transition; reduced motion and keyboard navigation swap immediately. Outgoing -controls are inert, with one backdrop and focus trap throughout. This happens -before changing the switch, including a reversal back to the saved -value. There is one backdrop and focus trap, not a second overlaid modal. Public → private says -“Only channel members will have access.” Private → public explicitly warns that everyone in -this community can view the channel's full history. Continue -only stages the choice; Create channel or Save changes still performs the write. Cancel/Escape or -the confirmation's Close control leaves the switch unchanged and returns to the -intact form with focus on the switch. All other draft fields—including Create's -selected destination—survive either path. The consequence -is shown once in the confirmation body, connected as the dialog's accessible description. -**Don’t show me this again** is saved only on **Continue** and skips both public and -private warnings. It is a device-local preference shared by Create and Edit for the -same community/viewer scope, using the existing view-state storage. Existing opt-outs -for either direction also skip both warnings. Cancel, Escape, -Close and backdrop dismissal do not remember it. If storage is unavailable or invalid, -the warning remains enabled. Skipping the warning still only stages the choice: -Create/Save, authorization and discard protection are unchanged. A busy creation -cannot open confirmation. +Create's Private switch stages a choice behind the existing in-modal consequence +confirmation. The device-local opt-out applies to that Create warning; Continue +only stages the choice and Create channel performs the write. + +Access management lives in the members dialog. **People with access** retains the +existing member list, row menus and confirmed role changes. Member and Guest are +shown as **Contribute** and **Read**; Owner and Admin retain their authority labels. +**General access** sits below the list: **Who can read** chooses Anyone in the +community or Invited people only; **New people can** chooses Contribute or Only read. +“People already in the channel keep their access.” explains that the second choice +is an admission default, separate from each person's current role. +Each selection opens a consequence confirmation. Cancel/Escape returns focus to +the unchanged Select. **Change access** saves immediately through the existing +channel-details capability, then reloads signed metadata. Loading, pending and +unconfirmed saves disable both controls. Rejections offer **Reload access**; +uncertain results survive closing the dialog and offer **Check save status**, +which reads without republishing. Opening or checking never writes. Private +channels with a Guest default remain intact when changing unrelated details. + In Edit, visibility is sent only when changed (`private` or `open`); text-only edits omit it so they do not reopen the channel in the remaining write race. The relay remains the final authority. @@ -771,8 +766,8 @@ survives failed reloads, so an untouched duration follows the next successful re while an explicit change to Ongoing remains staged. This baseline never grants editing authority: a failed read still clears the current base and disables Save. If a failed reload has lost the prior base, privacy adopts the fresh value -conservatively: a stale public text draft must not become reopening intent. Choose -visibility again after that recovery. Inspect the retained edits before +conservatively: a stale public text draft must not become reopening intent. +Inspect the retained edits before saving again. A lost publication response or failed/mismatched readback locks the submitted draft and offers **Check save status**, which only reads and never republishes. Uncertain intent survives panel close/reopen and cache clear within the same session; no @@ -1013,13 +1008,14 @@ another non-owner member: Agent identity is independent of channel role. Explicitly promoting a Bot to Admin grants authority to that agent’s own public key and preserves its verified agent identity; the human owner’s roles do not confer authority. The menu includes **Make guest** through the existing deliberate confirmation -flow. Member and Guest labels show each participant's verified stored role; -changing the admission default never changes those roles. Agent Bot roles retain -their existing Member presentation. - -Channel Settings keeps **Visibility** separate from **New participant role**. -Owners/admins can select Member or Guest in Edit details and save through the -existing kind:9002 command. Missing `join_role` means Member, matching legacy relay +flow. Contribute and Read captions represent each participant's verified Member +and Guest role; changing the admission default never changes those roles. +Agent Bot roles retain the contributing presentation. + +The members dialog's General access controls keep reading access and new people's +contribution access separate. The note explicitly preserves existing participants' +access; a Guest default leaves designated writers able to contribute. +Owners/admins save choices through the existing kind:9002 command. Missing `join_role` means Member, matching legacy relay behavior. An unrecognized or malformed explicit default maps to Guest so previews fail closed. These defaults never replace a participant's stored role. Unchanged or omitted defaults are not resent, and success requires matching fresh diff --git a/src/bundled/channels/ChannelDetailsEditor.test.tsx b/src/bundled/channels/ChannelDetailsEditor.test.tsx index b8bb9c115c..c154a9adb5 100644 --- a/src/bundled/channels/ChannelDetailsEditor.test.tsx +++ b/src/bundled/channels/ChannelDetailsEditor.test.tsx @@ -71,72 +71,6 @@ function harness() { }; return { capability, load, save, check, setAttempt }; } -it("edits the new participant role separately, preserving existing participants and canceling drafts", async () => { - const h = harness(); - h.load.mockResolvedValue({ ...base, joinRole: "member" }); - const user = userEvent.setup(); - render( - , - ); - await user.click(await screen.findByRole("button", { name: "Edit details" })); - const choice = screen.getByRole("combobox", { name: "New participant role" }); - expect(choice).toHaveTextContent("Member"); - expect(choice).toHaveAccessibleDescription( - /won’t change existing participants/, - ); - await user.click(choice); - await user.click(await screen.findByRole("option", { name: "Guest" })); - expect(h.save).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await user.click(screen.getByRole("button", { name: "Edit details" })); - expect( - screen.getByRole("combobox", { name: "New participant role" }), - ).toHaveTextContent("Member"); - await user.click( - screen.getByRole("combobox", { name: "New participant role" }), - ); - await user.click(await screen.findByRole("option", { name: "Guest" })); - await user.click(screen.getByRole("button", { name: "Save changes" })); - await waitFor(() => expect(h.save).toHaveBeenCalledOnce()); - expect(h.save.mock.calls[0]).toEqual([ - expect.objectContaining({ joinRole: "member" }), - expect.objectContaining({ joinRole: "guest" }), - expect.any(AbortSignal), - ]); -}); - -it("shows the legacy Member default without resending it until explicitly changed", async () => { - const h = harness(); - const user = userEvent.setup(); - render( - , - ); - await user.click(await screen.findByRole("button", { name: "Edit details" })); - const choice = screen.getByRole("combobox", { name: "New participant role" }); - expect(choice).toHaveTextContent("Member"); - expect(choice).toHaveAccessibleDescription( - /won’t change existing participants/, - ); - expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); - await user.click(choice); - await user.click(await screen.findByRole("option", { name: "Guest" })); - expect(h.save).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Save changes" })); - expect(h.save).toHaveBeenCalledWith( - base, - { ...base, joinRole: "guest" }, - expect.any(AbortSignal), - ); -}); - it("edits deliberately, cancels every field, and sends all fields only on Save", async () => { const h = harness(); const user = userEvent.setup(); @@ -154,11 +88,6 @@ it("edits deliberately, cancels every field, and sends all fields only on Save", await user.clear(screen.getByRole("textbox", { name: "Name" })); await user.type(screen.getByRole("textbox", { name: "Name" }), "Renamed"); await user.clear(screen.getByRole("textbox", { name: "Description" })); - await user.click(screen.getByRole("switch", { name: "Private" })); - expect(screen.getByRole("dialog")).toHaveAccessibleDescription( - "Only channel members will have access.", - ); - await user.click(screen.getByRole("button", { name: "Continue" })); await user.click(screen.getByRole("radio", { name: "Temporary" })); expect(h.save).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "Cancel" })); @@ -168,7 +97,6 @@ it("edits deliberately, cancels every field, and sends all fields only on Save", expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue( channel.description, ); - expect(screen.getByRole("switch", { name: "Private" })).not.toBeChecked(); expect(screen.getByRole("radio", { name: "Ongoing" })).toBeChecked(); await user.clear(screen.getByRole("textbox", { name: "Description" })); await user.click(screen.getByRole("button", { name: "Save changes" })); @@ -243,11 +171,7 @@ it("reloads authoritative privacy after a conflict and saves retained text edits h.load.mockResolvedValue(privateBase); await user.click(screen.getByRole("button", { name: "Reload details" })); await waitFor(() => - expect(screen.getByRole("switch", { name: "Private" })).toBeChecked(), - ); - expect(screen.getByRole("switch", { name: "Private" })).not.toHaveAttribute( - "aria-disabled", - "true", + expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(), ); expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue( "My renamed channel", @@ -321,10 +245,6 @@ it("unknown outcomes survive remount and checking never invokes Save", async () ); await user.click(screen.getByRole("radio", { name: "Temporary" })); expect(screen.getByRole("radio", { name: "Ongoing" })).toBeChecked(); - expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute( - "aria-disabled", - "true", - ); expect( screen.queryByRole("button", { name: "Save changes" }), ).not.toBeInTheDocument(); @@ -420,7 +340,7 @@ it("keeps details readable without editing authority or host support", async () , ); expect(screen.getByText(channel.description)).toBeVisible(); - expect(screen.getByText("Public")).toBeVisible(); + expect(screen.queryByText("Who can read")).not.toBeInTheDocument(); const editor = screen.getByRole("complementary", { name: "Channel settings", hidden: true, @@ -498,12 +418,7 @@ it("orders metadata before actions and dismisses each edit layer with Escape", a await user.tab(); expect(edit).toHaveFocus(); await user.tab(); - expect(screen.getByRole("button", { name: "Edit visibility" })).toHaveFocus(); - await user.tab(); - expect( - screen.getByRole("button", { name: "Edit new participant role" }), - ).toHaveFocus(); - await user.tab(); + expect(screen.getByRole("button", { name: "Copy channel id" })).toHaveFocus(); await user.tab(); expect(screen.getByRole("button", { name: "Leave channel" })).toHaveFocus(); @@ -683,7 +598,7 @@ it("a save that becomes uncertain reopens through a settings row in check-only r await user.click( screen.getByRole("button", { name: "Close edit channel details" }), ); - await user.click(screen.getByRole("button", { name: "Edit visibility" })); + await user.click(screen.getByRole("button", { name: "Edit description" })); expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue(draft.name); expect( screen.queryByRole("button", { name: "Save changes" }), @@ -693,96 +608,6 @@ it("a save that becomes uncertain reopens through a settings row in check-only r ).toBeEnabled(); expect(h.save).toHaveBeenCalledOnce(); }); -it("warns before reopening a private channel, discards on Cancel, and changes visibility only on Save", async () => { - const h = harness(); - const user = userEvent.setup(); - const privateBase = { ...base, visibility: "private" as const }; - h.load.mockResolvedValue(privateBase); - render( - , - ); - await user.click(await screen.findByRole("button", { name: "Edit details" })); - const privacy = screen.getByRole("switch", { name: "Private" }); - expect(privacy).toBeChecked(); - await user.click(privacy); - expect( - screen.queryByRole("switch", { name: "Private" }), - ).not.toBeInTheDocument(); - expect(screen.getByRole("dialog")).toHaveAccessibleDescription( - "Everyone in this community will be able to view this channel’s full history.", - ); - await user.click(screen.getByRole("button", { name: "Continue" })); - expect(screen.getByRole("switch", { name: "Private" })).not.toBeChecked(); - expect(h.save).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Cancel" })); - expect(screen.getByRole("button", { name: "Edit details" })).toHaveFocus(); - await user.click(screen.getByRole("button", { name: "Edit details" })); - expect(screen.getByRole("switch", { name: "Private" })).toBeChecked(); - expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); - await user.click(screen.getByRole("switch", { name: "Private" })); - await user.click(screen.getByRole("button", { name: "Continue" })); - await user.click(screen.getByRole("button", { name: "Save changes" })); - expect(h.save).toHaveBeenCalledWith( - privateBase, - { ...privateBase, visibility: "public" }, - expect.any(AbortSignal), - ); -}); - -it.each([false, true])( - "reload keeps explicit reopening but adopts untouched visibility (edited=%s)", - async (edited) => { - const h = harness(); - const user = userEvent.setup(); - const privateBase = { ...base, visibility: "private" as const }; - h.load.mockResolvedValue(privateBase); - h.save.mockRejectedValueOnce(new Error("Channel details changed")); - render( - , - ); - await user.click( - await screen.findByRole("button", { name: "Edit details" }), - ); - await user.type(screen.getByRole("textbox", { name: "Name" }), " edited"); - if (edited) { - await user.click(screen.getByRole("switch", { name: "Private" })); - await user.click(screen.getByRole("button", { name: "Continue" })); - } - await user.click(screen.getByRole("button", { name: "Save changes" })); - await screen.findByRole("alert"); - const reloaded = { ...privateBase, version: "v2" }; - h.load.mockResolvedValue(reloaded); - await user.click(screen.getByRole("button", { name: "Reload details" })); - await waitFor(() => - expect( - screen.getByRole("button", { name: "Save changes" }), - ).toBeEnabled(), - ); - expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute( - "aria-checked", - String(!edited), - ); - await user.click(screen.getByRole("button", { name: "Save changes" })); - expect(h.save).toHaveBeenLastCalledWith( - reloaded, - { - ...privateBase, - name: "Alpha edited", - visibility: edited ? "public" : "private", - }, - expect.any(AbortSignal), - ); - }, -); - it("failed reload never turns a stale public text draft into intent to reopen", async () => { const h = harness(); const user = userEvent.setup(); @@ -807,7 +632,7 @@ it("failed reload never turns a stale public text draft into intent to reopen", await waitFor(() => expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(), ); - expect(screen.getByRole("switch", { name: "Private" })).toBeChecked(); + await user.click(screen.getByRole("button", { name: "Save changes" })); expect(h.save).toHaveBeenLastCalledWith( reloaded, @@ -979,7 +804,7 @@ it.each([ ); }); -it("stages lifetime and privacy together until Save", async () => { +it("stages lifetime until Save, preserving access", async () => { const h = harness(); const user = userEvent.setup(); render( @@ -997,13 +822,11 @@ it("stages lifetime and privacy together until Save", async () => { await user.click(screen.getByRole("radio", { name: "Ongoing" })); expect(save).toBeDisabled(); await user.click(screen.getByRole("radio", { name: "Temporary" })); - await user.click(screen.getByRole("switch", { name: "Private" })); - await user.click(screen.getByRole("button", { name: "Continue" })); expect(h.save).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "Save changes" })); expect(h.save).toHaveBeenCalledWith( base, - { ...base, ttlSeconds: 604800, visibility: "private" }, + { ...base, ttlSeconds: 604800 }, expect.any(AbortSignal), ); }); @@ -1174,147 +997,6 @@ it("retains an explicit change to Ongoing through a failed conflict reload", asy ); }); -it.each(["public", "private"] as const)( - "replaces Edit for every Private toggle from %s without losing the draft", - async (visibility) => { - const h = harness(); - const user = userEvent.setup(); - h.load.mockResolvedValue({ ...base, visibility }); - render( - , - ); - await user.click( - await screen.findByRole("button", { name: "Edit details" }), - ); - await user.type(screen.getByRole("textbox", { name: "Name" }), " draft"); - await user.type( - screen.getByRole("textbox", { name: "Description" }), - " draft", - ); - await user.click(screen.getByRole("radio", { name: "Temporary" })); - const initialPrivate = visibility === "private"; - const dialog = screen.getByRole("dialog"); - for (const dismissal of ["Cancel", "Escape", "Close"]) { - await user.click(screen.getByRole("switch", { name: "Private" })); - const confirmation = screen.getByRole("dialog"); - expect(confirmation).toBe(dialog); - expect(screen.getAllByRole("dialog")).toHaveLength(1); - expect(confirmation).toHaveAccessibleName( - initialPrivate ? "Make channel public?" : "Make channel private?", - ); - expect( - screen.queryByRole("textbox", { name: "Name" }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Save changes" }), - ).not.toBeInTheDocument(); - expect( - within(confirmation).getByRole("button", { name: "Cancel" }), - ).toHaveFocus(); - const consequence = initialPrivate - ? "Everyone in this community will be able to view this channel’s full history." - : "Only channel members will have access."; - expect(confirmation).toHaveAccessibleDescription(consequence); - await waitFor(() => expect(screen.getByText(consequence)).toBeVisible()); - expect( - screen.getByText(consequence).closest(".buzz-dialog-body"), - ).toHaveAttribute("data-footer-gap", "compact"); - expect( - screen.queryByText(/This change takes effect/), - ).not.toBeInTheDocument(); - const remember = screen.getByRole("checkbox", { - name: "Don’t show me this again", - }); - expect(remember).not.toBeChecked(); - await user.click(remember); - if (dismissal === "Escape") await user.keyboard("{Escape}"); - else - await user.click( - within(confirmation).getByRole("button", { - name: - dismissal === "Close" ? "Back to edit channel details" : "Cancel", - }), - ); - expect( - screen.queryByRole("dialog", { name: /Make channel/ }), - ).not.toBeInTheDocument(); - expect(screen.getByRole("dialog", { name: "Edit channel details" })).toBe( - dialog, - ); - expect(screen.getByRole("switch", { name: "Private" })).toHaveFocus(); - expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute( - "aria-checked", - String(initialPrivate), - ); - expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue( - "Alpha draft", - ); - expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue( - `${base.description} draft`, - ); - expect(screen.getByRole("radio", { name: "Temporary" })).toBeChecked(); - } - await user.click(screen.getByRole("switch", { name: "Private" })); - await user.click(screen.getByRole("button", { name: "Continue" })); - expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute( - "aria-checked", - String(!initialPrivate), - ); - expect(screen.getByRole("switch", { name: "Private" })).toHaveFocus(); - expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(); - // Toggling back also confirms, even though it returns to the saved state. - await user.click(screen.getByRole("switch", { name: "Private" })); - expect(screen.getByRole("dialog")).toHaveAccessibleName( - initialPrivate ? "Make channel private?" : "Make channel public?", - ); - await user.click(screen.getByRole("button", { name: "Continue" })); - expect(screen.getByRole("switch", { name: "Private" })).toHaveAttribute( - "aria-checked", - String(initialPrivate), - ); - expect(h.save).not.toHaveBeenCalled(); - // Escape in the form asks before discarding the entire draft. - await user.keyboard("{Escape}"); - await user.click(screen.getByRole("button", { name: "Discard changes" })); - await user.click(screen.getByRole("button", { name: "Edit details" })); - expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Alpha"); - expect(screen.getByRole("radio", { name: "Ongoing" })).toBeChecked(); - expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); - }, -); - -it("drops an open privacy confirmation when the destination changes", async () => { - const h = harness(); - const user = userEvent.setup(); - const view = render( - , - ); - await user.click(await screen.findByRole("button", { name: "Edit details" })); - await user.click(screen.getByRole("switch", { name: "Private" })); - expect(screen.getByRole("dialog")).toBeVisible(); - h.load.mockResolvedValue({ ...base, channelId: "other" }); - view.rerender( - , - ); - await screen.findByRole("button", { name: "Edit details" }); - expect( - screen.queryByRole("dialog", { name: /Make channel/ }), - ).not.toBeInTheDocument(); - expect(h.save).not.toHaveBeenCalled(); -}); - it("renders status rows only when they have content, not an empty grid row beside Edit", async () => { const h = harness(); const loading = deferred(); @@ -1631,7 +1313,7 @@ it("protects retained text after a failed authority reload", async () => { ); }); -it.each(["description", "visibility", "channel name"])( +it.each(["description", "channel name"])( "opens the full editor from the %s action and returns focus without saving", async (field) => { const h = harness(); @@ -1716,3 +1398,36 @@ it.each([ ).not.toBeInTheDocument(); expect(h.load).not.toHaveBeenCalled(); }); + +it("keeps invited-only Read access intact when editing details", async () => { + const h = harness(); + const current = { + ...base, + visibility: "private" as const, + joinRole: "guest" as const, + }; + h.load.mockResolvedValue(current); + const user = userEvent.setup(); + render( + , + ); + await user.click(await screen.findByRole("button", { name: "Edit details" })); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect( + screen.queryByRole("switch", { name: "Private" }), + ).not.toBeInTheDocument(); + await user.type( + screen.getByRole("textbox", { name: "Description" }), + " updated", + ); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(h.save).toHaveBeenCalledWith( + current, + { ...current, description: `${current.description} updated` }, + expect.any(AbortSignal), + ); +}); diff --git a/src/bundled/channels/ChannelDetailsEditor.tsx b/src/bundled/channels/ChannelDetailsEditor.tsx index 1889840aa1..638cc940eb 100644 --- a/src/bundled/channels/ChannelDetailsEditor.tsx +++ b/src/bundled/channels/ChannelDetailsEditor.tsx @@ -18,15 +18,8 @@ import { } from "../../features/relay/channel-details-protocol"; import { Button } from "../../shared/design-system/ui/Button"; import { Dialog } from "../../shared/design-system/ui/Dialog"; -import { - ChannelPrivacyConfirmation, - skipPrivacyConfirmation, - rememberPrivacyConfirmation, -} from "./ChannelPrivacyConfirmation"; import { ChannelDurationField } from "./ChannelDurationField"; import { ChannelTextField } from "./ChannelTextField"; -import { Switch } from "../../shared/design-system/ui/Switch"; -import { Select } from "../../shared/design-system/ui/Select"; import { DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS } from "../../features/relay/channel-writes"; import styles from "./Channels.module.css"; @@ -59,7 +52,6 @@ export function ChannelDetailsEditor({ capability, id, scope, - skipWarning: false, loading: true, editing: false, focusField: "name" as "name" | "description", @@ -68,7 +60,6 @@ export function ChannelDetailsEditor({ temporaryTtl: undefined as number | undefined, // Comparison only: retained through failed reads, never used as authority. baselineTtl: undefined as number | undefined, - privacyChoice: undefined as ChannelDetailsDraft["visibility"] | undefined, error: "", base: undefined as ChannelDetails | undefined, draft: undefined as ChannelDetailsDraft | undefined, @@ -83,7 +74,6 @@ export function ChannelDetailsEditor({ const busy = useRef(false); const nameInput = useRef(null); const descriptionInput = useRef(null); - const privacyCancel = useRef(null); const keepEditing = useRef(null); const confirmDiscard = view.confirmDiscard && !attempt; const previousDiscard = useRef(confirmDiscard); @@ -92,21 +82,9 @@ export function ChannelDetailsEditor({ (confirmDiscard ? keepEditing.current : nameInput.current)?.focus(); previousDiscard.current = confirmDiscard; }, [view.editing, confirmDiscard]); - const previousPrivacyChoice = useRef(view.privacyChoice); - const privateSwitch = useRef(null); const editButton = useRef(null); const formId = useId(); - const privateControlId = useId(); const statusId = useId(); - useLayoutEffect(() => { - if (view.editing && view.privacyChoice !== previousPrivacyChoice.current) { - (view.privacyChoice - ? privacyCancel.current - : privateSwitch.current - )?.focus(); - } - previousPrivacyChoice.current = view.privacyChoice; - }, [view.editing, view.privacyChoice]); const patch = useCallback( ( next: @@ -122,26 +100,20 @@ export function ChannelDetailsEditor({ ); const load = useCallback( async (signal: AbortSignal) => { - patch({ loading: true, error: "", privacyChoice: undefined }); + patch({ loading: true, error: "" }); try { const base = await capability.load(id, signal); if (!signal.aborted) patch((old) => ({ base, baselineTtl: base.ttlSeconds, - // Preserve explicit choices, but adopt remote visibility/lifetime - // for untouched fields. Unknown prior privacy must never reopen. + // Access is managed in Members; text/lifespan edits adopt its + // current values without silently changing anybody's access. draft: old.draft ? { ...old.draft, - visibility: - old.base && old.draft.visibility !== old.base.visibility - ? old.draft.visibility - : base.visibility, - joinRole: - old.base && old.draft.joinRole !== old.base.joinRole - ? old.draft.joinRole - : base.joinRole, + visibility: base.visibility, + joinRole: base.joinRole, ttlSeconds: old.draft.ttlSeconds === old.baselineTtl ? base.ttlSeconds @@ -195,7 +167,6 @@ export function ChannelDetailsEditor({ DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS; const canSave = view.editing && - !view.privacyChoice && !confirmDiscard && !locked && canEdit && @@ -217,7 +188,6 @@ export function ChannelDetailsEditor({ patch({ editing: false, error: "", - privacyChoice: undefined, confirmDiscard: false, }); }; @@ -234,8 +204,7 @@ export function ChannelDetailsEditor({ draft.ttlSeconds !== view.base.ttlSeconds); const requestClose = () => { if (pending || busy.current) return; - if (view.privacyChoice) patch({ privacyChoice: undefined }); - else if (confirmDiscard) patch({ confirmDiscard: false }); + if (confirmDiscard) patch({ confirmDiscard: false }); else if (unsaved) patch({ confirmDiscard: true }); else close(); }; @@ -347,34 +316,24 @@ export function ChannelDetailsEditor({ { if (!open) requestClose(); }} dismissOnOutsideClick - title={ - confirmDiscard - ? "Discard changes?" - : view.privacyChoice - ? view.privacyChoice === "private" - ? "Make channel private?" - : "Make channel public?" - : "Edit channel details" - } + title={confirmDiscard ? "Discard changes?" : "Edit channel details"} description={ confirmDiscard ? "Your channel details have unsaved changes." : undefined } closeLabel={ - confirmDiscard || view.privacyChoice + confirmDiscard ? "Back to edit channel details" : "Close edit channel details" } @@ -400,57 +359,8 @@ export function ChannelDetailsEditor({ Discard changes - ) : view.privacyChoice ? ( - <> - - - ) : ( <> - {draft && ( - - { - if (locked || !canEdit) return; - const visibility = checked ? "private" : "public"; - if (skipPrivacyConfirmation(scope)) - patch({ draft: { ...draft, visibility } }); - else - patch({ - privacyChoice: visibility, - skipWarning: false, - }); - }} - /> - - - )} @@ -478,13 +388,7 @@ export function ChannelDetailsEditor({ ) } > - {confirmDiscard ? null : view.privacyChoice ? ( - patch({ skipWarning })} - /> - ) : ( + {confirmDiscard ? null : (
- { + if ( + (visibility === "public" || visibility === "private") && + visibility !== base?.visibility + ) + choose({ visibility }, 0); + }} + /> + { - if ( - (visibility === "public" || visibility === "private") && - visibility !== base?.visibility - ) - choose({ visibility }, 0); - }} - /> - { + if ( + (visibility === "public" || visibility === "private") && + visibility !== base?.visibility + ) + choose({ visibility }, 0); + }} + /> +

+ {!base + ? loading + ? "Checking channel access…" + : "Channel access could not be confirmed." + : base.visibility === "public" + ? `Anyone in the community can find and ${base.joinRole === "guest" ? "read" : "contribute"}.` + : `Invited people can ${base.joinRole === "guest" ? "read" : "contribute"}.`} +

+ +
+ {generalAccess && ( {current && membersOpen && ( { registerCanvas(ctx); From 4f973599a0bfbc562d5462d464f384e3f83491bd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 19:24:06 -0700 Subject: [PATCH 18/63] fix(channels): center access icons and enlarge community artwork Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelGeneralAccess.tsx | 35 ++++++++++--------- .../channels/ChannelMembersDialog.module.css | 3 ++ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index b987b88447..bf4f498cd2 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -55,27 +55,28 @@ export function ChannelGeneralAccess({ (entry) => communityDestination(entry.id).url === origin, ), ); - const communityAvatar = community ? ( - - ) : ( - - ); + const communityAvatar = (size: "compact" | "default") => + community ? ( + + ) : ( + + ); // Public previews and Guests can inspect signed discovery metadata without // calling the membership/write-gated channel-details capability. if (channel?.readOnly) return (

General access

-
-
From e9faa88ff2f9b95df8e3b18f120248a20d372a0b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 22:22:33 -0700 Subject: [PATCH 42/63] fix(channels): align access type with member identities Use regular body-small type and a text-aligned 24px link Select target, then match the access row vertical inset to member identities. Preserve shared selection, focus and confirmation behavior. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelMembersDialog.module.css | 1 + src/shared/design-system/DESIGN.md | 3 ++- src/shared/design-system/styles/forms.css | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css index 8d82f64d39..2f7204a51b 100644 --- a/src/bundled/channels/ChannelMembersDialog.module.css +++ b/src/bundled/channels/ChannelMembersDialog.module.css @@ -173,6 +173,7 @@ auto; align-items: start; gap: var(--space-row-gap); + padding-block: var(--space-half); } .accessIcon { display: flex; diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index adfc196815..32d5f51fe6 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -887,7 +887,8 @@ the composer's model name, `size="inline"` keeps a 24px minimum target with 4px horizontal padding and icon gap, and 12px icons. Pair it with `variant="ghost"` for an unframed label; shared hover, disabled, and keyboard focus still apply. Compact Selects use `appearance="link"` for a text-and-caret choice, such as channel access, without a capsule or horizontal -button inset; it retains the Select's keyboard behavior and focus treatment. +button inset. It uses regular body-small type and a 24px minimum target aligned +to the text's top edge; it retains the Select's keyboard behavior and focus treatment. Button defaults to `md` (40px minimum height); IconButton defaults to `sm` (32px square with 16px artwork) and a 10px rounded rectangle diff --git a/src/shared/design-system/styles/forms.css b/src/shared/design-system/styles/forms.css index 172ad1d52c..e67e50947a 100644 --- a/src/shared/design-system/styles/forms.css +++ b/src/shared/design-system/styles/forms.css @@ -302,7 +302,10 @@ block-size: var(--space-5); } .buzz-select[data-appearance="link"] .buzz-button { - padding-inline: 0; + @apply text-body-sm; + align-items: flex-start; + min-height: var(--space-6); + padding: 0; } /* Artwork provides its own visual inset; keep its edge closer to the frame. */ .buzz-select .buzz-button:has(.buzz-select-leading), From 9e390e9cca4dbd8e0bdbdf6881d734328e92e900 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 22:29:40 -0700 Subject: [PATCH 43/63] fix(channels): lead access rows without a heading Co-authored-by: Carl Signed-off-by: Taylor Ho --- docs/channels.md | 2 +- src/bundled/channels/ChannelGeneralAccess.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index c3d4683702..d377091bae 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -729,7 +729,7 @@ text before each row’s ellipsis button; Member and Guest are shown as **Partic and **Viewing only**. The ellipsis menu contains permitted role choices with the verified selection checked, alongside profile/removal actions and the existing role confirmation. Immutable roles such as Owner and self remain protected. -**General** is the first section of the scrolling member list. Its row aligns its +The access row leads the scrolling member list without a visible section heading. It aligns its avatar slot and text with the member rows: a community avatar/lock icon, a compact audience Select (**Who can read**), a short result description, and the trailing **New people can** Select (Participate / View only), with short diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index ecd5db903c..b30794b753 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -31,7 +31,7 @@ import { privacyConsequence } from "./ChannelPrivacyConfirmation"; const noCommunitySubscription = () => () => {}; /** General access uses the same signed save/readback owner as channel details. - * Choices are confirmed individually, like the member rows above this section. */ + * Choices are confirmed individually, like the member rows that follow. */ export function ChannelGeneralAccess({ communityReader, communityScope, @@ -72,7 +72,6 @@ export function ChannelGeneralAccess({ if (channel?.readOnly) return (
-

General

+ {generalAccess && ( + + )} )} From 67bcc0434a2f19302d2184b6b0fa38f2f2cf8453 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 23:13:46 -0700 Subject: [PATCH 47/63] fix(channels): match access inset to the artwork gap Use the same row-gap token for the leading inset and the space between artwork and text. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelMembersDialog.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css index 06b2f315b5..c5e69d2b5b 100644 --- a/src/bundled/channels/ChannelMembersDialog.module.css +++ b/src/bundled/channels/ChannelMembersDialog.module.css @@ -162,7 +162,7 @@ .generalAccess { border-top: 1px solid var(--border-standard); - padding: var(--space-4) 0 0 var(--space-control-inset); + padding: var(--space-4) 0 0 var(--space-row-gap); } .generalAccess > :where(.accessRow) ~ * { margin-block-start: var(--space-3); From 74bafd3a40bbe8536c35f5c07619dc22b29d6b00 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 23:15:03 -0700 Subject: [PATCH 48/63] fix(channels): tighten access separator spacing Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelMembersDialog.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css index c5e69d2b5b..ea720064c3 100644 --- a/src/bundled/channels/ChannelMembersDialog.module.css +++ b/src/bundled/channels/ChannelMembersDialog.module.css @@ -162,7 +162,7 @@ .generalAccess { border-top: 1px solid var(--border-standard); - padding: var(--space-4) 0 0 var(--space-row-gap); + padding: var(--space-2) 0 0 var(--space-row-gap); } .generalAccess > :where(.accessRow) ~ * { margin-block-start: var(--space-3); From 5376f68519cf87a2243700b4cb0d8e732f955e3b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 23:16:11 -0700 Subject: [PATCH 49/63] fix(channels): balance fixed access footer spacing Let the member scrollport meet the separator and add a small gap between the audience label and its description. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelGeneralAccess.tsx | 2 +- src/bundled/channels/ChannelMembersDialog.module.css | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index c3837cbcf5..5f8876799d 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -85,7 +85,7 @@ export function ChannelGeneralAccess({ /> )} -
+

{channel.visibility === "public" ? "Anyone in the community can read." diff --git a/src/bundled/channels/ChannelMembersDialog.module.css b/src/bundled/channels/ChannelMembersDialog.module.css index ea720064c3..9ff714cab2 100644 --- a/src/bundled/channels/ChannelMembersDialog.module.css +++ b/src/bundled/channels/ChannelMembersDialog.module.css @@ -34,6 +34,11 @@ .memberList > * { flex-shrink: 0; } +/* Let the scrollport meet the fixed access divider without a blank gutter. */ +.memberList:has(+ .generalAccess) { + padding-block-end: 0; + margin-block-end: calc(-1 * var(--space-4)); +} /* Headings already provide their top inset; tighten only adjacent member groups. */ .memberList > section + section:not(.memberGroup) { margin-block-start: calc(-1 * var(--space-2)); @@ -192,7 +197,7 @@ display: grid; min-width: 0; justify-items: start; - gap: 0; + gap: var(--space-1); } .accessAudience > * { max-width: 100%; From 13b2bbcdbc613a78d50f22a78c9297cc5b88d795 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 23:17:45 -0700 Subject: [PATCH 50/63] fix(channels): center access artwork on its text lines Account for the link target extending above the visible audience line when centering the artwork. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelGeneralAccess.tsx | 2 +- src/bundled/channels/ChannelMembersDialog.module.css | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index 5f8876799d..5a8853e8b5 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -212,7 +212,7 @@ function ChannelGeneralAccessEditor({ >

.accessIcon { width: 100%; } +/* The 24px link target extends 4px above its 20px text line. */ +.accessEditableIcon { + translate: 0 var(--space-half); +} .accessAudience { display: grid; min-width: 0; From 0f9fd0c39f9d551591d547e7dead5b39f638795b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 23:34:59 -0700 Subject: [PATCH 51/63] docs(channels): align access comment with fixed placement Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelGeneralAccess.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index 5a8853e8b5..5146966bf9 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -31,7 +31,7 @@ import { privacyConsequence } from "./ChannelPrivacyConfirmation"; const noCommunitySubscription = () => () => {}; /** General access uses the same signed save/readback owner as channel details. - * Choices are confirmed individually, like the member rows that follow. */ + * Choices are confirmed individually, like the member rows above this section. */ export function ChannelGeneralAccess({ communityReader, communityScope, From b4fcc19f2b0e70a8b854afc4561fa9a32b9b970f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 23:36:15 -0700 Subject: [PATCH 52/63] test(channels): follow details duration with the cancel action Keep creation focus checks on Private while checking the details form next tab stop after visibility moved to member management. Co-authored-by: Carl Signed-off-by: Taylor Ho --- tests/browser/channel-header-menu.spec.mjs | 7 ++++++- tests/browser/duration-layout.mjs | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/browser/channel-header-menu.spec.mjs b/tests/browser/channel-header-menu.spec.mjs index dbfbf23b98..75139ce7ab 100644 --- a/tests/browser/channel-header-menu.spec.mjs +++ b/tests/browser/channel-header-menu.spec.mjs @@ -288,7 +288,12 @@ test("header actions use the full-size menu and retain pane/dialog focus owners" await nameEditor .getByRole("radio", { name: "Temporary", exact: true }) .click(); - await checkDurationConsumer(page, nameEditor, "edit-duration", testInfo); + await checkDurationConsumer(page, nameEditor, "edit-duration", testInfo, { + nextControl: nameEditor.getByRole("button", { + name: "Cancel", + exact: true, + }), + }); await nameEditor.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(title).toBeFocused(); if (browserName === "chromium") { diff --git a/tests/browser/duration-layout.mjs b/tests/browser/duration-layout.mjs index efa8173fc3..aa7e8ea589 100644 --- a/tests/browser/duration-layout.mjs +++ b/tests/browser/duration-layout.mjs @@ -173,11 +173,18 @@ export async function checkDurationLayout( } // Other consumers need representative integration, not another app-startup matrix. -export async function checkDurationConsumer(page, dialog, prefix, testInfo) { - const nextControl = dialog.getByRole("switch", { - name: "Private", - exact: true, - }); +export async function checkDurationConsumer( + page, + dialog, + prefix, + testInfo, + { + nextControl = dialog.getByRole("switch", { + name: "Private", + exact: true, + }), + } = {}, +) { await checkDurationLayout(dialog, { selected: false, nextControl }); await page.screenshot({ path: testInfo.outputPath(`${prefix}-ongoing-wide.png`), From 975a1a329cf7bffa3712709180e1cd7bdb26def6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 29 Oct 2026 09:14:35 -0700 Subject: [PATCH 53/63] fix(channels): clarify public and private access labels Co-authored-by: Carl Signed-off-by: Taylor Ho --- docs/channels.md | 3 ++- .../channels/ChannelGeneralAccess.test.tsx | 23 +++++++++++-------- src/bundled/channels/ChannelGeneralAccess.tsx | 4 ++-- .../ChannelPrivacyConfirmation.test.tsx | 5 +--- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 6c10493636..2bc52b6ccb 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -731,7 +731,8 @@ the verified selection checked, alongside profile/removal actions and the existi confirmation. Immutable roles such as Owner and self remain protected. The access row stays below and outside the scrolling member list, without a visible section heading. It uses the same avatar slot and type sizes as member rows: a community avatar/lock -icon, a compact audience Select (**Who can read**), a short result description, +icon, a compact audience Select (**Who can read**) with **Public** / **Private** +labels, a hint explaining the audience and selected participation default, and the trailing **New people can** Select (Participate / View only), with short descriptions of the permitted actions. Audience options show the community avatar and lock icon inside the dropdown. The audience Select uses the shared text-link diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 5421ccb613..9a3a9c2e5a 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -167,10 +167,14 @@ it.each(["public", "private"] as const)( render( , ); - const option = - visibility === "public" - ? "Channel members only" - : "Anyone in the community"; + expect( + await screen.findByText( + visibility === "public" + ? "Anyone in the community can find and view." + : "New channel members can view.", + ), + ).toBeVisible(); + const option = visibility === "public" ? "Private" : "Public"; const control = await select(user, "Who can read", option); const confirm = screen.getByRole("alertdialog", { name: "Change general access?", @@ -187,9 +191,7 @@ it.each(["public", "private"] as const)( await user.keyboard("{Escape}"); await waitFor(() => expect(control).toHaveFocus()); expect(control).toHaveTextContent( - visibility === "public" - ? "Anyone in the community" - : "Channel members only", + visibility === "public" ? "Public" : "Private", ); await select(user, "Who can read", option); const next = { @@ -225,7 +227,10 @@ it("confirms new people's View only/Participate access independently of private ); expect( screen.getByRole("combobox", { name: "Who can read" }), - ).toHaveTextContent("Channel members only"); + ).toHaveTextContent("Private"); + expect( + screen.getByText("New channel members can participate."), + ).toBeVisible(); }); it("shows the absent Member default without saving, and a declined save reloads current access", async () => { const h = harness(); @@ -325,7 +330,7 @@ it("retires an open confirmation and late reads when the destination changes", a const view = render( , ); - await select(user, "Who can read", "Anyone in the community"); + await select(user, "Who can read", "Public"); const pending = new Promise(() => {}); h.load.mockReturnValueOnce(pending); view.rerender( diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index 5146966bf9..943dc77cc6 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -249,12 +249,12 @@ function ChannelGeneralAccessEditor({ options: [ { value: "public", - label: "Anyone in the community", + label: "Public", leading: communityAvatar("compact"), }, { value: "private", - label: "Channel members only", + label: "Private", leading: , }, ], diff --git a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx index 43a17f4f16..903cdb7989 100644 --- a/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx +++ b/src/bundled/channels/ChannelPrivacyConfirmation.test.tsx @@ -117,10 +117,7 @@ it.each(["private", "public"] as const)( await user.click(control); await user.click( await screen.findByRole("option", { - name: - choice === "private" - ? "Channel members only" - : "Anyone in the community", + name: choice === "private" ? "Private" : "Public", }), ); expect( From 4ffda7e9983c5d58c412403c848aac2cd8b2810c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 29 Oct 2026 09:56:21 -0700 Subject: [PATCH 54/63] feat(channels): default private additions to participation Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/channel-commands.mjs | 6 +- browser-host/channel-commands.test.mjs | 21 +++++- browser-host/relay-broker-api.test.mjs | 14 +++- docs/channels.md | 13 +++- .../channels/ChannelGeneralAccess.test.tsx | 36 +++++++-- src/bundled/channels/ChannelGeneralAccess.tsx | 74 ++++++++++--------- src/features/channel-members/members.test.ts | 44 +++++++++-- src/features/channel-members/members.ts | 15 +++- 8 files changed, 169 insertions(+), 54 deletions(-) diff --git a/browser-host/channel-commands.mjs b/browser-host/channel-commands.mjs index afecbac243..45c9e0e833 100644 --- a/browser-host/channel-commands.mjs +++ b/browser-host/channel-commands.mjs @@ -88,7 +88,11 @@ export function validChannelCommand(event) { if (event.kind === 9000) return ( event.content === "" && - tags.length === 2 && + (tags.length === 2 || + (tags.length === 3 && + tags[2].length === 2 && + tags[2][0] === "role" && + tags[2][1] === "member")) && p?.length === 2 && p[0] === "p" && /^[0-9a-f]{64}$/.test(p[1]) diff --git a/browser-host/channel-commands.test.mjs b/browser-host/channel-commands.test.mjs index 221bc04de7..4e32979f46 100644 --- a/browser-host/channel-commands.test.mjs +++ b/browser-host/channel-commands.test.mjs @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { validChannelCommand } from "./channel-commands.mjs"; const id = "11111111-1111-4111-8111-111111111111"; -it("allows only ordinary invitations, without extra roles", () => { +it("allows ordinary invitations with an optional explicit participant role", () => { const invite = { kind: 9000, created_at: 1, @@ -12,6 +12,20 @@ it("allows only ordinary invitations, without extra roles", () => { ], }; expect(validChannelCommand(invite)).toBe(true); + expect( + validChannelCommand({ + ...invite, + tags: [...invite.tags, ["role", "member"]], + }), + ).toBe(true); + for (const role of ["owner", "admin", "guest", "bot", ""]) { + expect( + validChannelCommand({ + ...invite, + tags: [...invite.tags, ["role", role]], + }), + ).toBe(false); + } expect( validChannelCommand({ ...invite, @@ -94,11 +108,16 @@ it("accepts one trailing outbox identifier for invitations and rejects ambiguous p = ["p", "a".repeat(64)], client = ["client-id", id]; expect(validChannelCommand(invite([h, p, client]))).toBe(true); + expect(validChannelCommand(invite([h, p, ["role", "member"], client]))).toBe( + true, + ); for (const tags of [ [h, p, client, client], [client, h, p], [h, p, ["client-id", "invalid"]], [h, p, [...client, "extra"]], + [h, p, ["role", "member", "extra"]], + [h, p, ["role", "member"], ["role", "member"]], ]) expect(validChannelCommand(invite(tags))).toBe(false); }); diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index f05f1f7008..7d1bc06aac 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -1769,7 +1769,19 @@ test("member addition works without channel creation, while role elevation remai ).toBe("accepted"), ); expect(h.publications).toHaveLength(1); - for (const role of ["owner", "admin", "member"]) { + const participantId = owner.outbox.send({ + kind: 9000, + content: "", + tags: [...tags, ["role", "member"]], + }); + await vi.waitFor(() => + expect( + owner.local.snapshot().find((row) => row.event.id === participantId) + ?.delivery, + ).toBe("accepted"), + ); + expect(h.publications).toHaveLength(2); + for (const role of ["owner", "admin", "guest"]) { const denied = await h.post("sign", { kind: 9000, content: "", diff --git a/docs/channels.md b/docs/channels.md index 2bc52b6ccb..8c4cbed64e 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -732,8 +732,8 @@ confirmation. Immutable roles such as Owner and self remain protected. The access row stays below and outside the scrolling member list, without a visible section heading. It uses the same avatar slot and type sizes as member rows: a community avatar/lock icon, a compact audience Select (**Who can read**) with **Public** / **Private** -labels, a hint explaining the audience and selected participation default, -and the trailing **New people can** Select (Participate / View only), with short +labels, a hint explaining the audience and, for public channels, the selected participation default, +and a public-only trailing **New people can** Select (Participate / View only), with short descriptions of the permitted actions. Audience options show the community avatar and lock icon inside the dropdown. The audience Select uses the shared text-link treatment; the participation Select is outlined and vertically centered against @@ -752,6 +752,15 @@ Uncertain results survive closing the dialog and offer **Check save status**, which reads without republishing. Opening or checking never writes. Private channels with a Guest default remain intact when changing unrelated details. +In the private-channel experiment, the footer omits **New people can** and says +"Only people you add can read this channel." Ordinary human additions explicitly +request Member (Participate), including when an older saved admission default is +Guest. Existing roles are not changed; **Add as** still permits an individual +View only or Admin choice. Public additions keep their admission-default behavior, +agents retain Bot, and Solo/system admission is unchanged. The saved default is +preserved so it reappears when the channel becomes public and the experiment can +be reverted without a metadata migration. + Readers see General access as plain text from verified channel discovery metadata, without calling the editing capability. Its audience description is followed by “Viewing only. Ask an owner or admin to participate.” The private-channel summary diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 9a3a9c2e5a..8b3c33c0b8 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -112,7 +112,7 @@ const base: ChannelDetails = { version: "v1", name: "Alpha", description: "Description", - visibility: "private", + visibility: "public", joinRole: "guest", canEdit: true, }; @@ -171,7 +171,7 @@ it.each(["public", "private"] as const)( await screen.findByText( visibility === "public" ? "Anyone in the community can find and view." - : "New channel members can view.", + : "Only people you add can read this channel.", ), ).toBeVisible(); const option = visibility === "public" ? "Private" : "Public"; @@ -207,9 +207,35 @@ it.each(["public", "private"] as const)( next, expect.any(AbortSignal), ); + if (visibility === "public") { + expect( + screen.queryByRole("combobox", { name: "New people can" }), + ).not.toBeInTheDocument(); + } else { + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toHaveTextContent("View only"); + } + }, +); +it.each(["member", "guest"] as const)( + "hides the private %s admission default without rewriting it", + async (joinRole) => { + const h = harness(); + h.load.mockResolvedValue({ ...base, visibility: "private", joinRole }); + render( + , + ); + expect( + await screen.findByText("Only people you add can read this channel."), + ).toBeVisible(); + expect( + screen.queryByRole("combobox", { name: "New people can" }), + ).not.toBeInTheDocument(); + expect(h.save).not.toHaveBeenCalled(); }, ); -it("confirms new people's View only/Participate access independently of private reading access", async () => { +it("confirms new people's View only/Participate access independently of public reading access", async () => { const h = harness(); const user = userEvent.setup(); render(); @@ -227,9 +253,9 @@ it("confirms new people's View only/Participate access independently of private ); expect( screen.getByRole("combobox", { name: "Who can read" }), - ).toHaveTextContent("Private"); + ).toHaveTextContent("Public"); expect( - screen.getByText("New channel members can participate."), + screen.getByText("Anyone in the community can find and participate."), ).toBeVisible(); }); it("shows the absent Member default without saving, and a declined save reloads current access", async () => { diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index 943dc77cc6..717ed368db 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -275,44 +275,46 @@ function ChannelGeneralAccessEditor({ : "Channel access could not be confirmed." : base.visibility === "public" ? `Anyone in the community can find and ${base.joinRole === "guest" ? "view" : "participate"}.` - : `New channel members can ${base.joinRole === "guest" ? "view" : "participate"}.`} + : "Only people you add can read this channel."}

-
- { + if ( + (joinRole === "member" || joinRole === "guest") && + joinRole !== (base?.joinRole ?? "member") + ) + choose({ joinRole }, 1); + }} + /> +
+ )}
{error && (

diff --git a/src/features/channel-members/members.test.ts b/src/features/channel-members/members.test.ts index d91566c96e..e46377e85c 100644 --- a/src/features/channel-members/members.test.ts +++ b/src/features/channel-members/members.test.ts @@ -38,7 +38,11 @@ it.each([ afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); }); -function setup(type = "stream") { +function setup( + type = "stream", + visibility: "private" | "public" = "private", + joinRole: "member" | "guest" = "member", +) { const viewer = keypair(), relay = keypair(), person = keypair(); @@ -94,7 +98,13 @@ function setup(type = "stream") { signed(relay, { kind: 39000, content: "", - tags: [["d", id], ["t", type], ["private"], ["name", "Design"]], + tags: [ + ["d", id], + ["t", type], + [visibility], + ["join_role", joinRole], + ["name", "Design"], + ], }), roster(foreign ? person : relay, id, members, clock), ].filter((event) => @@ -194,7 +204,7 @@ function setup(type = "stream") { addChannelMember(owner.session, id, person.pubkey, signal), }; } -it("adds a person without changing roles and confirms the live roster using membership capability alone", async () => { +it("adds a private-channel participant explicitly and confirms the live roster using membership capability alone", async () => { const t = setup(); await t.ready(); await t.add(); @@ -205,9 +215,7 @@ it("adds a person without changing roles and confirms the live roster using memb ["p", t.person.pubkey], ]), ); - expect( - t.publish.mock.calls[0]?.[0].tags.some(([tag]) => tag === "role"), - ).toBe(false); + expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "member"]); expect(t.session.channels.list().channels[0]?.members).toContain( t.person.pubkey, ); @@ -221,6 +229,30 @@ it("uses the bot role for an existing library agent", async () => { await t.add(); expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "bot"]); }); +it("adds a participant despite a private channel's saved Guest default", async () => { + const t = setup("stream", "private", "guest"); + await t.ready(); + await t.managedAdd(); + expect(t.publish.mock.calls[0]?.[0].tags).toContainEqual(["role", "member"]); +}); +it.each(["member", "guest"] as const)( + "leaves the public %s admission default to the relay", + async (joinRole) => { + const t = setup("stream", "public", joinRole); + await t.ready(); + await t.add(); + expect( + t.publish.mock.calls[0]?.[0].tags.some(([tag]) => tag === "role"), + ).toBe(false); + }, +); +it("does not resend an addition for someone already in the private channel", async () => { + const t = setup("stream", "private", "guest"); + await t.ready(); + t.confirmPerson(); + await t.managedAdd(); + expect(t.publish).not.toHaveBeenCalled(); +}); it("preserves a rejected operation and retries its exact identity", async () => { const t = setup(); await t.ready(); diff --git a/src/features/channel-members/members.ts b/src/features/channel-members/members.ts index 28b644246a..090ad73bf1 100644 --- a/src/features/channel-members/members.ts +++ b/src/features/channel-members/members.ts @@ -160,7 +160,8 @@ export async function addChannelMember( ([tag, value]) => tag === "p" && value === pubkey, ) && !event.tags.some( - ([tag, value]) => tag === "role" && value !== "bot", + ([tag, value]) => + tag === "role" && value !== "bot" && value !== "member", ), ); if (pending?.guarded && !channel()?.members?.includes(pubkey)) @@ -194,6 +195,16 @@ export async function addChannelMember( .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 = isAgent + ? "bot" + : !soloParticipant && + current?.visibility === "private" && + (current.channelType === "stream" || current.channelType === "forum") + ? "member" + : undefined; beforeDispatch?.(); const id = pending?.event.id ?? @@ -204,7 +215,7 @@ export async function addChannelMember( tags: [ ["h", channelId], ["p", pubkey], - ...(isAgent ? [["role", "bot"]] : []), + ...(role ? [["role", role]] : []), ], }, recovery, From 7970632e0659b2af45d28afc9e9d22a1090c0302 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 29 Oct 2026 10:04:39 -0700 Subject: [PATCH 55/63] feat(channels): choose participation before making a channel public Show the existing participation dropdown in the private-to-public confirmation. Stage its choice with visibility and save one channel-details update after confirmation. Keep Cancel, saved defaults and individual roles intact. Cover cancellation, both default roles, one signed publication and matching readback of both settings. Reuse shared AlertDialog, Field and Select controls. Co-authored-by: Carl Signed-off-by: Taylor Ho --- docs/channels.md | 7 ++- .../channels/ChannelGeneralAccess.test.tsx | 50 ++++++++++++++++ src/bundled/channels/ChannelGeneralAccess.tsx | 59 +++++++++++++------ src/features/relay/channel-details.test.ts | 42 +++++++++++++ 4 files changed, 139 insertions(+), 19 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 8c4cbed64e..207defbc42 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -735,7 +735,12 @@ icon, a compact audience Select (**Who can read**) with **Public** / **Private** labels, a hint explaining the audience and, for public channels, the selected participation default, and a public-only trailing **New people can** Select (Participate / View only), with short descriptions of the permitted actions. Audience options show the community avatar and -lock icon inside the dropdown. The audience Select uses the shared text-link +lock icon inside the dropdown. When making a private channel public, the confirmation +includes the same **New people can** dropdown. It starts with the saved public default +(Participate if absent); the selected role and public visibility publish in a single +details update after confirmation. Cancel saves neither. Both choices make the full +history readable by the community; View only limits new people's contributions. +The audience Select uses the shared text-link treatment; the participation Select is outlined and vertically centered against the audience row, with its popup aligned to the trigger’s right edge. On narrow screens the role control wraps under the audience. The confirmation explains “People already in the channel keep diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 8b3c33c0b8..3df5577f98 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -235,6 +235,56 @@ it.each(["member", "guest"] as const)( expect(h.save).not.toHaveBeenCalled(); }, ); +it.each(["member", "guest", undefined] as const)( + "stages public participation from the private %s default until confirmation", + async (joinRole) => { + const h = harness(); + const current = { ...base, visibility: "private" as const, joinRole }; + h.load.mockResolvedValue(current); + const user = userEvent.setup(); + render( + , + ); + const label = joinRole === "guest" ? "View only" : "Participate"; + const nextRole = joinRole === "guest" ? "member" : "guest"; + const nextLabel = nextRole === "guest" ? "View only" : "Participate"; + const control = await select(user, "Who can read", "Public"); + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toHaveTextContent(label); + await select(user, "New people can", nextLabel); + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toHaveTextContent(nextLabel); + expect(h.save).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(control).toHaveFocus()); + expect(control).toHaveTextContent("Private"); + expect(h.save).not.toHaveBeenCalled(); + + await select(user, "Who can read", "Public"); + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toHaveTextContent(label); + await select(user, "New people can", nextLabel); + const next: ChannelDetails = { + ...current, + visibility: "public", + joinRole: nextRole, + }; + h.load.mockResolvedValue(next); + await user.click(screen.getByRole("button", { name: "Change access" })); + await waitFor(() => expect(control).toHaveTextContent("Public")); + expect(h.save).toHaveBeenCalledExactlyOnceWith( + current, + next, + expect.any(AbortSignal), + ); + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toHaveTextContent(nextLabel); + }, +); 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 717ed368db..eb2a9a432e 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -16,6 +16,7 @@ import { Button } from "../../shared/design-system/ui/Button"; import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; import { Select } from "../../shared/design-system/ui/Select"; import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { Field } from "../../shared/design-system/ui/Field"; import type { CommunityReader } from "../../features/communities/service"; import { communityName } from "../../features/communities/names"; import { communityFromScope } from "../../features/relay/gifs"; @@ -29,6 +30,23 @@ import styles from "./ChannelMembersDialog.module.css"; import { privacyConsequence } from "./ChannelPrivacyConfirmation"; const noCommunitySubscription = () => () => {}; +const participationOptions = [ + { + label: "", + options: [ + { + value: "member", + label: "Participate", + description: "Send messages, reply, and react.", + }, + { + value: "guest", + label: "View only", + description: "Read messages and threads.", + }, + ], + }, +]; /** General access uses the same signed save/readback owner as channel details. * Choices are confirmed individually, like the member rows above this section. */ @@ -288,23 +306,7 @@ function ChannelGeneralAccessEditor({ value={base ? (base.joinRole ?? "member") : ""} placeholder={loading ? "Loading access…" : "Access unavailable"} disabled={locked} - groups={[ - { - label: "", - options: [ - { - value: "member", - label: "Participate", - description: "Send messages, reply, and react.", - }, - { - value: "guest", - label: "View only", - description: "Read messages and threads.", - }, - ], - }, - ]} + groups={participationOptions} onValueChange={(joinRole) => { if ( (joinRole === "member" || joinRole === "guest") && @@ -371,7 +373,28 @@ function ChannelGeneralAccessEditor({ } - /> + > + {base.visibility === "private" && choice.visibility === "public" && ( + New people can} + nativeLabel={false} + > +