feat: add support for NIP-29 #56
@@ -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<Event>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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)]
|
||||
pub enum Pin {
|
||||
Event(EventId),
|
||||
|
||||
@@ -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=<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];
|
||||
}
|
||||
|
||||
/// 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> }
|
||||
@@ -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/<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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user