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 1713ff1f31..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: "", @@ -3464,7 +3476,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 +3518,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 cd2689fa47..c0390ac267 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -518,9 +518,10 @@ navigation, not a new archived-channel directory. Search also finds active public channels the viewer has not joined, by name, labeled **Public channel · not joined** after joined matches. Opening one shows the -read-only preview. The preview offers **Join channel**; after the relay confirms +read-only preview. Member-default previews offer **Join channel**; after the relay confirms membership, the channel joins the sidebar and the composer becomes available and -focused. Joining a channel is not available for DMs, private, archived or session +focused. Guest-default previews omit Join and preserve reading without membership. +Joining a channel is not available for DMs, private, archived or session conversations. See [relay queries](relay-queries.md#public-channel-name-search-and-join). Unarchive publishes the existing narrow `9002` command with `archived=false` and requires fresh relay metadata with a missing/false archive tag before updating @@ -604,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 @@ -623,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 @@ -672,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. @@ -686,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. @@ -725,29 +725,61 @@ 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. It retains the existing member +list, row menus and confirmed role changes. Roles appear as muted +text before each row’s ellipsis button; Member and Guest are shown as **Participating** +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. +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, 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. 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 +their access.”; the note is not a permanent paragraph below the controls. +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. A pending save shows an inline spinner +in the leading icon slot instead of a separate saving message. Rejections offer +**Reload access**. +failures before publication identify the initial access check, signing, or final +access check that failed, retaining the underlying error. No request is retried. +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 +describes what new members can do; existing participants keep their roles. The compact timeline +label says “This channel is read-only.” for Guest-default channels; a Guest in a +Member-default channel sees “Read-only access.” Guest-default previews never suggest +joining to add people or agents. + 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. @@ -776,8 +808,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 @@ -1011,18 +1043,42 @@ 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 / Guest, after explicit confirmation; Participating is already selected | 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. Participating and Viewing only 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 +signed metadata; an older relay that ignores the choice cannot report success. + +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. Nonmembers of a public Guest-default channel +get the same reading view without Join; opening message or thread links does not +create membership. 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. + +The session keeps membership-based reading/lifecycle access separate from writing +permission. Direct guest writes and uploads are refused, and queued writes are +checked again before signing and publication, including signed retries. The +relay's verified agent-owner edit/delete exception is preserved; it does not +permit ordinary posts or reactions. +Guests can search the member directory without invitation controls. Public +previews whose admission default is Guest also omit invitations; existing +writing members retain them independently of the default. 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/docs/relay-queries.md b/docs/relay-queries.md index 663c9b00f4..edb01cec08 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -361,7 +361,14 @@ channel through `refer`, as any channel-only link does. Raw list, their label follows the exact reference, so a rename or lost access shows at once rather than the directory's older name. -A public preview offers **Join channel**. It sends the NIP-29 `9021` join request +A Member-default public preview offers **Join channel**. Guest-default previews +keep history and thread links readable without Join or writing controls, and +opening a link creates no membership. After Leave, fresh signed public metadata +can restore preview reading while retained membership stays denied; stale or +private metadata cannot reopen reading. Missing defaults mean Member; malformed +explicit defaults mean Guest. + +Join sends the NIP-29 `9021` join request through the purpose-bound channel-lifecycle sign/publish route; generic signing rejects `9021`. Before signing, the lifecycle owner re-reads signed metadata and the viewer roster: a current roster entry needs no request, and only active public 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/app/shell/SearchResults.test.tsx b/src/app/shell/SearchResults.test.tsx index f2e7689694..90b8966880 100644 --- a/src/app/shell/SearchResults.test.tsx +++ b/src/app/shell/SearchResults.test.tsx @@ -62,6 +62,61 @@ afterEach(() => { Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); }); +it("keeps a joined private Guest channel in recents without calling it public or unjoined", async () => { + const relay = keypair(), + viewer = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + const discovery = [ + metadata(relay, "guest", "Private updates", 1700000000, [["private"]]), + signed(relay, { + kind: 39002, + content: "", + tags: [ + ["d", "guest"], + ["p", viewer.pubkey, "", "guest"], + ], + }), + ]; + const owner = createRelaySession({ + ...wire.transport, + query: async (filters) => + discovery.filter((event) => + filters.some((filter) => matchFilter(filter as Filter, event)), + ), + }); + const props = { + session: owner.session, + onQueryChange() {}, + input: createRef(), + pages: [], + openConversation: vi.fn(), + }; + try { + const mounted = render(); + const recent = await screen.findByRole("group", { + name: "Recent activity", + }); + expect( + within(recent).getByRole("option", { name: /Private updates/ }), + ).toBeVisible(); + mounted.rerender(); + expect( + await screen.findByRole("option", { name: /Private updates/ }), + ).toHaveTextContent("Conversation"); + expect( + screen.queryByText("Public channel · not joined"), + ).not.toBeInTheDocument(); + expect(owner.session.channels.get?.("guest")).toMatchObject({ + readOnly: true, + viewerRole: "guest", + visibility: "private", + }); + } finally { + cleanup(); + owner.dispose(); + } +}); + it("opens with a conversation action and recent channels in activity order", async () => { const relay = keypair(); const viewer = keypair(); diff --git a/src/app/shell/SearchResults.tsx b/src/app/shell/SearchResults.tsx index c598214b2a..7676621c97 100644 --- a/src/app/shell/SearchResults.tsx +++ b/src/app/shell/SearchResults.tsx @@ -778,7 +778,7 @@ export function SearchResults({ ? channel.channelType === "solo" ? "Archived chat" : "Archived channel" - : channel.readOnly && !channel.cached + : channel.readOnly && !channel.cached && channel.viewerRole !== "guest" ? "Public channel · not joined" : channel.channelType === "dm" ? "Direct message" @@ -907,7 +907,9 @@ export function SearchResults({ const recent: SearchDestination[] = channels .filter( (channel) => - !channel.readOnly && !channel.archived && !sidebarHidden(channel), + (!channel.readOnly || channel.viewerRole === "guest") && + !channel.archived && + !sidebarHidden(channel), ) .sort( (a, b) => diff --git a/src/bundled/channels/CanvasEditor.test.tsx b/src/bundled/channels/CanvasEditor.test.tsx index fdf94d0d42..66e90bebcc 100644 --- a/src/bundled/channels/CanvasEditor.test.tsx +++ b/src/bundled/channels/CanvasEditor.test.tsx @@ -544,9 +544,7 @@ it.each([false, true])( expect( screen.getByRole("button", { name: "Restore this version" }), ).toBeDisabled(); - expect( - screen.getByText("Archived channel. Canvas is read-only."), - ).toBeVisible(); + expect(screen.getByText("Canvas is read-only.")).toBeVisible(); expect(canvas.save).not.toHaveBeenCalled(); await user.click(screen.getByRole("tab", { name: "Edit" })); expect(text).toHaveValue(content); diff --git a/src/bundled/channels/CanvasEditor.tsx b/src/bundled/channels/CanvasEditor.tsx index f4fefe98d2..78b3c1c643 100644 --- a/src/bundled/channels/CanvasEditor.tsx +++ b/src/bundled/channels/CanvasEditor.tsx @@ -247,9 +247,7 @@ export function CanvasEditor({ const body = ( <>
- {readOnly && ( -

Archived channel. Canvas is read-only.

- )} + {readOnly &&

Canvas is read-only.

} - 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", @@ -255,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(); @@ -354,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, @@ -432,8 +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: "Copy channel id" })).toHaveFocus(); await user.tab(); expect(screen.getByRole("button", { name: "Leave channel" })).toHaveFocus(); @@ -613,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" }), @@ -623,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(); @@ -737,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, @@ -909,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( @@ -927,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), ); }); @@ -1104,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(); @@ -1561,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(); @@ -1646,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 a565638a6a..638cc940eb 100644 --- a/src/bundled/channels/ChannelDetailsEditor.tsx +++ b/src/bundled/channels/ChannelDetailsEditor.tsx @@ -18,14 +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 { DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS } from "../../features/relay/channel-writes"; import styles from "./Channels.module.css"; @@ -58,7 +52,6 @@ export function ChannelDetailsEditor({ capability, id, scope, - skipWarning: false, loading: true, editing: false, focusField: "name" as "name" | "description", @@ -67,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, @@ -82,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); @@ -91,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: @@ -121,22 +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, + visibility: base.visibility, + joinRole: base.joinRole, ttlSeconds: old.draft.ttlSeconds === old.baselineTtl ? base.ttlSeconds @@ -181,6 +158,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 ?? @@ -189,7 +167,6 @@ export function ChannelDetailsEditor({ DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS; const canSave = view.editing && - !view.privacyChoice && !confirmDiscard && !locked && canEdit && @@ -211,7 +188,6 @@ export function ChannelDetailsEditor({ patch({ editing: false, error: "", - privacyChoice: undefined, confirmDiscard: false, }); }; @@ -224,11 +200,11 @@ 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; - 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(); }; @@ -340,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" } @@ -393,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, - }); - }} - /> - - - )} @@ -471,13 +388,7 @@ export function ChannelDetailsEditor({ ) } > - {confirmDiscard ? null : view.privacyChoice ? ( - patch({ skipWarning })} - /> - ) : ( + {confirmDiscard ? null : (
{ + const h = harness(); + h.load.mockResolvedValue({ ...base, visibility: "public" }); + const state: ReturnType = { + status: "ready", + relayAvailable: true, + profile: { name: "Viewer", picture: "" }, + selected: "https://other.example", + sync: { known: [], outbox: [] }, + memberships: [ + { + id: "https://other.example", + name: "Other", + icon: "https://other.example/icon.png", + }, + { + id: "https://community.example", + name: "Community", + icon: "https://community.example/old.png", + publishedIcon: "https://community.example/current.png", + }, + ], + }; + const reader: CommunityReader = { + subscribe: () => () => {}, + snapshot: () => state, + }; + const view = render( + , + ); + if (!readOnly) + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Who can read" }), + ).toBeEnabled(), + ); + expect(view.container.querySelector("img")).toHaveAttribute( + "src", + "https://community.example/current.png", + ); + }, +); + +it.each(["public", "private"] as const)( + "shows verified %s access to readers without invoking the editing capability", + (visibility) => { + const h = harness(); + h.load.mockRejectedValue( + new Error("Channel access changed. Reopen channel settings."), + ); + render( + , + ); + expect( + screen.getByRole("region", { name: "General access" }), + ).toHaveTextContent( + visibility === "public" + ? "Anyone in the community can read." + : "Only channel members can read.", + ); + expect( + screen.getByRole("region", { name: "General access" }), + ).toHaveTextContent("Viewing only. Ask an owner or admin to participate."); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(h.load).not.toHaveBeenCalled(); + expect(h.save).not.toHaveBeenCalled(); + }, +); + +const base: ChannelDetails = { + channelId: "alpha", + version: "v1", + name: "Alpha", + description: "Description", + visibility: "public", + joinRole: "guest", + canEdit: true, +}; +function harness() { + let attempt: DetailsAttempt | undefined; + const listeners = new Set<() => void>(); + const load = vi.fn(async () => base); + const save = vi.fn(async () => {}); + const check = vi.fn(async () => {}); + const capability: ChannelDetailsCapability = { + available: true, + load, + save, + check, + snapshot: () => attempt, + subscribe: (fn) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + }; + return { + capability, + load, + save, + check, + setAttempt(next: DetailsAttempt | undefined) { + attempt = next; + for (const fn of listeners) fn(); + }, + }; +} +async function select( + user: ReturnType, + field: string, + option: string, +) { + const control = screen.getByRole("combobox", { name: field }); + await waitFor(() => expect(control).toBeEnabled()); + await user.click(control); + await user.click(await screen.findByRole("option", { name: option })); + return control; +} +it.each(["public", "private"] as const)( + "confirms %s reading access, cancels with focus return, and preserves individual/default roles", + async (visibility) => { + const h = harness(); + const current = { ...base, visibility }; + h.load.mockResolvedValue(current); + const user = userEvent.setup(); + render( + , + ); + expect( + await screen.findByText( + visibility === "public" + ? "Anyone in the community can find and view." + : "Only people you add can read this channel.", + ), + ).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?", + }); + expect(confirm).toHaveAccessibleDescription( + visibility === "public" + ? "Only channel members will have access." + : "Everyone in this community will be able to view this channel’s full history.", + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(), + ); + expect(h.save).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(control).toHaveFocus()); + expect(control).toHaveTextContent( + visibility === "public" ? "Public" : "Private", + ); + await select(user, "Who can read", option); + const next = { + ...current, + visibility: + visibility === "public" ? ("private" as const) : ("public" as const), + }; + h.load.mockResolvedValue(next); + await user.click(screen.getByRole("button", { name: "Change access" })); + await waitFor(() => expect(control).toHaveTextContent(option)); + expect(h.save).toHaveBeenCalledExactlyOnceWith( + current, + 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.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(); + render(); + await select(user, "New people can", "Participate"); + expect(screen.getByRole("alertdialog")).toHaveTextContent( + "People already in the channel keep their access.", + ); + h.load.mockResolvedValue({ ...base, joinRole: "member" }); + await user.click(screen.getByRole("button", { name: "Change access" })); + await waitFor(() => expect(h.save).toHaveBeenCalledOnce()); + expect(h.save).toHaveBeenCalledWith( + base, + { ...base, joinRole: "member" }, + expect.any(AbortSignal), + ); + expect( + screen.getByRole("combobox", { name: "Who can read" }), + ).toHaveTextContent("Public"); + expect( + 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 () => { + const h = harness(); + const current = { ...base, joinRole: undefined }; + h.load.mockResolvedValue(current); + h.save.mockRejectedValueOnce(new Error("Channel details changed")); + const user = userEvent.setup(); + render(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toBeEnabled(), + ); + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toHaveTextContent("Participate"); + expect(h.save).not.toHaveBeenCalled(); + await select(user, "New people can", "View only"); + await user.click(screen.getByRole("button", { name: "Change access" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Channel details changed", + ); + h.load.mockResolvedValue({ ...base, canEdit: false }); + await user.click(screen.getByRole("button", { name: "Reload access" })); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toHaveTextContent("View only"), + ); + expect( + screen.getByRole("combobox", { name: "New people can" }), + ).toBeDisabled(); + expect(h.save).toHaveBeenCalledOnce(); +}); +it("locks both choices while saving and recovers an uncertain save after remount without resending", async () => { + const h = harness(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + h.save.mockImplementationOnce(async () => { + await gate; + h.setAttempt({ + draft: { ...base, joinRole: "member" }, + status: "unconfirmed", + }); + throw new Error("Readback unavailable"); + }); + const user = userEvent.setup(); + const view = render( + , + ); + await select(user, "New people can", "Participate"); + try { + await user.click(screen.getByRole("button", { name: "Change access" })); + await waitFor(() => expect(h.save).toHaveBeenCalledOnce()); + expect(screen.getByRole("status", { name: "Saving access" })).toBeVisible(); + expect(screen.queryByText("Saving access…")).not.toBeInTheDocument(); + for (const control of screen.getAllByRole("combobox")) + expect(control).toBeDisabled(); + await waitFor(() => + expect( + screen.getByRole("region", { name: "General access" }), + ).toHaveFocus(), + ); + } finally { + await act(async () => { + release(); + await gate; + }); + } + await screen.findByRole("button", { name: "Check save status" }); + expect( + screen.queryByRole("status", { name: "Saving access" }), + ).not.toBeInTheDocument(); + view.unmount(); + render(); + const check = await screen.findByRole("button", { + name: "Check save status", + }); + await waitFor(() => expect(check).toBeEnabled()); + h.check.mockImplementationOnce(async () => { + h.setAttempt(undefined); + }); + await user.click(check); + await waitFor(() => + expect(h.check).toHaveBeenCalledExactlyOnceWith( + "alpha", + expect.any(AbortSignal), + ), + ); + expect(h.save).toHaveBeenCalledOnce(); +}); +it("retires an open confirmation and late reads when the destination changes", async () => { + const h = harness(); + const user = userEvent.setup(); + const view = render( + , + ); + await select(user, "Who can read", "Public"); + const pending = new Promise(() => {}); + h.load.mockReturnValueOnce(pending); + view.rerender( + , + ); + await waitFor(() => + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(), + ); + for (const control of screen.getAllByRole("combobox")) + expect(control).toBeDisabled(); + expect(h.save).not.toHaveBeenCalled(); +}); diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx new file mode 100644 index 0000000000..eb2a9a432e --- /dev/null +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -0,0 +1,401 @@ +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { ChannelDetailsCapability } from "../../features/relay/channel-details"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { + ChannelDetails, + ChannelDetailsDraft, +} from "../../features/relay/channel-details-protocol"; +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"; +import { communityDestination } from "../../features/communities/destination"; +import { + CircleNotchIcon, + LockIcon, + GlobeIcon, +} from "../../shared/design-system/icons"; +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. */ +export function ChannelGeneralAccess({ + communityReader, + communityScope, + capability, + channelId, + channel, +}: { + communityReader?: CommunityReader | undefined; + communityScope?: string | undefined; + capability: ChannelDetailsCapability; + channelId: string; + channel?: ChannelSummary; +}) { + const origin = communityScope ? communityFromScope(communityScope) : null; + const community = useSyncExternalStore( + communityReader?.subscribe ?? noCommunitySubscription, + () => + communityReader + ?.snapshot() + .memberships.find( + (entry) => communityDestination(entry.id).url === origin, + ), + ); + 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 ( +
+
+ +
+

+ {channel.visibility === "public" + ? "Anyone in the community can read." + : channel.visibility === "private" + ? "Only channel members can read." + : "Reading access could not be confirmed."} +

+

+ {channel.viewerRole === "guest" || channel.joinRole === "guest" + ? "Viewing only. Ask an owner or admin to participate." + : channel.joinRole === "member" + ? "New people can participate. People already in the channel keep their access." + : "New people’s access could not be confirmed."} +

+
+
+
+ ); + return ( + + ); +} + +function ChannelGeneralAccessEditor({ + capability, + channelId, + communityAvatar, +}: { + capability: ChannelDetailsCapability; + channelId: string; + communityAvatar: (size: "compact" | "default") => ReactNode; +}) { + const attempt = useSyncExternalStore(capability.subscribe, () => + capability.snapshot(channelId), + ); + const [base, setBase] = useState(); + const [choice, setChoice] = useState(); + const [loading, setLoading] = useState(true); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const lifetime = useRef(undefined); + const busy = useRef(false); + const origin = useRef(null); + const section = useRef(null); + const cancel = useRef(null); + const load = useCallback( + async (signal: AbortSignal) => { + setLoading(true); + setError(""); + try { + const details = await capability.load(channelId, signal); + if (!signal.aborted) setBase(details); + } catch (reason) { + if (!signal.aborted) { + setBase(undefined); + setError(reason instanceof Error ? reason.message : String(reason)); + } + } finally { + if (!signal.aborted) setLoading(false); + } + }, + [capability, channelId], + ); + useEffect(() => { + const controller = new AbortController(); + lifetime.current = controller; + setBase(undefined); + setChoice(undefined); + setPending(false); + busy.current = false; + void load(controller.signal); + return () => controller.abort(); + }, [load]); + const locked = loading || pending || !!attempt || !base?.canEdit; + function choose(patch: Partial, index: number) { + if (locked || !base) return; + origin.current = + section.current?.querySelectorAll('[role="combobox"]')[ + index + ] ?? null; + setError(""); + setChoice({ ...base, ...patch }); + } + async function apply(check = false) { + const controller = lifetime.current; + if ( + !controller || + busy.current || + (check ? attempt?.status !== "unconfirmed" : !base || !choice || locked) + ) + return; + busy.current = true; + setPending(true); + setError(""); + // The choices lock while saving; keep keyboard focus in the access region. + origin.current = section.current; + setChoice(undefined); + try { + if (check) await capability.check(channelId, controller.signal); + else if (base && choice) + await capability.save(base, choice, controller.signal); + if (!controller.signal.aborted) await load(controller.signal); + } catch (reason) { + if (!controller.signal.aborted) + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + if (!controller.signal.aborted) { + busy.current = false; + setPending(false); + } + } + } + return ( +
+
+ + {pending ? ( + +
+ { + if ( + (joinRole === "member" || joinRole === "guest") && + joinRole !== (base?.joinRole ?? "member") + ) + choose({ joinRole }, 1); + }} + /> +
+ )} +
+ {error && ( +

+ {error} +

+ )} + {attempt?.status === "unconfirmed" ? ( + <> +

+ The change may have been saved. Check its status before trying + again. Checking never resends it. +

+ + + ) : ( + error && ( + + ) + )} + {choice && base && ( + setChoice(undefined)} + initialFocus={cancel} + finalFocus={origin} + actions={ + <> + + + + } + > + {base.visibility === "private" && choice.visibility === "public" && ( + New people can} + nativeLabel={false} + > +