feat: add support for NIP-29 #56

Merged
reya merged 27 commits from feat/nip29 into master 2026-10-01 04:23:21 +00:00
3 changed files with 81 additions and 7 deletions
Showing only changes of commit cd7b459751 - Show all commits
+13 -2
View File
@@ -10,8 +10,9 @@ use state::NostrRegistry;
use crate::LOCAL_KEYS; use crate::LOCAL_KEYS;
use crate::protocol::{ use crate::protocol::{
GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupPins, GroupRoles, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins,
MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
TimelineWindow,
}; };
const REPLAY_LIMIT: usize = 100; const REPLAY_LIMIT: usize = 100;
@@ -40,6 +41,7 @@ pub struct Group {
members: GroupMembers, members: GroupMembers,
admins: GroupAdmins, admins: GroupAdmins,
roles: GroupRoles, roles: GroupRoles,
participants: GroupParticipants,
pins: GroupPins, pins: GroupPins,
membership: Membership, membership: Membership,
membership_events: Vec<Event>, membership_events: Vec<Event>,
@@ -64,6 +66,7 @@ impl Group {
members: GroupMembers::default(), members: GroupMembers::default(),
admins: GroupAdmins::default(), admins: GroupAdmins::default(),
roles: GroupRoles::default(), roles: GroupRoles::default(),
participants: GroupParticipants::default(),
pins: GroupPins::default(), pins: GroupPins::default(),
membership: Membership::Unknown, membership: Membership::Unknown,
membership_events: Vec::new(), membership_events: Vec::new(),
@@ -99,6 +102,10 @@ impl Group {
&self.roles &self.roles
} }
pub fn participants(&self) -> &GroupParticipants {
&self.participants
}
pub fn pins(&self) -> &GroupPins { pub fn pins(&self) -> &GroupPins {
&self.pins &self.pins
} }
@@ -478,6 +485,10 @@ impl Group {
Ok(roles) => self.roles = roles, Ok(roles) => self.roles = roles,
Err(_) => return false, 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) { Kind::Custom(PIN_LIST) => match GroupPins::parse(event) {
Ok(pins) => self.pins = pins, Ok(pins) => self.pins = pins,
Err(_) => return false, Err(_) => return false,
+33 -1
View File
@@ -22,11 +22,12 @@ pub const RENDER_KINDS: [Kind; 5] = [
pub const PIN_LIST: u16 = 39_005; pub const PIN_LIST: u16 = 39_005;
pub const UPDATE_PIN_LIST: u16 = 9_010; pub const UPDATE_PIN_LIST: u16 = 9_010;
pub const STATE_KINDS: [Kind; 5] = [ pub const STATE_KINDS: [Kind; 6] = [
Kind::GroupMetadata, Kind::GroupMetadata,
Kind::GroupAdmins, Kind::GroupAdmins,
Kind::GroupMembers, Kind::GroupMembers,
Kind::GroupRoles, Kind::GroupRoles,
Kind::GroupLivekitParticipants,
Kind::Custom(PIN_LIST), Kind::Custom(PIN_LIST),
]; ];
@@ -519,6 +520,37 @@ impl GroupRoles {
} }
} }
#[derive(Debug, Clone, Default)]
pub struct GroupParticipants(Vec<PublicKey>);
impl GroupParticipants {
pub fn parse(event: &Event) -> Result<Self> {
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<Item = &PublicKey> {
self.0.iter()
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pin { pub enum Pin {
Event(EventId), Event(EventId),
+35 -4
View File
@@ -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 | | 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 | 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 | | 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 | | 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 | | 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 | | 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 |
@@ -235,6 +235,16 @@ impl GroupRoles {
pub fn roles(&self) -> &[Role]; 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. /// kind:10009 — the account's own list.
pub struct GroupList(Vec<GroupListEntry>); pub struct GroupList(Vec<GroupListEntry>);
pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option<String> } pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option<String> }
@@ -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 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 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. messages" button bound to `Group::loading_more()` to be observable at all.
5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token 5. LiveKit AV and fork/migration detection, split in three:
endpoint with a NIP-98 `27235` authorization event) and fork/migration 1. **`39004` participants. Done.** `GroupParticipants` joins `protocol.rs`
detection through the admins' `10009`. 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/<group-id>` 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 ## 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()` | | Load older messages | `GroupsRegistry::load_more(&key, cx)`; spinner from `Group::loading_more()` |
| Send / react | `Group::send_message` / `send_reaction` | | Send / react | `Group::send_message` / `send_reaction` |
| Members, admins, roles, flags | `Group::members/admins/roles/metadata` | | 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()` | | Membership badge (member, pending, refused, removed) | `Group::membership()` |
| Copy an invite link | `GroupMetadata::naddr(relay, invite)` | | Copy an invite link | `GroupMetadata::naddr(relay, invite)` |
| Leave | `GroupsRegistry::leave` | | Leave | `GroupsRegistry::leave` |