39 KiB
NIP-29 groups backend
Coop has two relay-backed backends today: crates/chat (NIP-17 gift-wrap DMs,
rendered by crates/chat_ui) and crates/community (Concord). This document is a
plan, not a description of shipped code; nothing below is wired up yet. It covers
the backend only — reading, writing, joining and leaving relay-based groups as
defined by NIP-29 —
and ends with the hooks a UI will use later.
The spec in backend terms
A group is a (relay, id) pair. The relay enforces membership and signs the
group's state; there is no cross-relay identity, no encryption, and no portable
"create". The same id on two relays is two groups (a fork), which the plan
models as two entities.
| Concern | Wire | Backend rule |
|---|---|---|
| Group id | random string, relay-chosen | h tag on everything a user sends; d tag on relay state events |
| Timeline | any event kind + h |
v1 renders kinds 1, 9, 11, 1111 and 7; every other kind is cached for the previous window but not rendered, except the membership kinds below |
| Timeline refs | previous tag, first 8 hex chars of event ids |
built from the last 50 events seen on that relay (any kind — NIP-29 timeline references), excluding our own; send up to 3 |
| Relay state | 39000 metadata, 39001 admins, 39002 members, 39003 roles, 39004 LiveKit participants |
relay-signed, addressable by d; read, never written by the client |
| Membership | 9021 join, 9022 leave (user-signed); relay answers with 9000 put-user / 9001 remove-user |
membership = latest 9000/9001 naming our pubkey; no event means not a member, and Pending/Refused are tracked locally for private groups |
| Own group list | 10009 (NIP-51 simple groups) |
["group", id, relay, name?] plus ["r", relay]; replaceable, published to NIP-65 relays |
| Reference | naddr1… of kind 39000 with the relay self pubkey and a relay hint, optionally ?invite=<code> |
the self pubkey is the author of the fetched 39000; no 39000 means no canonical naddr |
Two behaviours that shape the code:
- Late publication is rejected. Relays drop messages with old timestamps, so there is no offline send queue in v1: a send either goes now or fails visibly.
previousmust reference events the relay already has. Relays may enforce at least 3 references; a group we have little history for may have its sends rejected until the backlog loads. The relay'sOK falsemessage is surfaced as-is.
Not in v1
Moderation writes (9000–9010), group creation (9007, relay-dependent),
pinned events (39005/9010), subgroups (parent/child — parsed but inert),
LiveKit audio/video (livekit tag, 39004, the token endpoint), fork/migration
detection through admins' 10009, and relay browsing ({"kinds":[39000]} to
list a relay's public groups). All are additive; none change the model above.
What the local rust-nostr checkout gives us
Cross-checked against the pinned checkout
(~/.cargo/git/checkouts/nostr-619b808bb247a9ed/01f1950):
- Kinds exist for every NIP-29 event in v1:
Kind::GroupPutUser(9000),GroupRemoveUser(9001),GroupEditMetadata(9002),GroupDeleteEvent(9005),GroupCreateGroup(9007),GroupDeleteGroup(9008),GroupCreateInvite(9009),GroupJoinRequest(9021),GroupLeaveRequest(9022),GroupMetadata(39000),GroupAdmins(39001),GroupMembers(39002),GroupRoles(39003),GroupLivekitParticipants(39004), andKind::SimpleGroups(10009).Kind::ChatMessageis kind 9. 9010and39005are missing from this revision (Kind::Custom(9010),Kind::Custom(39005)if they are ever needed — they are not, in v1).- There is no NIP-29 module.
nostr/src/nips/has nonip29.rs, and noGroupId, metadata type, parser or builder exists. Everything inprotocol.rsbelow is ours; it is thin — tags and filters, no crypto. - The APIs to lean on are all present and already used in the app:
Tag::custom,Tag::event,Tag::identifier,Tag::public_key,Tag::content,Tag::as_slice.Filter::custom_tag(SingleLetterTag::LOWERCASE_H, id),.identifier(id),.author,.kinds,.limit,.since.ReqTarget::single(url, filters)for a group-scoped REQ,client.subscribe(..).with_id(..),client.fetch_events(..),client.send_event(&event).to([url]).EventBuilder::new(kind, content).tags(tags).finalize_async(&signer)— the app'sUniversalSignerworks here (NIP-46 included), as it does incrates/chat/src/room.rs.client.database().query/save_event/delete.Nip19Coordinate { coordinate, relays }withToBech32/FromBech32fornaddr.
- NIP-11 is not exposed.
Relayin this SDK has no document accessor, so the relay self pubkey is taken from the author of the group's39000event; a group whose relay publishes no39000therefore has no canonical naddr and cannot be shared or joined in v1.
What the app already does (and we copy)
The NIP-17 backend is the template. crates/chat has a global ChatRegistry
entity, one Room entity per conversation, a background task over
client.notifications() that sends signals through a flume channel, and a
foreground cx.spawn consumer that is the only place entities change. Local
messages are cached as ApplicationSpecificData (30078) events with a d tag
for the target id and an r tag for the room key (set_rumor/get_rumor,
crates/chat/src/lib.rs). crates/chat_ui renders chat::Message into a
ListState, resolving names through PersonRegistry.
crates/community shows the same pattern for relay-scoped state: routing
notifications by subscription id (route_of), bringing relays into the pool
before use (connect_relays), and treating a CLOSED message as a refusal.
Replacing a list is done in crates/workspace/src/panels/contact_list.rs:
sign the builder, client.send_event(&event).to_nip65().await — the client saves
the event locally even when the publish fails.
NIP-29 reuses all of this and drops what only NIP-17 needs: no encryption, no
per-member fan-out, no gift wraps, no SendReport per receiver — one relay, one
event, one OK.
Where this lands
A new crate crates/nip29, because NIP-29 is a different transport (plain relay
events), a different state authority (relay-signed 39000s) and a different UI
later. Making chat::Room polymorphic over gift-wrapped DMs and relay groups
would put two protocols in one entity. The alternative — modules inside
crates/chat — saves a Cargo.toml but was set aside (see Decisions).
| File | Owns |
|---|---|
crates/nip29/src/protocol.rs |
Pure NIP-29 types and their impl blocks: GroupId, GroupKey, GroupReference, GroupMetadata, admins/members/roles, GroupList, Membership, TimelineWindow. No I/O. |
crates/nip29/src/group.rs |
Group entity: state, timeline, membership, connect, ingest, send_message, send_reaction, join, leave, GroupEvent. |
crates/nip29/src/lib.rs |
GroupsRegistry global, init, standing subscriptions and the signal pump, the envelope cache, load/publish of the account's 10009. |
desktop/src/main.rs, web/src/lib.rs |
nip29::init(cx) after chat::init(cx). |
| UI later | crates/chat_ui (reuse chat::Message rendering) or a new crates/nip29_ui; sidebar section; a join dialog. |
protocol is re-exported at the crate root (pub use protocol::*) so callers
write nip29::GroupId, nip29::GroupMetadata. The crate's dependencies are the
ones chat already carries: state (client and signer), chat (Message),
gpui, nostr-sdk, anyhow, flume, futures, smallvec, log;
dev-dependencies nostr-memory and nostr-sdk with the local-relay feature
for the tests below.
Data flow
own 10009 (NIP-65 relays) group relay (one per group)
│ load / subscribe │ REQ #h=<id> (timeline + 9000/9001)
▼ │ REQ #d=<id> (39000..39003)
GroupsRegistry ──ensure relay──► Group entity ◄──ingest── background listener
│ index: GroupKey │ (saves envelope first)
│ │
└── flume Signal ◄──────────────────────┘
▲
UI later: GroupPanel (Vec<Message>), JoinDialog (naddr)
A group's subscriptions are the standing grp-<hash> REQ (timeline + state +
membership, the timeline replayed up to 100 events) and, on demand, a
pg-<hash> REQ that re-asks the same timeline filter with until set to the
oldest event hydrated so far. Both are recognised by the same pump and land in
the same envelope cache, so paging needs no separate read path.
Phase 0 — crates/nip29/src/protocol.rs (pure, no I/O)
The protocol is expressed as types with impl blocks: a parsed thing is a struct
and every operation on it is a method, so call sites read id.message(...),
metadata.naddr(...), admins.contains(&pk) instead of handing the pieces to
free builder functions.
/// The relay-chosen group id.
pub struct GroupId(String);
impl GroupId {
pub fn new(id: impl Into<String>) -> Result<Self, Error>; // trimmed, non-empty
pub fn as_str(&self) -> &str;
pub fn h_tag(&self) -> Tag; // ["h", id]
pub fn timeline_filter(&self, limit: usize) -> Filter; // #h = id
pub fn state_filter(&self) -> Filter; // #d = id, kinds 39000..39003
pub fn membership_filter(&self, me: PublicKey) -> Filter; // #h = id, #p = me, kinds 9000/9001
pub fn message(&self, content: String, replies: &[EventId], previous: Option<Tag>) -> EventBuilder; // kind 9
pub fn reaction(&self, target: EventId, emoji: String, previous: Option<Tag>) -> EventBuilder; // kind 7 + e
pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder; // 9021
pub fn leave_request(&self, reason: &str) -> EventBuilder; // 9022
}
/// Entity identity: the same id on another relay is another group (a fork).
pub struct GroupKey { relay: RelayUrl, id: GroupId }
impl GroupKey {
pub fn new(relay: RelayUrl, id: GroupId) -> Self;
pub fn relay(&self) -> &RelayUrl;
pub fn id(&self) -> &GroupId;
pub fn cache_tag(&self) -> String; // "<relay>|<id>"
pub fn uniq_id(&self) -> u64; // hashed, for panel and element ids
}
/// What a user pastes to join: naddr1…(+"?invite=<code>").
pub struct GroupReference { id: GroupId, relay: RelayUrl, invite: Option<String> }
impl GroupReference {
pub fn parse(input: &str) -> Result<Self, Error>;
pub fn id(&self) -> &GroupId;
pub fn relay(&self) -> &RelayUrl;
pub fn invite(&self) -> Option<&str>;
pub fn key(&self) -> GroupKey;
}
impl FromStr for GroupReference { .. } // so a pasted naddr can use `.parse()` too
/// kind:39000, relay-signed.
pub struct GroupMetadata {
pub name: Option<String>, pub picture: Option<String>, pub banner: Option<String>,
pub about: Option<String>, pub private: bool, pub restricted: bool, pub hidden: bool,
pub closed: bool, pub livekit: bool, pub parent: Option<GroupId>,
pub supported_kinds: Option<Vec<Kind>>, // None = unspecified (all); Some(vec![]) = none
}
impl GroupMetadata {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn name(&self) -> Option<&str>;
pub fn supports(&self, kind: Kind) -> bool;
pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result<String, Error>;
// Coordinate { kind: 39000, public_key: <the event author>, identifier: <the d tag> }
}
/// kind:39001 — each pubkey with its role labels.
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
impl GroupAdmins {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn entries(&self) -> &[(PublicKey, Vec<String>)];
pub fn contains(&self, public_key: &PublicKey) -> bool;
}
/// kind:39002 — may be absent or a subset; absence means "not published".
pub struct GroupMembers(Vec<PublicKey>);
impl GroupMembers {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn contains(&self, public_key: &PublicKey) -> bool;
pub fn iter(&self) -> impl Iterator<Item = &PublicKey>;
}
/// kind:39003 — role names are relay-defined.
pub struct GroupRoles(Vec<Role>);
pub struct Role { pub name: String, pub description: Option<String> }
impl GroupRoles {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn roles(&self) -> &[Role];
}
/// kind:39004 — who is live in the group's AV room; `participant` tags, not `p`.
/// Absence means nothing is known, which is not the same as an empty room.
pub struct GroupParticipants(Vec<PublicKey>);
impl GroupParticipants {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn contains(&self, public_key: &PublicKey) -> bool;
pub fn iter(&self) -> impl Iterator<Item = &PublicKey>;
}
/// kind:10009 — the account's own list.
pub struct GroupList(Vec<GroupListEntry>);
pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option<String> }
impl GroupList {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn entries(&self) -> &[GroupListEntry];
pub fn upsert(&mut self, entry: GroupListEntry);
pub fn remove(&mut self, key: &GroupKey);
pub fn to_builder(&self) -> EventBuilder; // kind 10009, group + r tags
}
pub enum Membership {
Unknown,
Pending { since: Timestamp }, // our 9021 went out; no answer yet
Member,
Refused { reason: Option<String> }, // the relay's OK false / CLOSED message
Removed,
}
impl Membership {
/// Folds 9000/9001 only; `Pending` and `Refused` are set by the join path.
pub fn from_events(events: &[Event], me: PublicKey) -> Self;
}
/// The newest-first window (≤50) that `previous` references are drawn from.
pub struct TimelineWindow(Vec<Event>);
impl TimelineWindow {
pub fn new() -> Self;
pub fn push(&mut self, event: Event); // dedups, caps at 50
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag>; // excludes `me`, ≤3 distinct 8-hex
}
Details that must be right:
- Every user event carries
["h", id](fromGroupId::h_tag);previousis added only when we have at least one eligible event, and its values are the first 8 characters of the hex ids. GroupReference::parsesplits?invite=before bech32 decoding; a missing relay hint is an error (there is nowhere to send the join request).GroupList::parseacceptsgrouptags with 1–3 values (id, relay, name).Membership::from_eventstakes the newest9000/9001whoseptags nameme; on an exact tie it prefers9001(removal).PendingandRefusedare not things the relay folds into an event: the join path sets them from the9021send and from the relay's refusal message.GroupMetadata::supportsdistinguishes "tag absent" from "tag present with no values": the first means all kinds, the second means no text messages.- The builders return
EventBuilders so the caller decides when to sign; nothing in this file does I/O.
Phase 1 — read path (group.rs + lib.rs)
GroupsRegistry (global entity, mirroring CommunityRegistry):
pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
index: HashMap<GroupKey, Entity<Group>>,
/// The account's 10009, loaded at start and mutated by join/leave
list: GroupList,
/// subscription id → group, rebuilt whenever a group resubscribes
routes: Arc<RwLock<HashMap<SubscriptionId, GroupKey>>>,
signal_tx: flume::Sender<Signal>, signal_rx: flume::Receiver<Signal>,
notification_listener: Option<Task<Result<()>>>, // cancelled on signer change
signal_consumer: Option<Task<Result<()>>>,
tasks: SmallVec<[Task<Result<()>>; 2]>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl GroupsRegistry {
pub fn global(cx: &App) -> Entity<Self>;
pub fn groups(&self) -> &[Entity<Group>];
pub fn group(&self, key: &GroupKey) -> Option<Entity<Group>>;
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>);
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>);
fn load(&mut self, cx); // own 10009 from the database into `self.list`
fn publish_list(&mut self, cx); // sign `self.list.to_builder()`, to_nip65, best-effort
}
- Subscriptions. One standing REQ per group, two filters in one id:
key.id().timeline_filter(100)andkey.id().state_filter(). The id isgrp-<8 hex of the group key hash>(NIP-01 caps ids at 64 chars; the real key lives inroutes). The relay is added and connected first (add_relay(url).and_connect()), likecommunity::sync::connect_relays. On open, also issue a one-shotkey.id().membership_filter(me)fetch so a restart learns9000/9001immediately; the state filter is fetched once (limit 1 per kind) so the sidebar has names before the live REQ settles. A dead relay costs only its own group. - Pump. The listener matches
ClientNotification::Messageand routesRelayMessage::Event/EndOfStoredEvents/Closedby subscription id throughroutes. Forh-tagged events it saves the envelope first, then sendsSignal::Event(key, event); state events sendSignal::State(key, event);ClosedsendsSignal::Refused(key, message). The consumer callsGroup::ingest, which is the only place entity state changes. - Envelope cache. Same shape as
set_rumor/get_rumorincrates/chat:ApplicationSpecificData(30078, addressable) withd= event id (dedup on replay),r="<relay>|<id>"(per-fork history),k= kind, content = the raw event JSON, signed by a crate-localLOCAL_KEYS. Everyh-tagged event the standing subscription delivers is enveloped — any kind, because thepreviouswindow is literally "the last 50 events seen" (NIP-29 timeline references) — while thed-tagged state events (39000–39003) are not. Reading isFilter::new().custom_tag(LOWERCASE_R, key), parsed and sorted bycreated_at, then partitioned: the render set (1,7,9,11,1111) becomes messages,9000/9001fold membership, the rest only feed the window. This is what makes the timeline fork-safe and restart-safe without teaching the database about relays. Groupstate.GroupKey,metadata: Option<GroupMetadata>,metadata_event: Option<Event>(whatGroupMetadata::naddris built from),members,admins,roles,membership: Membership(including the localPending/Refusedaccess states),messages: Vec<Message>for the render set (reusingchat::Message, which already parses any event into display fields), an id index, aTimelineWindowforprevious,loading,refused. It emitsGroupEvent::{Incoming(Event), Updated, Refused(String), Eose}— the panel subscribes to these the wayChatPanelsubscribes toRoomEvent.load(). Subscribe to our own10009(ReqTarget::auto, limit 1), then read the newest one from the database intoself.list, and add one group per entry. A newer10009arriving from another device is merged (GroupList::upsert) and the new groups are subscribed; removals by another client cannot be detected in a replaceable list and are not guessed at.
Phase 2 — write path
All writes sign with the app signer (finalize_async(&signer)), send to the
group's relay only, and treat the relay's OK as the truth.
- Send a message. Build with
key.id().message(content, &replies, window.previous_tag(me)), sign, thenclient.send_event(&event).to([relay]). Kind 9 is the default message kind; whenmetadata.supports(Kind::ChatMessage)is false the composer is disabled rather than sending a kind the relay does not take. On acceptance: save the envelope, insert intomessages, emitGroupEvent::Incoming. OnOK false: return the relay's message (it already carriesinvalid:/restricted:/duplicate:prefixes) and let the panel show it. Reactions go through the same path withkey.id().reaction(..). - Join.
GroupReference::parse→ error if no relay hint → add/connect the relay → fetch39000(limit 1) to confirm the group and learn the self pubkey;GroupMetadata::parseit; a hidden group may refuse this — proceed without metadata rather than failing → create the entity, fetch state + membership, subscribe → sendkey.id().join_request(code, ..)— thehtag plus thecodetag when an invite code was parsed → markMembership::Pending { since }andself.list.upsert(..), so the group stays listed while it waits. The relay either emits9000(member), refuses the9021withOK false(setRefused { reason }from its message — the NIP says the message explains whether the rejection is final, pending review or payment-required), or refuses the timeline REQ withrestricted:/auth-required:in aCLOSED(keep the state, do not raise an error toast). All three are observed on the standing subscription or the send result. - Leave. Send
key.id().leave_request(..); onOK, forget the group's envelopes (client.database().delete(filter)byrtag), drop the entity andself.list.remove(key), then publish. The relay also emits9001, which the (now gone) subscription would have seen. - Publish
10009. Signself.list.to_builder()and publish withto_nip65(). The registry'sGroupListis the one loaded at start, joinupserts an entry and leaveremoves one, so a rewrite can never drop a group we loaded. The client saves the event locally even when the publish fails, so the list survives offline.
Phase 3 — later, in rough order
- Moderation writes:
9000put-user (roles),9001remove-user,9002edit-metadata,9005delete-event,9009create-invite; gate the UI on the caller appearing in39001, let the relay be the enforcer. Done as pure writes: builders onGroupId, senders onGroupsharingdispatch.edit_metadatatakes aGroupMetadata, which is nowDefault-able (its privateid/authororigin areOption) so the UI can start an edit from scratch or fromGroup::metadata().9005is write-only for now: the relay stops serving the event but nothing is removed locally, so the deleted message lingers in the cache and timeline. The UI phase must drop the target envelope (filter on itsd= target id and the group'srcache tag) and evict it frommessages/index, otherwise only a reload clears it. - Group creation (
9007) where the relay supports it, and relay browsing. Done.GroupId::create_groupreuses the9002metadata tags, so the proposed id (h) and the initial name/flags go in one event; the relay republishes39000, which the standing subscription picks up.GroupsRegistry::createmirrorsjoin:ensure→ optimistic10009entrypublish_list→ publish, and onOK falseitforgets the group and emitsGroupsEvent::Error(reason)(that variant was previously unused). There is no NIP-11 support check available, so "where the relay supports it" is enforced by the relay refusing the9007.GroupsRegistry::browse(relay, cx)follows the same pipeline as every other read: it registers abrw-<hash>subscription (an opaque id, likegrp-<hash>; the relay is keyed in state, not encoded in the id, because the pump needs a prefix to recognise its own ids and a bareSubscriptionId::generate()would be indistinguishable from the other crates' ids on the shared client).add_relay+subscribe(auto-close on EOSE, 10s cap), and returns immediately. The global pump observes the events, andhandle_event/handle_eose/handle_closedfold each relay-signed39000intoself.browse, keyed by the subscription id, so no DB scoping is needed.GroupsRegistry::browsed(relay)returnsBrowse { relay, loading, groups: Vec<GroupCandidate> }for the UI, andGroupsEvent::Updatedsays when to re-read it. Nofetch_eventsanywhere: the DB cannot be scoped to a relay (a stored event carries no origin), so the subscription's own events are the source.joinnow delegates to a newjoin_key(key, invite), which is what a browse result needs (a candidate has a key, not aGroupReference).
- Pins and subgroups. Pins done:
Pin::{Event, Address}andGroupPinsinprotocol.rs, withPIN_LIST(39005) andUPDATE_PIN_LIST(9010) asu16consts used throughKind::Custom.39005joinsSTATE_KINDS, so the standing subscription fetches it,absorb_statefolds it intoGroup::pins(), andGroup::update_pins(pins)writes9010through the shareddispatch. Subgroups parsed:GroupMetadatagainedpub children: Vec<GroupId>(order preserved, parsed fromchildtags) andmetadata_tagsre-emits them —9002requires it, an edit that drops a child is rejected.parent/childrenare per-relay, so assembling the tree fromGroup::metadata()is UI work; there is no backend tree helper. - Range backfill: page the timeline with
untilfor "load more". Done.GroupId::timeline_page(limit, until)istimeline_filterplus.until(..), andGroup::load_pagesends it as apg-<hash>subscription (auto-close on EOSE, 10s cap) alongside the standinggrp-<hash>one, which keeps running so paging cannot replace the live tail. The read path is the usual one:pg-joinsis_own_subscription, the id is registered inroutesagainst the same key, andhandle_eventtherefore caches each page event andreloads the group, so it arrives through the envelope cache and the timeline like any other event.GroupsRegistry::load_more(key, cx)is the entry point: it does nothing while a page is in flight (Group::loading_more) or before anything is cached, and registers the route beforeGroup::load_pagesets the flag.handle_eoseand a pageCLOSEDremove the route and callGroup::page_finished, which clears the flag and emitsGroupEvent::Updatedso a panel's spinner can settle. The cursor isGroup::oldest(), the oldesth-tagged event ever hydrated, updated bytracknext towindow.push— not read fromTimelineWindow, which keeps only the newest 50 events, so a cursor taken from it would never move and would page the same window forever.untilis inclusive, so a page repeats the event it starts from;hydratededups by id, so that costs one redundant fetch, never a gap.forgetalso drops and unsubscribes thepg-route (resetalready sweepsroutes). Two gaps left for the UI phase: a relay that accepts the REQ but never EOSEs leavesloading_moretrue (the auto-close timeout closes the subscription locally and emits no notification — the same stickiness asBrowse::loading), and a group whose next page shares theuntilsecond cannot advance the cursor, so it can re-fetch the same page. Neither loops on its own; both need a "load older messages" button bound toGroup::loading_more()to be observable at all. - LiveKit AV and fork/migration detection, split in three:
39004participants. Done.GroupParticipantsjoinsprotocol.rswith the same shape asGroupMembersbut readsparticipanttags (the NIP does not usephere); one malformed pubkey fails the whole event, as inGroupAdmins. The kind joinsSTATE_KINDS, so the standing subscription already carries it, andabsorb_statefolds it intoGroup::participants(). Nothing is written by us — the relay publishes it and updates it as people come and go, so it rides the live tail like any other state event. "Absent" and "empty" are deliberately the same value here: unlikesupported_kinds, the NIP gives the empty list no special meaning, so the UI reads "no one is live" either way.- The
/.well-known/nip29/livekit/<group-id>token endpoint and the204probe at/.well-known/nip29/livekit. Done. The HTTP client is the one GPUI already owns:cx.http_client()returningArc<dyn HttpClient>, already wired toreqwest_client::ReqwestClienton desktop and tofetchon wasm — the same pathstate::nip05uses, which is why no HTTP dependency lands in this crate. Itsget()convenience cannot set headers, so the request goes throughHttpClient::sendwith aBuilder, an explicitRedirectPolicy::NoFollow(theAuthorizationheader is signed for exactly one URL, so a redirect could only ever fail verification) and a 10stimeout— both are opt-in extensions, and a request without them has none. The header itself is NIP-98 from the SDK:nostr/nip98is now enabled on the workspacenostrentry and nip29 declaresnostritself, soHttpData::new(url, HttpMethod::GET).to_authorization(&signer)buildsNostr <base64>— theu+methodtags that the crate's ownverify_auth_headerdemands, though the NIP's prose mentions onlyu.GroupKey::livekit_token_url/livekit_probe_urlderive the endpoint purely: the relay URL is rewritten to http(s) (keeping host and port), reset to the origin, and the path built withpop_if_empty().push(..)so each segment is escaped — a relay with its own path prefix loses it, since the NIP puts the endpoint at the absolute path.Group::livekit_sessionandGroup::livekit_supportedare the two calls, both&App+background_spawnlikepublish. The session is returned, not stored: tokens are per-join artifacts, not group state, so the caller owns it. Onlylivekit_supported's unauthenticated GET ignores status beyond204; the token endpoint's response shape is not specified by the NIP (see Decisions) and no LiveKit client library is involved — the backend stops at the JWT and server URL. - Fork/migration detection. Done. One standing
nip29-forkssubscription over{authors: <every admin of every group>, kinds: [10009]}onReqTarget::auto— the admins' own group lists live on their outbox relays, so this works with the group's relay down, and the admin pubkeys come from39001, which is hydrated from the envelope cache, so it works after a restart too. Each event is folded intoforks(author → newestGroupList, guarded oncreated_atbecause10009is replaceable), thenevaluate_forksrecomputes every group.Group::elsewhere()isSomewhen an admin of that group lists the same group id at a different relay; the data cannot distinguish a move from a fork, hence the neutral name. Evaluation is incremental, on each event, rather than batched on EOSE:ReqTarget::autofans out to several relays, so the first EOSE would otherwise evaluate — and clear — against one relay's partial view. Nothing is auto-joined, since the NIP asks the client to offer; the offer isGroupsRegistry::join_key(elsewhere, None, cx), which is exactly "fetch from the new relay and update our own10009". Two triggers, because the NIP's "periodically" and its "MUST when the relay is offline" are different things. Reactively, the group observer callssync_forks(false)onGroupEvent::Updated, subscribing as soon as the first39001lands and re-subscribing only when the admin set actually changes — the guard matters, sinceUpdatedalso fires for metadata, pins, membership and a finished page. Periodically, abackground_executor().timerloop started inloadpushesSignal::Forksevery 15 minutes andsync_forks(true)re-queries regardless: that is the recovery path for a subscription that died or a client that was offline, not the detection path.resetdrops the fork state and unsubscribes the REQ; aCLOSEDon it is only logged, because the tick retries and there is nothing to repair.
UI later — the hooks this backend leaves
| UI action | Call |
|---|---|
| Paste an naddr and join (or retry a refused join) | GroupReference::parse → GroupsRegistry::join |
| Open a group | GroupsRegistry::group(&key, cx) → panel subscribes to GroupEvent |
| Timeline | Group::messages() (already chat::Messages) |
| Load older messages | GroupsRegistry::load_more(&key, cx); spinner from Group::loading_more() |
| Send / react | Group::send_message / send_reaction |
| Members, admins, roles, flags | Group::members/admins/roles/metadata |
| Who is in the AV room | Group::participants() |
| Join AV (get a LiveKit session) | Group::livekit_session(cx) → await → LivekitSession { token, server_url } |
| Is AV available here | Group::livekit_supported(cx) after seeing metadata().livekit |
| The group may have moved or been forked | Group::elsewhere() → offer GroupsRegistry::join_key(elsewhere, None, cx) |
| Membership badge (member, pending, refused, removed) | Group::membership() |
| Copy an invite link | GroupMetadata::naddr(relay, invite) |
| Leave | GroupsRegistry::leave |
| Create a group | GroupMetadata { ..Default::default() } → GroupsRegistry::create(key, metadata, cx); failure surfaces as GroupsEvent::Error |
| Browse a relay's groups | GroupsRegistry::browse(relay, cx) → re-read GroupsRegistry::browsed(relay) on GroupsEvent::Updated; join a result with GroupsRegistry::join_key(candidate.key, None, cx) |
| Moderate (put/remove user, edit metadata, delete event, invite) | Group::put_user/remove_user/edit_metadata/delete_event/create_invite |
| Pins | Group::pins() to read, Group::update_pins(pins) to replace the list |
| Subgroup tree | Group::metadata() → parent/children, assembled per relay |
Where it lands: the sidebar gains a section reading GroupsRegistry::groups()
(the same shape as its Communities section) with pending and refused groups
listed alongside members; a new panel renders Vec<Message> with the existing
ListState + ui::message::MessageRow and PersonRegistry for authors. The
message data is already shared (chat::Message), so only ChatPanel's
row-rendering methods need lifting into ui (or copying) rather than the type. A
join dialog accepts a pasted naddr (with or without ?invite=). System
notifications can hang off GroupEvent::Incoming when the window is inactive.
Nothing new is needed from GPUI to support this backend. The local checkouts
are gpui-pre 0.3.7 (aliased as gpui) and gpui-base 0.7.0, which crates/ui
styles; the GPUI APIs the backend uses are the ones chat and community
already use — Entity/WeakEntity, Context::spawn, background_spawn,
EventEmitter, Subscription — plus two that state already uses:
App::http_client() for the AV token fetch and
App::background_executor().timer(..) for the fork tick. The panel's components
(ListState, MessageRow, Avatar, Button, Scrollbar) already exist in the
app.
Tests
protocol.rsunit tests:GroupReference::parse(naddr,?invite=, garbage, missing relay hint, an naddr whose kind is not39000);GroupMetadata::parseflags andsupportswithsupported_kindsabsent vs empty; admins with roles; members; roles;GroupListround trip (parse→upsert→to_builder);TimelineWindow::previous_tag(excludes self, caps at 3, 8-char prefixes, any-kind window,Nonewhen empty);Membership::from_events(9000vs9001, newest wins, tie prefers removal;Pending/Refusedare exercised through the join path);GroupId::messagecarrieshand the replyetags;GroupMetadata::naddrround trip with and without an invite code.- Cache round trip with
nostr-memoryas a dev-dependency (the patterncrates/communityalready uses): save two envelopes (a kind 9 and a kind9000), read them back sorted, assert the partition — the kind 9 renders, the9000only folds membership — re-save one and confirm no duplicate,deleteclears the group. - Routing: subscription id ↔
GroupKeymapping survives a resubscribe. - One integration test against the SDK's
LocalRelay(nostr-sdkdev-dependency with thelocal-relayfeature): seed a39000and a kind 9 with anhtag, runGroup::connect, assert metadata and the timeline land. Timeouts in GPUI-driven tests usecx.background_executor().timer(..), neversmol::Timer, perAGENTS.md.
Decisions
- Crate.
crates/nip29(the body above is written for it). - Timeline kinds. Render
1,9,11,1111and7; every other kind is still cached and feeds thepreviouswindow, per the NIP's timeline references rule. - Default message kind. Kind 9; the composer is disabled when
supported_kindsis present and lacks it. - Pending joins. Pending (and refused) groups are listed in the sidebar, with an access state on the row.
10009merge. Union-only; removals made by another client are not detected and no diffing is done.- Leave and the cache. Leaving wipes the group's envelopes.
- Restricted/private groups. A visible access state is required:
Membership::{Unknown, Pending, Member, Refused, Removed}with the relay's refusal reason shown and a retry, while the client's NIP-42 authenticator handles the reads. - References.
naddronly (?invite=accepted);id@relayis not. A group whose relay publishes no39000cannot be referenced or joined in v1. - LiveKit response shape. The NIP says the token endpoint returns "the
LiveKit JWT and the URL of the LiveKit server" but names no fields, so
LivekitSession::parsereadstoken(orjwt) andurl(orserver_url) and fails loudly on anything else. This is a guess, not a spec, and it is the one part of the AV path that a real relay can contradict; it is worth re-checking against whichever relay is targeted before the UI lands. Likewiselivekit_supportedtreats "not204" as "no AV" rather than an error, because it only feeds a button. Monitor. Not adopted. It is only reachable throughClientBuilder::monitor(Monitor::new(n)), so nothing can read per-relay status untilcrates/stateopts in, and itsStatusChangedstream does not distinguish a permanent outage from a reconnect — so it would need filtering before it could mean anything. The only thing it buys here is running the admins'10009check the instant a group's relay drops, rather than at the next 15-minute tick. The NIP frames that as MUST, but the substance is already covered: the admins come from the cached39001, so the check runs with the relay gone, just later. If that latency ever matters, the hook issync_forks(true, cx)driven by aStatusChangedconsumer — one place.