From 8da7ec3057f0f27bfa6bd046e9f22fafbf17754c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 12:07:43 +1100 Subject: [PATCH 01/24] feat(relay): parse the relay-advertised auto_join channel flag Add `ChannelSummary.autoJoin?: true`, set from the bare relay-authored `["auto_join"]` tag on kind 39000 in `DiscoveryState.get()`, the single parser behind live, session, `searchPublic`, and restored snapshots. The tag is effective-state (present only while the relay auto-adds), so the flag clears when a newer metadata version omits it. `setList` compares `autoJoin` so a toggle-only metadata change replaces the summary. The browser fixture gains an `autoJoinChannels` option beside `lifecycleVisibility`, and `buzz-mcp` channel listings/details show an `auto-join` flag next to `private`/`archived`. Contract and parsing only (A1); first-channel selection, the 9021 fallback, and the admin switch follow separately. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- src/buzz-mcp/tools.ts | 2 ++ src/features/relay/contracts.ts | 2 ++ .../relay/discovery-auto-join.test.ts | 36 +++++++++++++++++++ src/features/relay/discovery.ts | 6 ++++ src/features/relay/store.test.ts | 21 +++++++++++ src/features/relay/store.ts | 1 + tests/browser/fixture.mjs | 3 ++ 7 files changed, 71 insertions(+) create mode 100644 src/features/relay/discovery-auto-join.test.ts diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 774e75e92a..17e1d372c0 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -834,6 +834,7 @@ const HANDLERS: Readonly> = { tag(event, "t") === "stream" ? "" : tag(event, "t"), has(event, "private") ? "private" : "", has(event, "archived") ? "archived" : "", + has(event, "auto_join") ? "auto-join" : "", ids.has(id) ? "" : membershipsComplete @@ -1620,6 +1621,7 @@ class Tools { soloChannelMetadata(metadata as RelayEvent) ? "solo" : "", has(metadata, "private") ? "private" : "open", has(metadata, "archived") ? "archived" : "", + has(metadata, "auto_join") ? "auto-join" : "", tag(metadata, "ttl") ? `archives after ${tag(metadata, "ttl")}s idle` : "", diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index fc0ffec80e..81bdfd918b 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -47,6 +47,8 @@ export type ChannelSummary = Readonly<{ channelType?: "stream" | "forum" | "dm" | "solo" | "system"; /** Relay-authored channel visibility; private channels use restricted presentation. */ private?: true; + /** Relay-advertised default: the relay joins new community members here. */ + autoJoin?: true; /** Presentation-only parent from signed channel metadata; never grants access. */ parentChannelId?: string | undefined; /** Marked Huddle room; presented through its parent conversation, not the sidebar. */ diff --git a/src/features/relay/discovery-auto-join.test.ts b/src/features/relay/discovery-auto-join.test.ts new file mode 100644 index 0000000000..3ed49dd5b0 --- /dev/null +++ b/src/features/relay/discovery-auto-join.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { DiscoveryState } from "./discovery"; +import { keypair, metadata, roster } from "./testing"; + +it("marks a channel auto-join from the relay-signed auto_join tag", () => { + const viewer = keypair(), + relay = keypair(); + const state = new DiscoveryState(viewer.pubkey, relay.pubkey); + state.accept(roster(relay, "general", [viewer.pubkey])); + state.accept(metadata(relay, "general", "General")); + expect(state.get("general")?.autoJoin).toBeUndefined(); + + state.accept( + metadata(relay, "general", "General", 1_700_000_001, [["auto_join"]]), + ); + expect(state.get("general")).toMatchObject({ id: "general", autoJoin: true }); + + // The tag is effective-state: the relay drops it when it stops auto-adding. + state.accept(metadata(relay, "general", "General", 1_700_000_002)); + expect(state.get("general")?.autoJoin).toBeUndefined(); +}); + +it("ignores a non-relay-authored auto_join tag", () => { + const viewer = keypair(), + relay = keypair(), + stranger = keypair(); + const state = new DiscoveryState(viewer.pubkey, relay.pubkey); + state.accept(roster(relay, "general", [viewer.pubkey])); + state.accept(metadata(relay, "general", "General")); + expect( + state.accept( + metadata(stranger, "general", "General", 1_700_000_001, [["auto_join"]]), + ), + ).toBe(false); + expect(state.get("general")?.autoJoin).toBeUndefined(); +}); diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts index bcc5d90a62..037d77d9f2 100644 --- a/src/features/relay/discovery.ts +++ b/src/features/relay/discovery.ts @@ -367,6 +367,11 @@ export class DiscoveryState { const event = this.metadata.get(id); return !!event && event.tags.some(([name]) => name === "private"); } + /** Bare `auto_join` flag: present only while the relay actually auto-adds (open, unarchived). */ + autoJoin(id: string): boolean { + const event = this.metadata.get(id); + return !!event && event.tags.some(([name]) => name === "auto_join"); + } isSolo(id: string): boolean { if (this.starting.has(id)) return true; const event = this.metadata.get(id); @@ -431,6 +436,7 @@ export class DiscoveryState { members: roster ? this.parse(roster).members : noMembers, ...(this.hidden(id) ? { hidden: true } : {}), ...(this.isPrivate(id) ? { private: true } : {}), + ...(this.autoJoin(id) ? { autoJoin: true } : {}), ...(channelType ? { channelType } : {}), ...(channelType === "solo" && event ? { updatedAt: event.created_at } diff --git a/src/features/relay/store.test.ts b/src/features/relay/store.test.ts index 477eb98639..33caa5695c 100644 --- a/src/features/relay/store.test.ts +++ b/src/features/relay/store.test.ts @@ -98,6 +98,27 @@ describe("channel store", () => { expect(queries.list().channels[0]?.private).toBe(true); store.dispose(); }); + it("replaces a channel summary when only its auto-join flag changes", async () => { + const { store, queries, next } = setup(); + queries.ensureList(); + next().respond([ + roster(relay, "work", [viewer.pubkey]), + metadata(relay, "work", "Work"), + ]); + await flush(); + const plain = queries.list().channels[0]; + expect(plain?.autoJoin).toBeUndefined(); + + queries.refreshList?.(); + next().respond([ + roster(relay, "work", [viewer.pubkey]), + metadata(relay, "work", "Work", 1_700_000_001, [["auto_join"]]), + ]); + await flush(); + expect(queries.list().channels[0]).not.toBe(plain); + expect(queries.list().channels[0]?.autoJoin).toBe(true); + store.dispose(); + }); it("preserves summary identity on preview refreshes and detects same-length DM roster changes", () => { const wire = scriptedTransport(viewer.pubkey, relay.pubkey); let live!: LiveCallbacks; diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index c5f0048191..d361f8fa1a 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -388,6 +388,7 @@ export function createChannelStore( old.preview === preview && old.hidden === channel.hidden && old.private === channel.private && + old.autoJoin === channel.autoJoin && old.channelType === channel.channelType && old.parentChannelId === channel.parentChannelId && old.huddle === channel.huddle && diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 414a0670d1..9a71e32b29 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -67,6 +67,7 @@ export const test = base.extend({ lifecycleRole: ["owner", { option: true }], lifecycleOwnerAgent: [false, { option: true }], lifecycleVisibility: [{ archived: [], hidden: [] }, { option: true }], + autoJoinChannels: [[], { option: true }], sidebarIcons: [false, { option: true }], channelNames: [{}, { option: true }], expectedPageFailure: [false, { option: true }], @@ -142,6 +143,7 @@ export const test = base.extend({ lifecycleRole, lifecycleOwnerAgent, lifecycleVisibility, + autoJoinChannels, sidebarIcons, channelNames, expectedPageFailure, @@ -1304,6 +1306,7 @@ export const test = base.extend({ ], ...(id === guestChannelId ? [["join_role", "guest"]] : []), ...(archivedIds.has(id) ? [["archived", "true"]] : []), + ...(autoJoinChannels.includes(id) ? [["auto_join"]] : []), // Ordinary channels are explicitly public; do not add a public // flag to private Solo chats or change the separate DM fixtures. ...(!soloChannels.includes(id) && From 6bd1a6439cd1ed5b2b5cf5aad9c901505829c748 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 12:28:35 +1100 Subject: [PATCH 02/24] feat(channels): open the auto-join channel first and backstop it with 9021 With no saved selection, `ChannelsPage` now prefers the first `autoJoin` non-Solo channel ahead of its existing first-non-Solo fallback, so a new member lands in the community's default channel instead of "Select a channel to read it." The relay normally joins new members into its `auto_join` channels during the invite claim. `features/channels/default-channels.ts` is the backstop for open or old relays and failed adds: once the live roster is verified and empty, `ensureDefaultChannels` reads the public directory through a new store `defaultChannels()` reader (the same shared pager as `searchPublic`) and sends one 9021 per missing channel through `lifecycle.run("join")`, sequentially, tolerating relay rejections. A `default-channels` view-state marker in the `origin:viewer` scope makes it one-shot: the fallback runs unless the marker is `done`, and writes `done` once every candidate settled with success or a definitive `PublishRejected`, or once the verified roster turns out non-empty, so leaving every channel later never re-adds them. Unavailable channel actions, aborts and unconfirmed joins leave the marker for the next session. `useDefaultChannels` is wired in `ChannelsPage`'s workspace, keyed by `scope:generation`; no FOUNDATION edits. `markDefaultChannelsPending` is exported for an admission to write `pending`, but no caller is wired here: `CommunityDialog` is unchanged, so the new dialog test's `joined` case fails at this commit until "fix(channels): run the default-channel fallback only after a fresh joined claim" adds the write. Docs: `channels.md` local-first launch covers the selection preference and the backstop; `communities.md` Try it mentions what a new member sees. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- docs/channels.md | 13 ++ docs/communities.md | 4 +- .../channels/ChannelsPage.tabs.test.tsx | 97 +++++++++++ src/bundled/channels/ChannelsPage.tsx | 6 + .../channels/default-channels.test.ts | 161 ++++++++++++++++++ src/features/channels/default-channels.ts | 93 ++++++++++ .../communities/CommunityDialog.test.tsx | 61 +++++++ src/features/relay/contracts.ts | 4 + src/features/relay/store-discovery.test.ts | 40 +++++ src/features/relay/store.ts | 36 ++++ 10 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 src/features/channels/default-channels.test.ts create mode 100644 src/features/channels/default-channels.ts diff --git a/docs/channels.md b/docs/channels.md index 7038bf2ed2..ede6bd6b9d 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -1245,6 +1245,19 @@ 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 default for new members), then the first non-Solo channel; an +explicit empty route bypasses both. The relay normally joins a new member into +its `auto_join` channels during the invite claim, and the resulting membership +hints populate the sidebar live. `features/channels/default-channels.ts` is the +backstop for open or old relays and failed adds: once the live roster is +verified and empty, it reads the public directory for flagged channels and +sends one kind 9021 join per missing channel through the lifecycle owner, once +per community and viewer. The `default-channels` view-state marker is written +`pending` by a `joined` claim (never `already_member`, which may have left +channels on purpose) and `done` once every candidate settles or the roster +turns out non-empty; leaving the community purges it with the rest of the scope. + 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..54dd56d547 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -56,7 +56,9 @@ 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. A newly admitted member's sidebar +already lists the community's default channels and Messages opens the first of +them; see [local-first launch](channels.md#local-first-launch). diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index f0e1863634..81b46c2b7c 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -26,6 +26,7 @@ import { } from "../../features/relay/session"; import type { RelayData } from "../../features/relay/service"; import { matchesEvent } from "../../features/relay/projection"; +import type { RelayEvent } from "../../features/relay/events"; import { keypair, metadata, @@ -294,6 +295,102 @@ it.each(["side", "main"] as const)( }, ); +/** A page over `events` with no saved selection and no registered panels. */ +function workspace( + events: RelayEvent[], + viewer = keypair(), + authority = keypair(), +) { + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: authority.pubkey, + media: () => undefined, + query: async (filters) => + events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + writer: { + sign: async (template) => signed(viewer, template), + publish: async () => {}, + }, + }, + { outboxStorage: { load: () => [], save: () => {} } }, + ); + owners.push(owner); + const snapshot = { + status: "ready" as const, + generation: 1, + scope: `https://relay.test:${viewer.pubkey}`, + viewer: viewer.pubkey, + session: owner.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const none: RegisteredPanel[] = []; + const panels: Panels = { + snapshot: () => none, + subscribe: () => () => {}, + resolve: () => undefined, + register() {}, + requestOpen: () => false, + handleOpenRequests: () => () => {}, + }; + render( + + + , + ); + return { owner, viewer, authority }; +} + +it("opens the community's auto-join channel first when nothing is saved", async () => { + const viewer = keypair(), + authority = keypair(); + workspace( + [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]), + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["auto_join"], + ]), + ], + viewer, + authority, + ); + await screen.findByRole("textbox", { name: "Message #Beta" }); +}); + +it("selects a default channel the relay adds after the page opened", async () => { + const events: RelayEvent[] = []; + const { owner, viewer, authority } = workspace(events); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + expect(screen.queryByRole("textbox")).toBeNull(); + // A 44100 membership hint resolves the channel through the same store entry. + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["auto_join"], + ]), + ); + await act(() => owner.session.channels.resolve?.(["beta"])); + await screen.findByRole("textbox", { name: "Message #Beta" }); +}); + it("moving the last side app adopts its guest before the dock exit finishes", async () => { vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( new DOMRect(0, 0, 800, 500), diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index ceab8f31c3..8eb8510f6c 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -112,6 +112,7 @@ import type { } from "../../features/relay/contracts"; import { readView, writeView } from "../../shared/view-state"; import { useChannelLabels } from "./useChannelLabels"; +import { useDefaultChannels } from "../../features/channels/default-channels"; import { useComposerSent } from "./useComposerSent"; import { useSidebarPreferences } from "./useSidebarPreferences"; import { channelHasUnread } from "./useSidebarFilters"; @@ -397,6 +398,7 @@ function ChannelWorkspace({ useEffect(() => { if (list.status === "ready") void queries.unread.ensure(); }, [queries, list.status]); + useDefaultChannels(queries, scope, cached); const available = useSyncExternalStore( panels.subscribe, panels.snapshot, @@ -548,6 +550,10 @@ function ChannelWorkspace({ ? queries.channels.get?.(visiblePreview.current.channelId) : undefined) ?? channels.find((channel) => channel.id === selected) ?? + // A new member lands in the community's default channel when it has one. + channels.find( + (channel) => channel.autoJoin && channel.channelType !== "solo", + ) ?? channels.find((item) => item.channelType !== "solo")); // Exact links and saved selections must respect the same Huddle-only entry point. const resolving = diff --git a/src/features/channels/default-channels.test.ts b/src/features/channels/default-channels.test.ts new file mode 100644 index 0000000000..f4e7fc1c61 --- /dev/null +++ b/src/features/channels/default-channels.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { ChannelList, ChannelSummary } from "../relay/contracts"; +import { PublishRejected } from "../relay/outbox"; +import { ChannelLifecycleUnconfirmed } from "../relay/channel-lifecycle"; +import { relayPartition } from "../relay/partition"; +import { readView, writeView } from "../../shared/view-state"; +import { + ensureDefaultChannels, + markDefaultChannelsPending, + useDefaultChannels, +} from "./default-channels"; + +const viewer = "a".repeat(64); +const origin = "https://relay.test"; +const scope = relayPartition(origin, viewer); +const general = { id: "general", name: "General" } as ChannelSummary; +afterEach(() => localStorage.clear()); + +function harness({ + marker, + channels = [], + ids = ["general", "welcome"], + available = true, + run = async () => {}, +}: { + marker?: "pending" | "done"; + channels?: readonly ChannelSummary[]; + ids?: readonly string[]; + available?: boolean; + run?: (action: string, id: string, signal?: AbortSignal) => Promise; +} = {}) { + if (marker) writeView(scope, "default-channels", marker); + const defaultChannels = vi.fn(async () => ids); + const lifecycle = { available, run: vi.fn(run) }; + const list: ChannelList = { status: "ready", channels }; + return { + defaultChannels, + lifecycle, + marker: () => readView(scope, "default-channels", undefined), + ensure: (signal = new AbortController().signal) => + ensureDefaultChannels({ + queries: { list: () => list, defaultChannels }, + lifecycle, + scope, + signal, + }), + }; +} + +it("joins each flagged channel the viewer lacks, in order, then marks the scope done", async () => { + markDefaultChannelsPending(origin, viewer); + const h = harness(); + expect(h.marker()).toBe("pending"); + await h.ensure(); + expect( + h.lifecycle.run.mock.calls.map(([action, id]) => [action, id]), + ).toEqual([ + ["join", "general"], + ["join", "welcome"], + ]); + expect(h.marker()).toBe("done"); +}); + +it("a done marker reads nothing and sends nothing", async () => { + const h = harness({ marker: "done" }); + await h.ensure(); + expect(h.defaultChannels).not.toHaveBeenCalled(); + expect(h.lifecycle.run).not.toHaveBeenCalled(); +}); + +it("a non-empty roster spends the one shot without a directory read", async () => { + const h = harness({ marker: "pending", channels: [general] }); + await h.ensure(); + expect(h.defaultChannels).not.toHaveBeenCalled(); + expect(h.lifecycle.run).not.toHaveBeenCalled(); + expect(h.marker()).toBe("done"); +}); + +it("continues past a relay rejection and still settles", async () => { + const h = harness({ + run: async (_action, id) => { + if (id === "general") + throw new PublishRejected("Rejected", "restricted: invite only"); + }, + }); + await h.ensure(); + expect(h.lifecycle.run).toHaveBeenCalledTimes(2); + expect(h.marker()).toBe("done"); +}); + +it("tries every candidate but keeps the marker after an unconfirmed join", async () => { + const h = harness({ + marker: "pending", + run: async (_action, id) => { + if (id === "general") + throw new ChannelLifecycleUnconfirmed(new Error("timeout")); + }, + }); + await h.ensure(); + expect(h.lifecycle.run).toHaveBeenCalledTimes(2); + expect(h.marker()).toBe("pending"); +}); + +it("skips when channel actions are unavailable on this connection", async () => { + const h = harness({ marker: "pending", available: false }); + await h.ensure(); + expect(h.defaultChannels).not.toHaveBeenCalled(); + expect(h.marker()).toBe("pending"); +}); + +it("an aborted run stops and leaves the marker for the next session", async () => { + const controller = new AbortController(); + const h = harness({ + marker: "pending", + run: async (_action, _id, signal) => { + controller.abort(); + throw signal?.reason; + }, + }); + await h.ensure(controller.signal); + expect(h.lifecycle.run).toHaveBeenCalledTimes(1); + expect(h.marker()).toBe("pending"); +}); + +it("the effect waits for a verified live roster on a live connection", async () => { + let live = { roster: { state: "pending" } }; + const listeners = new Set<() => void>(); + const defaultChannels = vi.fn(async () => []); + const list: ChannelList = { status: "ready", channels: [] }; + const session = { + channels: { + list: () => list, + subscribeList: () => () => {}, + ensureList() {}, + defaultChannels, + }, + channelLifecycle: { available: true, run: vi.fn() }, + live: { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot: () => live, + }, + } as unknown as Parameters[0]; + const cached = renderHook(() => useDefaultChannels(session, scope, true)); + const connected = renderHook(() => useDefaultChannels(session, scope, false)); + expect(defaultChannels).not.toHaveBeenCalled(); + act(() => { + live = { roster: { state: "verified" } }; + for (const listener of listeners) listener(); + }); + await vi.waitFor(() => expect(defaultChannels).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect(readView(scope, "default-channels", undefined)).toBe("done"), + ); + cached.unmount(); + connected.unmount(); +}); diff --git a/src/features/channels/default-channels.ts b/src/features/channels/default-channels.ts new file mode 100644 index 0000000000..dc28cc9756 --- /dev/null +++ b/src/features/channels/default-channels.ts @@ -0,0 +1,93 @@ +import { useEffect, useSyncExternalStore } from "react"; +import type { ChannelLifecycleCapability } from "../relay/channel-lifecycle"; +import type { ChannelQueries } from "../relay/contracts"; +import { PublishRejected } from "../relay/outbox"; +import { relayPartition } from "../relay/partition"; +import { useChannelList } from "../relay/react"; +import type { RelaySession } from "../relay/session"; +import { readView, writeView } from "../../shared/view-state"; + +/** One-shot per community and viewer; leaving the community purges it with + * the rest of the view scope, so a rejoin starts fresh. */ +const KEY = "default-channels"; + +/** A fresh admission (`joined`, never `already_member`): the roster should + * carry the community's default channels once Messages opens. */ +export function markDefaultChannelsPending(origin: string, viewer: string) { + writeView(relayPartition(origin, viewer), KEY, "pending"); +} + +/** The relay joins new members into its `auto_join` channels itself. This is + * the backstop for open or old relays and failed adds: with a verified-empty + * roster, send 9021 for each flagged public channel, once. A non-empty + * roster spends the one shot too, so leaving every channel later never + * re-adds them. The marker stays until every candidate settled with + * success or a definitive relay rejection; anything else retries next time. */ +export async function ensureDefaultChannels({ + queries, + lifecycle, + scope, + signal, +}: { + queries: Pick; + lifecycle: Pick; + scope: string; + signal: AbortSignal; +}): Promise { + if (readView(scope, KEY, undefined) === "done") return; + if (queries.list().channels.length) { + writeView(scope, KEY, "done"); + return; + } + if (!lifecycle.available || !queries.defaultChannels) return; + let ids: readonly string[]; + try { + ids = await queries.defaultChannels({ signal }); + } catch { + return; + } + let settled = true; + // Sequential: the lifecycle owner is single-flight, and its join pre-read + // already sends nothing for a channel the relay added meanwhile. + for (const id of ids) { + if (signal.aborted) return; + try { + await lifecycle.run("join", id, signal); + } catch (error) { + if (signal.aborted) return; + if (!(error instanceof PublishRejected)) settled = false; + } + } + if (settled) writeView(scope, KEY, "done"); +} + +/** Runs the fallback once the live roster is verified; cached sessions wait + * for the real connection. */ +export function useDefaultChannels( + session: Pick, + scope: string, + cached: boolean, +) { + const list = useChannelList(session.channels); + const live = useSyncExternalStore( + session.live.subscribe, + session.live.snapshot, + session.live.snapshot, + ); + const verified = + !cached && + list.status === "ready" && + !list.coverage && + live.roster.state === "verified"; + useEffect(() => { + if (!verified) return; + const controller = new AbortController(); + void ensureDefaultChannels({ + queries: session.channels, + lifecycle: session.channelLifecycle, + scope, + signal: controller.signal, + }); + return () => controller.abort(); + }, [verified, session, scope]); +} diff --git a/src/features/communities/CommunityDialog.test.tsx b/src/features/communities/CommunityDialog.test.tsx index abbc575532..98df9b446b 100644 --- a/src/features/communities/CommunityDialog.test.tsx +++ b/src/features/communities/CommunityDialog.test.tsx @@ -15,6 +15,7 @@ import { Context } from "@deepseek-ai/cordis"; import { createCommunities, type Communities } from "./service"; import { stubAvatarBrowserApis } from "../agents/avatar-testing"; import { federatedFixture } from "../federated-identity/testing"; +import { readView } from "../../shared/view-state"; stubAvatarBrowserApis(); @@ -32,6 +33,7 @@ vi.mock("./api", async (actual) => ({ afterEach(() => { cleanup(); + localStorage.clear(); vi.clearAllMocks(); vi.unstubAllGlobals(); }); @@ -503,6 +505,65 @@ it("prefills an invite but requires explicit discovery and claim before membersh expect(communities.joined).not.toHaveBeenCalled(); }); +it.each([ + ["joined", "pending"], + ["already_member", undefined], +])( + "a %s claim leaves the default-channel marker %s", + async (status, marker) => { + const user = userEvent.setup(); + const viewer = "b".repeat(64); + api.inspectProfile + .mockRejectedValueOnce(new Error("not admitted")) + .mockResolvedValueOnce({ + exists: true, + existing: { name: "Fixture" }, + profile: { name: "Fixture", picture: "", about: "" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + const route = String(url).split("/").at(-1); + if (route === "register") return Response.json({ id: "x" }); + if (route === "info") return Response.json({ name: "Fixture" }); + if (route === "claim") return Response.json({ status }); + throw new Error(`Unexpected request: ${url}`); + }), + ); + const communities = { + snapshot: () => ({ + status: "ready", + relayAvailable: true, + viewer, + profile: { name: "", picture: "" }, + }), + joined: vi.fn(), + } as unknown as Communities; + render( + {}} + invite={{ community: "https://relay.example", code: "v2.abc" }} + />, + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + await screen.findByLabelText("Invite code (if required)"); + await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click( + await screen.findByRole("button", { name: "Open community" }), + ); + await waitFor(() => expect(communities.joined).toHaveBeenCalledOnce()); + expect( + readView( + `https://relay.example:${viewer}`, + "default-channels", + undefined, + ), + ).toBe(marker); + }, +); + it("renews policy acceptance after an expired receipt instead of replaying it", async () => { const user = userEvent.setup(); let version = "v1"; diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 81bdfd918b..012a1ea18c 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -267,6 +267,10 @@ export interface ChannelQueries { progress?: ((found: PublicChannelSearch) => void) | undefined; }, ): Promise; + /** Ids of the active public channels the relay flags `auto_join` that the + * viewer has not joined, from the same directory read as `searchPublic`. + * Candidates only: joining one re-reads its signed metadata and roster. */ + defaultChannels?(options?: ReadOptions): Promise; /** NIP-CL lookup of the channels `creator` made with `label`, oldest first. * Includes the system channels the viewer can read, since `#P` asks for them. * Matches become readable through `get`; they never enter list(). */ diff --git a/src/features/relay/store-discovery.test.ts b/src/features/relay/store-discovery.test.ts index 1c570d2f73..48970f7868 100644 --- a/src/features/relay/store-discovery.test.ts +++ b/src/features/relay/store-discovery.test.ts @@ -1586,6 +1586,46 @@ it("keeps the prior directory's names while a refresh reads, and when the refres expect(h.pending).toHaveLength(0); }); +it("lists the flagged, active, public channels the viewer has not joined as default candidates", async () => { + const h = setup(); + h.channels.ensureList(); + await h.rosters(1); + await h.metadata(1); + const joined = memberships[0] && tag(memberships[0], "d"); + assert.exists(joined); + const flagged = (id: string, name: string, extra: string[][] = []) => + metadata(relay, id, name, 1_700_000_000, [ + ["public"], + ["t", "stream"], + ["auto_join"], + ...extra, + ]); + const done = h.channels.defaultChannels?.(); + assert.exists(done); + const request = await h.next(39000); + expect(request.filters).toEqual([ + { kinds: [39000], authors: [relay.pubkey], limit: 500 }, + ]); + request.respond([ + flagged("zz-welcome", "Welcome"), + flagged("aa-general", "General"), + flagged("archived", "Archived", [["archived", "true"]]), + metadata(relay, "private", "Private", 1_700_000_000, [ + ["private"], + ["t", "stream"], + ["auto_join"], + ]), + metadata(relay, "plain", "Plain", 1_700_000_000, [ + ["public"], + ["t", "stream"], + ]), + flagged(joined, "Joined"), + ]); + // Candidates only, in name order; joining each one re-reads it. + expect(await done).toEqual(["aa-general", "zz-welcome"]); + expect(h.pending).toHaveLength(0); +}); + it("reads every public metadata page, and a keystroke that gives up leaves the read for the next", async () => { let clock = 1_000_000; const h = setup({ now: () => clock }); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index d361f8fa1a..4f25819ea8 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -2717,6 +2717,41 @@ export function createChannelStore( publicSearchReserve -= PUBLIC_CHANNEL_RESERVE_BYTES; } } + /** The post-join fallback's candidates: flagged, active, public, not joined; + * in the list's own order (name, then id). */ + async function defaultChannels( + settings?: ReadOptions, + ): Promise { + if (disposed || !transport || !discovery || options.cachedOnly) + throw new Error("Relay is unavailable"); + if (list.status !== "ready") + throw new Error("Channel list is not ready for channel search"); + const generation = epoch; + // Candidates only: a join re-reads each one, so a reused page never + // grants access by itself. + const entry = currentPublicDirectory(transport, generation); + const signal = settings?.signal; + signal?.throwIfAborted(); + await new Promise((resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + entry.read.then(resolve, reject); + }); + if (disposed || generation !== epoch) + throw new DOMException("Stale channel search", "AbortError"); + return [...latestPublicMetadata(entry.events).entries()] + .filter( + ([id, event]) => + openMetadata(event) && + !hasTag(event, "archived", "true") && + event.tags.some(([key]) => key === "auto_join") && + !discovery?.authorized(id), + ) + .map(([id, event]) => ({ id, name: metadataName(event) ?? "" })) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) + .map(({ id }) => id); + } /** NIP-CL lookup: the channels `creator` made with `label`, oldest first. * The page only names candidates; `resolve` re-reads each one before it * becomes readable through `get`. Matches never enter `list()`. */ @@ -3219,6 +3254,7 @@ export function createChannelStore( }, resolve, searchPublic, + defaultChannels, findLabeled, matchPublic, readDirectory, From 260bc0f60c3b9c42d43eefb11da3bf013ed88222 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 12:46:00 +1100 Subject: [PATCH 03/24] feat(channels): add the Auto-join new members switch to channel settings `ChannelLifecycleCommand` gains `auto-join-on` and `auto-join-off`, commands rather than actions so the confirmation menu and dialog are untouched. `lifecycleTemplate` maps both to the narrow kind 9002 `[["h", id], ["auto_join", "true"|"false"]]`, and the two host validators move in lockstep: `validateLifecycleTemplate` (also imported by the dev broker) and the Rust `channel_writes.rs lifecycle()` accept the second tag as `archived` or `auto_join` with `true|false`, nothing else. `lifecycle.run` pre-reads signed 39000 and requires an active public stream channel before signing; channel roles cannot show community ownership, so the relay's owner/admin rule is left to the relay and its refusal is surfaced. Readback is the bare effective-state `auto_join` tag on the republished 39000, so success is that tag's presence (on) or absence (off), not the written value; a missing tag after an accepted write stays `ChannelLifecycleUnconfirmed`. `ChannelSettingsPanel` renders a `SwitchPreferenceRow` between Visibility and Members, shown only when the panel is editable, the channel is a public stream, and `managesCommunity(role)` holds over the shared `adminAccess.membership` roster, which the row `ensure()`s while mounted. The channels bundle injects `adminAccess` and threads `membership` through `ChannelsPage`. No optimistic state: the switch disables while `run` is in flight and then follows `channel.autoJoin`. `PublishRejected` shows the relay's reason or "Only community owners and admins can change this."; other failures show the lifecycle owner's refresh guidance. Docs: `channels.md` lifecycle and details sections. Tests: lifecycle template/readback/refusal cases, Rust and broker validator matrices, and panel visibility, pending, rejection and unconfirmed cases. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- browser-host/relay-broker-api.test.mjs | 36 ++- docs/channels.md | 22 +- src-tauri/src/relay/channel_writes.rs | 45 +++- .../channels/ChannelSettingsPanel.test.tsx | 242 ++++++++++++++++++ src/bundled/channels/ChannelSettingsPanel.tsx | 106 +++++++- src/bundled/channels/Channels.module.css | 4 + src/bundled/channels/ChannelsPage.tsx | 9 + src/bundled/channels/index.tsx | 3 + .../relay/channel-lifecycle-protocol.ts | 25 +- src/features/relay/channel-lifecycle.test.ts | 128 ++++++++- src/features/relay/channel-lifecycle.ts | 34 ++- 11 files changed, 624 insertions(+), 30 deletions(-) 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 ede6bd6b9d..539ebe6388 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 **Auto-join 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,12 @@ 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. +**Auto-join 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 refresh guidance beside itself. It appears only to community owners and admins +(the relay-signed kind `13534` roster) on an active public stream channel; the +relay enforces that rule, so the gate is display-only. **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 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/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index 3438571579..358e7d1e97 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -4,6 +4,14 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { ChannelSettingsPanel } from "./ChannelSettingsPanel"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { + ChannelLifecycleUnconfirmed, + type ChannelLifecycleCapability, +} from "../../features/relay/channel-lifecycle"; +import { PublishRejected } from "../../features/relay/outbox"; +import type { Role } from "../../features/communities/roster"; +import type { CommunityMembership } from "../moderation/membership"; afterEach(() => { cleanup(); @@ -250,3 +258,237 @@ it.each(["resolve", "reject"])( ); }, ); + +function lifecycle(available = true) { + return { + available, + load: vi.fn(), + run: vi.fn(async () => {}), + snapshot: () => ({ status: "ready", hidden: [] }), + subscribe: () => () => {}, + observeRemoval: () => () => {}, + refreshVisibility: async () => {}, + } satisfies ChannelLifecycleCapability; +} +function membership(role: Role | undefined) { + const release = vi.fn(); + return { + snapshot: () => ({ + status: "ready", + members: [], + ...(role ? { role } : {}), + refreshing: false, + }), + subscribe: () => () => true, + ensure: vi.fn(() => release), + refresh: async () => {}, + dispose: () => {}, + release, + } satisfies CommunityMembership & { release: unknown }; +} +const publicStream: ChannelSummary = { + id: "11111111-1111-4111-8111-111111111111", + name: "Alpha", + channelType: "stream", + visibility: "public", + members: ["one"], +}; +function autoJoinPanel( + channel: ChannelSummary, + role: Role | undefined, + capability = lifecycle(), + roster: CommunityMembership | null = membership(role), +) { + return ( + {}} + > + Diagnostics + + ); +} +const autoJoinSwitch = () => + screen.getByRole("switch", { name: "Auto-join new members" }); +const noAutoJoinSwitch = () => + expect( + screen.queryByRole("switch", { name: "Auto-join new members" }), + ).not.toBeInTheDocument(); + +// [case, channel, community role, switch shown, roster read held] +it.each<[string, ChannelSummary, Role | undefined, boolean, boolean]>([ + [ + "a community owner on a public stream channel", + publicStream, + "owner", + true, + true, + ], + [ + "a community admin on a public stream channel", + publicStream, + "admin", + true, + true, + ], + ["an ordinary community member", publicStream, "member", false, true], + ["an unknown community role", publicStream, undefined, false, true], + [ + "a private channel", + { ...publicStream, visibility: "private" }, + "owner", + false, + false, + ], + [ + "unknown visibility", + { ...publicStream, visibility: undefined }, + "owner", + false, + false, + ], + [ + "a forum channel", + { ...publicStream, channelType: "forum" }, + "owner", + false, + false, + ], + [ + "an archived channel", + { ...publicStream, archived: true }, + "owner", + false, + false, + ], + [ + "a read-only preview", + { ...publicStream, readOnly: true }, + "owner", + false, + false, + ], + [ + "a cached channel", + { ...publicStream, cached: true }, + "owner", + false, + false, + ], +])( + "shows the auto-join switch for %s: %s", + (_, channel, role, shown, ensured) => { + const roster = membership(role); + const { unmount } = render( + autoJoinPanel(channel, role, lifecycle(), roster), + ); + if (shown) { + const control = autoJoinSwitch(); + expect(control).toBeVisible(); + expect(control).toHaveAccessibleDescription( + "New community members are added to this channel automatically.", + ); + expect(control).not.toBeChecked(); + expect( + screen.getByText("Visibility").compareDocumentPosition(control), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(control.compareDocumentPosition(screen.getByText("Members"))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + } else noAutoJoinSwitch(); + // The role read is held only while a channel-eligible row is mounted. + expect(roster.ensure).toHaveBeenCalledTimes(ensured ? 1 : 0); + unmount(); + expect(roster.release).toHaveBeenCalledTimes(ensured ? 1 : 0); + }, +); + +it("hides the switch without a membership owner and reflects the signed flag", () => { + const { rerender } = render( + autoJoinPanel(publicStream, "owner", lifecycle(), null), + ); + noAutoJoinSwitch(); + rerender(autoJoinPanel({ ...publicStream, autoJoin: true }, "owner")); + expect(autoJoinSwitch()).toBeChecked(); +}); + +it("runs the matching command, stays pending until it settles, then follows the metadata", async () => { + const user = userEvent.setup(); + const capability = lifecycle(); + let settle!: () => void; + capability.run.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }), + ); + const { rerender } = render(autoJoinPanel(publicStream, "owner", capability)); + await user.click(autoJoinSwitch()); + try { + expect(capability.run).toHaveBeenCalledExactlyOnceWith( + "auto-join-on", + publicStream.id, + expect.any(AbortSignal), + ); + expect(autoJoinSwitch()).toHaveAttribute("aria-disabled", "true"); + expect(autoJoinSwitch()).toHaveAttribute("aria-busy", "true"); + // No optimistic state: the switch follows the signed metadata. + expect(autoJoinSwitch()).not.toBeChecked(); + } finally { + await act(async () => settle()); + } + expect(autoJoinSwitch()).not.toHaveAttribute("aria-disabled"); + expect(autoJoinSwitch()).not.toHaveAttribute("aria-busy"); + rerender( + autoJoinPanel({ ...publicStream, autoJoin: true }, "owner", capability), + ); + expect(autoJoinSwitch()).toBeChecked(); + await user.click(autoJoinSwitch()); + expect(capability.run).toHaveBeenLastCalledWith( + "auto-join-off", + publicStream.id, + expect.any(AbortSignal), + ); +}); + +it.each([ + [ + "the relay's own reason", + new PublishRejected("rejected", "invalid: actor not authorized"), + "invalid: actor not authorized", + ], + [ + "the default reason", + new PublishRejected("rejected"), + "Only community owners and admins can change this.", + ], + [ + "refresh guidance for an uncertain outcome", + new ChannelLifecycleUnconfirmed("connection lost"), + "The request may have taken effect. Refresh channels before trying again.", + ], +])( + "reports %s and lets the switch be tried again", + async (_, failure, copy) => { + const user = userEvent.setup(); + const capability = lifecycle(); + capability.run.mockRejectedValueOnce(failure); + render(autoJoinPanel(publicStream, "owner", capability)); + await user.click(autoJoinSwitch()); + expect(await screen.findByRole("alert")).toHaveTextContent(copy); + expect(autoJoinSwitch()).not.toHaveAttribute("aria-disabled"); + expect(autoJoinSwitch()).not.toBeChecked(); + await user.click(autoJoinSwitch()); + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect(capability.run).toHaveBeenCalledTimes(2); + }, +); + +it("disables the switch when channel actions are unavailable on this connection", () => { + render(autoJoinPanel(publicStream, "owner", lifecycle(false))); + expect(autoJoinSwitch()).toHaveAttribute("aria-disabled", "true"); +}); diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index 7eae903c62..683fb4371b 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -4,11 +4,16 @@ import { useId, useRef, useState, + useSyncExternalStore, type ReactElement, type ComponentProps, type ReactNode, } from "react"; import type { ChannelSummary } from "../../features/relay/contracts"; +import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle"; +import { PublishRejected } from "../../features/relay/outbox"; +import { managesCommunity } from "../../features/communities/roster"; +import type { CommunityMembership } from "../moderation/membership"; import { channelIcon } from "../../features/channels/channel-icon"; import { GearIcon, @@ -21,6 +26,7 @@ import { Tabs } from "../../shared/design-system/ui/Tabs"; import { Tooltip } from "../../shared/design-system/ui/Tooltip"; import { Panel } from "../../shared/design-system/ui/Panel"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; +import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow"; import styles from "./Channels.module.css"; import type { ChannelDetailsCapability } from "../../features/relay/channel-details"; import { @@ -36,6 +42,8 @@ export function ChannelSettingsPanel({ children, setupTools, details, + lifecycle, + membership, openMembers, }: { channel: ChannelSummary | undefined; @@ -44,6 +52,9 @@ export function ChannelSettingsPanel({ children: ReactNode; setupTools?: ReactNode; details?: ChannelDetailsCapability; + /** With `membership`, offers the community owner/admin auto-join switch. */ + lifecycle?: ChannelLifecycleCapability | undefined; + membership?: CommunityMembership | undefined; openMembers?(trigger: HTMLButtonElement): void; }) { const tabbed = !!usePanelTabHost(); @@ -56,6 +67,12 @@ export function ChannelSettingsPanel({ ?.focus({ preventScroll: true }); }, [tabbed]); const ChannelIcon = channelIcon(channel); + const editable = + channel && + (channel.channelType === "stream" || channel.channelType === "forum") && + !channel.readOnly && + !channel.cached && + !channel.archived; const renderPanel = ( edit?: ChannelDetailsAction, editStatus?: ReactNode, @@ -165,6 +182,18 @@ export function ChannelSettingsPanel({ } /> )} + {editable && + channel.channelType === "stream" && + channel.visibility === "public" && + lifecycle && + membership && ( + + )} {channel.members && ( ); - const editable = - channel && - (channel.channelType === "stream" || channel.channelType === "forum") && - !channel.readOnly && - !channel.cached && - !channel.archived; return editable && details?.available ? ( membership.snapshot().role, + ); + useEffect(() => membership.ensure(), [membership]); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const operation = useRef(undefined); + useEffect(() => () => operation.current?.abort(), []); + if (!managesCommunity(role)) return null; + const toggle = async (enabled: boolean) => { + if (operation.current) return; + const controller = new AbortController(); + operation.current = controller; + setPending(true); + setError(""); + try { + await lifecycle.run( + enabled ? "auto-join-on" : "auto-join-off", + channel.id, + controller.signal, + ); + } catch (error) { + if (!controller.signal.aborted) + setError( + error instanceof PublishRejected + ? (error.reason ?? + "Only community owners and admins can change this.") + : error instanceof Error + ? error.message + : String(error), + ); + } finally { + operation.current = undefined; + if (!controller.signal.aborted) setPending(false); + } + }; + return ( +
+ void toggle(enabled)} + /> + {error && ( +

+ {error} +

+ )} +
+ ); +} + /** A native button's hit area covers the row without nesting controls or losing list semantics. */ function SettingsDetail({ label, diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index ec2bb9c27c..fbdf457a80 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -872,6 +872,10 @@ html[data-keyboard-navigation] margin: var(--space-1) 0 0; color: var(--text-subtle); } +/* The preference row brings its own vertical padding; keep the list rhythm. */ +.settingsDetails > .settingsSwitch { + padding-block: calc(var(--space-4) - var(--space-3)); +} .settingsId { overflow-wrap: anywhere; } diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 8eb8510f6c..c9c192a109 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -38,6 +38,7 @@ import { } from "../../features/channel-navigation/routes"; import { WorkspaceControl } from "../../features/solo/WorkspaceControl"; import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; +import type { CommunityMembership } from "../moderation/membership"; import { SoloHeading, SoloToolbar, useSoloMoveRecovery } from "./SoloHeading"; import { NewMessage } from "../../features/direct-messages/NewMessage"; import { Panel } from "../../shared/design-system/ui/Panel"; @@ -143,12 +144,15 @@ export function ChannelsPage({ communities, archive, read, + membership, }: { extensions?: ConversationExtensions | undefined; chatMenus?: Conversation["chatMenus"] | undefined; /** For adding a whole team of the owner's agents from the members dialog. */ agents?: Agents | undefined; communities?: CommunityReader | undefined; + /** The viewer's community role, for the auto-join switch in Channel settings. */ + membership?: CommunityMembership | undefined; relay: RelayData; navigation?: PageNavigation | undefined; navigator: Navigation; @@ -304,6 +308,7 @@ export function ChannelsPage({ companion={companion} agents={agents} communities={communities} + membership={membership} /> )} @@ -334,10 +339,12 @@ function ChannelWorkspace({ communities, archive, read, + membership, }: { extensions?: ConversationExtensions | undefined; chatMenus?: Conversation["chatMenus"] | undefined; agents?: Agents | undefined; + membership?: CommunityMembership | undefined; companion?: ReactNode; communities?: CommunityReader | undefined; scope: string; @@ -1789,6 +1796,8 @@ function ChannelWorkspace({ } channel={current} details={queries.channelDetails} + lifecycle={queries.channelLifecycle} + membership={membership} close={closeSettings} > diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx index 41d2cdc064..ae54b251ed 100644 --- a/src/bundled/channels/index.tsx +++ b/src/bundled/channels/index.tsx @@ -25,6 +25,7 @@ export const inject = [ "agents", "shortcuts", "communityReader", + "adminAccess", ]; export const apply: PluginModule["apply"] = (ctx) => { registerCanvas(ctx); @@ -114,6 +115,7 @@ export const apply: PluginModule["apply"] = (ctx) => { run: () => read.current?.run(), }); const agents = ctx.agents; + const membership = ctx.adminAccess.membership; ctx.settingsCards.register({ id: "templates", title: "Channel templates", @@ -145,6 +147,7 @@ export const apply: PluginModule["apply"] = (ctx) => { companion={companion} agents={agents} communities={ctx.communityReader} + membership={membership} /> ), }); diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts index a1f648b4ba..0af7cdfcd2 100644 --- a/src/features/relay/channel-lifecycle-protocol.ts +++ b/src/features/relay/channel-lifecycle-protocol.ts @@ -7,8 +7,13 @@ export type ChannelLifecycleAction = | "delete" | "leave" | "hide"; -/** Join is not a confirmed member action: it starts from a nonmember preview. */ -export type ChannelLifecycleCommand = ChannelLifecycleAction | "join"; +/** Join is not a confirmed member action: it starts from a nonmember preview. + * The auto-join switch is a relay owner/admin metadata edit without confirmation. */ +export type ChannelLifecycleCommand = + | ChannelLifecycleAction + | "join" + | "auto-join-on" + | "auto-join-off"; export const CHANNEL_LIFECYCLE_KINDS = [9002, 9008, 9021, 9022, 41012] as const; export const DM_VISIBILITY_KIND = 30622; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -27,6 +32,8 @@ export function lifecycleTemplate( const kind = { archive: 9002, unarchive: 9002, + "auto-join-on": 9002, + "auto-join-off": 9002, delete: 9008, join: 9021, leave: 9022, @@ -40,7 +47,9 @@ export function lifecycleTemplate( ["h", lifecycleChannelId(channelId)], ...(action === "archive" || action === "unarchive" ? [["archived", action === "archive" ? "true" : "false"]] - : []), + : action === "auto-join-on" || action === "auto-join-off" + ? [["auto_join", action === "auto-join-on" ? "true" : "false"]] + : []), ], }; validateLifecycleTemplate(event); @@ -64,12 +73,14 @@ export function validateLifecycleTemplate(event: EventTemplate): void { lifecycleChannelId(event.tags[0]?.[1] ?? ""); if ( event.kind === 9002 && - !["true", "false"].some( - (value) => - JSON.stringify(event.tags[1]) === JSON.stringify(["archived", value]), + !["archived", "auto_join"].some((name) => + ["true", "false"].some( + (value) => + JSON.stringify(event.tags[1]) === JSON.stringify([name, value]), + ), ) ) - throw new Error("Only archive metadata may be changed here"); + throw new Error("Only archive or auto-join metadata may be changed here"); } export function exactLifecycleTag(event: RelayEvent, name: string) { diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index d8ef091f4c..07acd07a3f 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -53,12 +53,13 @@ function harness(role = "owner", type = "stream", owners = 1) { let timestamp = 100; const record = (kind: number, tags: string[][], content = "") => finalizeEvent({ kind, tags, content, created_at: timestamp++ }, relayKey); - const metadata = (archived = false) => + const metadata = (archived = false, autoJoin = false) => record(39000, [ ["d", id], ["t", type], ["name", "fixture"], ...(archived ? [["archived", "true"]] : []), + ...(autoJoin ? [["auto_join"]] : []), ]); const roles = (role: string) => record(39001, [ @@ -104,6 +105,9 @@ function harness(role = "owner", type = "stream", owners = 1) { event.tags.some( ([key, value]) => key === "archived" && value === "true", ), + event.tags.some( + ([key, value]) => key === "auto_join" && value === "true", + ), ), ...events.filter((event) => event.kind !== 39000), ]; @@ -2030,6 +2034,14 @@ describe("Unarchive", () => { ], [["archived", "false", "extra"]], [["archived", "invalid"]], + [["auto_join"]], + [["auto_join", "maybe"]], + [["auto_join", "true", "extra"]], + [ + ["archived", "false"], + ["auto_join", "true"], + ], + [["autojoin", "true"]], ])("does not broaden the metadata command: %j", (...tags) => { expect(() => validateLifecycleTemplate({ @@ -2042,6 +2054,120 @@ describe("Unarchive", () => { }); }); +describe("auto-join switch", () => { + function publicStream( + tags: string[][] = [["public"]], + type = "stream", + role = "member", + ) { + const h = harness(role, type); + const metadata = (autoJoin: boolean) => + h.record(39000, [ + ["d", id], + ["t", type], + ["name", "fixture"], + ...tags, + ...(autoJoin ? [["auto_join"]] : []), + ]); + h.setEvents([metadata(false), ...h.getEvents().slice(1)]); + h.publish.mockImplementation(async (event: RelayEvent) => { + if (event.kind === 9002) + h.setEvents([ + metadata( + event.tags.some( + ([key, value]) => key === "auto_join" && value === "true", + ), + ), + ...h.getEvents().filter((event) => event.kind !== 39000), + ]); + }); + return { ...h, metadata }; + } + + it.each([ + ["auto-join-on", "true"], + ["auto-join-off", "false"], + ] as const)( + "%s publishes the narrow 9002 and confirms the bare readback tag", + async (action, value) => { + const clock = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const h = publicStream(); + if (action === "auto-join-off") + h.setEvents([h.metadata(true), ...h.getEvents().slice(1)]); + try { + await h.owner.capability.run(action, id); + expect(h.sign.mock.calls[0]?.[0]).toEqual( + lifecycleTemplate(action, id), + ); + expect(h.sign.mock.calls[0]?.[0]?.tags).toEqual([ + ["h", id], + ["auto_join", value], + ]); + expect(h.publish).toHaveBeenCalledOnce(); + expect(h.acceptDiscovery).toHaveBeenCalledOnce(); + expect( + h.acceptDiscovery.mock.calls[0]?.[0]?.[0]?.tags.some( + ([key]: string[]) => key === "auto_join", + ), + ).toBe(action === "auto-join-on"); + // Channel roles cannot show relay ownership: only signed metadata is read. + for (const [filters] of h.read.mock.calls) + expect(filters.map((filter) => filter.kinds)).toEqual([[39000]]); + } finally { + clock.mockRestore(); + h.owner.dispose(); + } + }, + ); + + it.each([ + [[["public"], ["private"]], "stream"], + [[], "stream"], + [[["public"], ["archived", "true"]], "stream"], + [[["public"]], "forum"], + ])( + "refuses metadata %j on a %s channel before signing", + async (tags, type) => { + const h = publicStream(tags, type); + try { + await expect( + h.owner.capability.run("auto-join-on", id), + ).rejects.toThrow( + "Only active public channels can auto-join new members.", + ); + expect(h.sign).not.toHaveBeenCalled(); + expect(h.acceptDiscovery).not.toHaveBeenCalled(); + } finally { + h.owner.dispose(); + } + }, + ); + + it("surfaces the relay's refusal and keeps an unconfirmed flag uncertain", async () => { + const h = publicStream(); + try { + h.publish.mockRejectedValueOnce( + new PublishRejected("rejected", "invalid: actor not authorized"), + ); + await expect( + h.owner.capability.run("auto-join-on", id), + ).rejects.toMatchObject({ reason: "invalid: actor not authorized" }); + // A relay that ignores the tag acks the write but never advertises it. + h.publish.mockImplementationOnce(async () => {}); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const pending = expect( + h.owner.capability.run("auto-join-on", id), + ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + await vi.advanceTimersByTimeAsync(2500); + await pending; + expect(h.acceptDiscovery).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + h.owner.dispose(); + } + }); +}); + describe("caller publication guard", () => { function setup(action: "archive" | "unarchive") { const h = harness(); diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts index bfff170e22..e3c501d42f 100644 --- a/src/features/relay/channel-lifecycle.ts +++ b/src/features/relay/channel-lifecycle.ts @@ -9,6 +9,7 @@ import { yieldToHost } from "./yield"; import { hasTag, newer, type RelayEvent } from "./events"; import { openMetadata } from "./discovery"; import { + channelTypeTag, DM_VISIBILITY_KIND, exactLifecycleTag, lifecycleChannelId, @@ -399,8 +400,29 @@ export function createChannelLifecycle({ throw new Error("This conversation can’t be joined right now."); return false; }; + // The relay accepts the auto-join flag only from community owners + // and admins, a role the channel's own records cannot show. Check + // what signed metadata can: an active public stream channel. + const flaggable = async () => { + assertAccess(id); + const metadata = await read([39000], id, signal); + assertAccess(id); + const channel = lifecycleRecord(metadata, 39000, id, relayAuthor); + if ( + !channel || + !openMetadata(channel) || + exactLifecycleTag(channel, "archived") === "true" || + channelTypeTag(channel) !== "stream" + ) + throw new Error( + "Only active public channels can auto-join new members.", + ); + return false; + }; const authorize = async () => { if (action === "join") return joined(); + if (action === "auto-join-on" || action === "auto-join-off") + return flaggable(); const settings = await load( id, signal, @@ -483,7 +505,17 @@ export function createChannelLifecycle({ read([kind], id, signal, action === "leave", true), ); const record = lifecycleRecord(events, kind, id, relayAuthor); - if (action === "archive" || action === "unarchive") { + if (action === "auto-join-on" || action === "auto-join-off") { + // Readback is the bare effective-state tag, not the written value. + if ( + record && + record.tags.some(([key]) => key === "auto_join") === + (action === "auto-join-on") + ) { + acceptDiscovery([record]); + return; + } + } else if (action === "archive" || action === "unarchive") { const archived = record && exactLifecycleTag(record, "archived"); // The relay omits the archived tag on active channels. Absence From 922c29ae910bcf157fe0949473ce5da0aa222edb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 13:02:14 +1100 Subject: [PATCH 04/24] fix(channels): drop the dead pending marker, honour partial reads, keep the computed selection Addresses the review of 54560ac23..dbaaa0e9c. `ensureDefaultChannels` treated an absent marker and `pending` alike, so the `joined`-only write in `CommunityDialog` had no effect and the docs promised an `already_member` guarantee the code did not keep. Keep the "absent runs" semantics (open relays never claim) and delete `markDefaultChannelsPending`, the `admitted` ref and its test; `done` is the only marker value. `channels.md` and the module comment now state the real guarantee: at most once per community, viewer and device, suppressed thereafter by `done`; a non-empty verified roster also marks it done; a member who left every default channel may be re-added once on a device that never marked done. `defaultChannels()` returns `{ ids, partial }` from the shared directory pager, and the fallback leaves the marker unset when the page was capped, so flagged channels past the bound are not silently given up on. `ChannelsPage` keeps the computed choice sticky: a `chosen` ref tracks the last resolved destination, consulted after the saved selection and before the autoJoin/first-non-Solo preference, so a later flag or relay add never swaps the channel being read; the preference re-evaluates only when nothing is chosen or the chosen channel left the list. The late-44100 case still selects the arriving default channel. Tests: partial read and busy-owner `Error` (not `PublishRejected`) leave the marker unset; a reader on the first non-Solo channel stays when a flagged channel arrives (fails without the ref). Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- docs/channels.md | 13 ++-- .../channels/ChannelsPage.tabs.test.tsx | 26 ++++++++ src/bundled/channels/ChannelsPage.tsx | 6 ++ .../channels/default-channels.test.ts | 52 ++++++++++------ src/features/channels/default-channels.ts | 28 ++++----- .../communities/CommunityDialog.test.tsx | 60 ------------------- src/features/relay/contracts.ts | 9 ++- src/features/relay/store-discovery.test.ts | 5 +- src/features/relay/store.ts | 21 +++---- 9 files changed, 108 insertions(+), 112 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 539ebe6388..f108632525 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -1268,11 +1268,14 @@ its `auto_join` channels during the invite claim, and the resulting membership hints populate the sidebar live. `features/channels/default-channels.ts` is the backstop for open or old relays and failed adds: once the live roster is verified and empty, it reads the public directory for flagged channels and -sends one kind 9021 join per missing channel through the lifecycle owner, once -per community and viewer. The `default-channels` view-state marker is written -`pending` by a `joined` claim (never `already_member`, which may have left -channels on purpose) and `done` once every candidate settles or the roster -turns out non-empty; leaving the community purges it with the rest of the scope. +sends one kind 9021 join per missing channel through the lifecycle owner. It +runs at most once per community, viewer and device: the `default-channels` +view-state marker becomes `done` once the directory read was complete and every +candidate settled (joined or definitively rejected), or once the verified roster +turns out non-empty, and suppresses the fallback thereafter. A member who left +every default channel may therefore be re-added once on a device that has never +marked the scope done. Leaving the community purges the marker with the rest of +the scope. A cached roster can display previously downloaded, reverified history for up to 24 hours. It cannot authorize head/history reads, unread evidence, typing or diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 81b46c2b7c..11e57653e1 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -391,6 +391,32 @@ it("selects a default channel the relay adds after the page opened", async () => await screen.findByRole("textbox", { name: "Message #Beta" }); }); +it("keeps the channel being read when a default channel arrives later", async () => { + const viewer = keypair(), + authority = keypair(); + const events: RelayEvent[] = [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]), + ]; + const { owner } = workspace(events, viewer, authority); + await screen.findByRole("textbox", { name: "Message #Alpha" }); + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["auto_join"], + ]), + ); + await act(() => owner.session.channels.resolve?.(["beta"])); + await vi.waitFor(() => + expect( + owner.session.channels.list().channels.map(({ id }) => id), + ).toContain("beta"), + ); + expect(screen.getByRole("textbox", { name: "Message #Alpha" })).toBeVisible(); + expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull(); +}); + it("moving the last side app adopts its guest before the dock exit finishes", async () => { vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( new DOMRect(0, 0, 800, 500), diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index c9c192a109..f297faee0d 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -532,6 +532,8 @@ function ChannelWorkspace({ channelId: string; queries: RelaySession; }>(undefined); + // The last resolved channel, saved or computed; see `requestedDestination`. + const chosen = useRef(undefined); const previewAvailable = resolved?.request === navigation ? resolved?.available @@ -557,6 +559,9 @@ function ChannelWorkspace({ ? queries.channels.get?.(visiblePreview.current.channelId) : undefined) ?? channels.find((channel) => channel.id === selected) ?? + // A computed choice stays put while its channel is listed, so a channel + // flagged or added later never swaps the one being read. + channels.find((channel) => channel.id === chosen.current) ?? // A new member lands in the community's default channel when it has one. channels.find( (channel) => channel.autoJoin && channel.channelType !== "solo", @@ -571,6 +576,7 @@ function ChannelWorkspace({ ? undefined : requestedDestination; useLayoutEffect(() => { + chosen.current = requestedDestination?.id; visiblePreview.current = current?.readOnly && !current.cached && !cached ? { channelId: current.id, queries } diff --git a/src/features/channels/default-channels.test.ts b/src/features/channels/default-channels.test.ts index f4e7fc1c61..c412fa2665 100644 --- a/src/features/channels/default-channels.test.ts +++ b/src/features/channels/default-channels.test.ts @@ -6,11 +6,7 @@ import { PublishRejected } from "../relay/outbox"; import { ChannelLifecycleUnconfirmed } from "../relay/channel-lifecycle"; import { relayPartition } from "../relay/partition"; import { readView, writeView } from "../../shared/view-state"; -import { - ensureDefaultChannels, - markDefaultChannelsPending, - useDefaultChannels, -} from "./default-channels"; +import { ensureDefaultChannels, useDefaultChannels } from "./default-channels"; const viewer = "a".repeat(64); const origin = "https://relay.test"; @@ -22,17 +18,19 @@ function harness({ marker, channels = [], ids = ["general", "welcome"], + partial = false, available = true, run = async () => {}, }: { - marker?: "pending" | "done"; + marker?: "done"; channels?: readonly ChannelSummary[]; ids?: readonly string[]; + partial?: boolean; available?: boolean; run?: (action: string, id: string, signal?: AbortSignal) => Promise; } = {}) { if (marker) writeView(scope, "default-channels", marker); - const defaultChannels = vi.fn(async () => ids); + const defaultChannels = vi.fn(async () => ({ ids, partial })); const lifecycle = { available, run: vi.fn(run) }; const list: ChannelList = { status: "ready", channels }; return { @@ -50,9 +48,8 @@ function harness({ } it("joins each flagged channel the viewer lacks, in order, then marks the scope done", async () => { - markDefaultChannelsPending(origin, viewer); const h = harness(); - expect(h.marker()).toBe("pending"); + expect(h.marker()).toBeUndefined(); await h.ensure(); expect( h.lifecycle.run.mock.calls.map(([action, id]) => [action, id]), @@ -71,7 +68,7 @@ it("a done marker reads nothing and sends nothing", async () => { }); it("a non-empty roster spends the one shot without a directory read", async () => { - const h = harness({ marker: "pending", channels: [general] }); + const h = harness({ channels: [general] }); await h.ensure(); expect(h.defaultChannels).not.toHaveBeenCalled(); expect(h.lifecycle.run).not.toHaveBeenCalled(); @@ -90,9 +87,8 @@ it("continues past a relay rejection and still settles", async () => { expect(h.marker()).toBe("done"); }); -it("tries every candidate but keeps the marker after an unconfirmed join", async () => { +it("tries every candidate but leaves the shot unspent after an unconfirmed join", async () => { const h = harness({ - marker: "pending", run: async (_action, id) => { if (id === "general") throw new ChannelLifecycleUnconfirmed(new Error("timeout")); @@ -100,20 +96,40 @@ it("tries every candidate but keeps the marker after an unconfirmed join", async }); await h.ensure(); expect(h.lifecycle.run).toHaveBeenCalledTimes(2); - expect(h.marker()).toBe("pending"); + expect(h.marker()).toBeUndefined(); +}); + +it("a busy lifecycle owner is not a settled outcome: the next session retries", async () => { + // `lifecycle.run` is single-flight; a user-initiated action throws a plain + // Error at the fallback, not a PublishRejected. + const h = harness({ + run: async (_action, id) => { + if (id === "general") + throw new Error("Another channel action is still in progress"); + }, + }); + await h.ensure(); + expect(h.lifecycle.run).toHaveBeenCalledTimes(2); + expect(h.marker()).toBeUndefined(); +}); + +it("a capped directory page joins what it found but leaves the shot unspent", async () => { + const h = harness({ partial: true }); + await h.ensure(); + expect(h.lifecycle.run).toHaveBeenCalledTimes(2); + expect(h.marker()).toBeUndefined(); }); it("skips when channel actions are unavailable on this connection", async () => { - const h = harness({ marker: "pending", available: false }); + const h = harness({ available: false }); await h.ensure(); expect(h.defaultChannels).not.toHaveBeenCalled(); - expect(h.marker()).toBe("pending"); + expect(h.marker()).toBeUndefined(); }); it("an aborted run stops and leaves the marker for the next session", async () => { const controller = new AbortController(); const h = harness({ - marker: "pending", run: async (_action, _id, signal) => { controller.abort(); throw signal?.reason; @@ -121,13 +137,13 @@ it("an aborted run stops and leaves the marker for the next session", async () = }); await h.ensure(controller.signal); expect(h.lifecycle.run).toHaveBeenCalledTimes(1); - expect(h.marker()).toBe("pending"); + expect(h.marker()).toBeUndefined(); }); it("the effect waits for a verified live roster on a live connection", async () => { let live = { roster: { state: "pending" } }; const listeners = new Set<() => void>(); - const defaultChannels = vi.fn(async () => []); + const defaultChannels = vi.fn(async () => ({ ids: [], partial: false })); const list: ChannelList = { status: "ready", channels: [] }; const session = { channels: { diff --git a/src/features/channels/default-channels.ts b/src/features/channels/default-channels.ts index dc28cc9756..4287e3bef4 100644 --- a/src/features/channels/default-channels.ts +++ b/src/features/channels/default-channels.ts @@ -2,27 +2,23 @@ import { useEffect, useSyncExternalStore } from "react"; import type { ChannelLifecycleCapability } from "../relay/channel-lifecycle"; import type { ChannelQueries } from "../relay/contracts"; import { PublishRejected } from "../relay/outbox"; -import { relayPartition } from "../relay/partition"; import { useChannelList } from "../relay/react"; import type { RelaySession } from "../relay/session"; import { readView, writeView } from "../../shared/view-state"; -/** One-shot per community and viewer; leaving the community purges it with - * the rest of the view scope, so a rejoin starts fresh. */ +/** `done` once per community, viewer and device; leaving the community purges + * it with the rest of the view scope, so a rejoin starts fresh. */ const KEY = "default-channels"; -/** A fresh admission (`joined`, never `already_member`): the roster should - * carry the community's default channels once Messages opens. */ -export function markDefaultChannelsPending(origin: string, viewer: string) { - writeView(relayPartition(origin, viewer), KEY, "pending"); -} - /** The relay joins new members into its `auto_join` channels itself. This is * the backstop for open or old relays and failed adds: with a verified-empty - * roster, send 9021 for each flagged public channel, once. A non-empty - * roster spends the one shot too, so leaving every channel later never - * re-adds them. The marker stays until every candidate settled with - * success or a definitive relay rejection; anything else retries next time. */ + * roster, send 9021 for each flagged public channel, at most once per + * community, viewer and device. A non-empty roster spends the one shot too, + * so leaving every channel later never re-adds them on this device; a device + * that has never marked the scope done may re-add them once. The marker is + * written only when the directory read was complete and every candidate + * settled with success or a definitive relay rejection; anything else retries + * next time. */ export async function ensureDefaultChannels({ queries, lifecycle, @@ -41,12 +37,14 @@ export async function ensureDefaultChannels({ } if (!lifecycle.available || !queries.defaultChannels) return; let ids: readonly string[]; + let partial: boolean; try { - ids = await queries.defaultChannels({ signal }); + ({ ids, partial } = await queries.defaultChannels({ signal })); } catch { return; } - let settled = true; + // A capped directory page may hide flagged channels; leave the shot unspent. + let settled = !partial; // Sequential: the lifecycle owner is single-flight, and its join pre-read // already sends nothing for a channel the relay added meanwhile. for (const id of ids) { diff --git a/src/features/communities/CommunityDialog.test.tsx b/src/features/communities/CommunityDialog.test.tsx index 98df9b446b..4c2c242d0e 100644 --- a/src/features/communities/CommunityDialog.test.tsx +++ b/src/features/communities/CommunityDialog.test.tsx @@ -15,7 +15,6 @@ import { Context } from "@deepseek-ai/cordis"; import { createCommunities, type Communities } from "./service"; import { stubAvatarBrowserApis } from "../agents/avatar-testing"; import { federatedFixture } from "../federated-identity/testing"; -import { readView } from "../../shared/view-state"; stubAvatarBrowserApis(); @@ -505,65 +504,6 @@ it("prefills an invite but requires explicit discovery and claim before membersh expect(communities.joined).not.toHaveBeenCalled(); }); -it.each([ - ["joined", "pending"], - ["already_member", undefined], -])( - "a %s claim leaves the default-channel marker %s", - async (status, marker) => { - const user = userEvent.setup(); - const viewer = "b".repeat(64); - api.inspectProfile - .mockRejectedValueOnce(new Error("not admitted")) - .mockResolvedValueOnce({ - exists: true, - existing: { name: "Fixture" }, - profile: { name: "Fixture", picture: "", about: "" }, - }); - vi.stubGlobal( - "fetch", - vi.fn(async (url: string) => { - const route = String(url).split("/").at(-1); - if (route === "register") return Response.json({ id: "x" }); - if (route === "info") return Response.json({ name: "Fixture" }); - if (route === "claim") return Response.json({ status }); - throw new Error(`Unexpected request: ${url}`); - }), - ); - const communities = { - snapshot: () => ({ - status: "ready", - relayAvailable: true, - viewer, - profile: { name: "", picture: "" }, - }), - joined: vi.fn(), - } as unknown as Communities; - render( - {}} - invite={{ community: "https://relay.example", code: "v2.abc" }} - />, - ); - await user.click(screen.getByRole("button", { name: "Continue" })); - await screen.findByLabelText("Invite code (if required)"); - await user.click(screen.getByRole("button", { name: "Continue" })); - await user.click( - await screen.findByRole("button", { name: "Open community" }), - ); - await waitFor(() => expect(communities.joined).toHaveBeenCalledOnce()); - expect( - readView( - `https://relay.example:${viewer}`, - "default-channels", - undefined, - ), - ).toBe(marker); - }, -); - it("renews policy acceptance after an expired receipt instead of replaying it", async () => { const user = userEvent.setup(); let version = "v1"; diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 012a1ea18c..0ccb70e0b4 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -268,9 +268,12 @@ export interface ChannelQueries { }, ): Promise; /** Ids of the active public channels the relay flags `auto_join` that the - * viewer has not joined, from the same directory read as `searchPublic`. - * Candidates only: joining one re-reads its signed metadata and roster. */ - defaultChannels?(options?: ReadOptions): Promise; + * viewer has not joined, from the same directory read as `searchPublic`, + * with its `partial` bound. Candidates only: joining one re-reads its + * signed metadata and roster. */ + defaultChannels?( + options?: ReadOptions, + ): Promise>; /** NIP-CL lookup of the channels `creator` made with `label`, oldest first. * Includes the system channels the viewer can read, since `#P` asks for them. * Matches become readable through `get`; they never enter list(). */ diff --git a/src/features/relay/store-discovery.test.ts b/src/features/relay/store-discovery.test.ts index 48970f7868..4e8a9550de 100644 --- a/src/features/relay/store-discovery.test.ts +++ b/src/features/relay/store-discovery.test.ts @@ -1622,7 +1622,10 @@ it("lists the flagged, active, public channels the viewer has not joined as defa flagged(joined, "Joined"), ]); // Candidates only, in name order; joining each one re-reads it. - expect(await done).toEqual(["aa-general", "zz-welcome"]); + expect(await done).toEqual({ + ids: ["aa-general", "zz-welcome"], + partial: false, + }); expect(h.pending).toHaveLength(0); }); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 4f25819ea8..42b67b6e71 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -2719,9 +2719,7 @@ export function createChannelStore( } /** The post-join fallback's candidates: flagged, active, public, not joined; * in the list's own order (name, then id). */ - async function defaultChannels( - settings?: ReadOptions, - ): Promise { + async function defaultChannels(settings?: ReadOptions) { if (disposed || !transport || !discovery || options.cachedOnly) throw new Error("Relay is unavailable"); if (list.status !== "ready") @@ -2732,15 +2730,17 @@ export function createChannelStore( const entry = currentPublicDirectory(transport, generation); const signal = settings?.signal; signal?.throwIfAborted(); - await new Promise((resolve, reject) => { - signal?.addEventListener("abort", () => reject(signal.reason), { - once: true, - }); - entry.read.then(resolve, reject); - }); + const { partial } = await new Promise<{ partial: boolean }>( + (resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + entry.read.then(resolve, reject); + }, + ); if (disposed || generation !== epoch) throw new DOMException("Stale channel search", "AbortError"); - return [...latestPublicMetadata(entry.events).entries()] + const ids = [...latestPublicMetadata(entry.events).entries()] .filter( ([id, event]) => openMetadata(event) && @@ -2751,6 +2751,7 @@ export function createChannelStore( .map(([id, event]) => ({ id, name: metadataName(event) ?? "" })) .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) .map(({ id }) => id); + return { ids, partial }; } /** NIP-CL lookup: the channels `creator` made with `label`, oldest first. * The page only names candidates; `resolve` re-reads each one before it From d8260a44a7da143705634237d463206ed7e2dda2 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 13:19:32 +1100 Subject: [PATCH 05/24] fix(channels): keep the computed selection across an opened and closed draft Addresses the review of 883bcaef0. `ChannelsPage` cleared `chosen.current` whenever `requestedDestination` was undefined, which includes the `emptyDestination` case: an open Solo draft or an explicit empty route. A reader on a computed channel (no `selected-channel` saved, since that effect fires only for conversation navigation) who opened and closed New chat re-evaluated the autoJoin/first-non-Solo preference on close, so a flag that arrived meanwhile moved them. Only overwrite the ref when a destination resolved; a stale id for a channel that left the list is harmless because the `find` falls through anyway. The `default-channels.ts` module comment already states the accurate guarantee (a capped page leaves the marker unset, but a non-empty roster on a later mount spends the shot), so it is unchanged. Test: `workspace()` in `ChannelsPage.tabs.test.tsx` gains a `visit()` rerender; a reader on #Alpha who visits the draft route while a flagged guard, landing on #Beta). Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../channels/ChannelsPage.tabs.test.tsx | 64 +++++++++++++++++-- src/bundled/channels/ChannelsPage.tsx | 3 +- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 11e57653e1..b946e694e0 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -20,6 +20,8 @@ import type { RegisteredPanel, } from "../../features/panels/service"; import type { PageNavigation } from "../../features/navigation/service"; +import type { OpenTarget } from "../../features/navigation/targets"; +import { soloDraftTarget } from "../../features/channel-navigation/routes"; import { createRelaySession, type RelaySession, @@ -341,16 +343,39 @@ function workspace( requestOpen: () => false, handleOpenRequests: () => () => {}, }; - render( + const navigator = createNavigationController( + createMemoryHistory(), + ).navigation; + const page = (navigation?: PageNavigation) => ( - , + ); - return { owner, viewer, authority }; + const { rerender } = render(page()); + /** Re-route the mounted page; `undefined` returns to the unrouted workspace. */ + const visit = (target?: OpenTarget) => + rerender( + page( + target && { + entryId: "visit", + target, + signal: new AbortController().signal, + position: () => undefined, + savePosition() {}, + forSession() { + return this; + }, + resolve: () => true, + complete: () => true, + }, + ), + ); + return { owner, viewer, authority, scope: snapshot.scope, visit }; } it("opens the community's auto-join channel first when nothing is saved", async () => { @@ -417,6 +442,37 @@ it("keeps the channel being read when a default channel arrives later", async () expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull(); }); +it("keeps the computed channel across a New chat draft opened and closed meanwhile", async () => { + const viewer = keypair(), + authority = keypair(); + const events: RelayEvent[] = [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]), + ]; + const { owner, scope, visit } = workspace(events, viewer, authority); + await screen.findByRole("textbox", { name: "Message #Alpha" }); + visit(soloDraftTarget(scope)); + await screen.findByRole("region", { name: "New chat" }); + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["auto_join"], + ]), + ); + await act(() => owner.session.channels.resolve?.(["beta"])); + await vi.waitFor(() => + expect( + owner.session.channels.list().channels.map(({ id }) => id), + ).toContain("beta"), + ); + visit(); + expect( + await screen.findByRole("textbox", { name: "Message #Alpha" }), + ).toBeVisible(); + expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull(); +}); + it("moving the last side app adopts its guest before the dock exit finishes", async () => { vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( new DOMRect(0, 0, 800, 500), diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index f297faee0d..9714d48ce0 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -576,7 +576,8 @@ function ChannelWorkspace({ ? undefined : requestedDestination; useLayoutEffect(() => { - chosen.current = requestedDestination?.id; + // An open draft resolves nothing; keep the choice for when it closes. + if (requestedDestination) chosen.current = requestedDestination.id; visiblePreview.current = current?.readOnly && !current.cached && !cached ? { channelId: current.id, queries } From 01477aba37f99ac1b68f43b63d6aa34452ea3cee Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 16:06:15 +1100 Subject: [PATCH 06/24] test(panels): provide adminAccess now that channels injects it Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/features/panels/service.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/panels/service.test.tsx b/src/features/panels/service.test.tsx index 5a5fb41b2c..44c99f8a68 100644 --- a/src/features/panels/service.test.tsx +++ b/src/features/panels/service.test.tsx @@ -52,6 +52,8 @@ it("independently unloads panels without removing the page or shared data", asyn sync: emptySync(), }), }); + // Channels hands community membership to its page for auto-join settings. + root.provide("adminAccess", { membership: undefined }); // GitHub reads through gh where the host can run it; here it cannot. root.provide("host", {}); provideNavigation(root); From 6d6691e3c78c0019fec637559ed408fe95f75c37 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 19:31:55 +1100 Subject: [PATCH 07/24] fix(channels): rename the auto-join switch to "Starter channel for new members" Copy change only: the `SwitchPreferenceRow` label in `ChannelSettingsPanel`, the two role queries in its tests, and the two `channels.md` mentions. The description sentence under the row still reads correctly and is unchanged. Code identifiers (`auto-join-on`/`auto-join-off`, `autoJoin`, the `auto_join` tag) keep their names. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- docs/channels.md | 4 ++-- src/bundled/channels/ChannelSettingsPanel.test.tsx | 4 ++-- src/bundled/channels/ChannelSettingsPanel.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index f108632525..e8da5b2a72 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -562,7 +562,7 @@ 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 **Auto-join new members** switch as two commands +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 @@ -652,7 +652,7 @@ 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. -**Auto-join new members**, between Description and Members, is separate: an +**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 refresh guidance beside itself. It appears only to community owners and admins diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index 358e7d1e97..ece2b683a5 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -312,10 +312,10 @@ function autoJoinPanel( ); } const autoJoinSwitch = () => - screen.getByRole("switch", { name: "Auto-join new members" }); + screen.getByRole("switch", { name: "Starter channel for new members" }); const noAutoJoinSwitch = () => expect( - screen.queryByRole("switch", { name: "Auto-join new members" }), + screen.queryByRole("switch", { name: "Starter channel for new members" }), ).not.toBeInTheDocument(); // [case, channel, community role, switch shown, roster read held] diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index 683fb4371b..b9ef8c8b11 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -288,7 +288,7 @@ function AutoJoinSetting({ return (
Date: Mon, 26 Oct 2026 23:29:02 +1100 Subject: [PATCH 08/24] fix(channels): give the auto-join row its own key and stub adminAccess in fixture pages Fixes the five Browser-journey shards failing on PR #1624, both caused by 21a245e31. `AutoJoinSetting` and `ChannelIdDetail` are siblings in the settings `
` and both used `key={channel.id}`, so React logged "two children with the same key" and the fixture console gate failed `channel-header-menu.spec:19` and `navigation-groups.spec:622`/`:838`. The row keeps a per-channel key (its pending/error state must not survive a channel switch) under a distinct `auto-join:` prefix. The channels bundle now injects `adminAccess`, so the hand-built plugin contexts in `tests/fixtures/mentions.tsx` and `conversation.tsx` never activated the plugin (`channel-completion.spec:7`/`:184`, `conversation.spec:13`). Both now provide `{ membership: undefined }`, as `service.test.tsx` already does; the panel hides the switch without a roster. Test: the auto-join visibility matrix asserts no duplicate-key warning (fails without the key change in the four cases that mount the row). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelSettingsPanel.test.tsx | 6 ++++++ src/bundled/channels/ChannelSettingsPanel.tsx | 2 +- tests/fixtures/conversation.tsx | 2 ++ tests/fixtures/mentions.tsx | 2 ++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index ece2b683a5..f2d79a85b0 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -382,6 +382,7 @@ it.each<[string, ChannelSummary, Role | undefined, boolean, boolean]>([ "shows the auto-join switch for %s: %s", (_, channel, role, shown, ensured) => { const roster = membership(role); + const error = vi.spyOn(console, "error"); const { unmount } = render( autoJoinPanel(channel, role, lifecycle(), roster), ); @@ -399,6 +400,11 @@ it.each<[string, ChannelSummary, Role | undefined, boolean, boolean]>([ Node.DOCUMENT_POSITION_FOLLOWING, ); } else noAutoJoinSwitch(); + // The row and the channel ID detail are siblings; their keys must differ. + expect(error).not.toHaveBeenCalledWith( + expect.stringContaining("same key"), + expect.anything(), + ); // The role read is held only while a channel-eligible row is mounted. expect(roster.ensure).toHaveBeenCalledTimes(ensured ? 1 : 0); unmount(); diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index b9ef8c8b11..b3975a6227 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -188,7 +188,7 @@ export function ChannelSettingsPanel({ lifecycle && membership && ( noAgents, subscribe: () => () => {}, }); +// Channels hands community membership to its page for the auto-join switch; no roster here. +ctx.provide("adminAccess", { membership: undefined }); Object.assign(window, { conversationFixture: { report, diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx index eca6b5719c..b7f75e7ade 100644 --- a/tests/fixtures/mentions.tsx +++ b/tests/fixtures/mentions.tsx @@ -381,6 +381,8 @@ if (channels) { sync: emptySync(), }), }); + // Channels hands community membership to its page for the auto-join switch; no roster here. + context.provide("adminAccess", { membership: undefined }); // Only the composer is mounted; the Channels page never consumes this service. context.provide("relay", { snapshot: () => ({ session: namedSession }), From c727c4793c4521b2f4a7267d0417ddbbb10d21d7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 16:27:40 +1100 Subject: [PATCH 09/24] fix(channels): run the default-channel fallback only after a fresh joined claim Addresses the two blockers on `src/features/channels/default-channels.ts` in the PR #1624 review (threads on lines 33 and 34). Line 33, a missing marker re-adds existing members who left: absence of the `default-channels` marker now means "do nothing". `admitCommunity` reports `joined: true` only when its claim returned `joined` (never `already_member`), and `CommunityDialog` writes `pending` for that origin and viewer right after admission, so a later Back/Continue reclaim that answers `already_member` keeps it. Upgrades, new devices, cleared storage, recovered claims and code-less admissions write nothing, matching the relay contract in block/buzz#8594 ("once, at that moment ... a later kind:9022 leave stands"). No other client moment proves a new membership: a code-less admission is either an open-community newcomer or a returning member who never published a profile, so open communities get no client fallback. Line 34, one successful join strands the remaining defaults: the "non-empty verified roster -> done" early exit is gone. While `pending`, candidates are the flagged public channels the viewer has not joined (the store already excludes rostered ones), joined sequentially as before; `done` is written only when the directory read was complete and every candidate settled. Otherwise the marker stays `pending` and the next run joins only what is still missing. Optional sub-point from the same thread: a `PublishNotSent` (how the transport maps NIP-01 `rate-limited:`) or a `PublishRejected` whose text starts with `rate-limited:` no longer counts as settled. Tests: absent and `done` markers read and send nothing with an empty roster; two runs where #general's membership is retained and #welcome is still outstanding (the second joins only #welcome, then marks done); rate limits and unsent writes stay pending; `admitCommunity` reports `joined` only for a `joined` claim; the dialog writes `pending` for `joined` and nothing for `already_member`. The five fallback cases and the dialog `joined` case fail on the previous code. Module comment, `channels.md` and `communities.md` state the new guarantee. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- docs/channels.md | 31 +++-- docs/communities.md | 8 +- .../channels/default-channels.test.ts | 121 ++++++++++++------ src/features/channels/default-channels.ts | 56 ++++---- .../communities/CommunityDialog.test.tsx | 56 ++++++++ src/features/communities/CommunityDialog.tsx | 8 +- src/features/communities/join.test.ts | 14 +- src/features/communities/join.ts | 14 +- 8 files changed, 227 insertions(+), 81 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index e8da5b2a72..ee8a68c211 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -1263,19 +1263,24 @@ the ordinary cold connection flow; unavailable/corrupt storage never grants acce With no saved selection, Messages opens the first channel the relay flags `auto_join` (its default for new members), then the first non-Solo channel; an -explicit empty route bypasses both. The relay normally joins a new member into -its `auto_join` channels during the invite claim, and the resulting membership -hints populate the sidebar live. `features/channels/default-channels.ts` is the -backstop for open or old relays and failed adds: once the live roster is -verified and empty, it reads the public directory for flagged channels and -sends one kind 9021 join per missing channel through the lifecycle owner. It -runs at most once per community, viewer and device: the `default-channels` -view-state marker becomes `done` once the directory read was complete and every -candidate settled (joined or definitively rejected), or once the verified roster -turns out non-empty, and suppresses the fallback thereafter. A member who left -every default channel may therefore be re-added once on a device that has never -marked the scope done. Leaving the community purges the marker with the rest of -the scope. +explicit empty route bypasses both. The relay joins a new member into its +`auto_join` channels once, during the invite claim, and the resulting membership +hints populate the sidebar live; it never re-adds them, so a later leave stands. +`features/channels/default-channels.ts` backs up an add that failed at the +claim, and only then: when the claim made on this device returned `joined` +(never `already_member`), the `default-channels` view-state marker is `pending`, +and once the live roster is verified Messages joins each flagged public channel +the viewer is not in through the lifecycle owner. The marker becomes `done` only +when the directory read was complete and every candidate either joined or was +definitively refused; a rate limit, an unsent or unconfirmed join, a capped +directory read or leaving Messages keeps it `pending`, and the next launch joins +only what is still missing. Without a marker nothing runs, so upgrading, +another device, cleared storage, a recovered claim whose reply was lost, and +setup without an invite code never re-add channels the viewer left. Open +communities, which require no claim, therefore get no client fallback: the +relay advertises the flag there but auto-joins nobody, and the client cannot +tell a newcomer from a returning member. Leaving the community purges the marker +with the rest of the scope. A cached roster can display previously downloaded, reverified history for up to 24 hours. It cannot authorize head/history reads, unread evidence, typing or diff --git a/docs/communities.md b/docs/communities.md index 54dd56d547..1744803fb4 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -56,9 +56,11 @@ 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. A newly admitted member's sidebar -already lists the community's default channels and Messages opens the first of -them; see [local-first launch](channels.md#local-first-launch). +this dialog, prefilled, as the app opens. A member newly admitted by an invite +claim already has the community's default channels in the sidebar, and Messages +opens the first of them. In an open community, which needs no claim, neither the +relay nor the client joins them. See +[local-first launch](channels.md#local-first-launch). diff --git a/src/features/channels/default-channels.test.ts b/src/features/channels/default-channels.test.ts index c412fa2665..88995f9bc6 100644 --- a/src/features/channels/default-channels.test.ts +++ b/src/features/channels/default-channels.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; -import type { ChannelList, ChannelSummary } from "../relay/contracts"; -import { PublishRejected } from "../relay/outbox"; +import type { ChannelList } from "../relay/contracts"; +import { PublishNotSent, PublishRejected } from "../relay/outbox"; import { ChannelLifecycleUnconfirmed } from "../relay/channel-lifecycle"; import { relayPartition } from "../relay/partition"; import { readView, writeView } from "../../shared/view-state"; @@ -11,35 +11,47 @@ import { ensureDefaultChannels, useDefaultChannels } from "./default-channels"; const viewer = "a".repeat(64); const origin = "https://relay.test"; const scope = relayPartition(origin, viewer); -const general = { id: "general", name: "General" } as ChannelSummary; afterEach(() => localStorage.clear()); function harness({ - marker, - channels = [], + marker = "pending", + joined = [], ids = ["general", "welcome"], partial = false, available = true, run = async () => {}, }: { - marker?: "done"; - channels?: readonly ChannelSummary[]; + /** `null` writes no marker. */ + marker?: "pending" | "done" | null; + joined?: readonly string[]; ids?: readonly string[]; partial?: boolean; available?: boolean; run?: (action: string, id: string, signal?: AbortSignal) => Promise; } = {}) { if (marker) writeView(scope, "default-channels", marker); - const defaultChannels = vi.fn(async () => ({ ids, partial })); - const lifecycle = { available, run: vi.fn(run) }; - const list: ChannelList = { status: "ready", channels }; + // The store's contract: candidates are the flagged channels the viewer has + // not joined, so a join that succeeded stays out of the next run. + const roster = new Set(joined); + const defaultChannels = vi.fn(async () => ({ + ids: ids.filter((id) => !roster.has(id)), + partial, + })); + const lifecycle = { + available, + run: vi.fn(async (action: string, id: string, signal?: AbortSignal) => { + await run(action, id, signal); + roster.add(id); + }), + }; return { defaultChannels, lifecycle, marker: () => readView(scope, "default-channels", undefined), + joins: () => lifecycle.run.mock.calls.map(([, id]) => id), ensure: (signal = new AbortController().signal) => ensureDefaultChannels({ - queries: { list: () => list, defaultChannels }, + queries: { defaultChannels }, lifecycle, scope, signal, @@ -49,7 +61,6 @@ function harness({ it("joins each flagged channel the viewer lacks, in order, then marks the scope done", async () => { const h = harness(); - expect(h.marker()).toBeUndefined(); await h.ensure(); expect( h.lifecycle.run.mock.calls.map(([action, id]) => [action, id]), @@ -60,17 +71,40 @@ it("joins each flagged channel the viewer lacks, in order, then marks the scope expect(h.marker()).toBe("done"); }); -it("a done marker reads nothing and sends nothing", async () => { - const h = harness({ marker: "done" }); +it.each([ + ["no marker (upgrade, another device, cleared storage)", null], + ["a done marker", "done"], +] as const)( + "%s reads nothing and sends nothing, even with an empty roster", + async (_case, marker) => { + const h = harness({ marker }); + await h.ensure(); + expect(h.defaultChannels).not.toHaveBeenCalled(); + expect(h.lifecycle.run).not.toHaveBeenCalled(); + expect(h.marker()).toBe(marker ?? undefined); + }, +); + +it("resumes after one join succeeded: the next run joins only what is still missing", async () => { + let failing = true; + const h = harness({ + run: async (_action, id) => { + if (id === "welcome" && failing) throw new Error("connection lost"); + }, + }); await h.ensure(); - expect(h.defaultChannels).not.toHaveBeenCalled(); - expect(h.lifecycle.run).not.toHaveBeenCalled(); + expect(h.joins()).toEqual(["general", "welcome"]); + // #general's membership is kept, so the roster is no longer empty. + expect(h.marker()).toBe("pending"); + failing = false; + await h.ensure(); + expect(h.joins()).toEqual(["general", "welcome", "welcome"]); + expect(h.marker()).toBe("done"); }); -it("a non-empty roster spends the one shot without a directory read", async () => { - const h = harness({ channels: [general] }); +it("skips channels already joined and settles when none is missing", async () => { + const h = harness({ joined: ["general", "welcome"] }); await h.ensure(); - expect(h.defaultChannels).not.toHaveBeenCalled(); expect(h.lifecycle.run).not.toHaveBeenCalled(); expect(h.marker()).toBe("done"); }); @@ -87,44 +121,52 @@ it("continues past a relay rejection and still settles", async () => { expect(h.marker()).toBe("done"); }); -it("tries every candidate but leaves the shot unspent after an unconfirmed join", async () => { - const h = harness({ - run: async (_action, id) => { - if (id === "general") - throw new ChannelLifecycleUnconfirmed(new Error("timeout")); - }, - }); - await h.ensure(); - expect(h.lifecycle.run).toHaveBeenCalledTimes(2); - expect(h.marker()).toBeUndefined(); -}); - -it("a busy lifecycle owner is not a settled outcome: the next session retries", async () => { +it.each([ + [ + "a rate-limit refusal", + new PublishRejected("Rejected", "rate-limited: slow down"), + ], + [ + "a rate-limit refusal without a reason", + new PublishRejected("rate-limited: slow down"), + ], + [ + "a write that never left the device", + new PublishNotSent("Relay asked the client to wait"), + ], + [ + "an unconfirmed join", + new ChannelLifecycleUnconfirmed(new Error("timeout")), + ], // `lifecycle.run` is single-flight; a user-initiated action throws a plain // Error at the fallback, not a PublishRejected. + [ + "a busy lifecycle owner", + new Error("Another channel action is still in progress"), + ], +])("tries every candidate but stays pending after %s", async (_case, error) => { const h = harness({ run: async (_action, id) => { - if (id === "general") - throw new Error("Another channel action is still in progress"); + if (id === "general") throw error; }, }); await h.ensure(); expect(h.lifecycle.run).toHaveBeenCalledTimes(2); - expect(h.marker()).toBeUndefined(); + expect(h.marker()).toBe("pending"); }); -it("a capped directory page joins what it found but leaves the shot unspent", async () => { +it("a capped directory page joins what it found but stays pending", async () => { const h = harness({ partial: true }); await h.ensure(); expect(h.lifecycle.run).toHaveBeenCalledTimes(2); - expect(h.marker()).toBeUndefined(); + expect(h.marker()).toBe("pending"); }); it("skips when channel actions are unavailable on this connection", async () => { const h = harness({ available: false }); await h.ensure(); expect(h.defaultChannels).not.toHaveBeenCalled(); - expect(h.marker()).toBeUndefined(); + expect(h.marker()).toBe("pending"); }); it("an aborted run stops and leaves the marker for the next session", async () => { @@ -137,7 +179,7 @@ it("an aborted run stops and leaves the marker for the next session", async () = }); await h.ensure(controller.signal); expect(h.lifecycle.run).toHaveBeenCalledTimes(1); - expect(h.marker()).toBeUndefined(); + expect(h.marker()).toBe("pending"); }); it("the effect waits for a verified live roster on a live connection", async () => { @@ -161,6 +203,7 @@ it("the effect waits for a verified live roster on a live connection", async () snapshot: () => live, }, } as unknown as Parameters[0]; + writeView(scope, "default-channels", "pending"); const cached = renderHook(() => useDefaultChannels(session, scope, true)); const connected = renderHook(() => useDefaultChannels(session, scope, false)); expect(defaultChannels).not.toHaveBeenCalled(); diff --git a/src/features/channels/default-channels.ts b/src/features/channels/default-channels.ts index 4287e3bef4..39f3add4ab 100644 --- a/src/features/channels/default-channels.ts +++ b/src/features/channels/default-channels.ts @@ -1,40 +1,51 @@ import { useEffect, useSyncExternalStore } from "react"; import type { ChannelLifecycleCapability } from "../relay/channel-lifecycle"; import type { ChannelQueries } from "../relay/contracts"; -import { PublishRejected } from "../relay/outbox"; +import { PublishNotSent, PublishRejected } from "../relay/outbox"; +import { relayPartition } from "../relay/partition"; import { useChannelList } from "../relay/react"; import type { RelaySession } from "../relay/session"; import { readView, writeView } from "../../shared/view-state"; -/** `done` once per community, viewer and device; leaving the community purges - * it with the rest of the view scope, so a rejoin starts fresh. */ +/** `pending` from a fresh admission until the fallback settles, then `done`; + * leaving the community purges it with the rest of the view scope. */ const KEY = "default-channels"; -/** The relay joins new members into its `auto_join` channels itself. This is - * the backstop for open or old relays and failed adds: with a verified-empty - * roster, send 9021 for each flagged public channel, at most once per - * community, viewer and device. A non-empty roster spends the one shot too, - * so leaving every channel later never re-adds them on this device; a device - * that has never marked the scope done may re-add them once. The marker is - * written only when the directory read was complete and every candidate - * settled with success or a definitive relay rejection; anything else retries - * next time. */ +/** A claim that made this viewer a new member (`joined`, never + * `already_member`): the fallback may reconcile the community's default + * channels once, on this device. */ +export function markDefaultChannelsPending(origin: string, viewer: string) { + writeView(relayPartition(origin, viewer), KEY, "pending"); +} + +/** A definitive refusal settles a candidate; a rate limit, or a write that + * never left this device, may succeed later. */ +const refused = (error: unknown) => + error instanceof PublishRejected && + !(error instanceof PublishNotSent) && + !(error.reason ?? error.message).startsWith("rate-limited:"); + +/** The relay joins new members into its `auto_join` channels itself, once, at + * the claim. This is the backstop for an add that failed there: only while the + * scope is `pending` (written by `markDefaultChannelsPending`), send 9021 for + * each flagged public channel the verified roster lacks. No marker means no + * fresh admission on this device (an upgrade, another device, cleared storage + * or an open community), so nothing runs and a member's earlier leave stands. + * The marker becomes `done` only when the directory read was complete and + * every candidate settled with success or a definitive relay refusal; anything + * else leaves it `pending`, and the next run joins what is still missing. */ export async function ensureDefaultChannels({ queries, lifecycle, scope, signal, }: { - queries: Pick; + queries: Pick; lifecycle: Pick; scope: string; signal: AbortSignal; }): Promise { - if (readView(scope, KEY, undefined) === "done") return; - if (queries.list().channels.length) { - writeView(scope, KEY, "done"); - return; - } + if (readView(scope, KEY, undefined) !== "pending") return; if (!lifecycle.available || !queries.defaultChannels) return; let ids: readonly string[]; let partial: boolean; @@ -43,17 +54,18 @@ export async function ensureDefaultChannels({ } catch { return; } - // A capped directory page may hide flagged channels; leave the shot unspent. + // A capped directory page may hide flagged channels; stay pending. let settled = !partial; - // Sequential: the lifecycle owner is single-flight, and its join pre-read - // already sends nothing for a channel the relay added meanwhile. + // Joined channels are not candidates, so a resumed run sends only what is + // still missing. Sequential: the lifecycle owner is single-flight, and its + // join pre-read already sends nothing for a channel the relay added meanwhile. for (const id of ids) { if (signal.aborted) return; try { await lifecycle.run("join", id, signal); } catch (error) { if (signal.aborted) return; - if (!(error instanceof PublishRejected)) settled = false; + if (!refused(error)) settled = false; } } if (settled) writeView(scope, KEY, "done"); diff --git a/src/features/communities/CommunityDialog.test.tsx b/src/features/communities/CommunityDialog.test.tsx index 4c2c242d0e..d562bc57a1 100644 --- a/src/features/communities/CommunityDialog.test.tsx +++ b/src/features/communities/CommunityDialog.test.tsx @@ -15,6 +15,8 @@ import { Context } from "@deepseek-ai/cordis"; import { createCommunities, type Communities } from "./service"; import { stubAvatarBrowserApis } from "../agents/avatar-testing"; import { federatedFixture } from "../federated-identity/testing"; +import { relayPartition } from "../relay/partition"; +import { readView } from "../../shared/view-state"; stubAvatarBrowserApis(); @@ -504,6 +506,60 @@ it("prefills an invite but requires explicit discovery and claim before membersh expect(communities.joined).not.toHaveBeenCalled(); }); +it.each([ + ["joined", "pending"], + ["already_member", undefined], +])( + "a %s claim leaves the default-channel marker %s", + async (status, marker) => { + const viewer = "c".repeat(64); + api.inspectProfile.mockRejectedValueOnce(new Error("not admitted")); + api.inspectProfile.mockResolvedValueOnce({ + exists: false, + existing: {}, + profile: { name: "", picture: "", about: "" }, + }); + const user = userEvent.setup(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + const route = String(url).split("/").at(-1); + if (route === "register") return Response.json({ id: "x" }); + if (route === "info") return Response.json({ name: "Fixture" }); + if (route === "claim") return Response.json({ status }); + throw new Error(`Unexpected request: ${url}`); + }), + ); + const communities = { + snapshot: () => ({ + status: "ready", + relayAvailable: true, + viewer, + profile: { name: "", picture: "" }, + }), + } as unknown as Communities; + render( + {}} + invite={{ community: "https://relay.example", code: "v2.abc" }} + />, + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + await screen.findByLabelText("Invite code (if required)"); + await user.click(screen.getByRole("button", { name: "Continue" })); + await screen.findByRole("button", { name: "Publish profile & open" }); + expect( + readView( + relayPartition("https://relay.example", viewer), + "default-channels", + undefined, + ), + ).toBe(marker); + }, +); + it("renews policy acceptance after an expired receipt instead of replaying it", async () => { const user = userEvent.setup(); let version = "v1"; diff --git a/src/features/communities/CommunityDialog.tsx b/src/features/communities/CommunityDialog.tsx index 0f85ac8ea3..ef5af2984b 100644 --- a/src/features/communities/CommunityDialog.tsx +++ b/src/features/communities/CommunityDialog.tsx @@ -18,6 +18,7 @@ import { CommunitySignIn } from "../federated-identity/CommunitySignIn"; import type { FederatedIdentity } from "../federated-identity/service"; import { OnboardingBack } from "../identity/OnboardingBack"; import { OnboardingScreen } from "../identity/OnboardingScreen"; +import { markDefaultChannelsPending } from "../channels/default-channels"; import styles from "./Communities.module.css"; export function CommunityDialog({ @@ -194,7 +195,12 @@ export function CommunityDialog({ consent: { adult, agreed }, current, }); - if (admission) showProfile(admission.found, admission.entry); + if (!admission) return; + // The relay auto-joins default channels at this claim; Messages backs + // it up once. Marked now, so a later `already_member` reclaim keeps it. + if (admission.joined && destination && client.viewer) + markDefaultChannelsPending(destination.url, client.viewer); + showProfile(admission.found, admission.entry); }); } else { if (!profile.name.trim()) return; diff --git a/src/features/communities/join.test.ts b/src/features/communities/join.test.ts index b41f053edc..0fcf3667bf 100644 --- a/src/features/communities/join.test.ts +++ b/src/features/communities/join.test.ts @@ -89,10 +89,22 @@ it("journals the join before any remote effect, then accepts the policy, claims code: "CODE", policy_receipt: "policy-receipt", }); - expect(admission).toEqual({ found: none, entry: entries()[0] }); + expect(admission).toEqual({ found: none, entry: entries()[0], joined: true }); expect(entries()).toEqual([{ id: expect.any(String), community }]); }); +it("reports a new member only for a joined claim", async () => { + vi.mocked(communityRequest).mockResolvedValueOnce({ + status: "already_member", + }); + const admit = (code: string) => + admitCommunity({ community, code, consent, current: () => true }); + expect(await admit("CODE")).toEqual({ found: none }); + // Without a code nothing is claimed, so admission cannot show a new member. + expect(await admit("")).toEqual({ found: none }); + expect(communityRequest).toHaveBeenCalledOnce(); +}); + it("sends nothing when the journal record cannot be persisted", async () => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); diff --git a/src/features/communities/join.ts b/src/features/communities/join.ts index 1f2427d884..86f2522666 100644 --- a/src/features/communities/join.ts +++ b/src/features/communities/join.ts @@ -35,6 +35,8 @@ export type Admission = { /** The journal record to finish with the membership; absent without a journal. */ entry?: PendingJoin; found: Awaited>; + /** This claim made the key a new member (`joined`, never `already_member`). */ + joined?: true; }; /** @@ -43,7 +45,9 @@ export type Admission = { * this key, even once its invite expired); otherwise journal the join before * any remote effect, so a storage failure blocks the claim, then accept the * policy, claim the code (when given) and prove admission with the signed - * profile read. `undefined` means a `current` fence stopped the caller. + * profile read. `joined` reports a claim that admitted a new member; a + * recovered entry or a code-less admission cannot tell, so it is absent. + * `undefined` means a `current` fence stopped the caller. */ export async function admitCommunity({ community, @@ -72,6 +76,7 @@ export async function admitCommunity({ if (found) return { found, entry: pending }; } const transaction = journal?.begin(community); + let joined = false; if (code.trim()) { const receipt = policy ? ( @@ -97,11 +102,16 @@ export async function admitCommunity({ }); if (!["joined", "already_member"].includes(claim.status)) throw new Error("Membership was not confirmed"); + joined = claim.status === "joined"; } if (!current(transaction)) return undefined; const found = await inspectProfile(community); if (!current(transaction)) return undefined; - return { found, ...(transaction ? { entry: transaction } : {}) }; + return { + found, + ...(transaction ? { entry: transaction } : {}), + ...(joined ? { joined: true as const } : {}), + }; } /** From 9a22b1450df99969d6cac13ed966038ae27406a4 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 16:29:34 +1100 Subject: [PATCH 10/24] fix(relay): give each channel lifecycle command a distinct created_at Addresses the PR #1624 review thread on `src/features/relay/channel-lifecycle-protocol.ts:50` (on -> off -> on within one second loses the last toggle). `created_at` has one-second resolution and lifecycle tags are fixed, so a third toggle inside a second re-signed the first event byte for byte. The relay deduplicates by id before applying metadata, so the flag stayed off and the run ended `ChannelLifecycleUnconfirmed`. The lifecycle owner now keeps the last `created_at` it issued per channel and signs the next command for that channel at least one second later. That covers every lifecycle command (join/leave and archive/unarchive repeat the same way); `lifecycleTemplate` stays a pure template shared with buzz-mcp. Test: on, off, on under a frozen `Date.now` against a publish mock that deduplicates by id publishes three distinct ids at consecutive seconds and ends with the flag on (fails before: the third run is unconfirmed). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/features/relay/channel-lifecycle.test.ts | 35 ++++++++++++++++++++ src/features/relay/channel-lifecycle.ts | 11 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 07acd07a3f..7b7b8bc111 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -2120,6 +2120,41 @@ describe("auto-join switch", () => { }, ); + it("signs on, off, on within one second as three distinct events", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const h = publicStream(); + // The relay deduplicates by event id before applying metadata. + const apply = h.publish.getMockImplementation(); + assert(apply); + const seen = new Set(); + h.publish.mockImplementation(async (event: RelayEvent) => { + if (seen.has(event.id)) return; + seen.add(event.id); + await apply(event); + }); + try { + for (const action of [ + "auto-join-on", + "auto-join-off", + "auto-join-on", + ] as const) + await h.owner.capability.run(action, id); + const published = h.publish.mock.calls.map(([event]) => event); + expect(new Set(published.map((event) => event.id)).size).toBe(3); + expect(published.map((event) => event.created_at)).toEqual([ + 1_700_000_000, 1_700_000_001, 1_700_000_002, + ]); + expect( + h.acceptDiscovery.mock.lastCall?.[0]?.[0]?.tags.some( + ([key]: string[]) => key === "auto_join", + ), + ).toBe(true); + } finally { + clock.mockRestore(); + h.owner.dispose(); + } + }); + it.each([ [[["public"], ["private"]], "stream"], [[], "stream"], diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts index e3c501d42f..d6217c7537 100644 --- a/src/features/relay/channel-lifecycle.ts +++ b/src/features/relay/channel-lifecycle.ts @@ -74,6 +74,10 @@ export function createChannelLifecycle({ let closed = false; let busy = false; let epoch = 0; + // `created_at` has one-second resolution and lifecycle tags are fixed, so + // on -> off -> on within a second would re-sign the first event's id, which + // the relay deduplicates. Each channel's commands stay strictly increasing. + const issued = new Map(); // Each in-flight operation and its scope; visibility reads have no channel. const controllers = new Map(); const listeners = new Set<() => void>(); @@ -451,7 +455,12 @@ export function createChannelLifecycle({ ); }; if (await authorize()) return; - const template = lifecycleTemplate(action, id); + const fresh = lifecycleTemplate(action, id); + const template = { + ...fresh, + created_at: Math.max(fresh.created_at, (issued.get(id) ?? -1) + 1), + }; + issued.set(id, template.created_at); const signed = await writer.sign(structuredClone(template), signal); signal.throwIfAborted(); if ( From f57a8352c2b28e21bcd8863268017ccbcd33fafc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 16:47:34 +1100 Subject: [PATCH 11/24] fix(channels): say "try again" for an unconfirmed auto-join and give the row dt/dd semantics Addresses the two low PR #1624 review threads on `src/bundled/channels/ChannelSettingsPanel.tsx`. Line 294 (copy): a confirmation timeout showed the raw `ChannelLifecycleUnconfirmed` message, two sentences that both say "Refresh channels before trying again" in a panel with no refresh action. It now maps to one sentence, as `ChannelJoinNotice` does: "Saved, but not confirmed yet. Try again to check." Setting the same value again is idempotent, so retrying is the check. Refusals and other failures keep their copy. Line 303 (a11y): the row and its `role="alert"` were direct `
` children beside `div > dt/dd` rows (axe `definition-list`). The row is now a `div` whose `dt` holds the visible label (a `