feat: add support for NIP-29 #56
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
Reference in New Issue
Block a user