Files
coop/docs/nip29-backend-plan.md
T

553 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](https://github.com/nostr-protocol/nips/blob/master/29.md) —
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 | 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.
- **`previous` must 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's `OK false` message 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), and
`Kind::SimpleGroups` (10009). `Kind::ChatMessage` is kind 9.
- **`9010` and `39005` are 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 no `nip29.rs`, and no
`GroupId`, metadata type, parser or builder exists. Everything in `protocol.rs`
below 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's `UniversalSigner` works here (NIP-46 included), as it does in
`crates/chat/src/room.rs`.
- `client.database().query/save_event/delete`.
- `Nip19Coordinate { coordinate, relays }` with `ToBech32`/`FromBech32` for
`naddr`.
- **NIP-11 is not exposed.** `Relay` in this SDK has no document accessor, so the
relay self pubkey is taken from the author of the group's `39000` event; a
group whose relay publishes no `39000` therefore 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 `39000`s) 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.
```rust
/// 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: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]` (from `GroupId::h_tag`); `previous` is
added only when we have at least one eligible event, and its values are the
first 8 characters of the hex ids.
- `GroupReference::parse` splits `?invite=` before bech32 decoding; a missing
relay hint is an error (there is nowhere to send the join request).
- `GroupList::parse` accepts `group` tags with 1–3 values (id, relay, name).
- `Membership::from_events` takes the newest `9000`/`9001` whose `p` tags name
`me`; on an exact tie it prefers `9001` (removal). `Pending` and `Refused` are
not things the relay folds into an event: the join path sets them from the
`9021` send and from the relay's refusal message.
- `GroupMetadata::supports` distinguishes "tag absent" from "tag present with no
values": the first means all kinds, the second means no text messages.
- The builders return `EventBuilder`s 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`):
```rust
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)` and `key.id().state_filter()`. The id is
`grp-<8 hex of the group key hash>` (NIP-01 caps ids at 64 chars; the real key
lives in `routes`). The relay is added and connected first
(`add_relay(url).and_connect()`), like `community::sync::connect_relays`. On
open, also issue a one-shot `key.id().membership_filter(me)` fetch so a restart
learns `9000`/`9001` immediately; 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::Message` and routes
`RelayMessage::Event`/`EndOfStoredEvents`/`Closed` by subscription id through
`routes`. For `h`-tagged events it saves the envelope first, then sends
`Signal::Event(key, event)`; state events send `Signal::State(key, event)`;
`Closed` sends `Signal::Refused(key, message)`. The consumer calls
`Group::ingest`, which is the only place entity state changes.
- **Envelope cache.** Same shape as `set_rumor`/`get_rumor` in `crates/chat`:
`ApplicationSpecificData` (30078, addressable) with `d` = event id (dedup on
replay), `r` = `"<relay>|<id>"` (per-fork history), `k` = kind, content = the
raw event JSON, signed by a crate-local `LOCAL_KEYS`. Every `h`-tagged event
the standing subscription delivers is enveloped — any kind, because the
`previous` window is literally "the last 50 events seen" (NIP-29 timeline
references) — while the `d`-tagged state events (`39000`–`39003`) are not.
Reading is `Filter::new().custom_tag(LOWERCASE_R, key)`, parsed and sorted by
`created_at`, then partitioned: the render set (`1`, `7`, `9`, `11`, `1111`)
becomes messages, `9000`/`9001` fold membership, the rest only feed the
window. This is what makes the timeline fork-safe and restart-safe without
teaching the database about relays.
- **`Group` state.** `GroupKey`, `metadata: Option<GroupMetadata>`,
`metadata_event: Option<Event>` (what `GroupMetadata::naddr` is built from),
`members`, `admins`, `roles`, `membership: Membership` (including the local
`Pending`/`Refused` access states), `messages: Vec<Message>` for the render set
(reusing `chat::Message`, which already parses any event into display fields),
an id index, a `TimelineWindow` for `previous`, `loading`, `refused`. It emits
`GroupEvent::{Incoming(Event), Updated, Refused(String), Eose}` — the panel
subscribes to these the way `ChatPanel` subscribes to `RoomEvent`.
- **`load()`.** Subscribe to our own `10009` (`ReqTarget::auto`, limit 1), then
read the newest one from the database into `self.list`, and add one group per
entry. A newer `10009` arriving 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, then
`client.send_event(&event).to([relay])`. Kind 9 is the default message kind;
when `metadata.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 into `messages`, emit `GroupEvent::Incoming`. On `OK false`:
return the relay's message (it already carries `invalid:` / `restricted:` /
`duplicate:` prefixes) and let the panel show it. Reactions go through the same
path with `key.id().reaction(..)`.
- **Join.** `GroupReference::parse` → error if no relay hint → add/connect the
relay → fetch `39000` (`limit 1`) to confirm the group and learn the self
pubkey; `GroupMetadata::parse` it; a hidden group may refuse this — proceed
without metadata rather than failing → create the entity, fetch state +
membership, subscribe → send `key.id().join_request(code, ..)` — the `h` tag
plus the `code` tag when an invite code was parsed → mark
`Membership::Pending { since }` and `self.list.upsert(..)`, so the group stays
listed while it waits. The relay either emits `9000` (member), refuses the
`9021` with `OK false` (set `Refused { 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 with `restricted:` /
`auth-required:` in a `CLOSED` (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(..)`; on `OK`, forget the group's
envelopes (`client.database().delete(filter)` by `r` tag), drop the entity and
`self.list.remove(key)`, then publish. The relay also emits `9001`, which the
(now gone) subscription would have seen.
- **Publish `10009`.** Sign `self.list.to_builder()` and publish with
`to_nip65()`. The registry's `GroupList` is the one loaded at start, join
`upsert`s an entry and leave `remove`s 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
1. Moderation writes: `9000` put-user (roles), `9001` remove-user, `9002`
edit-metadata, `9005` delete-event, `9009` create-invite; gate the UI on the
caller appearing in `39001`, let the relay be the enforcer. **Done** as pure
writes: builders on `GroupId`, senders on `Group` sharing `dispatch`.
`edit_metadata` takes a `GroupMetadata`, which is now `Default`-able (its
private `id`/`author` origin are `Option`) so the UI can start an edit from
scratch or from `Group::metadata()`. **`9005` is 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 its `d` = target id and the group's `r` cache tag) and
evict it from `messages`/`index`, otherwise only a reload clears it.
2. Group creation (`9007`) where the relay supports it, and relay browsing.
**Done.** `GroupId::create_group` reuses the `9002` metadata tags, so the
proposed id (`h`) and the initial name/flags go in one event; the relay
republishes `39000`, which the standing subscription picks up.
`GroupsRegistry::create` mirrors `join`: `ensure` → optimistic `10009` entry
+ `publish_list` → publish, and on `OK false` it `forget`s the group and emits
`GroupsEvent::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 the `9007`.
`GroupsRegistry::browse(relay, cx)` follows the same pipeline as every
other read: it registers a `brw-<hash>` subscription (an opaque id, like
`grp-<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 bare
`SubscriptionId::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, and `handle_event`/`handle_eose`/
`handle_closed` fold each relay-signed `39000` into `self.browse`, keyed by
the subscription id, so no DB scoping is needed. `GroupsRegistry::browsed(relay)`
returns `Browse { relay, loading, groups: Vec<GroupCandidate> }` for the UI, and
`GroupsEvent::Updated` says when to re-read it. No `fetch_events` anywhere:
the DB cannot be scoped to a relay (a stored event carries no origin), so the
subscription's own events are the source. `join` now delegates to a new
`join_key(key, invite)`, which is what a browse result needs (a candidate has
a key, not a `GroupReference`).
3. Pins and subgroups. **Pins done:** `Pin::{Event, Address}` and
`GroupPins` in `protocol.rs`, with `PIN_LIST` (39005) and `UPDATE_PIN_LIST`
(9010) as `u16` consts used through `Kind::Custom`. `39005` joins
`STATE_KINDS`, so the standing subscription fetches it, `absorb_state` folds
it into `Group::pins()`, and `Group::update_pins(pins)` writes `9010` through
the shared `dispatch`. **Subgroups parsed:** `GroupMetadata` gained
`pub children: Vec<GroupId>` (order preserved, parsed from `child` tags) and
`metadata_tags` re-emits them — `9002` requires it, an edit that drops a
child is rejected. `parent`/`children` are per-relay, so assembling the tree
from `Group::metadata()` is UI work; there is no backend tree helper.
4. Range backfill: page the timeline with `until` for "load more". **Done.**
`GroupId::timeline_page(limit, until)` is `timeline_filter` plus `.until(..)`,
and `Group::load_page` sends it as a `pg-<hash>` subscription (auto-close on
EOSE, 10s cap) alongside the standing `grp-<hash>` one, which keeps running so
paging cannot replace the live tail. The read path is the usual one: `pg-`
joins `is_own_subscription`, the id is registered in `routes` against the same
key, and `handle_event` therefore caches each page event and `reload`s 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 before `Group::load_page` sets the flag.
`handle_eose` and a page `CLOSED` remove the route and call
`Group::page_finished`, which clears the flag and emits `GroupEvent::Updated`
so a panel's spinner can settle. The cursor is `Group::oldest()`, the oldest
`h`-tagged event ever hydrated, updated by `track` next to `window.push` —
*not* read from `TimelineWindow`, which keeps only the newest 50 events, so a
cursor taken from it would never move and would page the same window forever.
`until` is inclusive, so a page repeats the event it starts from; `hydrate`
dedups by id, so that costs one redundant fetch, never a gap. `forget` also
drops and unsubscribes the `pg-` route (`reset` already sweeps `routes`). Two
gaps left for the UI phase: a relay that accepts the REQ but never EOSEs leaves
`loading_more` true (the auto-close timeout closes the subscription locally and
emits no notification — the same stickiness as `Browse::loading`), and a group
whose next page shares the `until` second 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 to `Group::loading_more()` to be observable at all.
5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token
endpoint with a NIP-98 `27235` authorization event) and fork/migration
detection through the admins' `10009`.
## 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::Message`s) |
| 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` |
| 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.5 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui`
styles; the only GPUI APIs the backend uses are the ones `chat` and `community`
already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`,
`EventEmitter`, `Subscription` — and the panel's components (`ListState`,
`MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the app.
## Tests
- `protocol.rs` unit tests: `GroupReference::parse` (naddr, `?invite=`, garbage,
missing relay hint, an naddr whose kind is not `39000`); `GroupMetadata::parse`
flags and `supports` with `supported_kinds` absent vs empty; admins with roles;
members; roles; `GroupList` round trip (`parse` → `upsert` → `to_builder`);
`TimelineWindow::previous_tag` (excludes self, caps at 3, 8-char prefixes,
any-kind window, `None` when empty); `Membership::from_events` (`9000` vs
`9001`, newest wins, tie prefers removal; `Pending`/`Refused` are exercised
through the join path); `GroupId::message` carries `h` and the reply `e` tags;
`GroupMetadata::naddr` round trip with and without an invite code.
- Cache round trip with `nostr-memory` as a dev-dependency (the pattern
`crates/community` already uses): save two envelopes (a kind 9 and a kind
`9000`), read them back sorted, assert the partition — the kind 9 renders, the
`9000` only folds membership — re-save one and confirm no duplicate, `delete`
clears the group.
- Routing: subscription id ↔ `GroupKey` mapping survives a resubscribe.
- One integration test against the SDK's `LocalRelay`
(`nostr-sdk` dev-dependency with the `local-relay` feature): seed a `39000`
and a kind 9 with an `h` tag, run `Group::connect`, assert metadata and the
timeline land. Timeouts in GPUI-driven tests use
`cx.background_executor().timer(..)`, never `smol::Timer`, per `AGENTS.md`.
## Decisions
1. **Crate.** `crates/nip29` (the body above is written for it).
2. **Timeline kinds.** Render `1`, `9`, `11`, `1111` and `7`; every other kind
is still cached and feeds the `previous` window, per the NIP's timeline
references rule.
3. **Default message kind.** Kind 9; the composer is disabled when
`supported_kinds` is present and lacks it.
4. **Pending joins.** Pending (and refused) groups are listed in the sidebar,
with an access state on the row.
5. **`10009` merge.** Union-only; removals made by another client are not
detected and no diffing is done.
6. **Leave and the cache.** Leaving wipes the group's envelopes.
7. **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.
8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group
whose relay publishes no `39000` cannot be referenced or joined in v1.