From cd7b4597510f99aaa0fca9d2f09c6d4b44cd6c4a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 12:52:26 +0700 Subject: [PATCH] add GroupParticipants state event support --- crates/nip29/src/group.rs | 15 ++++++++++++-- crates/nip29/src/protocol.rs | 34 ++++++++++++++++++++++++++++++- docs/nip29-backend-plan.md | 39 ++++++++++++++++++++++++++++++++---- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index c15a8f40..f1adc9af 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -10,8 +10,9 @@ use state::NostrRegistry; use crate::LOCAL_KEYS; use crate::protocol::{ - GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupPins, GroupRoles, - MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow, + GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins, + GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, + TimelineWindow, }; const REPLAY_LIMIT: usize = 100; @@ -40,6 +41,7 @@ pub struct Group { members: GroupMembers, admins: GroupAdmins, roles: GroupRoles, + participants: GroupParticipants, pins: GroupPins, membership: Membership, membership_events: Vec, @@ -64,6 +66,7 @@ impl Group { members: GroupMembers::default(), admins: GroupAdmins::default(), roles: GroupRoles::default(), + participants: GroupParticipants::default(), pins: GroupPins::default(), membership: Membership::Unknown, membership_events: Vec::new(), @@ -99,6 +102,10 @@ impl Group { &self.roles } + pub fn participants(&self) -> &GroupParticipants { + &self.participants + } + pub fn pins(&self) -> &GroupPins { &self.pins } @@ -478,6 +485,10 @@ impl Group { Ok(roles) => self.roles = roles, Err(_) => return false, }, + Kind::GroupLivekitParticipants => match GroupParticipants::parse(event) { + Ok(participants) => self.participants = participants, + Err(_) => return false, + }, Kind::Custom(PIN_LIST) => match GroupPins::parse(event) { Ok(pins) => self.pins = pins, Err(_) => return false, diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index fbf82431..c6c3c41a 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -22,11 +22,12 @@ pub const RENDER_KINDS: [Kind; 5] = [ pub const PIN_LIST: u16 = 39_005; pub const UPDATE_PIN_LIST: u16 = 9_010; -pub const STATE_KINDS: [Kind; 5] = [ +pub const STATE_KINDS: [Kind; 6] = [ Kind::GroupMetadata, Kind::GroupAdmins, Kind::GroupMembers, Kind::GroupRoles, + Kind::GroupLivekitParticipants, Kind::Custom(PIN_LIST), ]; @@ -519,6 +520,37 @@ impl GroupRoles { } } +#[derive(Debug, Clone, Default)] +pub struct GroupParticipants(Vec); + +impl GroupParticipants { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupLivekitParticipants { + bail!("not a group participants event: kind {}", event.kind); + } + + let mut participants = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "participant") { + let Some(public_key) = tag.content() else { + continue; + }; + + participants.push(PublicKey::parse(public_key)?); + } + + Ok(Self(participants)) + } + + pub fn contains(&self, public_key: &PublicKey) -> bool { + self.0.contains(public_key) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Pin { Event(EventId), diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md index e592e28a..7275e067 100644 --- a/docs/nip29-backend-plan.md +++ b/docs/nip29-backend-plan.md @@ -19,7 +19,7 @@ models as two entities. | 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 | +| 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=` | the self pubkey is the author of the fetched `39000`; no `39000` means no canonical naddr | @@ -235,6 +235,16 @@ impl GroupRoles { 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); + +impl GroupParticipants { + pub fn parse(event: &Event) -> Result; + pub fn contains(&self, public_key: &PublicKey) -> bool; + pub fn iter(&self) -> impl Iterator; +} + /// kind:10009 — the account's own list. pub struct GroupList(Vec); pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option } @@ -469,9 +479,29 @@ group's relay only, and treat the relay's `OK` as the truth. 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`. +5. LiveKit AV and fork/migration detection, split in three: + 1. **`39004` participants. Done.** `GroupParticipants` joins `protocol.rs` + with the same shape as `GroupMembers` but reads `participant` tags (the NIP + does not use `p` here); one malformed pubkey fails the whole event, as in + `GroupAdmins`. The kind joins `STATE_KINDS`, so the standing subscription + already carries it, and `absorb_state` folds it into `Group::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: unlike + `supported_kinds`, the NIP gives the empty list no special meaning, so the + UI reads "no one is live" either way. + 2. The `/.well-known/nip29/livekit/` token endpoint: a NIP-98 + `27235` event in `Authorization`, returning the LiveKit JWT and server URL, + plus the `204` probe at `/.well-known/nip29/livekit` that tells the UI + whether a relay supports AV at all. Not started. This is the only part that + needs an HTTP client in the crate, so it is where the dependency decision + (if any) lands; `livekit` on `39000` is already parsed but currently inert. + 3. Fork/migration detection: read the `10009` of the group's admins (pubkeys + from `39001`, cached locally) and compare the relay hint against the relay + we are talking to; a different hint means the group may have moved or been + forked, which is the client's cue to offer fetching from the new relay. + Not started; the periodic — and MUST-when-offline — trigger is what + `Monitor` would feed, so the two decisions go together. ## UI later — the hooks this backend leaves @@ -483,6 +513,7 @@ group's relay only, and treat the relay's `OK` as the truth. | 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()` | | Membership badge (member, pending, refused, removed) | `Group::membership()` | | Copy an invite link | `GroupMetadata::naddr(relay, invite)` | | Leave | `GroupsRegistry::leave` |