diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index b69e0d3b7a..168dcdd3ac 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -3291,6 +3291,24 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ ["name", "rename"], ], }, + { ...template, kind: 9002, tags: [["h", id], ["auto_join"]] }, + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["auto_join", "maybe"], + ], + }, + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["archived", "true"], + ["auto_join", "true"], + ], + }, { ...template, kind: 9022, @@ -3349,7 +3367,23 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ expect(restore).toMatchObject(unarchive); expect((await h.post("publish", restore)).status).toBe(400); await transport.channelLifecycle.publish(restore, signal); - expect(h.publications).toHaveLength(2); + for (const value of ["true", "false"]) { + const flag = { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["auto_join", value], + ], + }; + expect((await h.post("sign", flag)).status).toBe(400); + expect((await h.post("channel-details-sign", flag)).status).toBe(400); + const flagged = await transport.channelLifecycle.sign(flag, signal); + expect(verifyEvent(flagged)).toBe(true); + expect(flagged).toMatchObject(flag); + await transport.channelLifecycle.publish(flagged, signal); + } + expect(h.publications).toHaveLength(4); } finally { live?.dispose(); await h.close(); diff --git a/docs/channels.md b/docs/channels.md index 7038bf2ed2..cc62118723 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -562,16 +562,26 @@ the returned command, and confirms relay-owned state before removing a row. Arch confirmed Delete/Leave use the existing access-loss purge. Commands use narrow development-broker routes or the packaged native `relay_channel_sign` and `relay_channel_publish` commands, never the message outbox or automatic replay. +The same route carries the **Starter channel for new members** switch as two commands +without a confirmation dialog, `auto-join-on` and `auto-join-off`, each a `9002` +with `auto_join=true|false`. The lifecycle owner requires fresh relay metadata for an +active public stream channel before signing (channel roles cannot show community +ownership; the relay accepts the flag only from community owners and admins) and +confirms the result from the republished `39000`: the relay advertises the flag +as a bare `auto_join` tag only while it is in effect, so success is that tag's +presence or absence, not the written value. A published lifecycle command keeps its bounded, fresh, strong confirmation when access loss retires access-dependent work. Retired confirmation reads are reread; caller cancellation, session clear/disposal and the original deadline still abort its signal. A rejected publication or definitive confirmation-read failure still fails the command; an aborted read alone never confirms it. These signals do not forcibly interrupt an in-flight native publication IPC. -Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive) -or `archived=false` (Unarchive); other values, extra tags, cross-route commands and +Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive), +`archived=false` (Unarchive) or `auto_join=true|false` (the switch); other values, +extra tags, cross-route commands and altered or foreign signatures are rejected. Hosts without this capability display -an unavailable notice. Native Unarchive requires rebuilding/restarting the desktop +an unavailable notice. Native Unarchive and the auto-join switch require +rebuilding/restarting the desktop binary; updating the frontend alone does not update the Rust validator. Rust validator and production-IPC tests cover both values, but do not establish an installed desktop Archive → Unarchive round trip against a live relay; that @@ -642,6 +652,23 @@ row are omitted. 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. +**Starter channel for new members**, between Description and Members, is separate: an +immediate switch that publishes its own narrow `9002` (see +[Channel lifecycle](#channel-lifecycle)) and shows pending, the relay's refusal +or, when the change is not confirmed, a prompt to try again beside itself. Once the +channel's metadata shows the value that toggle wanted, the prompt clears, so trying +again cannot undo a change that landed. It appears only to community owners and admins +(the relay-signed kind `13534` roster) who are members of an active public stream +channel, not previewing it or a guest in it, and only once the channel is loaded +live rather than from cache. The relay checks the community role, not channel +membership, so the gate is display-only and narrower: an owner or admin must join +the channel to see the switch. +Archiving a starter channel or making it private hides the switch and pauses the +flag without clearing it: the relay stops advertising and applying it, refuses +to change it on a private channel, and resumes adding new members on Unarchive +or once the channel is public again. For a starter channel, the Archive and +**Change general access?** (to Private) confirmations say so, so an owner or +admin can turn the switch off first. **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 @@ -1245,6 +1272,26 @@ concurrently with the real connection handshake. Saved groups, stars and channel names are display data, not a confirmed preference mutation base. No cache means the ordinary cold connection flow; unavailable/corrupt storage never grants access. +With no saved selection, Messages opens the first channel the relay flags +`auto_join` (its starter channel for new members), then the first non-Solo +channel; an explicit empty route bypasses both. The relay adds people to its +`auto_join` channels itself, and the resulting membership hints populate the +sidebar live: + +- A community that requires relay membership adds a member once, when they join + its roster: an invite claim that answers `joined`, or an admin's kind `9030` + add. A reclaim answering `already_member` adds nothing, and existing members + are not added to a channel flagged later. +- An open community has no roster to join. After each human WebSocket AUTH + (agents are skipped) the relay adds the person to every `auto_join` channel + they have never been a member of, including channels flagged after they first + connected. + +Either way a later leave stands: the roster path does not run again for an +existing member, and on the open path a leave or removal keeps a membership +record, so the relay does not add the person back. The client only reads the +flag; it never sends a join on a member's behalf. + A cached roster can display previously downloaded, reverified history for up to 24 hours. It cannot authorize head/history reads, unread evidence, typing or publishing. Unconfirmed membership is not resaved with a fresh lease. An identical diff --git a/docs/communities.md b/docs/communities.md index 3e1f04ab6c..19f7f8e732 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -56,7 +56,12 @@ restore of the account's [known communities](#known-communities) joins each admitted destination through the same read and call. `Welcome` is also the second caller of the invite intent (`invite-intent.ts`) beside OS deep links: an address typed on the welcome screen that the restore did not admit opens -this dialog, prefilled, as the app opens. +this dialog, prefilled, as the app opens. The relay adds a member newly admitted +by an invite claim to the community's starter channels, so they are already in +the sidebar and Messages opens the first of them. An open community needs no +claim: there the relay adds each person, though not agents, to the starter +channels they have never been in each time they connect, so those channels +appear a moment later. See [local-first launch](channels.md#local-first-launch). diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs index 5a50f46ef1..24ec1aa51f 100644 --- a/src-tauri/src/relay/channel_writes.rs +++ b/src-tauri/src/relay/channel_writes.rs @@ -90,8 +90,14 @@ fn lifecycle(event: &EventTemplate) -> bool { { return false; } + // The narrow 9002 flips one relay-enforced flag: archive, or auto-join + // for community owners/admins. Generic metadata edits take the details route. tag(event, 0, "h").is_some_and(uuid) - && (event.kind != 9002 || matches!(tag(event, 1, "archived"), Some("true" | "false"))) + && (event.kind != 9002 + || matches!( + tag(event, 1, "archived").or_else(|| tag(event, 1, "auto_join")), + Some("true" | "false") + )) } fn archive(event: &EventTemplate) -> bool { if !matches!(event.kind, 9035 | 9036) @@ -480,12 +486,17 @@ mod boundary_tests { } } #[test] - fn lifecycle_accepts_only_exact_archive_values() { + fn lifecycle_accepts_only_exact_archive_and_auto_join_values() { + for flag in ["archived", "auto_join"] { + lifecycle_accepts_only_exact_flag_values(flag); + } + } + fn lifecycle_accepts_only_exact_flag_values(flag: &str) { let event = serde_json::json!({ "kind": 9002, "created_at": 1700000010, "content": "", - "tags": [["h", uuid::Uuid::nil().to_string()], ["archived", "false"]] + "tags": [["h", uuid::Uuid::nil().to_string()], [flag, "false"]] }); for value in ["true", "false"] { let mut command = event.clone(); @@ -510,23 +521,30 @@ mod boundary_tests { } for tags in [ serde_json::json!([["h", uuid::Uuid::nil().to_string()]]), - serde_json::json!([["h", uuid::Uuid::nil().to_string()], ["archived"]]), + serde_json::json!([["h", uuid::Uuid::nil().to_string()], [flag]]), serde_json::json!([ ["h", uuid::Uuid::nil().to_string()], - ["archived", "false", "extra"] + [flag, "false", "extra"] ]), serde_json::json!([ ["h", uuid::Uuid::nil().to_string()], - ["archived", "false"], - ["archived", "true"] + [flag, "false"], + [flag, "true"] ]), serde_json::json!([ ["h", uuid::Uuid::nil().to_string()], ["archived", "false"], + ["auto_join", "true"] + ]), + serde_json::json!([ + ["h", uuid::Uuid::nil().to_string()], + [flag, "false"], ["name", "injected"] ]), - serde_json::json!([["archived", "false"], ["h", uuid::Uuid::nil().to_string()]]), - serde_json::json!([["h", "not-a-channel"], ["archived", "false"]]), + serde_json::json!([[flag, "false"], ["h", uuid::Uuid::nil().to_string()]]), + serde_json::json!([["h", "not-a-channel"], [flag, "false"]]), + serde_json::json!([["h", uuid::Uuid::nil().to_string()], ["autojoin", "true"]]), + serde_json::json!([["h", uuid::Uuid::nil().to_string()], ["auto-join", "true"]]), ] { let mut command = event.clone(); command["tags"] = tags; @@ -537,14 +555,19 @@ mod boundary_tests { #[test] fn commands_reject_cross_route_and_foreign_signatures() { let host = IdentityHost::fixture(); - for (value, tampered) in [("true", "false"), ("false", "true")] { + for (flag, value, tampered) in [ + ("archived", "true", "false"), + ("archived", "false", "true"), + ("auto_join", "true", "false"), + ("auto_join", "false", "true"), + ] { let template = EventTemplate { kind: 9002, created_at: 1700000010, content: String::new(), tags: vec![ vec!["h".into(), uuid::Uuid::new_v4().to_string()], - vec!["archived".into(), value.into()], + vec![flag.into(), value.into()], ], }; let event = tauri::async_runtime::block_on(host.sign(template)).unwrap(); diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index a1477bdea7..07e7d7b6d0 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -138,6 +138,34 @@ it("keeps writes and consent closed when the fresh access read fails", async () expect(h.save).not.toHaveBeenCalled(); }); +it.each([ + { autoJoin: true, option: "Private", note: true }, + { autoJoin: true, option: "View only", note: false }, + { autoJoin: false, option: "Private", note: false }, +])( + "starter channel $autoJoin → $option says new members resume if made public: $note", + async ({ autoJoin, option, note }) => { + const h = harness(); + h.load.mockResolvedValue({ ...base, joinRole: "member" }); + const user = userEvent.setup(); + render( + , + ); + await select(user, "General access", option); + const starter = + "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first."; + const confirm = await screen.findByRole("alertdialog", { + name: "Change general access?", + }); + if (note) expect(confirm).toHaveTextContent(starter); + else expect(confirm).not.toHaveTextContent(starter); + }, +); + const policies = [ { visibility: "private", joinRole: "member", label: "Private" }, { visibility: "private", joinRole: "guest", label: "Private" }, diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index 692be7792f..b44c64643c 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -101,6 +101,7 @@ export function ChannelGeneralAccess({ recovery={recovery} channelId={channelId} mayHaveGuestMembers={mayHaveGuestMembers} + autoJoin={channel?.autoJoin ?? false} /> ); } @@ -110,11 +111,13 @@ function ChannelGeneralAccessEditor({ recovery, channelId, mayHaveGuestMembers, + autoJoin, }: { capability: ChannelDetailsCapability; recovery: RelayRecovery | undefined; channelId: string; mayHaveGuestMembers: boolean; + autoJoin: boolean; }) { const attempt = useSyncExternalStore(capability.subscribe, () => capability.snapshot(channelId), @@ -352,6 +355,10 @@ function ChannelGeneralAccessEditor({ choice.joinRole === "guest" ? "People already in the chat keep their access." : "", + // The relay keeps the flag but refuses changing it on a private channel. + choice.visibility === "private" && autoJoin + ? "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first." + : "", !solo && choice.visibility === "public" ? `New people will ${choice.joinRole === "guest" ? "only be able to view" : "be able to participate"}. People already in the ${place} keep their access.` : "", diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx index f071a2ed92..5d55cfd644 100644 --- a/src/bundled/channels/ChannelLifecycleDialog.tsx +++ b/src/bundled/channels/ChannelLifecycleDialog.tsx @@ -81,6 +81,7 @@ export function ChannelLifecycleDialog({ finalFocus, confirmed = false, undo, + autoJoin = false, }: { channelId: string; channelName: string; @@ -96,6 +97,8 @@ export function ChannelLifecycleDialog({ confirmed?: boolean; /** Offered from an immediate archive's result toast. */ undo?(): void; + /** A starter channel: the relay keeps the flag while archived. */ + autoJoin?: boolean; }) { const callbacks = useRef({ completed, failed }); useLayoutEffect(() => { @@ -105,6 +108,11 @@ export function ChannelLifecycleDialog({ const confirm = useRef(null); const confirms = reversible.has(action); const copy = copyFor(action, noun); + // The switch is hidden while archived, and Unarchive applies the flag again. + const description = + action === "archive" && autoJoin + ? `${copy.detail} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn off Starter channel for new members in Settings first.` + : copy.detail; const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const notify = useToastNotification(); @@ -191,7 +199,7 @@ export function ChannelLifecycleDialog({ ? `Delete ${channelName}?` : `${copy.title}: ${channelName}` } - description={copy.detail} + description={description} actions={ <>