Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ that agent in Buzz; they do not notify the external app through this connection.
Changing this choice does not change existing members' roles. Ordinary
participants have no role caption; Owner, Admin, and Viewing only mark
exceptions. Use a person's actions menu to change their role when permitted.
Community owners and admins also see **Default channel** on open channels:
when it is on, new community members join that channel automatically, with
the role the General access choice gives new people.
- **Message search** treats the last word you type as the start of a word, so
it searches only once that word has at least three characters, even with
`from:` or `in:`. A shorter last word shows how many characters to type, not
Expand Down
17 changes: 17 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,23 @@ Uncertain results survive closing the dialog and offer **Check save status**,
which reads without republishing. Opening or checking never writes. Private
channels with a Guest default remain intact when changing unrelated details.

Community owners and admins, as the relay-signed community roster names them,
also see a **Default channel** switch below the policy row on open, unarchived
stream channels (not Solo chats). When on, the relay joins every new community
member to the channel with its admission role, which the switch description
names (`NOSTR.md` § Default channels in block/buzz). Channel edit rights are not
required: the relay checks the community role. The switch saves immediately,
without a confirmation, as the exact two-tag `9002` command `[h, auto_join]`
with `true` or `false`; it never travels with other details, and both hosts'
channel-details validators admit that shape. The checked state is the bare
`auto_join` tag of fresh signed metadata, which the relay publishes only while
the setting is in effect, so it never shows optimistic success. Pending,
uncertain and rejected saves share the General access live region and recovery
above. Making a default channel private or archiving it leaves the relay's stored
setting in place; to clear it, switch it off while the channel is open. Readers'
previews show no switch, and a community admin must open the channel's members
dialog to change it.

For private channels, ordinary human additions explicitly request Member (Participate), including when an older saved admission default is
Guest. Existing roles are not changed; **Add as** still permits an individual
View only or Admin choice. Public additions keep their admission-default behavior,
Expand Down
61 changes: 61 additions & 0 deletions src-tauri/src/relay/channel_writes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ fn common(event: &EventTemplate) -> bool {
event.content.is_empty() && event.created_at <= i64::MAX as u64
}
fn details(event: &EventTemplate) -> bool {
// The default-channel setting travels alone: `[h, auto_join=true|false]`.
if event.kind == 9002 && common(event) && event.tags.len() == 2 {
return tag(event, 0, "h").is_some_and(uuid)
&& matches!(tag(event, 1, "auto_join"), Some("true" | "false"));
}
if event.kind != 9002 || !common(event) || !(3..=14).contains(&event.tags.len()) {
return false;
}
Expand Down Expand Up @@ -630,6 +635,62 @@ mod boundary_tests {
}
}

#[test]
fn details_admit_only_the_exact_default_channel_command() {
let host = IdentityHost::fixture();
let channel = || vec!["h".to_string(), uuid::Uuid::nil().to_string()];
let command = |tags: Vec<Vec<String>>| EventTemplate {
kind: 9002,
created_at: 1700000010,
content: String::new(),
tags,
};
for value in ["true", "false"] {
let template = command(vec![channel(), vec!["auto_join".into(), value.into()]]);
let event = serde_json::to_value(&template).unwrap();
assert!(validate("channel-details", &event, None).is_ok());
assert!(validate("channel-lifecycle", &event, None).is_err());
let signed = tauri::async_runtime::block_on(host.sign(template)).unwrap();
assert!(validate("channel-details", &signed, signed["pubkey"].as_str()).is_ok());
let mut altered = signed;
altered["tags"][1][1] =
Value::String(if value == "true" { "false" } else { "true" }.into());
assert!(validate("channel-details", &altered, altered["pubkey"].as_str()).is_err());
}
for tags in [
vec![channel(), vec!["auto_join".into()]],
vec![channel(), vec!["auto_join".into(), "".into()]],
vec![channel(), vec!["auto_join".into(), "yes".into()]],
vec![
channel(),
vec!["auto_join".into(), "true".into(), "extra".into()],
],
vec![vec!["auto_join".into(), "true".into()], channel()],
vec![
vec!["h".into(), "not-a-channel".into()],
vec!["auto_join".into(), "true".into()],
],
vec![
channel(),
vec!["auto_join".into(), "true".into()],
vec!["auto_join".into(), "true".into()],
],
vec![channel(), vec!["name".into(), "Team".into()]],
] {
assert!(
!valid("channel-details", &command(tags.clone())),
"{tags:?}"
);
}
// The setting never rides along with other channel details.
let mut combined = details_event(vec![vec!["auto_join".into(), "true".into()]]);
assert!(!valid("channel-details", &combined));
combined.content = "x".into();
combined.tags.truncate(1);
combined.tags.push(vec!["auto_join".into(), "true".into()]);
assert!(!valid("channel-details", &combined));
}

