Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
44a0b51
feat(channels): add admission roles and guest reading states
tellaho Oct 28, 2026
536b95e
fix(channels): enforce guest write permissions in sessions
tellaho Oct 28, 2026
ba65513
fix(channels): hide invitations without write access
tellaho Oct 28, 2026
b542c06
fix(channels): expose admission settings for older channels
tellaho Oct 28, 2026
045d7c8
fix(channels): preserve guest previews across permalink navigation
tellaho Oct 28, 2026
dbafdab
feat(channels): manage general access alongside people
tellaho Oct 28, 2026
cfae08a
fix(channels): reshape access controls as share rows
tellaho Oct 28, 2026
032e56b
fix(channels): restore member actions to ellipsis menus
tellaho Oct 28, 2026
3f23f83
fix(channels): identify failed access save requests
tellaho Oct 28, 2026
dc6a864
fix(channels): explain read-only access without editing errors
tellaho Oct 28, 2026
759180b
fix(channels): condense the read-only access explanation
tellaho Oct 28, 2026
146e855
fix(channels): label contribution policy in the reader footer
tellaho Oct 28, 2026
97bd261
fix(channels): clarify reading and contribution defaults
tellaho Oct 28, 2026
0add059
fix(channels): shorten the read-only footer label
tellaho Oct 28, 2026
0305f7a
fix(channels): clarify participation and viewing access
tellaho Oct 28, 2026
61c2570
fix(channels): show inline access progress and align role choices
tellaho Oct 29, 2026
ca90044
fix(channels): show community identity in access controls
tellaho Oct 29, 2026
4f97359
fix(channels): center access icons and enlarge community artwork
tellaho Oct 29, 2026
8504f46
fix(channels): name private access by channel membership
tellaho Oct 29, 2026
99e8815
fix(channels): render audience choice as a larger text link
tellaho Oct 29, 2026
36d8c3c
fix(channels): tighten audience caption spacing
tellaho Oct 29, 2026
de8b1f7
feat(channels): add role choices beside member invitations
tellaho Oct 29, 2026
8b53aa3
fix(channels): balance community image spacing across access views
tellaho Oct 29, 2026
55d260b
fix(channels): add room around the community image
tellaho Oct 29, 2026
c428c25
fix(channels): finish split Add menu states and role choices
tellaho Oct 29, 2026
6f204e8
fix(channels): center split caret and clarify add actions
tellaho Oct 29, 2026
5023788
fix(channels): describe roles beneath add menu heading
tellaho Oct 29, 2026
4017236
fix(channels): name participant role in add menu
tellaho Oct 29, 2026
95140ec
test(channels): locate guest access dialog by its current title
tellaho Oct 29, 2026
7505ecd
fix(channels): reconcile permissions with current shared contracts
tellaho Oct 29, 2026
27c5744
fix(channels): preserve cancellation errors before publication
tellaho Oct 29, 2026
d833df2
fix(channels): preserve guest membership commands
tellaho Oct 29, 2026
30c0fd1
fix(channels): retain guest search and canvas reading
tellaho Oct 29, 2026
e1e3c22
fix(channels): describe private access as a new-member default
tellaho Oct 29, 2026
eb88e8d
refactor(design-system): remove unused ghost select appearance
tellaho Oct 29, 2026
50d86b8
test(channels): exercise guest canvas panel access
tellaho Oct 29, 2026
b35d346
fix(channels): allow joined guests to open readable panels
tellaho Oct 29, 2026
d97e38e
docs(channels): match bot role actions to the participant menu
tellaho Oct 29, 2026
28ce9fb
fix(channels): retain guest panel context and tabs
tellaho Oct 29, 2026
711582f
fix(channels): separate canvas reading from contribution access
tellaho Oct 29, 2026
c5a33b2
fix(channels): lead the member list with general access
tellaho Oct 29, 2026
e9faa88
fix(channels): align access type with member identities
tellaho Oct 29, 2026
9e390e9
fix(channels): lead access rows without a heading
tellaho Oct 29, 2026
493a504
fix(channels): tighten access lines and trailing alignment
tellaho Oct 29, 2026
a7d38bc
fix(channels): give private access artwork a subtle backdrop
tellaho Oct 29, 2026
7f1dd94
fix(channels): restore fixed access controls below members
tellaho Oct 29, 2026
67bcc04
fix(channels): match access inset to the artwork gap
tellaho Oct 29, 2026
74bafd3
fix(channels): tighten access separator spacing
tellaho Oct 29, 2026
5376f68
fix(channels): balance fixed access footer spacing
tellaho Oct 29, 2026
13b2bbc
fix(channels): center access artwork on its text lines
tellaho Oct 29, 2026
0f9fd0c
docs(channels): align access comment with fixed placement
tellaho Oct 29, 2026
b4fcc19
test(channels): follow details duration with the cancel action
tellaho Oct 29, 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
17 changes: 15 additions & 2 deletions browser-host/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3464,7 +3464,20 @@ test("details routes are narrow, require the live owner, and do not widen lifecy
expect(signed).toMatchObject(change);
await transport.channelDetails.publish(signed, signal);
}
for (const role of ["member", "guest"]) {
const change = {
...template,
tags: [...template.tags, ["join_role", role]],
};
expect((await h.post("sign", change)).status).toBe(400);
expect((await h.post("channel-lifecycle-sign", change)).status).toBe(400);
const signed = await transport.channelDetails.sign(change, signal);
expect(signed).toMatchObject(change);
await transport.channelDetails.publish(signed, signal);
}
for (const tags of [
[...template.tags, ["join_role", "admin"]],
[...template.tags, ["join_role", "guest"], ["join_role", "member"]],
[...template.tags.slice(0, 3), ["ttl", "+1"]],
[...template.tags.slice(0, 3), ["ttl", "1\n"]],
[...template.tags.slice(0, 3), ["ttl", "1"], ["ttl", ""]],
Expand Down Expand Up @@ -3493,13 +3506,13 @@ test("details routes are narrow, require the live owner, and do not widen lifecy
expect(reopened).toMatchObject(reopening);
expect((await h.post("publish", reopened)).status).toBe(400);
await transport.channelDetails.publish(reopened, signal);
expect(h.publications).toHaveLength(4);
expect(h.publications).toHaveLength(6);
const foreign = finalizeEvent(
structuredClone(template),
new Uint8Array(32).fill(5),
);
expect((await h.post("channel-details-publish", foreign)).status).toBe(400);
expect(h.publications).toHaveLength(4);
expect(h.publications).toHaveLength(6);
} finally {
live?.dispose();
await h.close();
Expand Down
133 changes: 87 additions & 46 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,9 +518,10 @@ navigation, not a new archived-channel directory.

Search also finds active public channels the viewer has not joined, by name,
labeled **Public channel · not joined** after joined matches. Opening one shows the
read-only preview. The preview offers **Join channel**; after the relay confirms
read-only preview. Member-default previews offer **Join channel**; after the relay confirms
membership, the channel joins the sidebar and the composer becomes available and
focused. Joining a channel is not available for DMs, private, archived or session
focused. Guest-default previews omit Join and preserve reading without membership.
Joining a channel is not available for DMs, private, archived or session
conversations. See [relay queries](relay-queries.md#public-channel-name-search-and-join).
Unarchive publishes the existing narrow `9002` command with `archived=false` and
requires fresh relay metadata with a missing/false archive tag before updating
Expand Down Expand Up @@ -604,17 +605,15 @@ No dismissal saves, retries, replaces setup or confirms a destructive action.

## Editing channel details

Channel Settings shows the signed name, description and explicit visibility for
ordinary channels; missing visibility stays **Not available**, not implicitly
Public. The centered title opens the same editor, revealing a pencil with a short
Channel Settings shows the signed name and description for ordinary channels. The centered title opens the same editor, revealing a pencil with a short
left-to-right fade on hover or keyboard focus (always visible on touch, no animation
for reduced motion). The pencil follows the final text line;
balanced side padding keeps the text centered and lets long titles wrap without clipping it.
Description and Visibility have small pencils immediately after their labels,
Description has a small pencil after its label,
revealed with a short left-to-right fade on whole-row hover or keyboard focus
(always visible on touch, no animation for reduced motion) without shifting layout;
each whole row opens the shared Dialog with one Name/Description/Duration/Private draft.
Opening Description focuses its textarea; the title and Visibility still focus Name.
the whole row opens the shared Dialog with one Name/Description/Duration draft.
Opening Description focuses its textarea; the title focuses Name.
Only authorized editors get the interactive rows; other viewers retain plain metadata.
Members has a right chevron and opens the existing member list. Channel ID has a
small inline copy icon with the same hover/focus/touch behavior; selecting its row
Expand All @@ -623,8 +622,9 @@ growing the row; hovering or focusing does not open a hint or replay old feedbac
Clipboard failure shows an inline error and leaves the row available to retry. The redundant standalone
Edit details button, header-menu Edit details entry and informational Channel type
row are omitted.
Duration uses Create's Ongoing/Temporary cards, and Private uses the same
switch in the action row. These controls stage changes; neither publishes immediately.
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.
**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 @@ -672,8 +672,8 @@ keyboard navigation switch immediately; hidden controls are inert and excluded
from accessibility, with one complete accessible description per state.
Steps are **1 day ↔ 7 days ↔ 2 weeks**, starting at seven days, with
unavailable directions disabled. Switching to Ongoing and back retains the chosen
time within the draft. Private reuses the shared switch, left-aligned in each
footer with matching enabled/disabled label treatment. Both forms use a compact
time within the draft. Create keeps its Private switch in the footer; Edit details
leaves access management to the members dialog. Both forms use a compact
12px header-to-body gap and 16px field spacing. Write ownership stays with each
workflow.

Expand All @@ -686,7 +686,7 @@ Description on submission; Edit preserves its exact text and can explicitly clea

Both dialogs opt into backdrop dismissal and guard Close, Escape and backdrop
dismissal with an in-dialog discard confirmation when the draft differs. Comparison covers raw
text, staged privacy and effective duration. Reverting
text, effective duration and, for Create, staged privacy. Reverting
all fields removes the warning. Pending operations still block dismissal; once
settled, closing abandons any uncertain Create. Returning from a discard confirmation focuses Name and retains the draft.

Expand Down Expand Up @@ -719,29 +719,46 @@ and membership (`39002`) for the exact channel, plus current session participati
Only direct channel owners/admins may edit ordinary stream/forum channels. Cached,
read-only, archived, DM and Solo chat views do not offer this editor. A local
key, delegated agent role or community-admin status does not imply channel authority.
Public ↔ private is supported as a draft choice, never an immediate mutation.
In both Create and Edit, a Private toggle normally replaces the form with a confirmation
step in the same modal. The whole surface crossfades using `motion/react`: the form scales from
1 to 1.05 and blurs while confirmation sharpens from 0.95 to 1. Returning reverses
the transition; reduced motion and keyboard navigation swap immediately. Outgoing
controls are inert, with one backdrop and focus trap throughout. This happens
before changing the switch, including a reversal back to the saved
value. There is one backdrop and focus trap, not a second overlaid modal. Public → private says
“Only channel members will have access.” Private → public explicitly warns that everyone in
this community can view the channel's full history. Continue
only stages the choice; Create channel or Save changes still performs the write. Cancel/Escape or
the confirmation's Close control leaves the switch unchanged and returns to the
intact form with focus on the switch. All other draft fields—including Create's
selected destination—survive either path. The consequence
is shown once in the confirmation body, connected as the dialog's accessible description.
**Don’t show me this again** is saved only on **Continue** and skips both public and
private warnings. It is a device-local preference shared by Create and Edit for the
same community/viewer scope, using the existing view-state storage. Existing opt-outs
for either direction also skip both warnings. Cancel, Escape,
Close and backdrop dismissal do not remember it. If storage is unavailable or invalid,
the warning remains enabled. Skipping the warning still only stages the choice:
Create/Save, authorization and discard protection are unchanged. A busy creation
cannot open confirmation.
Create's Private switch stages a choice behind the existing in-modal consequence
confirmation. The device-local opt-out applies to that Create warning; Continue
only stages the choice and Create channel performs the write.

Access management lives in the members dialog. It retains the existing member
list, row menus and confirmed role changes. Roles appear as muted
text before each row’s ellipsis button; Member and Guest are shown as **Participating**
and **Viewing only**. The ellipsis menu contains permitted role choices with
the verified selection checked, alongside profile/removal actions and the existing role
confirmation. Immutable roles such as Owner and self remain protected.
The access row stays below and outside the scrolling member list, without a visible
section heading. It uses the same avatar slot and type sizes as member rows: a community avatar/lock
icon, a compact audience Select (**Who can read**), a short result description,
and the trailing **New people can** Select (Participate / View only), with short
descriptions of the permitted actions. Audience options show the community avatar and
lock icon inside the dropdown. The audience Select uses the shared text-link
treatment; the participation Select is outlined and vertically centered against
the audience row, with its popup aligned to the trigger’s right edge. On narrow screens the role control wraps
under the audience. The confirmation explains “People already in the channel keep
their access.”; the note is not a permanent paragraph below the controls.
Each selection opens a consequence confirmation. Cancel/Escape returns focus to
the unchanged Select. **Change access** saves immediately through the existing
channel-details capability, then reloads signed metadata. Loading, pending and
unconfirmed saves disable both controls. A pending save shows an inline spinner
in the leading icon slot instead of a separate saving message. Rejections offer
**Reload access**.
failures before publication identify the initial access check, signing, or final
access check that failed, retaining the underlying error. No request is retried.
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.

Readers see General access as plain text from verified channel discovery metadata,
without calling the editing capability. Its audience description is followed by
“Viewing only. Ask an owner or admin to participate.” The private-channel summary
describes what new members can do; existing participants keep their roles. The compact timeline
label says “This channel is read-only.” for Guest-default channels; a Guest in a
Member-default channel sees “Read-only access.” Guest-default previews never suggest
joining to add people or agents.

In Edit, visibility is sent only when changed (`private` or `open`);
text-only edits omit it so they do not reopen the channel in the remaining write race.
The relay remains the final authority.
Expand Down Expand Up @@ -770,8 +787,8 @@ survives failed reloads, so an untouched duration follows the next successful re
while an explicit change to Ongoing remains staged. This baseline never grants
editing authority: a failed read still clears the current base and disables Save.
If a failed reload has lost the prior base, privacy adopts the fresh value
conservatively: a stale public text draft must not become reopening intent. Choose
visibility again after that recovery. Inspect the retained edits before
conservatively: a stale public text draft must not become reopening intent.
Inspect the retained edits before
saving again. A lost publication response or failed/mismatched readback locks the submitted draft and
offers **Check save status**, which only reads and never republishes. Uncertain
intent survives panel close/reopen and cache clear within the same session; no
Expand Down Expand Up @@ -1005,18 +1022,42 @@ another non-owner member:

| Target role | Change role | Remove from this channel |
| --- | --- | --- |
| Admin / Member / Guest | Admin / Member, excluding the current role | Yes |
| Bot | Admin / Member, after explicit confirmation | Yes |
| Admin / Member / Guest | Admin / Member / Guest, excluding the current role | Yes |
| Bot | Admin / Guest, after explicit confirmation; Participating is already selected | Yes |
| Owner, self, unknown or inconsistent | No | No |

Agent identity is independent of channel role. Explicitly promoting a Bot to Admin grants authority to that agent’s own public key and preserves its verified agent identity; the human owner’s roles do not confer authority.

The menu deliberately omits **Make guest** while Guest's permission contract is
unsettled: the inspected relay message path does not enforce the role's documented
read-only meaning, while Git push policy does distinguish Guest from Member.
Existing Guest roles remain available to the permission/confirmation flow and can be deliberately changed to Member or
Admin, or removed; they are never automatically converted. This is a menu-only
restriction, not a change to relay semantics or the broker's supported commands.
The menu includes **Make guest** through the existing deliberate confirmation
flow. Participating and Viewing only captions represent each participant's
verified Member and Guest role; changing the admission default never changes those roles.
Agent Bot roles retain the contributing presentation.

The members dialog's General access controls keep reading access and new people's
contribution access separate. The note explicitly preserves existing participants'
access; a Guest default leaves designated writers able to contribute.
Owners/admins save choices through the existing kind:9002 command. Missing `join_role` means Member, matching legacy relay
behavior. An unrecognized or malformed explicit default maps to Guest so previews
fail closed. These defaults never replace a participant's stored role.
Unchanged or omitted defaults are not resent, and success requires matching fresh
signed metadata; an older relay that ignores the choice cannot report success.

A Guest's signed roster role makes the channel read-only independently of that
default. Channel and thread history remain available, with a read-only notice
instead of a composer or Join button. Nonmembers of a public Guest-default channel
get the same reading view without Join; opening message or thread links does not
create membership. Explicit promotion to Member restores the
composer; changing only `join_role` does not. Guests retain Leave, and the relay
remains the authority for every write.

The session keeps membership-based reading/lifecycle access separate from writing
permission. Direct guest writes and uploads are refused, and queued writes are
checked again before signing and publication, including signed retries. The
relay's verified agent-owner edit/delete exception is preserved; it does not
permit ordinary posts or reactions.
Guests can search the member directory without invitation controls. Public
previews whose admission default is Guest also omit invitations; existing
writing members retain them independently of the default.

Current members may also remove their own verified non-owner agents, but cannot
edit their roles or remove someone else's agent. The service verifies signed
Expand Down
9 changes: 8 additions & 1 deletion docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,14 @@ channel through `refer`, as any channel-only link does. Raw
list, their label follows the exact reference, so a rename or lost access
shows at once rather than the directory's older name.

A public preview offers **Join channel**. It sends the NIP-29 `9021` join request
A Member-default public preview offers **Join channel**. Guest-default previews
keep history and thread links readable without Join or writing controls, and
opening a link creates no membership. After Leave, fresh signed public metadata
can restore preview reading while retained membership stays denied; stale or
private metadata cannot reopen reading. Missing defaults mean Member; malformed
explicit defaults mean Guest.

Join sends the NIP-29 `9021` join request
through the purpose-bound channel-lifecycle sign/publish route; generic signing
rejects `9021`. Before signing, the lifecycle owner re-reads signed metadata and
the viewer roster: a current roster entry needs no request, and only active public
Expand Down
43 changes: 42 additions & 1 deletion src-tauri/src/relay/channel_writes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ fn common(event: &EventTemplate) -> bool {
event.content.is_empty() && event.created_at <= i64::MAX as u64
}
fn details(event: &EventTemplate) -> bool {
if event.kind != 9002 || !common(event) || !(3..=13).contains(&event.tags.len()) {
if event.kind != 9002 || !common(event) || !(3..=14).contains(&event.tags.len()) {
return false;
}
let (Some(id), Some(name), Some(about)) = (
Expand Down Expand Up @@ -47,6 +47,12 @@ fn details(event: &EventTemplate) -> bool {
index += 1;
}
// NIP-CL: trailing `t` tags replace the label set; one `["t", ""]` clears it.
if let Some(role) = tag(event, index, "join_role") {
if !matches!(role, "member" | "guest") {
return false;
}
index += 1;
}
let labels = &event.tags[index..];
let labels_valid = details_labels(labels);
uuid(id)
Expand Down Expand Up @@ -568,6 +574,41 @@ mod boundary_tests {
}
}

#[test]
fn details_admit_only_member_and_guest_admission_defaults() {
let host = IdentityHost::fixture();
for role in ["member", "guest"] {
let event =
serde_json::to_value(details_event(vec![vec!["join_role".into(), role.into()]]))
.unwrap();
let parsed = validate("channel-details", &event, None).unwrap();
assert!(validate("channel-lifecycle", &event, None).is_err());
let signed = tauri::async_runtime::block_on(host.sign(parsed)).unwrap();
assert!(validate("channel-details", &signed, signed["pubkey"].as_str()).is_ok());
}
for extra in [
vec![vec!["join_role", "owner"]],
vec![vec!["join_role", "admin"]],
vec![vec!["join_role", "bot"]],
vec![vec!["join_role", ""]],
vec![vec!["join_role"]],
vec![vec!["join_role", "guest", "extra"]],
vec![vec!["join_role", "member"], vec!["join_role", "guest"]],
vec![vec!["join_role", "guest"], vec!["ttl", "60"]],
] {
let template = details_event(
extra
.iter()
.map(|tag| tag.iter().map(|s| (*s).into()).collect())
.collect(),
);
let event = serde_json::to_value(&template).unwrap();
assert!(validate("channel-details", &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_err());
}
}

#[test]
fn details_reject_huddle_markers_at_sign_and_publish_boundaries() {
let mut event = details_event(vec![]);
Expand Down
Loading
Loading