diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs
index b69e0d3b7a..168dcdd3ac 100644
--- a/browser-host/relay-broker-api.test.mjs
+++ b/browser-host/relay-broker-api.test.mjs
@@ -3291,6 +3291,24 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
["name", "rename"],
],
},
+ { ...template, kind: 9002, tags: [["h", id], ["auto_join"]] },
+ {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["auto_join", "maybe"],
+ ],
+ },
+ {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["archived", "true"],
+ ["auto_join", "true"],
+ ],
+ },
{
...template,
kind: 9022,
@@ -3349,7 +3367,23 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
expect(restore).toMatchObject(unarchive);
expect((await h.post("publish", restore)).status).toBe(400);
await transport.channelLifecycle.publish(restore, signal);
- expect(h.publications).toHaveLength(2);
+ for (const value of ["true", "false"]) {
+ const flag = {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["auto_join", value],
+ ],
+ };
+ expect((await h.post("sign", flag)).status).toBe(400);
+ expect((await h.post("channel-details-sign", flag)).status).toBe(400);
+ const flagged = await transport.channelLifecycle.sign(flag, signal);
+ expect(verifyEvent(flagged)).toBe(true);
+ expect(flagged).toMatchObject(flag);
+ await transport.channelLifecycle.publish(flagged, signal);
+ }
+ expect(h.publications).toHaveLength(4);
} finally {
live?.dispose();
await h.close();
diff --git a/docs/channels.md b/docs/channels.md
index 7038bf2ed2..cc62118723 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -562,16 +562,26 @@ the returned command, and confirms relay-owned state before removing a row. Arch
confirmed Delete/Leave use the existing access-loss purge. Commands use narrow
development-broker routes or the packaged native `relay_channel_sign` and
`relay_channel_publish` commands, never the message outbox or automatic replay.
+The same route carries the **Starter channel for new members** switch as two commands
+without a confirmation dialog, `auto-join-on` and `auto-join-off`, each a `9002`
+with `auto_join=true|false`. The lifecycle owner requires fresh relay metadata for an
+active public stream channel before signing (channel roles cannot show community
+ownership; the relay accepts the flag only from community owners and admins) and
+confirms the result from the republished `39000`: the relay advertises the flag
+as a bare `auto_join` tag only while it is in effect, so success is that tag's
+presence or absence, not the written value.
A published lifecycle command keeps its bounded, fresh, strong confirmation
when access loss retires access-dependent work. Retired confirmation reads
are reread; caller cancellation, session clear/disposal and the original deadline
still abort its signal. A rejected publication or definitive confirmation-read
failure still fails the command; an aborted read alone never confirms it. These signals
do not forcibly interrupt an in-flight native publication IPC.
-Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive)
-or `archived=false` (Unarchive); other values, extra tags, cross-route commands and
+Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive),
+`archived=false` (Unarchive) or `auto_join=true|false` (the switch); other values,
+extra tags, cross-route commands and
altered or foreign signatures are rejected. Hosts without this capability display
-an unavailable notice. Native Unarchive requires rebuilding/restarting the desktop
+an unavailable notice. Native Unarchive and the auto-join switch require
+rebuilding/restarting the desktop
binary; updating the frontend alone does not update the Rust validator. Rust
validator and production-IPC tests cover both values, but do not establish an
installed desktop Archive → Unarchive round trip against a live relay; that
@@ -642,6 +652,23 @@ row are omitted.
Duration uses Create's Ongoing/Temporary cards. Access controls live in the members
dialog, separate from the text and lifespan draft. These details controls stage
changes; none publishes immediately.
+**Starter channel for new members**, between Description and Members, is separate: an
+immediate switch that publishes its own narrow `9002` (see
+[Channel lifecycle](#channel-lifecycle)) and shows pending, the relay's refusal
+or, when the change is not confirmed, a prompt to try again beside itself. Once the
+channel's metadata shows the value that toggle wanted, the prompt clears, so trying
+again cannot undo a change that landed. It appears only to community owners and admins
+(the relay-signed kind `13534` roster) who are members of an active public stream
+channel, not previewing it or a guest in it, and only once the channel is loaded
+live rather than from cache. The relay checks the community role, not channel
+membership, so the gate is display-only and narrower: an owner or admin must join
+the channel to see the switch.
+Archiving a starter channel or making it private hides the switch and pauses the
+flag without clearing it: the relay stops advertising and applying it, refuses
+to change it on a private channel, and resumes adding new members on Unarchive
+or once the channel is public again. For a starter channel, the Archive and
+**Change general access?** (to Private) confirmations say so, so an owner or
+admin can turn the switch off first.
**Save changes** submits the draft together and is enabled only for valid, changed values.
**Cancel** explicitly discards edits and closes without confirmation. Close, Escape
and backdrop clicks close untouched forms immediately; changed drafts first show
@@ -1245,6 +1272,26 @@ concurrently with the real connection handshake. Saved groups, stars and channel
names are display data, not a confirmed preference mutation base. No cache means
the ordinary cold connection flow; unavailable/corrupt storage never grants access.
+With no saved selection, Messages opens the first channel the relay flags
+`auto_join` (its starter channel for new members), then the first non-Solo
+channel; an explicit empty route bypasses both. The relay adds people to its
+`auto_join` channels itself, and the resulting membership hints populate the
+sidebar live:
+
+- A community that requires relay membership adds a member once, when they join
+ its roster: an invite claim that answers `joined`, or an admin's kind `9030`
+ add. A reclaim answering `already_member` adds nothing, and existing members
+ are not added to a channel flagged later.
+- An open community has no roster to join. After each human WebSocket AUTH
+ (agents are skipped) the relay adds the person to every `auto_join` channel
+ they have never been a member of, including channels flagged after they first
+ connected.
+
+Either way a later leave stands: the roster path does not run again for an
+existing member, and on the open path a leave or removal keeps a membership
+record, so the relay does not add the person back. The client only reads the
+flag; it never sends a join on a member's behalf.
+
A cached roster can display previously downloaded, reverified history for up to
24 hours. It cannot authorize head/history reads, unread evidence, typing or
publishing. Unconfirmed membership is not resaved with a fresh lease. An identical
diff --git a/docs/communities.md b/docs/communities.md
index 3e1f04ab6c..19f7f8e732 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -56,7 +56,12 @@ restore of the account's [known communities](#known-communities) joins each
admitted destination through the same read and call. `Welcome` is also the
second caller of the invite intent (`invite-intent.ts`) beside OS deep links:
an address typed on the welcome screen that the restore did not admit opens
-this dialog, prefilled, as the app opens.
+this dialog, prefilled, as the app opens. The relay adds a member newly admitted
+by an invite claim to the community's starter channels, so they are already in
+the sidebar and Messages opens the first of them. An open community needs no
+claim: there the relay adds each person, though not agents, to the starter
+channels they have never been in each time they connect, so those channels
+appear a moment later. See [local-first launch](channels.md#local-first-launch).
diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs
index 5a50f46ef1..24ec1aa51f 100644
--- a/src-tauri/src/relay/channel_writes.rs
+++ b/src-tauri/src/relay/channel_writes.rs
@@ -90,8 +90,14 @@ fn lifecycle(event: &EventTemplate) -> bool {
{
return false;
}
+ // The narrow 9002 flips one relay-enforced flag: archive, or auto-join
+ // for community owners/admins. Generic metadata edits take the details route.
tag(event, 0, "h").is_some_and(uuid)
- && (event.kind != 9002 || matches!(tag(event, 1, "archived"), Some("true" | "false")))
+ && (event.kind != 9002
+ || matches!(
+ tag(event, 1, "archived").or_else(|| tag(event, 1, "auto_join")),
+ Some("true" | "false")
+ ))
}
fn archive(event: &EventTemplate) -> bool {
if !matches!(event.kind, 9035 | 9036)
@@ -480,12 +486,17 @@ mod boundary_tests {
}
}
#[test]
- fn lifecycle_accepts_only_exact_archive_values() {
+ fn lifecycle_accepts_only_exact_archive_and_auto_join_values() {
+ for flag in ["archived", "auto_join"] {
+ lifecycle_accepts_only_exact_flag_values(flag);
+ }
+ }
+ fn lifecycle_accepts_only_exact_flag_values(flag: &str) {
let event = serde_json::json!({
"kind": 9002,
"created_at": 1700000010,
"content": "",
- "tags": [["h", uuid::Uuid::nil().to_string()], ["archived", "false"]]
+ "tags": [["h", uuid::Uuid::nil().to_string()], [flag, "false"]]
});
for value in ["true", "false"] {
let mut command = event.clone();
@@ -510,23 +521,30 @@ mod boundary_tests {
}
for tags in [
serde_json::json!([["h", uuid::Uuid::nil().to_string()]]),
- serde_json::json!([["h", uuid::Uuid::nil().to_string()], ["archived"]]),
+ serde_json::json!([["h", uuid::Uuid::nil().to_string()], [flag]]),
serde_json::json!([
["h", uuid::Uuid::nil().to_string()],
- ["archived", "false", "extra"]
+ [flag, "false", "extra"]
]),
serde_json::json!([
["h", uuid::Uuid::nil().to_string()],
- ["archived", "false"],
- ["archived", "true"]
+ [flag, "false"],
+ [flag, "true"]
]),
serde_json::json!([
["h", uuid::Uuid::nil().to_string()],
["archived", "false"],
+ ["auto_join", "true"]
+ ]),
+ serde_json::json!([
+ ["h", uuid::Uuid::nil().to_string()],
+ [flag, "false"],
["name", "injected"]
]),
- serde_json::json!([["archived", "false"], ["h", uuid::Uuid::nil().to_string()]]),
- serde_json::json!([["h", "not-a-channel"], ["archived", "false"]]),
+ serde_json::json!([[flag, "false"], ["h", uuid::Uuid::nil().to_string()]]),
+ serde_json::json!([["h", "not-a-channel"], [flag, "false"]]),
+ serde_json::json!([["h", uuid::Uuid::nil().to_string()], ["autojoin", "true"]]),
+ serde_json::json!([["h", uuid::Uuid::nil().to_string()], ["auto-join", "true"]]),
] {
let mut command = event.clone();
command["tags"] = tags;
@@ -537,14 +555,19 @@ mod boundary_tests {
#[test]
fn commands_reject_cross_route_and_foreign_signatures() {
let host = IdentityHost::fixture();
- for (value, tampered) in [("true", "false"), ("false", "true")] {
+ for (flag, value, tampered) in [
+ ("archived", "true", "false"),
+ ("archived", "false", "true"),
+ ("auto_join", "true", "false"),
+ ("auto_join", "false", "true"),
+ ] {
let template = EventTemplate {
kind: 9002,
created_at: 1700000010,
content: String::new(),
tags: vec![
vec!["h".into(), uuid::Uuid::new_v4().to_string()],
- vec!["archived".into(), value.into()],
+ vec![flag.into(), value.into()],
],
};
let event = tauri::async_runtime::block_on(host.sign(template)).unwrap();
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index a1477bdea7..07e7d7b6d0 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -138,6 +138,34 @@ it("keeps writes and consent closed when the fresh access read fails", async ()
expect(h.save).not.toHaveBeenCalled();
});
+it.each([
+ { autoJoin: true, option: "Private", note: true },
+ { autoJoin: true, option: "View only", note: false },
+ { autoJoin: false, option: "Private", note: false },
+])(
+ "starter channel $autoJoin → $option says new members resume if made public: $note",
+ async ({ autoJoin, option, note }) => {
+ const h = harness();
+ h.load.mockResolvedValue({ ...base, joinRole: "member" });
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await select(user, "General access", option);
+ const starter =
+ "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first.";
+ const confirm = await screen.findByRole("alertdialog", {
+ name: "Change general access?",
+ });
+ if (note) expect(confirm).toHaveTextContent(starter);
+ else expect(confirm).not.toHaveTextContent(starter);
+ },
+);
+
const policies = [
{ visibility: "private", joinRole: "member", label: "Private" },
{ visibility: "private", joinRole: "guest", label: "Private" },
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index 692be7792f..b44c64643c 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -101,6 +101,7 @@ export function ChannelGeneralAccess({
recovery={recovery}
channelId={channelId}
mayHaveGuestMembers={mayHaveGuestMembers}
+ autoJoin={channel?.autoJoin ?? false}
/>
);
}
@@ -110,11 +111,13 @@ function ChannelGeneralAccessEditor({
recovery,
channelId,
mayHaveGuestMembers,
+ autoJoin,
}: {
capability: ChannelDetailsCapability;
recovery: RelayRecovery | undefined;
channelId: string;
mayHaveGuestMembers: boolean;
+ autoJoin: boolean;
}) {
const attempt = useSyncExternalStore(capability.subscribe, () =>
capability.snapshot(channelId),
@@ -352,6 +355,10 @@ function ChannelGeneralAccessEditor({
choice.joinRole === "guest"
? "People already in the chat keep their access."
: "",
+ // The relay keeps the flag but refuses changing it on a private channel.
+ choice.visibility === "private" && autoJoin
+ ? "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first."
+ : "",
!solo && choice.visibility === "public"
? `New people will ${choice.joinRole === "guest" ? "only be able to view" : "be able to participate"}. People already in the ${place} keep their access.`
: "",
diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx
index f071a2ed92..5d55cfd644 100644
--- a/src/bundled/channels/ChannelLifecycleDialog.tsx
+++ b/src/bundled/channels/ChannelLifecycleDialog.tsx
@@ -81,6 +81,7 @@ export function ChannelLifecycleDialog({
finalFocus,
confirmed = false,
undo,
+ autoJoin = false,
}: {
channelId: string;
channelName: string;
@@ -96,6 +97,8 @@ export function ChannelLifecycleDialog({
confirmed?: boolean;
/** Offered from an immediate archive's result toast. */
undo?(): void;
+ /** A starter channel: the relay keeps the flag while archived. */
+ autoJoin?: boolean;
}) {
const callbacks = useRef({ completed, failed });
useLayoutEffect(() => {
@@ -105,6 +108,11 @@ export function ChannelLifecycleDialog({
const confirm = useRef(null);
const confirms = reversible.has(action);
const copy = copyFor(action, noun);
+ // The switch is hidden while archived, and Unarchive applies the flag again.
+ const description =
+ action === "archive" && autoJoin
+ ? `${copy.detail} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn off Starter channel for new members in Settings first.`
+ : copy.detail;
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const notify = useToastNotification();
@@ -191,7 +199,7 @@ export function ChannelLifecycleDialog({
? `Delete ${channelName}?`
: `${copy.title}: ${channelName}`
}
- description={copy.detail}
+ description={description}
actions={
<>
diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx
index 3438571579..8ce971f2dd 100644
--- a/src/bundled/channels/ChannelSettingsPanel.test.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx
@@ -2,8 +2,16 @@
import "@testing-library/jest-dom/vitest";
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 { afterEach, assert, 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 { PublishNotSent, PublishRejected } from "../../features/relay/outbox";
+import type { Role } from "../../features/communities/roster";
+import type { CommunityMembership } from "../moderation/membership";
afterEach(() => {
cleanup();
@@ -250,3 +258,311 @@ 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: "Starter channel for new members" });
+const noAutoJoinSwitch = () =>
+ expect(
+ screen.queryByRole("switch", { name: "Starter channel for new members" }),
+ ).not.toBeInTheDocument();
+/** Every child of the details list is a `div` of terms then definitions. */
+const expectDefinitionRows = (node: Element) => {
+ const list = node.closest("dl");
+ assert(list);
+ for (const row of list.children)
+ expect(
+ `${row.tagName}:${[...row.children].map((child) => child.tagName).join(",")}`,
+ ).toMatch(/^DIV:DT(,DT)*(,DD)+$/);
+};
+
+// [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 error = vi.spyOn(console, "error");
+ 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();
+ // The label is the row's term; the switch belongs to its definition.
+ expect(control.closest("dd")?.previousElementSibling).toHaveTextContent(
+ "Starter channel for new members",
+ );
+ expectDefinitionRows(control);
+ expect(
+ screen.getByText("Description").compareDocumentPosition(control),
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ expect(control.compareDocumentPosition(screen.getByText("Members"))).toBe(
+ 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();
+ 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));
+ // The visible term labels the switch.
+ await user.click(screen.getByText("Starter channel for new members"));
+ 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.",
+ ],
+ [
+ "a retry for an unconfirmed outcome",
+ // As the lifecycle owner throws it when readback never shows the flag.
+ new ChannelLifecycleUnconfirmed(
+ new Error(
+ "The relay accepted the request, but the change is not confirmed. Refresh channels before trying again.",
+ ),
+ ),
+ "Saved, but not confirmed yet. Try again to check.",
+ ],
+ [
+ "a retry for a rate limit, not a refusal",
+ // As the transport maps a NIP-01 `rate-limited:` OK reply.
+ new PublishNotSent("rate-limited: slow down"),
+ "Not sent. Try 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());
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toBe(copy);
+ expectDefinitionRows(alert);
+ expect(autoJoinSwitch()).toHaveAccessibleDescription(
+ `New community members are added to this channel automatically. ${copy}`,
+ );
+ expect(autoJoinSwitch()).not.toHaveAttribute("aria-disabled");
+ expect(autoJoinSwitch()).not.toBeChecked();
+ await user.click(autoJoinSwitch());
+ await waitFor(() =>
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
+ );
+ expect(autoJoinSwitch()).toHaveAccessibleDescription(
+ "New community members are added to this channel automatically.",
+ );
+ expect(capability.run).toHaveBeenCalledTimes(2);
+ },
+);
+
+it("drops an unconfirmed prompt once the signed flag shows the change landed", async () => {
+ const user = userEvent.setup();
+ const capability = lifecycle();
+ capability.run.mockRejectedValueOnce(
+ new ChannelLifecycleUnconfirmed(new Error("not confirmed")),
+ );
+ const { rerender } = render(autoJoinPanel(publicStream, "owner", capability));
+ await user.click(autoJoinSwitch());
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Saved, but not confirmed yet. Try again to check.",
+ );
+ expect(autoJoinSwitch().getAttribute("aria-describedby")).toContain(alert.id);
+ // A refresh that still lacks the flag keeps the prompt.
+ rerender(autoJoinPanel({ ...publicStream }, "owner", capability));
+ expect(screen.getByRole("alert")).toBe(alert);
+ // A later 39000 shows the flag on; "Try again" would now turn it off.
+ rerender(
+ autoJoinPanel({ ...publicStream, autoJoin: true }, "owner", capability),
+ );
+ expect(autoJoinSwitch()).toBeChecked();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(autoJoinSwitch().getAttribute("aria-describedby")).not.toContain(
+ alert.id,
+ );
+ expect(autoJoinSwitch()).toHaveAccessibleDescription(
+ "New community members are added to this channel automatically.",
+ );
+ // Another admin turning it off later does not bring the stale prompt back.
+ rerender(autoJoinPanel(publicStream, "owner", capability));
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(capability.run).toHaveBeenCalledOnce();
+});
+
+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..dc9bb1b048 100644
--- a/src/bundled/channels/ChannelSettingsPanel.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.tsx
@@ -4,11 +4,19 @@ import {
useId,
useRef,
useState,
+ useSyncExternalStore,
type ReactElement,
type ComponentProps,
type ReactNode,
} from "react";
import type { ChannelSummary } from "../../features/relay/contracts";
+import {
+ ChannelLifecycleUnconfirmed,
+ type ChannelLifecycleCapability,
+} from "../../features/relay/channel-lifecycle";
+import { PublishNotSent, 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 +29,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 { Switch } from "../../shared/design-system/ui/Switch";
import styles from "./Channels.module.css";
import type { ChannelDetailsCapability } from "../../features/relay/channel-details";
import {
@@ -36,6 +45,8 @@ export function ChannelSettingsPanel({
children,
setupTools,
details,
+ lifecycle,
+ membership,
openMembers,
}: {
channel: ChannelSummary | undefined;
@@ -44,6 +55,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 +70,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 +185,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<{ enabled: boolean; message: string }>();
+ // Once the signed flag shows what a failed toggle wanted, the change is in
+ // effect, and "Try again" would flip it back.
+ if (error && !!channel.autoJoin === error.enabled) setError(undefined);
+ const operation = useRef(undefined);
+ useEffect(() => () => operation.current?.abort(), []);
+ const control = useId();
+ const description = useId();
+ const alert = useId();
+ if (!managesCommunity(role)) return null;
+ const disabled = pending || !lifecycle.available;
+ const toggle = async (enabled: boolean) => {
+ if (operation.current) return;
+ const controller = new AbortController();
+ operation.current = controller;
+ setPending(true);
+ setError(undefined);
+ try {
+ await lifecycle.run(
+ enabled ? "auto-join-on" : "auto-join-off",
+ channel.id,
+ controller.signal,
+ );
+ } catch (error) {
+ if (!controller.signal.aborted)
+ setError({
+ enabled,
+ message:
+ // Setting the same value again is idempotent, so retrying is the check.
+ error instanceof ChannelLifecycleUnconfirmed
+ ? "Saved, but not confirmed yet. Try again to check."
+ : // A rate limit or a write that never left this device, not a refusal.
+ error instanceof PublishNotSent
+ ? "Not sent. Try again."
+ : 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);
+ }
+ };
+ // A row of the details list: the label is its term, and the description,
+ // switch and any error are its definition.
+ return (
+
+
+ Starter channel for new members
+
+
+
+ New community members are added to this channel automatically.
+
+ void toggle(enabled)}
+ />
+ {error && (
+
+ {error.message}
+
+ )}
+
+
+ );
+}
+
/** 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..dc8fab440b 100644
--- a/src/bundled/channels/Channels.module.css
+++ b/src/bundled/channels/Channels.module.css
@@ -872,6 +872,19 @@ html[data-keyboard-navigation]
margin: var(--space-1) 0 0;
color: var(--text-subtle);
}
+/* The switch trails its description; an error spans the row below both. */
+.settingsSwitch > dd {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-1) var(--space-3);
+}
+.settingsSwitch > dd > [role="alert"] {
+ grid-column: 1 / -1;
+}
+.settingsSwitch[data-disabled] > dt {
+ color: var(--text-unavailable);
+}
.settingsId {
overflow-wrap: anywhere;
}
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index f0e1863634..7aa3249c8c 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -20,12 +20,15 @@ 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,
} 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,
@@ -39,6 +42,7 @@ import { setThreadPlacementPreference } from "../../features/messages/thread-pre
import { SOLO_LABEL } from "../../features/solo/labels";
import { composerDOMFixture } from "../../features/messages/composer-testing";
import { ChannelsPage } from "./ChannelsPage";
+import type { CommunityMembership } from "../moderation/membership";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
import { BrowserHostView } from "../../features/browser/BrowserHostView";
import { BrowserSessions } from "../../features/browser/sessions";
@@ -294,6 +298,244 @@ 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(),
+ {
+ membership,
+ lifecycle,
+ }: {
+ membership?: CommunityMembership;
+ lifecycle?: (event: RelayEvent) => Promise;
+ } = {},
+) {
+ 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 () => {},
+ },
+ ...(lifecycle && {
+ channelLifecycle: {
+ sign: async (template) => signed(viewer, template),
+ publish: lifecycle,
+ },
+ }),
+ },
+ { 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: () => () => {},
+ };
+ const navigator = createNavigationController(
+ createMemoryHistory(),
+ ).navigation;
+ const page = (navigation?: PageNavigation) => (
+
+
+
+ );
+ 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 () => {
+ 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("hands the community roster to Channel settings and follows the republished flag", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ const id = "11111111-1111-4111-8111-111111111111";
+ const open = (at: number, extra: string[][] = []) =>
+ metadata(authority, id, "Alpha", at, [
+ ["t", "stream"],
+ ["public"],
+ ...extra,
+ ]);
+ const events = [roster(authority, id, [viewer.pubkey]), open(1_700_000_000)];
+ const ensure = vi.fn(() => () => {});
+ workspace(events, viewer, authority, {
+ membership: {
+ snapshot: () => ({
+ status: "ready",
+ members: [],
+ role: "owner",
+ refreshing: false,
+ }),
+ subscribe: () => () => true,
+ ensure,
+ refresh: async () => {},
+ dispose: () => {},
+ },
+ // The relay applies the accepted 9002 and republishes its metadata.
+ lifecycle: async (event) => {
+ if (event.kind === 9002)
+ events.push(open(1_700_000_100, [["auto_join"]]));
+ },
+ });
+ await screen.findByRole("textbox", { name: "Message #Alpha" });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "Channel actions" }));
+ await user.click(
+ await screen.findByRole("menuitem", { name: "View channel details" }),
+ );
+ const control = await screen.findByRole("switch", {
+ name: "Starter channel for new members",
+ });
+ expect(ensure).toHaveBeenCalledOnce();
+ expect(control).not.toBeChecked();
+ await user.click(control);
+ await vi.waitFor(() => expect(control).toBeChecked());
+ expect(control).not.toHaveAttribute("aria-busy");
+ expect(control.closest("dd")?.querySelector("[role=alert]")).toBeNull();
+});
+
+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("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("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 ceab8f31c3..bb03354f21 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";
@@ -142,12 +143,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;
@@ -303,6 +307,7 @@ export function ChannelsPage({
companion={companion}
agents={agents}
communities={communities}
+ membership={membership}
/>
)}
@@ -333,10 +338,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;
@@ -523,6 +530,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
@@ -548,6 +557,13 @@ 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",
+ ) ??
channels.find((item) => item.channelType !== "solo"));
// Exact links and saved selections must respect the same Huddle-only entry point.
const resolving =
@@ -558,6 +574,8 @@ function ChannelWorkspace({
? undefined
: requestedDestination;
useLayoutEffect(() => {
+ // 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 }
@@ -1783,6 +1801,8 @@ function ChannelWorkspace({
}
channel={current}
details={queries.channelDetails}
+ lifecycle={queries.channelLifecycle}
+ membership={membership}
close={closeSettings}
>
diff --git a/src/bundled/channels/index.test.tsx b/src/bundled/channels/index.test.tsx
index 9c6fa2d4fa..051314835a 100644
--- a/src/bundled/channels/index.test.tsx
+++ b/src/bundled/channels/index.test.tsx
@@ -23,6 +23,7 @@ function registered() {
relay: {},
panels: {},
agents: {},
+ adminAccess: { membership: undefined },
communityReader: {},
} as unknown as Parameters[0]);
return new Map(
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/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/channel-navigation/ChannelNavigationState.test.tsx b/src/features/channel-navigation/ChannelNavigationState.test.tsx
index 6991fc8f72..848f1b2dc3 100644
--- a/src/features/channel-navigation/ChannelNavigationState.test.tsx
+++ b/src/features/channel-navigation/ChannelNavigationState.test.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
import { StrictMode, type ReactNode } from "react";
import {
act,
@@ -179,6 +180,28 @@ it("hands both entrances to one confirmation and preserves its origin until clos
});
});
+it.each([true, false])(
+ "says whether archiving starter channel (%s) resumes adding new members on Unarchive",
+ async (autoJoin) => {
+ const view = mount(fixture().relay, true);
+ act(() =>
+ view.result.current.openLifecycle(
+ { id: "general", name: "general", ...(autoJoin && { autoJoin }) },
+ "archive",
+ ),
+ );
+ const archive =
+ "Archive this channel for everyone and remove it from the sidebar. Messages are kept. Find it in search and open Settings to unarchive it later.";
+ expect(
+ await screen.findByRole("dialog", { name: "Archive channel: general" }),
+ ).toHaveAccessibleDescription(
+ autoJoin
+ ? `${archive} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn off Starter channel for new members in Settings first.`
+ : archive,
+ );
+ },
+);
+
it("retains the settings tab close control when an archive action remounts", () => {
const view = mount(fixture().relay);
const content = (archived: boolean) => (
diff --git a/src/features/channel-navigation/ChannelNavigationState.tsx b/src/features/channel-navigation/ChannelNavigationState.tsx
index 3684bf7ee7..92cb360c4d 100644
--- a/src/features/channel-navigation/ChannelNavigationState.tsx
+++ b/src/features/channel-navigation/ChannelNavigationState.tsx
@@ -362,6 +362,7 @@ export function ChannelNavigationProvider({
noun={lifecycleNoun(state.lifecycleDialog.channel)}
action={state.lifecycleDialog.action}
confirmed={state.lifecycleDialog.confirmed ?? false}
+ autoJoin={state.lifecycleDialog.channel.autoJoin ?? false}
undo={() => {
// The toast outlives this dialog: Undo restores the channel it archived.
if (archived) value.undoArchive(archived);
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);
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..7b7b8bc111 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,155 @@ 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("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"],
+ [[["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..d6217c7537 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,
@@ -73,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>();
@@ -399,8 +404,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,
@@ -429,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 (
@@ -483,7 +514,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
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) &&
diff --git a/tests/fixtures/conversation.tsx b/tests/fixtures/conversation.tsx
index 37c52de08c..54a3d0bf46 100644
--- a/tests/fixtures/conversation.tsx
+++ b/tests/fixtures/conversation.tsx
@@ -309,6 +309,8 @@ ctx.provide("agents", {
snapshot: () => 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 }),