From f761a3579acf6bb2e4c5c95e4fce2bc511c9d10d Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 13:37:01 -0500 Subject: [PATCH 1/2] Let community admins set default channels Members -> General access gets a Default channel switch for community owners and admins on open, unarchived stream channels. It sends the relay's narrow kind:9002 auto_join command on its own and changes only after the channel's kind:39000 readback confirms it. Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- docs/buzz-help.md | 3 + docs/channels.md | 17 ++ src-tauri/src/relay/channel_writes.rs | 61 +++++++ .../channels/ChannelGeneralAccess.test.tsx | 103 +++++++++++ src/bundled/channels/ChannelGeneralAccess.tsx | 61 +++++-- src/bundled/channels/ChannelMembersDialog.tsx | 2 + .../relay/channel-details-protocol.ts | 76 +++++++- src/features/relay/channel-details.test.ts | 172 ++++++++++++++++++ src/features/relay/channel-details.ts | 13 +- 9 files changed, 494 insertions(+), 14 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index caa068d50e..cdcc34eb43 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -158,6 +158,9 @@ that agent in Buzz; they do not notify the external app through this connection. Changing this choice does not change existing members' roles. Ordinary participants have no role caption; Owner, Admin, and Viewing only mark exceptions. Use a person's actions menu to change their role when permitted. + Community owners and admins also see **Default channel** on open channels: + when it is on, new community members join that channel automatically, with + the role the General access choice gives new people. - **Message search** treats the last word you type as the start of a word, so it searches only once that word has at least three characters, even with `from:` or `in:`. A shorter last word shows how many characters to type, not diff --git a/docs/channels.md b/docs/channels.md index 9c9dab1c7f..52fa79c88b 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -798,6 +798,23 @@ 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. +Community owners and admins, as the relay-signed community roster names them, +also see a **Default channel** switch below the policy row on open, unarchived +stream channels (not Solo chats). When on, the relay joins every new community +member to the channel with its admission role, which the switch description +names (`NOSTR.md` § Default channels in block/buzz). Channel edit rights are not +required: the relay checks the community role. The switch saves immediately, +without a confirmation, as the exact two-tag `9002` command `[h, auto_join]` +with `true` or `false`; it never travels with other details, and both hosts' +channel-details validators admit that shape. The checked state is the bare +`auto_join` tag of fresh signed metadata, which the relay publishes only while +the setting is in effect, so it never shows optimistic success. Pending, +uncertain and rejected saves share the General access live region and recovery +above. Making a default channel private or archiving it leaves the relay's stored +setting in place; to clear it, switch it off while the channel is open. Readers' +previews show no switch, and a community admin must open the channel's members +dialog to change it. + For private channels, 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, diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs index 5a50f46ef1..0e0001f2c9 100644 --- a/src-tauri/src/relay/channel_writes.rs +++ b/src-tauri/src/relay/channel_writes.rs @@ -20,6 +20,11 @@ fn common(event: &EventTemplate) -> bool { event.content.is_empty() && event.created_at <= i64::MAX as u64 } fn details(event: &EventTemplate) -> bool { + // The default-channel setting travels alone: `[h, auto_join=true|false]`. + if event.kind == 9002 && common(event) && event.tags.len() == 2 { + return tag(event, 0, "h").is_some_and(uuid) + && matches!(tag(event, 1, "auto_join"), Some("true" | "false")); + } if event.kind != 9002 || !common(event) || !(3..=14).contains(&event.tags.len()) { return false; } @@ -630,6 +635,62 @@ mod boundary_tests { } } + #[test] + fn details_admit_only_the_exact_default_channel_command() { + let host = IdentityHost::fixture(); + let channel = || vec!["h".to_string(), uuid::Uuid::nil().to_string()]; + let command = |tags: Vec>| EventTemplate { + kind: 9002, + created_at: 1700000010, + content: String::new(), + tags, + }; + for value in ["true", "false"] { + let template = command(vec![channel(), vec!["auto_join".into(), value.into()]]); + let event = serde_json::to_value(&template).unwrap(); + assert!(validate("channel-details", &event, None).is_ok()); + assert!(validate("channel-lifecycle", &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_ok()); + let mut altered = signed; + altered["tags"][1][1] = + Value::String(if value == "true" { "false" } else { "true" }.into()); + assert!(validate("channel-details", &altered, altered["pubkey"].as_str()).is_err()); + } + for tags in [ + vec![channel(), vec!["auto_join".into()]], + vec![channel(), vec!["auto_join".into(), "".into()]], + vec![channel(), vec!["auto_join".into(), "yes".into()]], + vec![ + channel(), + vec!["auto_join".into(), "true".into(), "extra".into()], + ], + vec![vec!["auto_join".into(), "true".into()], channel()], + vec![ + vec!["h".into(), "not-a-channel".into()], + vec!["auto_join".into(), "true".into()], + ], + vec![ + channel(), + vec!["auto_join".into(), "true".into()], + vec!["auto_join".into(), "true".into()], + ], + vec![channel(), vec!["name".into(), "Team".into()]], + ] { + assert!( + !valid("channel-details", &command(tags.clone())), + "{tags:?}" + ); + } + // The setting never rides along with other channel details. + let mut combined = details_event(vec![vec!["auto_join".into(), "true".into()]]); + assert!(!valid("channel-details", &combined)); + combined.content = "x".into(); + combined.tags.truncate(1); + combined.tags.push(vec!["auto_join".into(), "true".into()]); + assert!(!valid("channel-details", &combined)); + } + #[test] fn details_reject_huddle_markers_at_sign_and_publish_boundaries() { let mut event = details_event(vec![]); diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 717ef30c8f..58e0da7712 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -516,3 +516,106 @@ it.each([true, false])( expect(h.save).not.toHaveBeenCalled(); }, ); + +const relay = "e".repeat(64); +const viewer = "a".repeat(64); +function roster(role: "owner" | "admin" | "member") { + return { + relayAuthor: relay, + read: vi.fn(async () => [ + { + id: "1".repeat(64), + kind: 13534, + pubkey: relay, + created_at: 1, + content: "", + sig: "2".repeat(128), + tags: [["member", viewer, role]], + }, + ]), + }; +} + +it("lets a community admin without channel edit rights choose a default channel, only confirming the relay readback", async () => { + const h = harness(); + const current: ChannelDetails = { + ...base, + joinRole: "member", + canEdit: false, + autoJoin: false, + }; + h.load.mockResolvedValue(current); + let finish = () => {}; + h.save.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)), + ); + const user = userEvent.setup(); + render( + , + ); + const setting = await screen.findByRole("switch", { + name: "Default channel", + }); + expect( + screen.getByText("New community members join automatically as members."), + ).toBeInTheDocument(); + expect(setting).not.toBeChecked(); + // Channel edit rights still gate the access policy itself. + expect( + screen.getByRole("combobox", { name: "General access" }), + ).toBeDisabled(); + h.load.mockResolvedValue({ ...current, autoJoin: true }); + setting.focus(); + await user.keyboard(" "); + await waitFor(() => expect(h.save).toHaveBeenCalledOnce()); + expect(h.save).toHaveBeenCalledWith( + current, + { ...current, autoJoin: true }, + expect.any(AbortSignal), + ); + // Pending: no optimistic success, and no second submission. + expect(setting).not.toBeChecked(); + expect(setting).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("status")).toHaveTextContent( + "Saving default channel…", + ); + await user.click(setting); + expect(h.save).toHaveBeenCalledOnce(); + await act(async () => finish()); + await waitFor(() => expect(setting).toBeChecked()); + expect(screen.getByRole("status")).toHaveTextContent("Access updated"); +}); + +it.each([ + { role: "member", details: { autoJoin: false } }, + { role: "admin", details: { autoJoin: undefined } }, + { role: "owner", details: { autoJoin: false, labels: ["buzz.solo"] } }, +] as const)( + "omits the default channel setting for a community $role on $details", + async ({ role, details }) => { + const h = harness(); + const reader = roster(role); + h.load.mockResolvedValue({ ...base, ...details }); + render( + , + ); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "General access" }), + ).toBeEnabled(), + ); + await waitFor(() => expect(reader.read).toHaveBeenCalled()); + await act(async () => {}); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + }, +); diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index 8142afbf69..190f8f8ed3 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -9,13 +9,17 @@ import type { ChannelDetailsCapability } from "../../features/relay/channel-deta import type { ChannelSummary } from "../../features/relay/contracts"; import type { RelayRecovery } from "../../features/relay/recovery"; import { useReauthorizedRetry } from "../../features/relay/RecoveryNotice"; -import type { - ChannelDetails, - ChannelDetailsDraft, +import { + changesDefault, + type ChannelDetails, + type ChannelDetailsDraft, } from "../../features/relay/channel-details-protocol"; +import type { RosterReader } from "../../features/communities/roster"; +import { useCommunityRole } from "../../features/communities/useCommunityRole"; 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 { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { CircleNotchIcon, @@ -37,13 +41,16 @@ import { } from "../../features/channels/general-access"; /** General access uses the same signed save/readback owner as channel details. - * Each policy is confirmed once, like the individual member roles. */ + * Each policy is confirmed once, like the individual member roles. Community + * owners/admins also choose whether new community members join it. */ export function ChannelGeneralAccess({ capability, recovery, channelId, channel, mayHaveGuestMembers = true, + roster, + viewer, }: { capability: ChannelDetailsCapability; recovery?: RelayRecovery | undefined; @@ -51,6 +58,9 @@ export function ChannelGeneralAccess({ channel?: ChannelSummary; /** False only when the signed member-role snapshot verifies no Guests. */ mayHaveGuestMembers?: boolean; + /** The community roster offers the default-channel setting to its managers. */ + roster?: RosterReader | undefined; + viewer?: string | undefined; }) { const place = channel?.channelType === "solo" ? "chat" : "channel"; const Icon = @@ -101,6 +111,8 @@ export function ChannelGeneralAccess({ recovery={recovery} channelId={channelId} mayHaveGuestMembers={mayHaveGuestMembers} + roster={roster} + viewer={viewer} /> ); } @@ -110,11 +122,15 @@ function ChannelGeneralAccessEditor({ recovery, channelId, mayHaveGuestMembers, + roster, + viewer, }: { capability: ChannelDetailsCapability; recovery: RelayRecovery | undefined; channelId: string; mayHaveGuestMembers: boolean; + roster: RosterReader | undefined; + viewer: string | undefined; }) { const attempt = useSyncExternalStore(capability.subscribe, () => capability.snapshot(channelId), @@ -165,7 +181,9 @@ function ChannelGeneralAccessEditor({ void load(controller.signal); return () => controller.abort(); }, [load]); - const locked = loading || pending || !!attempt || !base?.canEdit; + const community = useCommunityRole(roster, viewer); + const settling = loading || pending || !!attempt; + const locked = settling || !base?.canEdit; const solo = soloLabels(base?.labels); const place = solo ? "chat" : "channel"; const access = base ? generalAccessValue(base) : ""; @@ -189,26 +207,36 @@ function ChannelGeneralAccessEditor({ ...generalAccessPolicy(option.value, base.joinRole), }); } - async function apply(check = false) { + async function apply(check = false, change = choice) { const controller = lifetime.current; + // The relay checks the community role a default-channel change needs. + const toggled = !!base && !!change && changesDefault(change, base); if ( !controller || busy.current || - (check ? attempt?.status !== "unconfirmed" : !base || !choice || locked) + (check + ? attempt?.status !== "unconfirmed" + : !base || !change || (toggled ? settling : locked)) ) return; - if (check) section.current?.focus(); + if (check || toggled) section.current?.focus(); busy.current = true; setPending(true); - setStatus(check ? "Checking save status…" : "Saving access…"); + setStatus( + check + ? "Checking save status…" + : toggled + ? "Saving default channel…" + : "Saving access…", + ); 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); + else if (base && change) + await capability.save(base, change, controller.signal); if (!controller.signal.aborted) { setStatus("Access updated"); await load(controller.signal); @@ -317,6 +345,17 @@ function ChannelGeneralAccessEditor({ /> + {base?.autoJoin !== undefined && !solo && community.manager && ( + + void apply(false, { ...base, autoJoin: checked }) + } + /> + )} {choice && base && ( ; export type ChannelDetails = ChannelDetailsDraft & Readonly<{ @@ -87,6 +90,10 @@ export function detailsDraftErrors(draft: ChannelDetailsDraft) { draft.joinRole !== "guest" ? "New participant role could not be verified." : undefined, + autoJoin: + draft?.autoJoin !== undefined && typeof draft.autoJoin !== "boolean" + ? "Default channel setting could not be verified." + : undefined, }; } @@ -118,7 +125,8 @@ export function validateDetailsDraft(draft: ChannelDetailsDraft): void { errors.visibility ?? errors.lifetime ?? errors.labels ?? - errors.joinRole; + errors.joinRole ?? + errors.autoJoin; if (error) throw new Error(error); } @@ -145,6 +153,7 @@ export function detailsSettings( const ttlSeconds = parseDetailsTtl(ttl); if (!name || !visibility) throw new Error("Current channel details could not be verified."); + const archived = exactLifecycleTag(metadata, "archived") === "true"; const details: ChannelDetails = Object.freeze({ channelId: id, version: metadata.id, @@ -154,20 +163,58 @@ export function detailsSettings( ...(joinRole ? { joinRole } : {}), ...(ttlSeconds !== undefined ? { ttlSeconds } : {}), labels: channelIdentity(metadata).labels, + // The relay publishes a bare tag only while the setting is effective. + ...(authority.channelType === "stream" && + visibility === "public" && + !archived + ? { autoJoin: metadata.tags.some((tag) => tag[0] === "auto_join") } + : {}), canEdit: authority.channelType !== "dm" && - exactLifecycleTag(metadata, "archived") !== "true" && + !archived && (role === "owner" || role === "admin"), }); return { details, metadata }; } +/** Only a community owner/admin may set the default, possibly without channel + * edit rights, so it is sent alone rather than with the channel's details. */ +export function changesDefault( + draft: ChannelDetailsDraft, + base: ChannelDetailsDraft, +) { + return draft.autoJoin !== undefined && draft.autoJoin !== base.autoJoin; +} + export function detailsTemplate( id: string, draft: ChannelDetailsDraft, base: ChannelDetailsDraft, ): EventTemplate { validateDetailsDraft(draft); + if (changesDefault(draft, base)) { + if (base.autoJoin === undefined) + throw new Error("This channel cannot be a default channel."); + if ( + draft.name !== base.name || + draft.description !== base.description || + draft.visibility !== base.visibility || + draft.ttlSeconds !== base.ttlSeconds || + (draft.joinRole !== undefined && draft.joinRole !== base.joinRole) || + (draft.labels !== undefined && + !sameLabels(draft.labels, base.labels ?? [])) + ) + throw new Error("Change the default channel setting on its own."); + return { + kind: 9002, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["h", lifecycleChannelId(id)], + ["auto_join", draft.autoJoin ? "true" : "false"], + ], + }; + } return { kind: 9002, created_at: Math.floor(Date.now() / 1000), @@ -249,6 +296,7 @@ function parseDetailsTtl(value: string | undefined): number | undefined { /** Separate from lifecycle and outbox admission. Admits only explicit open/private visibility. */ export function validateDetailsTemplate(event: EventTemplate): void { + if (defaultTemplate(event)) return; if ( event?.kind !== 9002 || event.content !== "" || @@ -299,3 +347,27 @@ export function validateDetailsTemplate(event: EventTemplate): void { ...(event.tags.some(([key]) => key === "t") ? { labels } : {}), }); } + +/** The exact two-tag default-channel command `[h, auto_join=true|false]`. */ +function defaultTemplate(event: EventTemplate) { + if ( + event?.kind !== 9002 || + event.content !== "" || + !Number.isSafeInteger(event.created_at) || + event.created_at < 0 || + !Array.isArray(event.tags) || + event.tags.length !== 2 || + event.tags[1]?.[0] !== "auto_join" + ) + return false; + const [channel, setting] = event.tags; + if ( + channel?.length !== 2 || + channel[0] !== "h" || + setting?.length !== 2 || + (setting[1] !== "true" && setting[1] !== "false") + ) + throw new Error("Invalid channel details command."); + lifecycleChannelId(channel[1] ?? ""); + return true; +} diff --git a/src/features/relay/channel-details.test.ts b/src/features/relay/channel-details.test.ts index 88edc4fe65..b2478ebc41 100644 --- a/src/features/relay/channel-details.test.ts +++ b/src/features/relay/channel-details.test.ts @@ -1157,3 +1157,175 @@ it("keeps a region label when an editor replaces the label set with empty", asyn await h.owner.capability.save(base, { ...base, labels: [] }); expect((await h.owner.capability.load(id)).labels).toEqual([region]); }); + +it("sets and clears a default channel with the exact narrow command, without channel edit rights, and requires the relay readback", async () => { + const h = harness("member"); + const flagged = () => + h.record(39000, [...h.metadata().tags.slice(1), ["auto_join"]]); + const base = await h.owner.capability.load(id); + expect(base).toMatchObject({ autoJoin: false, canEdit: false }); + // A relay OK that leaves the setting unchanged must not report success. + h.publish.mockImplementationOnce(async () => {}); + await expect( + h.owner.capability.save(base, { ...base, autoJoin: true }), + ).rejects.toThrow("may have been saved"); + expect(h.sign.mock.calls[0]?.[0].tags).toEqual([ + ["h", id], + ["auto_join", "true"], + ]); + h.set([flagged(), ...h.events().slice(1)]); + await h.owner.capability.check(id); + expect(h.owner.capability.snapshot(id)).toBeUndefined(); + const current = await h.owner.capability.load(id); + expect(current.autoJoin).toBe(true); + h.publish.mockImplementationOnce(async () => { + h.set([h.metadata(), ...h.events().slice(1)]); + }); + await h.owner.capability.save(current, { ...current, autoJoin: false }); + expect(h.sign.mock.calls[1]?.[0].tags).toEqual([ + ["h", id], + ["auto_join", "false"], + ]); + expect(h.owner.capability.snapshot(id)).toBeUndefined(); + // The setting never rides along with other details. + const cleared = await h.owner.capability.load(id); + await expect( + h.owner.capability.save(cleared, { + ...cleared, + name: "renamed", + autoJoin: true, + }), + ).rejects.toThrow("Change the default channel setting on its own."); + expect(h.sign).toHaveBeenCalledTimes(2); + expect(h.publish).toHaveBeenCalledTimes(2); +}); + +it.each([ + { + case: "private", + tags: [ + ["name", "original"], + ["about", ""], + ["private"], + ["t", "stream"], + ["auto_join"], + ], + }, + { + case: "archived", + tags: [ + ["name", "original"], + ["about", ""], + ["public"], + ["t", "stream"], + ["archived", "true"], + ], + }, + { + case: "forum", + tags: [["name", "original"], ["about", ""], ["public"], ["t", "forum"]], + }, +])( + "offers no default channel setting on a $case channel and refuses to send one", + async ({ tags }) => { + const h = harness(); + h.set([h.record(39000, tags), ...h.events().slice(1)]); + const base = await h.owner.capability.load(id); + expect(base.autoJoin).toBeUndefined(); + await expect( + h.owner.capability.save(base, { ...base, autoJoin: true }), + ).rejects.toThrow("permission"); + expect(h.sign).not.toHaveBeenCalled(); + }, +); + +it("makes a default channel private without sending or confirming the default setting", async () => { + const h = harness(); + h.set([ + h.record(39000, [...h.metadata().tags.slice(1), ["auto_join"]]), + ...h.events().slice(1), + ]); + const base = await h.owner.capability.load(id); + expect(base.autoJoin).toBe(true); + // The relay keeps the stored flag but stops advertising it while private. + await h.owner.capability.save(base, { ...base, ...draft }); + const command = h.sign.mock.calls[0]?.[0]; + expect(command?.tags.some(([key]) => key === "auto_join")).toBe(false); + expect(h.owner.capability.snapshot(id)).toBeUndefined(); + expect((await h.owner.capability.load(id)).autoJoin).toBeUndefined(); +}); + +it("admits only the exact two-tag default channel command at the host boundary", () => { + const command = (tags: string[][]) => ({ + kind: 9002, + created_at: 1, + content: "", + tags, + }); + for (const value of ["true", "false"]) + expect(() => + validateDetailsTemplate( + command([ + ["h", id], + ["auto_join", value], + ]), + ), + ).not.toThrow(); + for (const tags of [ + [["h", id], ["auto_join"]], + [ + ["h", id], + ["auto_join", ""], + ], + [ + ["h", id], + ["auto_join", "yes"], + ], + [ + ["h", id], + ["auto_join", "true", "extra"], + ], + [ + ["auto_join", "true"], + ["h", id], + ], + [ + ["h", "not-a-channel"], + ["auto_join", "true"], + ], + [ + ["h", id], + ["auto_join", "true"], + ["auto_join", "true"], + ], + [ + ["h", id], + ["name", "Team"], + ["about", ""], + ["auto_join", "true"], + ], + ]) + expect( + () => validateDetailsTemplate(command(tags)), + JSON.stringify(tags), + ).toThrow(); + expect(() => + validateDetailsTemplate({ + ...command([ + ["h", id], + ["auto_join", "true"], + ]), + content: "x", + }), + ).toThrow(); + expect( + detailsTemplate( + id, + { name: "a", description: "", visibility: "public", autoJoin: true }, + { name: "a", description: "", visibility: "public", autoJoin: false }, + ).tags, + ).toEqual([ + ["h", id], + ["auto_join", "true"], + ]); +}); diff --git a/src/features/relay/channel-details.ts b/src/features/relay/channel-details.ts index 21c0a56735..94eabb54a9 100644 --- a/src/features/relay/channel-details.ts +++ b/src/features/relay/channel-details.ts @@ -7,6 +7,7 @@ import type { RelayEvent } from "./events"; import { PublishRejected } from "./outbox"; import { lifecycleChannelId } from "./channel-lifecycle-protocol"; import { + changesDefault, detailsSettings, detailsTemplate, sameLabels, @@ -124,6 +125,10 @@ export function createChannelDetails({ details.visibility !== draft.visibility || details.ttlSeconds !== draft.ttlSeconds || (draft.joinRole !== undefined && details.joinRole !== draft.joinRole) || + // A channel made private no longer reports its default setting. + (draft.autoJoin !== undefined && + details.autoJoin !== undefined && + details.autoJoin !== draft.autoJoin) || (draft.labels !== undefined && JSON.stringify([...new Set(draft.labels)].sort()) !== JSON.stringify([...(details.labels ?? [])].sort())) @@ -180,6 +185,7 @@ export function createChannelDetails({ if (attempts.has(id)) throw new Error("Check the previous change before saving again."); validateDetailsDraft(input); + const defaultChange = changesDefault(input, base); // Renaming a Solo chat records that its title is chosen, not automatic. const chosenName = soloLabels(base.labels) && @@ -224,7 +230,12 @@ export function createChannelDetails({ async (signal) => { const authorize = async () => { const { details } = await read(id, signal); - if (!details.canEdit) + // The relay decides the community owner/admin role a default needs. + if ( + defaultChange + ? details.autoJoin === undefined + : !details.canEdit + ) throw new Error( "You no longer have permission to edit this channel.", ); From 3b74e39ed5f9d31bcda94dad059ddafe68cb12a8 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 13:58:56 -0500 Subject: [PATCH 2/2] Confirm default channel changes only on exact readback A default-channel attempt now remembers that it is one and confirms only when the readback shows the requested value; archived and private metadata keep the change uncertain. A failed community role read shows an error with a retry instead of hiding the setting silently. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist --- .../channels/ChannelGeneralAccess.test.tsx | 28 ++++++++++++++++ src/bundled/channels/ChannelGeneralAccess.tsx | 17 ++++++++++ src/features/relay/channel-details.test.ts | 32 +++++++++++++++++++ src/features/relay/channel-details.ts | 26 +++++++++------ 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 58e0da7712..ce67b10ef0 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -619,3 +619,31 @@ it.each([ expect(screen.queryByRole("switch")).not.toBeInTheDocument(); }, ); + +it("explains a failed community role read beside the setting and retries it in place", async () => { + const h = harness(); + const reader = roster("admin"); + reader.read.mockRejectedValueOnce(new Error("offline")); + h.load.mockResolvedValue({ ...base, autoJoin: false }); + const user = userEvent.setup(); + render( + , + ); + expect( + await screen.findByText(/Couldn’t check your community role/), + ).toBeInTheDocument(); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect( + await screen.findByRole("switch", { name: "Default channel" }), + ).not.toBeChecked(); + expect( + screen.queryByText(/Couldn’t check your community role/), + ).not.toBeInTheDocument(); + expect(reader.read).toHaveBeenCalledTimes(2); +}); diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index 190f8f8ed3..097d66f749 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -345,6 +345,23 @@ function ChannelGeneralAccessEditor({ /> + {base?.autoJoin !== undefined && + !solo && + !community.manager && + community.readError && ( +
+

+ Couldn’t check your community role, so the default channel + setting is unavailable. +

+ +
+ )} {base?.autoJoin !== undefined && !solo && community.manager && ( { + const h = harness("member"); + const tags = h.metadata().tags.slice(1); + const flagged = h.record(39000, [...tags, ["auto_join"]]); + const archived = h.record(39000, [...tags, ["archived", "true"]]); + h.set([flagged, ...h.events().slice(1)]); + const base = await h.owner.capability.load(id); + // Archived before the relay applied the change: nothing confirms it. + h.publish.mockImplementationOnce(async () => { + h.set([archived, ...h.events().slice(1)]); + }); + await expect( + h.owner.capability.save(base, { ...base, autoJoin: false }), + ).rejects.toThrow("may have been saved"); + await expect(h.owner.capability.check(id)).rejects.toThrow( + "may have been saved", + ); + expect(h.owner.capability.snapshot(id)).toMatchObject({ + status: "unconfirmed", + defaultChange: true, + }); + // Unarchived with the flag still stored: the change did not take. + h.set([flagged, ...h.events().slice(1)]); + await expect(h.owner.capability.check(id)).rejects.toThrow( + "may have been saved", + ); + h.set([h.metadata(), ...h.events().slice(1)]); + await h.owner.capability.check(id); + expect(h.owner.capability.snapshot(id)).toBeUndefined(); + expect(h.publish).toHaveBeenCalledOnce(); +}); + it.each([ { case: "private", diff --git a/src/features/relay/channel-details.ts b/src/features/relay/channel-details.ts index 94eabb54a9..b89c235cce 100644 --- a/src/features/relay/channel-details.ts +++ b/src/features/relay/channel-details.ts @@ -21,6 +21,8 @@ import { SOLO_NAMED_LABEL, soloLabels } from "../solo/labels"; export type DetailsAttempt = Readonly<{ draft: ChannelDetailsDraft; status: "saving" | "unconfirmed"; + /** A default-channel command confirms only when the readback shows it. */ + defaultChange?: true; }>; export interface ChannelDetailsCapability { readonly available: boolean; @@ -112,7 +114,7 @@ export function createChannelDetails({ } async function confirm( id: string, - draft: ChannelDetailsDraft, + { draft, defaultChange }: DetailsAttempt, signal: AbortSignal, ) { const { details, metadata } = await read(id, signal); @@ -125,10 +127,9 @@ export function createChannelDetails({ details.visibility !== draft.visibility || details.ttlSeconds !== draft.ttlSeconds || (draft.joinRole !== undefined && details.joinRole !== draft.joinRole) || - // A channel made private no longer reports its default setting. - (draft.autoJoin !== undefined && - details.autoJoin !== undefined && - details.autoJoin !== draft.autoJoin) || + // Other saves never send the setting. Archived and private channels + // keep it without reporting it, so missing state proves nothing. + (defaultChange && details.autoJoin !== draft.autoJoin) || (draft.labels !== undefined && JSON.stringify([...new Set(draft.labels)].sort()) !== JSON.stringify([...(details.labels ?? [])].sort())) @@ -168,7 +169,7 @@ export function createChannelDetails({ const attempt = attempts.get(id); if (attempt?.status !== "unconfirmed") throw new Error("No unconfirmed change to check."); - await confirm(id, attempt.draft, signal); + await confirm(id, attempt, signal); }, caller, ); @@ -220,9 +221,15 @@ export function createChannelDetails({ !sameLabels(draft.labels, base.labels ?? []) ) throw channelIdentityUnsupported(); + const attempt = (status: DetailsAttempt["status"]): DetailsAttempt => + Object.freeze({ + draft, + status, + ...(defaultChange ? { defaultChange } : {}), + }); let publicationStarted = false; let phase = "Could not verify current channel access"; - attempts.set(id, Object.freeze({ draft, status: "saving" })); + attempts.set(id, attempt("saving")); emit(); try { await owned( @@ -266,14 +273,13 @@ export function createChannelDetails({ throw new DOMException("Channel edit cancelled", "AbortError"); publicationStarted = true; await writer.publish(signed, signal); - await confirm(id, draft, signal); + await confirm(id, attempt("saving"), signal); }, caller, ); } catch (error) { if (publicationStarted && !(error instanceof PublishRejected)) { - if (!closed) - attempts.set(id, Object.freeze({ draft, status: "unconfirmed" })); + if (!closed) attempts.set(id, attempt("unconfirmed")); throw new Error(uncertain); } attempts.delete(id);