#[test]
fn details_reject_huddle_markers_at_sign_and_publish_boundaries() {
let mut event = details_event(vec![]);
Expand Down
131 changes: 131 additions & 0 deletions src/bundled/channels/ChannelGeneralAccess.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -516,3 +516,134 @@ it.each([true, false])(
expect(h.save).not.toHaveBeenCalled();
},
);

const relay = "e".repeat(64);
const viewer = "a".repeat(64);
function roster(role: "owner" | "admin" | "member") {
return {
relayAuthor: relay,
read: vi.fn(async () => [
{
id: "1".repeat(64),
kind: 13534,
pubkey: relay,
created_at: 1,
content: "",
sig: "2".repeat(128),
tags: [["member", viewer, role]],
},
]),
};
}

it("lets a community admin without channel edit rights choose a default channel, only confirming the relay readback", async () => {
const h = harness();
const current: ChannelDetails = {
...base,
joinRole: "member",
canEdit: false,
autoJoin: false,
};
h.load.mockResolvedValue(current);
let finish = () => {};
h.save.mockImplementationOnce(
() => new Promise<void>((resolve) => (finish = resolve)),
);
const user = userEvent.setup();
render(
<ChannelGeneralAccess
capability={h.capability}
channelId="alpha"
roster={roster("admin")}
viewer={viewer}
/>,
);
const setting = await screen.findByRole("switch", {
name: "Default channel",
});
expect(
screen.getByText("New community members join automatically as members."),
).toBeInTheDocument();
expect(setting).not.toBeChecked();
// Channel edit rights still gate the access policy itself.
expect(
screen.getByRole("combobox", { name: "General access" }),
).toBeDisabled();
h.load.mockResolvedValue({ ...current, autoJoin: true });
setting.focus();
await user.keyboard(" ");
await waitFor(() => expect(h.save).toHaveBeenCalledOnce());
expect(h.save).toHaveBeenCalledWith(
current,
{ ...current, autoJoin: true },
expect.any(AbortSignal),
);
// Pending: no optimistic success, and no second submission.
expect(setting).not.toBeChecked();
expect(setting).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("status")).toHaveTextContent(
"Saving default channel…",
);
await user.click(setting);
expect(h.save).toHaveBeenCalledOnce();
await act(async () => finish());
await waitFor(() => expect(setting).toBeChecked());
expect(screen.getByRole("status")).toHaveTextContent("Access updated");
});

it.each([
{ role: "member", details: { autoJoin: false } },
{ role: "admin", details: { autoJoin: undefined } },
{ role: "owner", details: { autoJoin: false, labels: ["buzz.solo"] } },
] as const)(
"omits the default channel setting for a community $role on $details",
async ({ role, details }) => {
const h = harness();
const reader = roster(role);
h.load.mockResolvedValue({ ...base, ...details });
render(
<ChannelGeneralAccess
capability={h.capability}
channelId="alpha"
roster={reader}
viewer={viewer}
/>,
);
await waitFor(() =>
expect(
screen.getByRole("combobox", { name: "General access" }),
).toBeEnabled(),
);
await waitFor(() => expect(reader.read).toHaveBeenCalled());
await act(async () => {});
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
},
);

it("explains a failed community role read beside the setting and retries it in place", async () => {
const h = harness();
const reader = roster("admin");
reader.read.mockRejectedValueOnce(new Error("offline"));
h.load.mockResolvedValue({ ...base, autoJoin: false });
const user = userEvent.setup();
render(
<ChannelGeneralAccess
capability={h.capability}
channelId="alpha"
roster={reader}
viewer={viewer}
/>,
);
expect(
await screen.findByText(/Couldn’t check your community role/),
).toBeInTheDocument();
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(
await screen.findByRole("switch", { name: "Default channel" }),
).not.toBeChecked();
expect(
screen.queryByText(/Couldn’t check your community role/),
).not.toBeInTheDocument();
expect(reader.read).toHaveBeenCalledTimes(2);
});
Loading
Loading