add NIP-29 LiveKit token and fork detection

This commit is contained in:
2026-09-30 13:08:05 +07:00
parent cd7b459751
commit cfec6dfc85
7 changed files with 369 additions and 19 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" }
+83 -2
View File
@@ -3,7 +3,10 @@ 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;
@@ -11,13 +14,14 @@ use state::NostrRegistry;
use crate::LOCAL_KEYS; use crate::LOCAL_KEYS;
use crate::protocol::{ use crate::protocol::{
GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins,
GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS,
TimelineWindow, STATE_KINDS, TimelineWindow,
}; };
const REPLAY_LIMIT: usize = 100; const REPLAY_LIMIT: usize = 100;
const PAGE_LIMIT: usize = 50; const PAGE_LIMIT: usize = 50;
const PAGE_TIMEOUT: Duration = Duration::from_secs(10); 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 {
@@ -45,6 +49,7 @@ pub struct Group {
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>,
@@ -70,6 +75,7 @@ impl Group {
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(),
@@ -114,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
} }
@@ -258,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();
@@ -330,6 +349,68 @@ impl Group {
cx.notify(); 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);
+126
View File
@@ -19,11 +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 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);
@@ -51,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))
} }
@@ -79,6 +86,7 @@ fn is_page(id: &SubscriptionId) -> bool {
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 == LIST_SUBSCRIPTION
|| id == FORK_SUBSCRIPTION
|| id.starts_with(GROUP_PREFIX) || id.starts_with(GROUP_PREFIX)
|| id.starts_with(BROWSE_PREFIX) || id.starts_with(BROWSE_PREFIX)
|| id.starts_with(PAGE_PREFIX) || id.starts_with(PAGE_PREFIX)
@@ -92,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<()>>,
@@ -142,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,
@@ -317,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);
@@ -324,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();
@@ -368,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>) {
@@ -382,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;
@@ -399,6 +511,7 @@ impl GroupsRegistry {
} }
GroupEvent::Updated => { GroupEvent::Updated => {
this.refresh_entry(group, cx); this.refresh_entry(group, cx);
this.sync_forks(false, cx);
} }
_ => {} _ => {}
}), }),
@@ -572,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;
@@ -660,6 +778,11 @@ 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;
}; };
@@ -761,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();
} }
+70
View File
@@ -251,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)]
@@ -605,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,
+83 -16
View File
@@ -490,18 +490,61 @@ group's relay only, and treat the relay's `OK` as the truth.
"Absent" and "empty" are deliberately the same value here: unlike "Absent" and "empty" are deliberately the same value here: unlike
`supported_kinds`, the NIP gives the empty list no special meaning, so the `supported_kinds`, the NIP gives the empty list no special meaning, so the
UI reads "no one is live" either way. UI reads "no one is live" either way.
2. The `/.well-known/nip29/livekit/<group-id>` token endpoint: a NIP-98 2. The `/.well-known/nip29/livekit/<group-id>` token endpoint and the `204`
`27235` event in `Authorization`, returning the LiveKit JWT and server URL, probe at `/.well-known/nip29/livekit`. **Done.** The HTTP client is the one
plus the `204` probe at `/.well-known/nip29/livekit` that tells the UI GPUI already owns: `cx.http_client()` returning `Arc<dyn HttpClient>`,
whether a relay supports AV at all. Not started. This is the only part that already wired to `reqwest_client::ReqwestClient` on desktop and to `fetch`
needs an HTTP client in the crate, so it is where the dependency decision on wasm — the same path `state::nip05` uses, which is why no HTTP
(if any) lands; `livekit` on `39000` is already parsed but currently inert. dependency lands in this crate. Its `get()` convenience cannot set headers,
3. Fork/migration detection: read the `10009` of the group's admins (pubkeys so the request goes through `HttpClient::send` with a `Builder`, an
from `39001`, cached locally) and compare the relay hint against the relay explicit `RedirectPolicy::NoFollow` (the `Authorization` header is signed
we are talking to; a different hint means the group may have moved or been for exactly one URL, so a redirect could only ever fail verification) and a
forked, which is the client's cue to offer fetching from the new relay. 10s `timeout` — both are opt-in extensions, and a request without them has
Not started; the periodic — and MUST-when-offline — trigger is what none. The header itself is NIP-98 from the SDK: `nostr/nip98` is now
`Monitor` would feed, so the two decisions go together. 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
@@ -514,6 +557,9 @@ group's relay only, and treat the relay's `OK` as the truth.
| 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()` | | 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` |
@@ -533,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
@@ -581,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.