3 Commits
Author SHA1 Message Date
reya cfec6dfc85 add NIP-29 LiveKit token and fork detection 2026-09-30 13:08:05 +07:00
reya cd7b459751 add GroupParticipants state event support 2026-09-30 12:52:26 +07:00
reya d621b125a9 add timeline paging for older messages 2026-09-30 12:49:37 +07:00
7 changed files with 600 additions and 18 deletions
Generated
+3
View File
@@ -4602,9 +4602,12 @@ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
"flume 0.11.1", "flume 0.11.1",
"futures",
"gpui-pre", "gpui-pre",
"log", "log",
"nostr",
"nostr-sdk", "nostr-sdk",
"serde_json",
"smallvec", "smallvec",
"state", "state",
] ]
+1 -1
View File
@@ -32,7 +32,7 @@ nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
# Crypto (NIP-17 encrypted file messages) # Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10" aes-gcm = "0.10"
+3
View File
@@ -5,8 +5,11 @@ edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
nostr.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
futures.workspace = true
serde_json.workspace = true
state = { path = "../state" } state = { path = "../state" }
chat = { path = "../chat" } chat = { path = "../chat" }
+158 -4
View File
@@ -1,19 +1,27 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::time::Duration;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use chat::Message; use chat::Message;
use futures::io::AsyncReadExt;
use gpui::http_client::{AsyncBody, Builder, HttpRequestExt, Method, RedirectPolicy, StatusCode};
use gpui::{App, AppContext, Context, EventEmitter, Task}; use gpui::{App, AppContext, Context, EventEmitter, Task};
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; 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, LivekitSession, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS,
STATE_KINDS, TimelineWindow,
}; };
const REPLAY_LIMIT: usize = 100; const REPLAY_LIMIT: usize = 100;
const PAGE_LIMIT: usize = 50;
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
const LIVEKIT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Absorbed { enum Absorbed {
@@ -37,14 +45,18 @@ 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>,
elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>, state_ids: HashMap<Kind, EventId>,
messages: Vec<Message>, messages: Vec<Message>,
index: HashSet<EventId>, index: HashSet<EventId>,
window: TimelineWindow, window: TimelineWindow,
oldest: Option<Timestamp>,
loading: bool, loading: bool,
loading_more: bool,
refused: Option<String>, refused: Option<String>,
tasks: SmallVec<[Task<()>; 2]>, tasks: SmallVec<[Task<()>; 2]>,
} }
@@ -59,14 +71,18 @@ 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(),
elsewhere: None,
state_ids: HashMap::new(), state_ids: HashMap::new(),
messages: Vec::new(), messages: Vec::new(),
index: HashSet::new(), index: HashSet::new(),
window: TimelineWindow::new(), window: TimelineWindow::new(),
oldest: None,
loading: true, loading: true,
loading_more: false,
refused: None, refused: None,
tasks: smallvec![], tasks: smallvec![],
} }
@@ -92,6 +108,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
} }
@@ -100,6 +120,10 @@ impl Group {
&self.membership &self.membership
} }
pub fn elsewhere(&self) -> Option<&GroupKey> {
self.elsewhere.as_ref()
}
pub fn messages(&self) -> &[Message] { pub fn messages(&self) -> &[Message] {
&self.messages &self.messages
} }
@@ -108,6 +132,14 @@ impl Group {
self.loading self.loading
} }
pub fn loading_more(&self) -> bool {
self.loading_more
}
pub fn oldest(&self) -> Option<Timestamp> {
self.oldest
}
pub fn refused(&self) -> Option<&str> { pub fn refused(&self) -> Option<&str> {
self.refused.as_deref() self.refused.as_deref()
} }
@@ -236,6 +268,15 @@ impl Group {
cx.notify(); cx.notify();
} }
pub(crate) fn set_elsewhere(&mut self, elsewhere: Option<GroupKey>, cx: &mut Context<Self>) {
if self.elsewhere == elsewhere {
return;
}
self.elsewhere = elsewhere;
cx.emit(GroupEvent::Updated);
cx.notify();
}
pub(crate) fn connect(&mut self, cx: &mut Context<Self>) { pub(crate) fn connect(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -269,6 +310,107 @@ impl Group {
})); }));
} }
pub(crate) fn load_page(&mut self, cx: &mut Context<Self>) {
let Some(until) = self.oldest else {
return;
};
self.loading_more = true;
cx.notify();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let id = crate::page_subscription(&self.key);
let relay = self.key.relay().clone();
let filter = self.key.id().timeline_page(PAGE_LIMIT, until);
self.tasks.push(cx.background_spawn(async move {
client.add_relay(relay.clone()).and_connect().await.ok();
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(PAGE_TIMEOUT));
if let Err(error) = client
.subscribe(ReqTarget::single(relay, [filter]))
.with_id(id)
.close_on(opts)
.await
{
log::warn!("nip29: loading older messages failed: {error}");
}
}));
}
pub(crate) fn page_finished(&mut self, cx: &mut Context<Self>) {
self.loading_more = false;
cx.emit(GroupEvent::Updated);
cx.notify();
}
pub fn livekit_session(&self, cx: &App) -> Task<Result<LivekitSession>> {
let url = match self.key.livekit_token_url() {
Ok(url) => url,
Err(error) => return Task::ready(Err(error)),
};
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let client = cx.http_client();
cx.background_spawn(async move {
let authorization = HttpData::new(url.clone(), HttpMethod::GET)
.to_authorization(&signer)
.await?;
let request = Builder::new()
.uri(url.as_str())
.method(Method::GET)
.header("Authorization", authorization)
.follow_redirects(RedirectPolicy::NoFollow)
.timeout(LIVEKIT_TIMEOUT)
.body(AsyncBody::default())?;
let response = client.send(request).await?;
if !response.status().is_success() {
bail!(
"nip29: the AV token endpoint answered {}",
response.status()
);
}
let mut body = Vec::new();
response.into_body().read_to_end(&mut body).await?;
LivekitSession::parse(&body)
})
}
pub fn livekit_supported(&self, cx: &App) -> Task<Result<bool>> {
let url = match self.key.livekit_probe_url() {
Ok(url) => url,
Err(error) => return Task::ready(Err(error)),
};
let client = cx.http_client();
cx.background_spawn(async move {
let request = Builder::new()
.uri(url.as_str())
.method(Method::GET)
.follow_redirects(RedirectPolicy::NoFollow)
.timeout(LIVEKIT_TIMEOUT)
.body(AsyncBody::default())?;
let response = client.send(request).await?;
Ok(response.status() == StatusCode::NO_CONTENT)
})
}
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> { pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
let cache_tag = self.key.cache_tag(); let cache_tag = self.key.cache_tag();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -366,7 +508,7 @@ impl Group {
} }
if MEMBERSHIP_KINDS.contains(&event.kind) { if MEMBERSHIP_KINDS.contains(&event.kind) {
self.window.push(event.clone()); self.track(event);
if !event.tags.public_keys().any(|key| key == me) { if !event.tags.public_keys().any(|key| key == me) {
return Absorbed::Ignored; return Absorbed::Ignored;
@@ -387,7 +529,7 @@ impl Group {
}; };
} }
self.window.push(event.clone()); self.track(event);
if RENDER_KINDS.contains(&event.kind) { if RENDER_KINDS.contains(&event.kind) {
Absorbed::Rendered { Absorbed::Rendered {
@@ -398,6 +540,14 @@ impl Group {
} }
} }
fn track(&mut self, event: &Event) {
self.window.push(event.clone());
if self.oldest.is_none_or(|oldest| event.created_at < oldest) {
self.oldest = Some(event.created_at);
}
}
fn absorb_state(&mut self, event: &Event) -> bool { fn absorb_state(&mut self, event: &Event) -> bool {
match event.kind { match event.kind {
Kind::GroupMetadata => match GroupMetadata::parse(event) { Kind::GroupMetadata => match GroupMetadata::parse(event) {
@@ -416,6 +566,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,
+187 -1
View File
@@ -19,10 +19,13 @@ pub use protocol::*;
pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
const LIST_SUBSCRIPTION: &str = "nip29-list"; const LIST_SUBSCRIPTION: &str = "nip29-list";
const FORK_SUBSCRIPTION: &str = "nip29-forks";
const GROUP_PREFIX: &str = "grp-"; const GROUP_PREFIX: &str = "grp-";
const BROWSE_PREFIX: &str = "brw-"; const BROWSE_PREFIX: &str = "brw-";
const PAGE_PREFIX: &str = "pg-";
const BROWSE_LIMIT: usize = 500; const BROWSE_LIMIT: usize = 500;
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
@@ -50,12 +53,17 @@ enum Signal {
Event(SubscriptionId, Event), Event(SubscriptionId, Event),
Eose(SubscriptionId), Eose(SubscriptionId),
Closed(SubscriptionId, String), Closed(SubscriptionId, String),
Forks,
} }
fn list_subscription_id() -> SubscriptionId { fn list_subscription_id() -> SubscriptionId {
SubscriptionId::new(LIST_SUBSCRIPTION) SubscriptionId::new(LIST_SUBSCRIPTION)
} }
fn fork_subscription_id() -> SubscriptionId {
SubscriptionId::new(FORK_SUBSCRIPTION)
}
fn subscription_id(key: &GroupKey) -> SubscriptionId { fn subscription_id(key: &GroupKey) -> SubscriptionId {
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
} }
@@ -67,9 +75,21 @@ fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
} }
fn page_subscription(key: &GroupKey) -> SubscriptionId {
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
}
fn is_page(id: &SubscriptionId) -> bool {
id.as_str().starts_with(PAGE_PREFIX)
}
fn is_own_subscription(id: &SubscriptionId) -> bool { fn is_own_subscription(id: &SubscriptionId) -> bool {
let id = id.as_str(); let id = id.as_str();
id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) id == LIST_SUBSCRIPTION
|| id == FORK_SUBSCRIPTION
|| id.starts_with(GROUP_PREFIX)
|| id.starts_with(BROWSE_PREFIX)
|| id.starts_with(PAGE_PREFIX)
} }
impl EventEmitter<GroupsEvent> for GroupsRegistry {} impl EventEmitter<GroupsEvent> for GroupsRegistry {}
@@ -80,6 +100,8 @@ pub struct GroupsRegistry {
browse: HashMap<SubscriptionId, Browse>, browse: HashMap<SubscriptionId, Browse>,
routes: HashMap<SubscriptionId, GroupKey>, routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>, observers: HashMap<GroupKey, Subscription>,
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
fork_authors: HashSet<PublicKey>,
signal_tx: flume::Sender<Signal>, signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>, signal_rx: flume::Receiver<Signal>,
notification_listener: Option<Task<()>>, notification_listener: Option<Task<()>>,
@@ -130,6 +152,8 @@ impl GroupsRegistry {
browse: HashMap::new(), browse: HashMap::new(),
routes: HashMap::new(), routes: HashMap::new(),
observers: HashMap::new(), observers: HashMap::new(),
forks: HashMap::new(),
fork_authors: HashSet::new(),
signal_tx: tx, signal_tx: tx,
signal_rx: rx, signal_rx: rx,
notification_listener: None, notification_listener: None,
@@ -259,6 +283,22 @@ impl GroupsRegistry {
self.browse.get(&browse_subscription(relay)) self.browse.get(&browse_subscription(relay))
} }
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(key, cx) else {
return;
};
if group.read(cx).loading_more() || group.read(cx).oldest().is_none() {
return;
}
self.routes.insert(page_subscription(key), key.clone());
group.update(cx, |group, cx| {
group.load_page(cx);
});
}
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>) { pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(key, cx) else { let Some(group) = self.group(key, cx) else {
return; return;
@@ -289,6 +329,8 @@ impl GroupsRegistry {
self.groups.clear(); self.groups.clear();
self.observers.clear(); self.observers.clear();
self.forks.clear();
self.fork_authors.clear();
self.list = GroupList::default(); self.list = GroupList::default();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -296,6 +338,7 @@ impl GroupsRegistry {
let mut ids: Vec<SubscriptionId> = routes.into_keys().collect(); let mut ids: Vec<SubscriptionId> = routes.into_keys().collect();
ids.push(list_subscription_id()); ids.push(list_subscription_id());
ids.push(fork_subscription_id());
ids.extend(self.browse.keys().cloned()); ids.extend(self.browse.keys().cloned());
self.browse.clear(); self.browse.clear();
@@ -340,6 +383,8 @@ impl GroupsRegistry {
} }
Err(error) => log::warn!("nip29: loading the group list failed: {error}"), Err(error) => log::warn!("nip29: loading the group list failed: {error}"),
})); }));
self.watch_forks(cx);
} }
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) { fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
@@ -354,6 +399,101 @@ impl GroupsRegistry {
cx.notify(); cx.notify();
} }
fn watch_forks(&mut self, cx: &mut Context<Self>) {
let executor = cx.background_executor().clone();
let tx = self.signal_tx.clone();
self.tasks.push(cx.background_spawn(async move {
loop {
executor.timer(FORK_INTERVAL).await;
if tx.send_async(Signal::Forks).await.is_err() {
break;
}
}
}));
}
fn admin_keys(&self, cx: &App) -> HashSet<PublicKey> {
let mut keys = HashSet::new();
for group in &self.groups {
for (admin, _) in group.read(cx).admins().entries() {
keys.insert(*admin);
}
}
keys
}
fn sync_forks(&mut self, force: bool, cx: &mut Context<Self>) {
let authors = self.admin_keys(cx);
if authors.is_empty() || (!force && authors == self.fork_authors) {
return;
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let filter = Filter::new()
.authors(authors.iter().copied())
.kind(Kind::SimpleGroups);
self.fork_authors = authors;
self.tasks.push(cx.background_spawn(async move {
if let Err(error) = client
.subscribe(ReqTarget::auto([filter]))
.with_id(fork_subscription_id())
.await
{
log::warn!("nip29: subscribing to the admins' group lists failed: {error}");
}
}));
}
fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context<Self>) {
let Ok(list) = GroupList::parse(event) else {
log::warn!("nip29: ignoring a malformed admin group list");
return;
};
let author = event.pubkey;
if let Some((seen, _)) = self.forks.get(&author)
&& *seen >= event.created_at
{
return;
}
self.forks.insert(author, (event.created_at, list));
self.evaluate_forks(cx);
}
fn evaluate_forks(&mut self, cx: &mut Context<Self>) {
let groups = self.groups.clone();
for group in groups {
let elsewhere = {
let group = group.read(cx);
let key = group.key();
group
.admins()
.entries()
.iter()
.filter_map(|(admin, _)| self.forks.get(admin))
.flat_map(|(_, list)| list.entries())
.find(|entry| entry.id == *key.id() && entry.relay != *key.relay())
.map(|entry| GroupKey::new(entry.relay.clone(), entry.id.clone()))
};
group.update(cx, |group, cx| {
group.set_elsewhere(elsewhere, cx);
});
}
}
fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) { fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) {
if self.group(&key, cx).is_some() { if self.group(&key, cx).is_some() {
return; return;
@@ -371,6 +511,7 @@ impl GroupsRegistry {
} }
GroupEvent::Updated => { GroupEvent::Updated => {
this.refresh_entry(group, cx); this.refresh_entry(group, cx);
this.sync_forks(false, cx);
} }
_ => {} _ => {}
}), }),
@@ -394,9 +535,11 @@ impl GroupsRegistry {
fn forget(&mut self, key: GroupKey, cx: &mut Context<Self>) { fn forget(&mut self, key: GroupKey, cx: &mut Context<Self>) {
let id = subscription_id(&key); let id = subscription_id(&key);
let page = page_subscription(&key);
let cache_tag = key.cache_tag(); let cache_tag = key.cache_tag();
self.routes.remove(&id); self.routes.remove(&id);
self.routes.remove(&page);
self.observers.remove(&key); self.observers.remove(&key);
self.groups.retain(|group| group.read(cx).key() != &key); self.groups.retain(|group| group.read(cx).key() != &key);
self.list.remove(&key); self.list.remove(&key);
@@ -405,9 +548,11 @@ impl GroupsRegistry {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
for id in [id, page] {
if let Err(error) = client.unsubscribe(&id).await { if let Err(error) = client.unsubscribe(&id).await {
log::warn!("nip29: unsubscribing {id} failed: {error}"); log::warn!("nip29: unsubscribing {id} failed: {error}");
} }
}
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
@@ -540,6 +685,11 @@ impl GroupsRegistry {
return; return;
} }
if id == fork_subscription_id() {
self.absorb_fork_list(&event, cx);
return;
}
if self.browse.contains_key(&id) { if self.browse.contains_key(&id) {
self.absorb_candidate(&id, &event, cx); self.absorb_candidate(&id, &event, cx);
return; return;
@@ -595,6 +745,18 @@ impl GroupsRegistry {
return; return;
}; };
if is_page(&id) {
self.routes.remove(&id);
if let Some(group) = self.group(&key, cx) {
group.update(cx, |group, cx| {
group.page_finished(cx);
});
}
return;
}
let Some(group) = self.group(&key, cx) else { let Some(group) = self.group(&key, cx) else {
return; return;
}; };
@@ -616,10 +778,31 @@ impl GroupsRegistry {
return; return;
} }
if id == fork_subscription_id() {
log::warn!("nip29: the admins' group lists were refused: {reason}");
return;
}
let Some(key) = self.routes.get(&id).cloned() else { let Some(key) = self.routes.get(&id).cloned() else {
return; return;
}; };
if is_page(&id) {
self.routes.remove(&id);
log::warn!(
"nip29: loading older messages in {} was refused: {reason}",
key.id()
);
if let Some(group) = self.group(&key, cx) {
group.update(cx, |group, cx| {
group.page_finished(cx);
});
}
return;
}
let Some(group) = self.group(&key, cx) else { let Some(group) = self.group(&key, cx) else {
return; return;
}; };
@@ -701,6 +884,9 @@ impl GroupsRegistry {
Signal::Closed(id, reason) => { Signal::Closed(id, reason) => {
this.handle_closed(id, reason, cx); this.handle_closed(id, reason, cx);
} }
Signal::Forks => {
this.sync_forks(true, cx);
}
}) })
.ok(); .ok();
} }
+107 -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),
]; ];
@@ -71,6 +72,10 @@ impl GroupId {
.limit(limit) .limit(limit)
} }
pub fn timeline_page(&self, limit: usize, until: Timestamp) -> Filter {
self.timeline_filter(limit).until(until)
}
pub fn state_filter(&self) -> Filter { pub fn state_filter(&self) -> Filter {
Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS) Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS)
} }
@@ -246,6 +251,47 @@ impl GroupKey {
self.hash(&mut hasher); self.hash(&mut hasher);
hasher.finish() hasher.finish()
} }
pub fn livekit_token_url(&self) -> Result<Url> {
self.livekit_url(Some(self.id.as_str()))
}
pub fn livekit_probe_url(&self) -> Result<Url> {
self.livekit_url(None)
}
fn livekit_url(&self, group: Option<&str>) -> Result<Url> {
let mut url: Url = self.relay.clone().into();
let scheme = if self.relay.scheme().is_secure() {
"https"
} else {
"http"
};
url.set_path("/");
url.set_query(None);
url.set_fragment(None);
url.set_scheme(scheme)
.map_err(|_| anyhow!("{scheme} is not available for {}", self.relay))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| anyhow!("{} cannot carry a path", self.relay))?;
segments
.pop_if_empty()
.push(".well-known")
.push("nip29")
.push("livekit");
if let Some(group) = group {
segments.push(group);
}
}
Ok(url)
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -515,6 +561,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),
@@ -569,6 +646,35 @@ impl GroupPins {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LivekitSession {
pub token: String,
pub server_url: String,
}
impl LivekitSession {
pub fn parse(body: &[u8]) -> Result<Self> {
let value: serde_json::Value = serde_json::from_slice(body)?;
let token = value
.get("token")
.or_else(|| value.get("jwt"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow!("the AV response carries no token"))?;
let server_url = value
.get("url")
.or_else(|| value.get("server_url"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow!("the AV response carries no server url"))?;
Ok(Self {
token: token.to_owned(),
server_url: server_url.to_owned(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupListEntry { pub struct GroupListEntry {
pub id: GroupId, pub id: GroupId,
+139 -9
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 |
@@ -136,6 +136,12 @@ GroupsRegistry ──ensure relay──► Group entity ◄──ingest── ba
UI later: GroupPanel (Vec<Message>), JoinDialog (naddr) 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) ## 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 The protocol is expressed as types with `impl` blocks: a parsed thing is a struct
@@ -229,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> }
@@ -437,10 +453,98 @@ group's relay only, and treat the relay's `OK` as the truth.
`metadata_tags` re-emits them — `9002` requires it, an edit that drops a `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 child is rejected. `parent`/`children` are per-relay, so assembling the tree
from `Group::metadata()` is UI work; there is no backend tree helper. from `Group::metadata()` is UI work; there is no backend tree helper.
4. Range backfill: page the timeline with `until` for "load more". 4. Range backfill: page the timeline with `until` for "load more". **Done.**
5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token `GroupId::timeline_page(limit, until)` is `timeline_filter` plus `.until(..)`,
endpoint with a NIP-98 `27235` authorization event) and fork/migration and `Group::load_page` sends it as a `pg-<hash>` subscription (auto-close on
detection through the admins' `10009`. 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 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 and the `204`
probe at `/.well-known/nip29/livekit`. **Done.** The HTTP client is the one
GPUI already owns: `cx.http_client()` returning `Arc<dyn HttpClient>`,
already wired to `reqwest_client::ReqwestClient` on desktop and to `fetch`
on wasm — the same path `state::nip05` uses, which is why no HTTP
dependency lands in this crate. Its `get()` convenience cannot set headers,
so the request goes through `HttpClient::send` with a `Builder`, an
explicit `RedirectPolicy::NoFollow` (the `Authorization` header is signed
for exactly one URL, so a redirect could only ever fail verification) and a
10s `timeout` — both are opt-in extensions, and a request without them has
none. The header itself is NIP-98 from the SDK: `nostr/nip98` is now
enabled on the workspace `nostr` entry and nip29 declares `nostr` itself,
so `HttpData::new(url, HttpMethod::GET).to_authorization(&signer)` builds
`Nostr <base64>` — the `u` + `method` tags that the crate's own
`verify_auth_header` demands, though the NIP's prose mentions only `u`.
`GroupKey::livekit_token_url` / `livekit_probe_url` derive the endpoint
purely: the relay URL is rewritten to http(s) (keeping host and port),
reset to the origin, and the path built with `pop_if_empty().push(..)` so
each segment is escaped — a relay with its own path prefix loses it, since
the NIP puts the endpoint at the absolute path. `Group::livekit_session`
and `Group::livekit_supported` are the two calls, both `&App` +
`background_spawn` like `publish`. The session is returned, not stored:
tokens are per-join artifacts, not group state, so the caller owns it.
Only `livekit_supported`'s unauthenticated GET ignores status beyond
`204`; the token endpoint's response shape is **not specified by the NIP**
(see Decisions) and no LiveKit client library is involved — the backend
stops at the JWT and server URL.
3. Fork/migration detection. **Done.** One standing `nip29-forks`
subscription over `{authors: <every admin of every group>, kinds: [10009]}`
on `ReqTarget::auto` — the admins' own group lists live on their outbox
relays, so this works with the group's relay down, and the admin pubkeys
come from `39001`, which is hydrated from the envelope cache, so it works
after a restart too. Each event is folded into `forks` (author → newest
`GroupList`, guarded on `created_at` because `10009` is replaceable), then
`evaluate_forks` recomputes every group. `Group::elsewhere()` is `Some`
when an admin of that group lists the same group id at a different relay;
the data cannot distinguish a move from a fork, hence the neutral name.
Evaluation is incremental, on each event, rather than batched on EOSE:
`ReqTarget::auto` fans out to several relays, so the first EOSE would
otherwise evaluate — and clear — against one relay's partial view.
Nothing is auto-joined, since the NIP asks the client to *offer*; the
offer is `GroupsRegistry::join_key(elsewhere, None, cx)`, which is exactly
"fetch from the new relay and update our own `10009`". Two triggers,
because the NIP's "periodically" and its "MUST when the relay is offline"
are different things. Reactively, the group observer calls
`sync_forks(false)` on `GroupEvent::Updated`, subscribing as soon as the
first `39001` lands and re-subscribing only when the admin set actually
changes — the guard matters, since `Updated` also fires for metadata,
pins, membership and a finished page. Periodically, a
`background_executor().timer` loop started in `load` pushes
`Signal::Forks` every 15 minutes and `sync_forks(true)` re-queries
regardless: that is the recovery path for a subscription that died or a
client that was offline, not the detection path. `reset` drops the fork
state and unsubscribes the REQ; a `CLOSED` on it is only logged, because
the tick retries and there is nothing to repair.
## UI later — the hooks this backend leaves ## UI later — the hooks this backend leaves
@@ -449,8 +553,13 @@ group's relay only, and treat the relay's `OK` as the truth.
| Paste an naddr and join (or retry a refused join) | `GroupReference::parse` → `GroupsRegistry::join` | | 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` | | Open a group | `GroupsRegistry::group(&key, cx)` → panel subscribes to `GroupEvent` |
| Timeline | `Group::messages()` (already `chat::Message`s) | | 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` | | 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()` |
| Join AV (get a LiveKit session) | `Group::livekit_session(cx)` → `await` → `LivekitSession { token, server_url }` |
| Is AV available here | `Group::livekit_supported(cx)` after seeing `metadata().livekit` |
| The group may have moved or been forked | `Group::elsewhere()` → offer `GroupsRegistry::join_key(elsewhere, None, cx)` |
| 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` |
@@ -470,11 +579,14 @@ join dialog accepts a pasted naddr (with or without `?invite=`). System
notifications can hang off `GroupEvent::Incoming` when the window is inactive. notifications can hang off `GroupEvent::Incoming` when the window is inactive.
Nothing new is needed from GPUI to support this backend. The local checkouts 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` are `gpui-pre` 0.3.7 (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` styles; the GPUI APIs the backend uses are the ones `chat` and `community`
already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`, already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`,
`EventEmitter`, `Subscription` — and the panel's components (`ListState`, `EventEmitter`, `Subscription` — plus two that `state` already uses:
`MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the app. `App::http_client()` for the AV token fetch and
`App::background_executor().timer(..)` for the fork tick. The panel's components
(`ListState`, `MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the
app.
## Tests ## Tests
@@ -518,3 +630,21 @@ already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`,
handles the reads. handles the reads.
8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group 8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group
whose relay publishes no `39000` cannot be referenced or joined in v1. whose relay publishes no `39000` cannot be referenced or joined in v1.
9. **LiveKit response shape.** The NIP says the token endpoint returns "the
LiveKit JWT and the URL of the LiveKit server" but names no fields, so
`LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`)
and fails loudly on anything else. This is a guess, not a spec, and it is the
one part of the AV path that a real relay can contradict; it is worth
re-checking against whichever relay is targeted before the UI lands.
Likewise `livekit_supported` treats "not `204`" as "no AV" rather than an
error, because it only feeds a button.
10. **`Monitor`.** Not adopted. It is only reachable through
`ClientBuilder::monitor(Monitor::new(n))`, so nothing can read per-relay
status until `crates/state` opts in, and its `StatusChanged` stream does not
distinguish a permanent outage from a reconnect — so it would need filtering
before it could mean anything. The only thing it buys here is running the
admins' `10009` check the instant a group's relay drops, rather than at the
next 15-minute tick. The NIP frames that as MUST, but the substance is
already covered: the admins come from the cached `39001`, so the check runs
with the relay gone, just later. If that latency ever matters, the hook is
`sync_forks(true, cx)` driven by a `StatusChanged` consumer — one place.