Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8da7ec3
feat(relay): parse the relay-advertised auto_join channel flag
matt2e Oct 26, 2026
6bd1a64
feat(channels): open the auto-join channel first and backstop it with…
matt2e Oct 26, 2026
260bc0f
feat(channels): add the Auto-join new members switch to channel settings
matt2e Oct 26, 2026
922c29a
fix(channels): drop the dead pending marker, honour partial reads, ke…
matt2e Oct 26, 2026
d8260a4
fix(channels): keep the computed selection across an opened and close…
matt2e Oct 26, 2026
01477ab
test(panels): provide adminAccess now that channels injects it
matt2e Oct 26, 2026
6d6691e
fix(channels): rename the auto-join switch to "Starter channel for ne…
matt2e Oct 26, 2026
36e3842
fix(channels): give the auto-join row its own key and stub adminAcces…
matt2e Oct 26, 2026
c727c47
fix(channels): run the default-channel fallback only after a fresh jo…
matt2e Oct 29, 2026
9a22b14
fix(relay): give each channel lifecycle command a distinct created_at
matt2e Oct 29, 2026
f57a835
fix(channels): say "try again" for an unconfirmed auto-join and give …
matt2e Oct 29, 2026
02a37e7
test(channels): cover the membership wiring from ChannelsPage to the …
matt2e Oct 29, 2026
110e792
fix(channels): owe only the default channels the claim missed
matt2e Oct 29, 2026
6c36c66
fix(channels): show retry copy for an unsent auto-join toggle and des…
matt2e Oct 29, 2026
eb046c5
fix(channels): store the owed set before joining and settle joins tha…
matt2e Oct 29, 2026
026c361
fix(channels): stop a stale fallback run from overwriting a successor…
matt2e Oct 29, 2026
e244dbf
fix(channels): settle a late fallback join into whatever owed set is …
matt2e Oct 29, 2026
20b044f
fix(channels): serialize the fallback's marker updates across windows…
matt2e Oct 29, 2026
f350681
fix(channels): recheck the owed set right before publishing a fallbac…
matt2e Oct 29, 2026
3da63bd
test(channels): anchor the starter-channel row after Description
matt2e Nov 1, 2026
9c2fdc6
refactor(channels): remove the client-side default-channel join fallback
matt2e Nov 2, 2026
3d75091
fix(channels): warn before archiving or privatizing a starter channel…
matt2e Nov 2, 2026
f27af19
test(channels): provide adminAccess in the plugin shortcut test
matt2e Nov 4, 2026
6e0eac8
fix(channels): clear a stale starter-channel error once the flag land…
matt2e Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion browser-host/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
53 changes: 50 additions & 3 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/communities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).



Expand Down
45 changes: 34 additions & 11 deletions src-tauri/src/relay/channel_writes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions src/bundled/channels/ChannelGeneralAccess.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChannelGeneralAccess
capability={h.capability}
channelId="alpha"
channel={{ id: "alpha", name: "Alpha", ...(autoJoin && { autoJoin }) }}
/>,
);
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" },
Expand Down
7 changes: 7 additions & 0 deletions src/bundled/channels/ChannelGeneralAccess.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function ChannelGeneralAccess({
recovery={recovery}
channelId={channelId}
mayHaveGuestMembers={mayHaveGuestMembers}
autoJoin={channel?.autoJoin ?? false}
/>
);
}
Expand All @@ -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),
Expand Down Expand Up @@ -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.`
: "",
Expand Down
10 changes: 9 additions & 1 deletion src/bundled/channels/ChannelLifecycleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function ChannelLifecycleDialog({
finalFocus,
confirmed = false,
undo,
autoJoin = false,
}: {
channelId: string;
channelName: string;
Expand All @@ -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(() => {
Expand All @@ -105,6 +108,11 @@ export function ChannelLifecycleDialog({
const confirm = useRef<HTMLButtonElement>(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();
Expand Down Expand Up @@ -191,7 +199,7 @@ export function ChannelLifecycleDialog({
? `Delete ${channelName}?`
: `${copy.title}: ${channelName}`
}
description={copy.detail}
description={description}
actions={
<>
<Button ref={cancel} type="button" onClick={close}>
Expand Down
Loading
Loading