10 Commits
Author SHA1 Message Date
reya 99124eaf1b add nip29 document 2026-09-30 13:35:59 +07:00
reya 4678e5879f clean up 2026-09-30 13:35:33 +07:00
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
reya de862637f3 add NIP-29 moderation, pins, browse, and create-group support 2026-09-30 12:38:02 +07:00
reya ee9a852a8f add send and publish 2026-09-30 09:50:49 +07:00
reya c6f7956dff add groups registry 2026-09-30 09:23:57 +07:00
reya c477246bb8 init nip29 crate 2026-09-30 08:34:00 +07:00
reya 338783f394 add plan 2026-09-30 06:26:10 +07:00
13 changed files with 2968 additions and 784 deletions
Generated
+19
View File
@@ -1458,6 +1458,7 @@ dependencies = [
"gpui-pre-reqwest-client",
"gpui-pre-windows",
"log",
"nip29",
"nostr-sdk",
"person",
"settings",
@@ -1487,6 +1488,7 @@ dependencies = [
"gpui-pre-web",
"instant",
"log",
"nip29",
"person",
"settings",
"state",
@@ -4593,6 +4595,23 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nip29"
version = "1.0.2"
dependencies = [
"anyhow",
"chat",
"flume 0.11.1",
"futures",
"gpui-pre",
"log",
"nostr",
"nostr-sdk",
"serde_json",
"smallvec",
"state",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
+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-connect = { 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)
aes-gcm = "0.10"
+8 -3
View File
@@ -349,7 +349,9 @@ impl Community {
}
fn start_round(&mut self, channel: ChannelId, intent: Intent, cx: &mut Context<Self>) {
let client = NostrRegistry::global(cx).read(cx).client();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let held = self.held_keys(&channel);
let relays = self.state.relays.clone();
let pages = self.pages.clone();
@@ -498,7 +500,8 @@ impl Community {
/// Write the local state document out.
fn persist(&mut self, cx: &Context<Self>) {
let client = NostrRegistry::global(cx).read(cx).client();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let state = self.state.clone();
let task = cx.background_spawn(async move {
@@ -520,7 +523,9 @@ impl Community {
limit: usize,
cx: &App,
) -> Task<Result<Timeline>> {
let client = NostrRegistry::global(cx).read(cx).client();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let channel = *channel;
let owner = self.state.owner;
let community_id = self.state.id;
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "nip29"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
futures.workspace = true
serde_json.workspace = true
state = { path = "../state" }
chat = { path = "../chat" }
gpui.workspace = true
flume.workspace = true
log.workspace = true
smallvec.workspace = true
+608
View File
@@ -0,0 +1,608 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
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 nostr::nips::nip98::{HttpData, HttpMethod};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use crate::LOCAL_KEYS;
use crate::protocol::{
GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins,
GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS,
STATE_KINDS, TimelineWindow,
};
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)]
enum Absorbed {
Ignored,
Rendered { fresh: bool },
Changed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupEvent {
Incoming(Event),
Sent(Event),
Updated,
Eose,
Refused(String),
}
pub struct Group {
key: GroupKey,
metadata: Option<GroupMetadata>,
members: GroupMembers,
admins: GroupAdmins,
roles: GroupRoles,
participants: GroupParticipants,
pins: GroupPins,
membership: Membership,
membership_events: Vec<Event>,
elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>,
messages: Vec<Message>,
index: HashSet<EventId>,
window: TimelineWindow,
oldest: Option<Timestamp>,
loading: bool,
loading_more: bool,
refused: Option<String>,
tasks: SmallVec<[Task<()>; 2]>,
}
impl EventEmitter<GroupEvent> for Group {}
impl Group {
pub(crate) fn new(key: GroupKey) -> Self {
Self {
key,
metadata: None,
members: GroupMembers::default(),
admins: GroupAdmins::default(),
roles: GroupRoles::default(),
participants: GroupParticipants::default(),
pins: GroupPins::default(),
membership: Membership::Unknown,
membership_events: Vec::new(),
elsewhere: None,
state_ids: HashMap::new(),
messages: Vec::new(),
index: HashSet::new(),
window: TimelineWindow::new(),
oldest: None,
loading: true,
loading_more: false,
refused: None,
tasks: smallvec![],
}
}
pub fn key(&self) -> &GroupKey {
&self.key
}
pub fn metadata(&self) -> Option<&GroupMetadata> {
self.metadata.as_ref()
}
pub fn members(&self) -> &GroupMembers {
&self.members
}
pub fn admins(&self) -> &GroupAdmins {
&self.admins
}
pub fn roles(&self) -> &GroupRoles {
&self.roles
}
pub fn participants(&self) -> &GroupParticipants {
&self.participants
}
pub fn pins(&self) -> &GroupPins {
&self.pins
}
pub fn membership(&self) -> &Membership {
&self.membership
}
pub fn elsewhere(&self) -> Option<&GroupKey> {
self.elsewhere.as_ref()
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn loading(&self) -> bool {
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> {
self.refused.as_deref()
}
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
self.window.previous_tag(me)
}
pub fn send_message(
&mut self,
content: &str,
replies: Vec<EventId>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.message(content, &replies, prev), cx)
}
pub fn send_reaction(
&mut self,
target: EventId,
emoji: String,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx)
}
pub fn put_user(
&mut self,
public_key: PublicKey,
roles: Vec<String>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
}
pub fn remove_user(
&mut self,
public_key: PublicKey,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
}
pub fn edit_metadata(
&mut self,
metadata: &GroupMetadata,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx)
}
pub fn delete_event(&mut self, id: EventId, cx: &mut Context<Self>) -> Task<Result<()>> {
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
}
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
}
pub fn update_pins(&mut self, pins: Vec<Pin>, cx: &mut Context<Self>) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx)
}
fn dispatch<F>(&mut self, build: F, cx: &mut Context<Self>) -> Task<Result<()>>
where
F: FnOnce(&GroupId, Option<Tag>) -> EventBuilder,
{
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return Task::ready(Err(anyhow!("nip29: no signed-in account")));
};
let builder = build(self.key.id(), self.window.previous_tag(me));
let published = self.publish(builder, cx);
cx.spawn(async move |this, cx| {
let event = published.await?;
this.update(cx, |_this, cx| {
cx.emit(GroupEvent::Sent(event));
})?;
Ok(())
})
}
pub(crate) fn publish(&self, builder: EventBuilder, cx: &App) -> Task<Result<Event>> {
let nostr = NostrRegistry::global(cx).read(cx);
let client = nostr.client();
let signer = nostr.signer();
let relay = self.key.relay().clone();
cx.background_spawn(async move {
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
log::warn!("nip29: connecting {relay} failed: {error}");
}
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).to([relay]).await?;
if let Some(reason) = output.failed.into_values().next() {
bail!("{reason}");
}
Ok(event)
})
}
pub(crate) fn mark_pending(&mut self, since: Timestamp, cx: &mut Context<Self>) {
if self.membership != Membership::Member {
self.membership = Membership::Pending { since };
cx.emit(GroupEvent::Updated);
cx.notify();
}
}
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
if self.membership == Membership::Member {
return;
}
self.membership = Membership::Refused {
reason: Some(reason),
};
cx.emit(GroupEvent::Updated);
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>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(me) = nostr.read(cx).current_user() else {
return;
};
let key = self.key.clone();
let id = crate::subscription_id(&key);
let relay = key.relay().clone();
self.tasks.push(cx.background_spawn(async move {
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
log::warn!("nip29: connecting {relay} failed: {error}");
}
let filters = vec![
key.id().timeline_filter(REPLAY_LIMIT),
key.id().state_filter(),
key.id().membership_filter(me),
];
if let Err(error) = client
.subscribe(ReqTarget::single(relay, filters))
.with_id(id)
.await
{
log::warn!("nip29: subscribing to {} failed: {error}", key.id());
}
}));
}
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<()>> {
let cache_tag = self.key.cache_tag();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let tags = vec![
Tag::identifier(event.id),
Tag::custom("r", [cache_tag]),
Tag::custom("k", [event.kind.to_string()]),
];
let envelope = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
.tags(tags)
.finalize_async(&*LOCAL_KEYS)
.await?;
client.database().save_event(&envelope).await?;
Ok(())
})
}
pub(crate) fn load_envelopes(&self, cx: &App) -> Task<Result<Vec<Event>>> {
let cache_tag = self.key.cache_tag();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
let mut events: Vec<Event> = client
.database()
.query(filter)
.await?
.into_iter()
.filter_map(|envelope| Event::from_json(&envelope.content).ok())
.collect();
events.sort_by_key(|event| event.created_at);
Ok(events)
})
}
pub(crate) fn hydrate(&mut self, events: Vec<Event>, me: PublicKey, cx: &mut Context<Self>) {
if events.is_empty() {
return;
}
let mut rendered = Vec::new();
let mut changed = false;
for event in &events {
match self.absorb(event, me) {
Absorbed::Rendered { fresh: true } => rendered.push(event.clone()),
Absorbed::Changed => changed = true,
_ => {}
}
}
for event in rendered {
cx.emit(GroupEvent::Incoming(event));
}
if changed {
cx.emit(GroupEvent::Updated);
}
cx.notify();
}
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
self.loading = false;
cx.emit(GroupEvent::Eose);
cx.notify();
}
pub(crate) fn refuse(&mut self, reason: String, cx: &mut Context<Self>) {
self.loading = false;
self.refused = Some(reason.clone());
cx.emit(GroupEvent::Refused(reason));
cx.notify();
}
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
if STATE_KINDS.contains(&event.kind) {
return if self.absorb_state(event) {
Absorbed::Changed
} else {
Absorbed::Ignored
};
}
if MEMBERSHIP_KINDS.contains(&event.kind) {
self.track(event);
if !event.tags.public_keys().any(|key| key == me) {
return Absorbed::Ignored;
}
if !self
.membership_events
.iter()
.any(|existing| existing.id == event.id)
{
self.membership_events.push(event.clone());
}
return if self.refresh_membership(me) {
Absorbed::Changed
} else {
Absorbed::Ignored
};
}
self.track(event);
if RENDER_KINDS.contains(&event.kind) {
Absorbed::Rendered {
fresh: self.push_message(event),
}
} else {
Absorbed::Ignored
}
}
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 {
match event.kind {
Kind::GroupMetadata => match GroupMetadata::parse(event) {
Ok(metadata) => self.metadata = Some(metadata),
Err(_) => return false,
},
Kind::GroupAdmins => match GroupAdmins::parse(event) {
Ok(admins) => self.admins = admins,
Err(_) => return false,
},
Kind::GroupMembers => match GroupMembers::parse(event) {
Ok(members) => self.members = members,
Err(_) => return false,
},
Kind::GroupRoles => match GroupRoles::parse(event) {
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,
},
_ => return false,
}
self.state_ids.insert(event.kind, event.id) != Some(event.id)
}
fn push_message(&mut self, event: &Event) -> bool {
if !self.index.insert(event.id) {
return false;
}
let message = Message::from(event);
let position = self
.messages
.partition_point(|existing| existing.created_at <= message.created_at);
self.messages.insert(position, message);
true
}
fn refresh_membership(&mut self, me: PublicKey) -> bool {
let derived = Membership::from_events(&self.membership_events, me);
if derived != Membership::Unknown && derived != self.membership {
self.membership = derived;
return true;
}
false
}
}
+895
View File
@@ -0,0 +1,895 @@
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::sync::LazyLock;
use std::time::Duration;
use anyhow::{Error, Result};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
mod group;
mod protocol;
pub use group::*;
pub use protocol::*;
pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
const LIST_SUBSCRIPTION: &str = "nip29-list";
const FORK_SUBSCRIPTION: &str = "nip29-forks";
const GROUP_PREFIX: &str = "grp-";
const BROWSE_PREFIX: &str = "brw-";
const PAGE_PREFIX: &str = "pg-";
const BROWSE_LIMIT: usize = 500;
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
pub fn init(cx: &mut App) {
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
}
struct GlobalGroupsRegistry(Entity<GroupsRegistry>);
impl Global for GlobalGroupsRegistry {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupsEvent {
Updated,
Error(String),
}
#[derive(Debug, Clone)]
pub struct Browse {
pub relay: RelayUrl,
pub loading: bool,
pub groups: Vec<GroupCandidate>,
}
#[derive(Debug)]
enum Signal {
Event(SubscriptionId, Event),
Eose(SubscriptionId),
Closed(SubscriptionId, String),
Forks,
}
fn list_subscription_id() -> SubscriptionId {
SubscriptionId::new(LIST_SUBSCRIPTION)
}
fn fork_subscription_id() -> SubscriptionId {
SubscriptionId::new(FORK_SUBSCRIPTION)
}
fn subscription_id(key: &GroupKey) -> SubscriptionId {
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
}
fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
let mut hasher = DefaultHasher::new();
relay.hash(&mut hasher);
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 {
let id = id.as_str();
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 {}
pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
list: GroupList,
browse: HashMap<SubscriptionId, Browse>,
routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>,
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
fork_authors: HashSet<PublicKey>,
signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>,
notification_listener: Option<Task<()>>,
signal_consumer: Option<Task<()>>,
tasks: SmallVec<[Task<()>; 2]>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl GroupsRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalGroupsRegistry>().0.clone()
}
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalGroupsRegistry(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let this = cx.entity().downgrade();
let (tx, rx) = flume::unbounded::<Signal>();
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() {
this.reset(cx);
this.handle_notifications(cx);
this.load(cx);
}
}));
cx.defer(move |cx| {
if let Err(error) = this.update(cx, |this, cx| {
this.handle_notifications(cx);
if nostr.read(cx).current_user().is_some() {
this.load(cx);
}
}) {
log::warn!("nip29: initializing the registry failed: {error}");
}
});
Self {
groups: Vec::new(),
list: GroupList::default(),
browse: HashMap::new(),
routes: HashMap::new(),
observers: HashMap::new(),
forks: HashMap::new(),
fork_authors: HashSet::new(),
signal_tx: tx,
signal_rx: rx,
notification_listener: None,
signal_consumer: None,
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
pub fn groups(&self) -> &[Entity<Group>] {
&self.groups
}
pub fn group(&self, key: &GroupKey, cx: &App) -> Option<Entity<Group>> {
self.groups
.iter()
.find(|group| group.read(cx).key() == key)
.cloned()
}
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>) {
self.join_key(reference.key(), reference.invite().map(str::to_owned), cx);
}
pub fn join_key(&mut self, key: GroupKey, invite: Option<String>, cx: &mut Context<Self>) {
self.ensure(key.clone(), cx);
let Some(group) = self.group(&key, cx) else {
return;
};
self.upsert_entry(&key, None);
self.publish_list(cx);
cx.emit(GroupsEvent::Updated);
cx.notify();
let builder = key.id().join_request(invite.as_deref(), "");
let sent = group.read(cx).publish(builder, cx);
group.update(cx, |group, cx| {
group.mark_pending(Timestamp::now(), cx);
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = sent.await {
let reason = error.to_string();
if let Err(error) = this.update(cx, |this, cx| {
if let Some(group) = this.group(&key, cx) {
group.update(cx, |group, cx| {
group.mark_refused(reason, cx);
});
}
}) {
log::warn!("nip29: marking the refused join failed: {error}");
}
}
}));
}
pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context<Self>) {
self.ensure(key.clone(), cx);
let Some(group) = self.group(&key, cx) else {
return;
};
self.upsert_entry(&key, metadata.name().map(str::to_owned));
self.publish_list(cx);
cx.emit(GroupsEvent::Updated);
cx.notify();
let builder = key.id().create_group(&metadata, None);
let published = group.read(cx).publish(builder, cx);
let logged = key.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = published.await {
this.update(cx, |this, cx| {
this.forget(logged, cx);
cx.emit(GroupsEvent::Error(e.to_string()));
})
.ok();
}
}));
}
pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let id = browse_subscription(&relay);
self.browse.insert(
id.clone(),
Browse {
relay: relay.clone(),
loading: true,
groups: Vec::new(),
},
);
cx.emit(GroupsEvent::Updated);
cx.notify();
self.tasks.push(cx.background_spawn(async move {
client.add_relay(relay.clone()).and_connect().await.ok();
let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT);
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(BROWSE_TIMEOUT));
if let Err(error) = client
.subscribe(ReqTarget::single(relay, [filter]))
.with_id(id)
.close_on(opts)
.await
{
log::warn!("nip29: browsing failed: {error}");
}
}));
}
pub fn browsed(&self, relay: &RelayUrl) -> Option<&Browse> {
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>) {
let Some(group) = self.group(key, cx) else {
return;
};
let builder = key.id().leave_request("");
let sent = group.read_with(cx, |group, cx| group.publish(builder, cx));
let key = key.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = sent.await {
log::warn!("nip29: leaving {} failed: {error}", key.id());
return;
}
this.update(cx, |this, cx| {
this.forget(key, cx);
})
.ok();
}));
}
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
self.tasks.clear();
let routes = std::mem::take(&mut self.routes);
self.groups.clear();
self.observers.clear();
self.forks.clear();
self.fork_authors.clear();
self.list = GroupList::default();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut ids: Vec<SubscriptionId> = routes.into_keys().collect();
ids.push(list_subscription_id());
ids.push(fork_subscription_id());
ids.extend(self.browse.keys().cloned());
self.browse.clear();
self.tasks.push(cx.background_spawn(async move {
for id in ids {
if let Err(error) = client.unsubscribe(&id).await {
log::warn!("nip29: unsubscribing {id} failed: {error}");
}
}
}));
cx.notify();
}
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
client
.subscribe(ReqTarget::auto([filter.clone()]))
.with_id(list_subscription_id())
.await?;
match client.database().query(filter).await?.into_iter().next() {
Some(event) => GroupList::parse(&event),
None => Ok(GroupList::default()),
}
});
self.tasks
.push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => {
if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) {
log::warn!("nip29: applying 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>) {
let entries = list.entries().to_vec();
for entry in entries {
self.list.upsert(entry.clone());
self.ensure(GroupKey::new(entry.relay.clone(), entry.id.clone()), cx);
}
cx.emit(GroupsEvent::Updated);
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>) {
if self.group(&key, cx).is_some() {
return;
}
let group = cx.new(|_| Group::new(key.clone()));
self.groups.push(group.clone());
self.observers.insert(
key.clone(),
cx.subscribe(&group, |this, group, event, cx| match event {
GroupEvent::Sent(event) => {
let key = group.read(cx).key().clone();
this.reload(key, Some(event.clone()), cx);
}
GroupEvent::Updated => {
this.refresh_entry(group, cx);
this.sync_forks(false, cx);
}
_ => {}
}),
);
self.subscribe(key.clone(), cx);
self.reload(key, None, cx);
}
fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(&key, cx) else {
return;
};
self.routes.insert(subscription_id(&key), key);
group.update(cx, |group, cx| {
group.connect(cx);
});
}
fn forget(&mut self, key: GroupKey, cx: &mut Context<Self>) {
let id = subscription_id(&key);
let page = page_subscription(&key);
let cache_tag = key.cache_tag();
self.routes.remove(&id);
self.routes.remove(&page);
self.observers.remove(&key);
self.groups.retain(|group| group.read(cx).key() != &key);
self.list.remove(&key);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
self.tasks.push(cx.background_spawn(async move {
for id in [id, page] {
if let Err(error) = client.unsubscribe(&id).await {
log::warn!("nip29: unsubscribing {id} failed: {error}");
}
}
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
if let Err(error) = client.database().delete(filter).await {
log::warn!("nip29: clearing the group cache failed: {error}");
}
}));
self.publish_list(cx);
cx.emit(GroupsEvent::Updated);
cx.notify();
}
fn entry_name(&self, key: &GroupKey) -> Option<String> {
self.list
.entries()
.iter()
.find(|entry| entry.id == *key.id() && entry.relay == *key.relay())
.and_then(|entry| entry.name.clone())
}
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>) {
let name = name.or_else(|| self.entry_name(key));
self.list.upsert(GroupListEntry {
id: key.id().clone(),
relay: key.relay().clone(),
name,
});
}
fn refresh_entry(&mut self, group: Entity<Group>, cx: &mut Context<Self>) {
let (key, Some(name)) = ({
let group = group.read(cx);
(
group.key().clone(),
group
.metadata()
.and_then(|metadata| metadata.name().map(str::to_owned)),
)
}) else {
return;
};
if self.entry_name(&key).as_deref() == Some(name.as_str()) {
return;
}
self.upsert_entry(&key, Some(name));
self.publish_list(cx);
cx.emit(GroupsEvent::Updated);
cx.notify();
}
fn publish_list(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let builder = self.list.to_builder();
self.tasks.push(cx.spawn(async move |_this, _cx| {
let event = match builder.finalize_async(&signer).await {
Ok(event) => event,
Err(error) => {
log::warn!("nip29: signing the group list failed: {error}");
return;
}
};
match client.send_event(&event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => log::warn!(
"nip29: {} relay(s) rejected the group list",
output.failed.len()
),
Err(error) => log::warn!("nip29: publishing the group list failed: {error}"),
}
}));
}
fn reload(&mut self, key: GroupKey, event: Option<Event>, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(group) = self.group(&key, cx) else {
return;
};
let Some(me) = nostr.read(cx).current_user() else {
return;
};
let logged = key.clone();
let load = group.read(cx).load_envelopes(cx);
self.tasks.push(cx.spawn(async move |_this, cx| {
if let Some(event) = event
&& let Err(error) = group
.read_with(cx, |group, cx| group.save_envelope(event, cx))
.await
{
log::warn!("nip29: caching failed: {error}");
return;
}
match load.await {
Ok(events) => {
group.update(cx, |group, cx| {
group.hydrate(events, me, cx);
});
}
Err(error) => {
log::warn!("nip29: reloading {} failed: {error}", logged.id());
}
}
}));
}
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
if id == list_subscription_id() {
match GroupList::parse(&event) {
Ok(list) => self.merge(list, cx),
Err(error) => log::warn!("nip29: ignoring a malformed group list: {error}"),
}
return;
}
if id == fork_subscription_id() {
self.absorb_fork_list(&event, cx);
return;
}
if self.browse.contains_key(&id) {
self.absorb_candidate(&id, &event, cx);
return;
}
let Some(key) = self.routes.get(&id).cloned() else {
return;
};
self.reload(key, Some(event), cx);
}
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
let Some(browse) = self.browse.get_mut(id) else {
return;
};
let Ok(metadata) = GroupMetadata::parse(event) else {
log::warn!(
"nip29: ignoring malformed group metadata from {}",
browse.relay
);
return;
};
let Some(group) = metadata.id().cloned() else {
return;
};
let candidate = GroupCandidate {
key: GroupKey::new(browse.relay.clone(), group),
metadata,
};
match browse.groups.iter_mut().find(|e| e.key == candidate.key) {
Some(existing) => *existing = candidate,
None => browse.groups.push(candidate),
}
cx.emit(GroupsEvent::Updated);
cx.notify();
}
fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
if let Some(browse) = self.browse.get_mut(&id) {
browse.loading = false;
cx.emit(GroupsEvent::Updated);
cx.notify();
return;
}
let Some(key) = self.routes.get(&id).cloned() else {
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 {
return;
};
group.update(cx, |group, cx| {
group.eose(cx);
});
cx.notify();
}
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
if let Some(browse) = self.browse.get_mut(&id) {
log::warn!("nip29: browsing {} was refused: {reason}", browse.relay);
browse.loading = false;
cx.emit(GroupsEvent::Updated);
cx.notify();
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 {
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 {
return;
};
group.update(cx, |group, cx| {
group.refuse(reason, cx);
});
cx.notify();
}
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
let nostr = NostrRegistry::global(cx).read(cx);
let client = nostr.client();
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed = HashSet::new();
const MAX_PROCESSED: usize = 10_000;
while let Some(notification) = notifications.next().await {
let ClientNotification::Message { message, .. } = notification else {
continue;
};
let signal = match *message {
RelayMessage::Event {
subscription_id,
event,
} => {
let id = subscription_id.into_owned();
if !is_own_subscription(&id) {
None
} else {
if processed.len() >= MAX_PROCESSED {
processed.clear();
}
processed
.insert(event.id)
.then(|| Signal::Event(id, event.into_owned()))
}
}
RelayMessage::EndOfStoredEvents(id) => {
let id = id.into_owned();
is_own_subscription(&id).then_some(Signal::Eose(id))
}
RelayMessage::Closed {
subscription_id,
message,
} => {
let id = subscription_id.into_owned();
is_own_subscription(&id).then_some(Signal::Closed(id, message.into_owned()))
}
_ => None,
};
if let Some(signal) = signal {
tx.send_async(signal).await.ok();
}
}
}));
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(signal) = rx.recv_async().await {
this.update(cx, |this, cx| match signal {
Signal::Event(id, event) => {
this.handle_event(id, event, cx);
}
Signal::Eose(id) => {
this.handle_eose(id, cx);
}
Signal::Closed(id, reason) => {
this.handle_closed(id, reason, cx);
}
Signal::Forks => {
this.sync_forks(true, cx);
}
})
.ok();
}
}));
}
}
+837
View File
@@ -0,0 +1,837 @@
use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use anyhow::{Result, anyhow, bail};
use nostr_sdk::prelude::*;
pub const TIMELINE_WINDOW: usize = 50;
const PREVIOUS_REFS: usize = 3;
const PREVIOUS_REF_LEN: usize = 8;
pub const RENDER_KINDS: [Kind; 5] = [
Kind::TextNote,
Kind::ChatMessage,
Kind::Thread,
Kind::Comment,
Kind::Reaction,
];
pub const PIN_LIST: u16 = 39_005;
pub const UPDATE_PIN_LIST: u16 = 9_010;
pub const STATE_KINDS: [Kind; 6] = [
Kind::GroupMetadata,
Kind::GroupAdmins,
Kind::GroupMembers,
Kind::GroupRoles,
Kind::GroupLivekitParticipants,
Kind::Custom(PIN_LIST),
];
pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser];
fn has_tag(tags: &Tags, name: &str) -> bool {
tags.iter().any(|tag| tag.kind() == name)
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter()
.find(|tag| tag.kind() == name)
.and_then(|tag| tag.content())
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GroupId(String);
impl GroupId {
pub fn new(id: impl Into<String>) -> Result<Self> {
let id = id.into();
let id = id.trim();
if id.is_empty() {
bail!("group id is empty");
}
Ok(Self(id.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn h_tag(&self) -> Tag {
Tag::custom("h", [self.0.clone()])
}
pub fn timeline_filter(&self, limit: usize) -> Filter {
Filter::new()
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
.limit(limit)
}
pub fn timeline_page(&self, limit: usize, until: Timestamp) -> Filter {
self.timeline_filter(limit).until(until)
}
pub fn state_filter(&self) -> Filter {
Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS)
}
pub fn membership_filter(&self, me: PublicKey) -> Filter {
Filter::new()
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
.custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex())
.kinds(MEMBERSHIP_KINDS)
}
pub fn message(
&self,
content: &str,
replies: &[EventId],
previous: Option<Tag>,
) -> EventBuilder {
EventBuilder::new(Kind::ChatMessage, content)
.tag(self.h_tag())
.tags(replies.iter().copied().map(Tag::event))
.tag_maybe(previous)
}
pub fn reaction(&self, target: EventId, emoji: &str, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::Reaction, emoji)
.tag(self.h_tag())
.tag(Tag::event(target))
.tag_maybe(previous)
}
pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder {
EventBuilder::new(Kind::GroupJoinRequest, reason)
.tag(self.h_tag())
.tag_maybe(code.map(|code| Tag::custom("code", [code])))
}
pub fn leave_request(&self, reason: &str) -> EventBuilder {
EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag())
}
pub fn put_user(
&self,
public_key: PublicKey,
roles: &[String],
previous: Option<Tag>,
) -> EventBuilder {
let mut values = Vec::with_capacity(1 + roles.len());
values.push(public_key.to_hex());
values.extend(roles.iter().cloned());
EventBuilder::new(Kind::GroupPutUser, "")
.tag(self.h_tag())
.tag(Tag::custom("p", values))
.tag_maybe(previous)
}
pub fn remove_user(&self, public_key: PublicKey, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupRemoveUser, "")
.tag(self.h_tag())
.tag(Tag::public_key(public_key))
.tag_maybe(previous)
}
pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupEditMetadata, "")
.tags(self.metadata_tags(metadata))
.tag_maybe(previous)
}
pub fn create_group(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupCreateGroup, "")
.tags(self.metadata_tags(metadata))
.tag_maybe(previous)
}
fn metadata_tags(&self, metadata: &GroupMetadata) -> Vec<Tag> {
let mut tags = vec![self.h_tag()];
for (name, value) in [
("name", &metadata.name),
("picture", &metadata.picture),
("banner", &metadata.banner),
("about", &metadata.about),
] {
if let Some(value) = value {
tags.push(Tag::custom(name, [value.clone()]));
}
}
for (name, set) in [
("private", metadata.private),
("restricted", metadata.restricted),
("hidden", metadata.hidden),
("closed", metadata.closed),
("livekit", metadata.livekit),
] {
if set {
tags.push(Tag::custom(name, Vec::<String>::new()));
}
}
if let Some(parent) = &metadata.parent {
tags.push(Tag::custom("parent", [parent.as_str()]));
}
for child in &metadata.children {
tags.push(Tag::custom("child", [child.as_str()]));
}
if let Some(kinds) = &metadata.supported_kinds {
tags.push(Tag::custom(
"supported_kinds",
kinds.iter().map(ToString::to_string),
));
}
tags
}
pub fn delete_event(&self, id: EventId, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupDeleteEvent, "")
.tag(self.h_tag())
.tag(Tag::event(id))
.tag_maybe(previous)
}
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupCreateInvite, "")
.tag(self.h_tag())
.tag(Tag::custom("code", [code]))
.tag_maybe(previous)
}
pub fn update_pin_list(&self, pins: &[Pin], previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::Custom(UPDATE_PIN_LIST), "")
.tag(self.h_tag())
.tags(pins.iter().map(Pin::tag))
.tag_maybe(previous)
}
}
impl fmt::Display for GroupId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GroupKey {
relay: RelayUrl,
id: GroupId,
}
impl GroupKey {
pub fn new(relay: RelayUrl, id: GroupId) -> Self {
Self { relay, id }
}
pub fn relay(&self) -> &RelayUrl {
&self.relay
}
pub fn id(&self) -> &GroupId {
&self.id
}
pub fn cache_tag(&self) -> String {
format!("{}|{}", self.relay.as_str(), self.id)
}
pub fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
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)]
pub struct GroupReference {
id: GroupId,
relay: RelayUrl,
invite: Option<String>,
}
impl GroupReference {
pub fn parse(input: &str) -> Result<Self> {
let input = input.trim();
let (address, query) = match input.split_once('?') {
Some((address, query)) => (address, Some(query)),
None => (input, None),
};
let coordinate = Nip19Coordinate::from_bech32(address)?;
if coordinate.kind != Kind::GroupMetadata {
bail!("not a group reference: kind {}", coordinate.kind);
}
let relay = coordinate
.relays
.first()
.cloned()
.ok_or_else(|| anyhow!("group reference has no relay hint"))?;
let id = GroupId::new(coordinate.identifier.as_str())?;
let invite = query
.and_then(|query| {
query
.split('&')
.find_map(|pair| pair.strip_prefix("invite="))
})
.map(str::to_owned);
Ok(Self { id, relay, invite })
}
pub fn id(&self) -> &GroupId {
&self.id
}
pub fn relay(&self) -> &RelayUrl {
&self.relay
}
pub fn invite(&self) -> Option<&str> {
self.invite.as_deref()
}
pub fn key(&self) -> GroupKey {
GroupKey::new(self.relay.clone(), self.id.clone())
}
}
impl FromStr for GroupReference {
type Err = anyhow::Error;
fn from_str(input: &str) -> Result<Self> {
Self::parse(input)
}
}
#[derive(Debug, Clone, Default)]
pub struct GroupMetadata {
pub name: Option<String>,
pub picture: Option<String>,
pub banner: Option<String>,
pub about: Option<String>,
pub private: bool,
pub restricted: bool,
pub hidden: bool,
pub closed: bool,
pub livekit: bool,
pub parent: Option<GroupId>,
pub children: Vec<GroupId>,
pub supported_kinds: Option<Vec<Kind>>,
id: Option<GroupId>,
author: Option<PublicKey>,
}
impl GroupMetadata {
pub fn parse(event: &Event) -> Result<Self> {
if event.kind != Kind::GroupMetadata {
bail!("not a group metadata event: kind {}", event.kind);
}
let identifier = event
.tags
.identifier()
.ok_or_else(|| anyhow!("group metadata has no d tag"))?;
let mut supported_kinds: Option<Vec<Kind>> = None;
for tag in event
.tags
.iter()
.filter(|tag| tag.kind() == "supported_kinds")
{
let kinds = supported_kinds.get_or_insert_with(Vec::new);
for value in tag.as_slice().iter().skip(1) {
kinds.push(Kind::from_str(value)?);
}
}
let parent = match tag_value(&event.tags, "parent") {
Some(parent) => Some(GroupId::new(parent)?),
None => None,
};
let mut children = Vec::new();
for tag in event.tags.iter().filter(|tag| tag.kind() == "child") {
if let Some(child) = tag.content() {
children.push(GroupId::new(child)?);
}
}
Ok(Self {
name: tag_value(&event.tags, "name").map(str::to_owned),
picture: tag_value(&event.tags, "picture").map(str::to_owned),
banner: tag_value(&event.tags, "banner").map(str::to_owned),
about: tag_value(&event.tags, "about").map(str::to_owned),
private: has_tag(&event.tags, "private"),
restricted: has_tag(&event.tags, "restricted"),
hidden: has_tag(&event.tags, "hidden"),
closed: has_tag(&event.tags, "closed"),
livekit: has_tag(&event.tags, "livekit"),
parent,
children,
supported_kinds,
id: Some(GroupId::new(identifier)?),
author: Some(event.pubkey),
})
}
pub(crate) fn id(&self) -> Option<&GroupId> {
self.id.as_ref()
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn supports(&self, kind: Kind) -> bool {
match &self.supported_kinds {
None => true,
Some(kinds) => kinds.contains(&kind),
}
}
pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result<String> {
let id = self.id.as_ref().ok_or_else(|| anyhow!("no id"))?;
let author = self.author.ok_or_else(|| anyhow!("no author"))?;
let coordinate = Coordinate::new(Kind::GroupMetadata, author).identifier(id.as_str());
let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?;
Ok(match invite {
Some(invite) => format!("{address}?invite={invite}"),
None => address,
})
}
}
#[derive(Debug, Clone)]
pub struct GroupCandidate {
pub key: GroupKey,
pub metadata: GroupMetadata,
}
#[derive(Debug, Clone, Default)]
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
impl GroupAdmins {
pub fn parse(event: &Event) -> Result<Self> {
if event.kind != Kind::GroupAdmins {
bail!("not a group admins event: kind {}", event.kind);
}
let mut admins = Vec::new();
for tag in event.tags.iter().filter(|tag| tag.kind() == "p") {
let mut fields = tag.as_slice().iter().skip(1);
let public_key = fields
.next()
.ok_or_else(|| anyhow!("admin tag has no public key"))?;
let public_key = PublicKey::from_hex(public_key)?;
admins.push((public_key, fields.cloned().collect()));
}
Ok(Self(admins))
}
pub fn entries(&self) -> &[(PublicKey, Vec<String>)] {
&self.0
}
pub fn contains(&self, public_key: &PublicKey) -> bool {
self.0.iter().any(|(key, _)| key == public_key)
}
}
#[derive(Debug, Clone, Default)]
pub struct GroupMembers(Vec<PublicKey>);
impl GroupMembers {
pub fn parse(event: &Event) -> Result<Self> {
if event.kind != Kind::GroupMembers {
bail!("not a group members event: kind {}", event.kind);
}
Ok(Self(event.tags.public_keys().collect()))
}
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 struct Role {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GroupRoles(Vec<Role>);
impl GroupRoles {
pub fn parse(event: &Event) -> Result<Self> {
if event.kind != Kind::GroupRoles {
bail!("not a group roles event: kind {}", event.kind);
}
let mut roles = Vec::new();
for tag in event.tags.iter().filter(|tag| tag.kind() == "role") {
let mut fields = tag.as_slice().iter().skip(1);
let name = fields
.next()
.ok_or_else(|| anyhow!("role tag has no name"))?;
roles.push(Role {
name: name.clone(),
description: fields.next().cloned(),
});
}
Ok(Self(roles))
}
pub fn roles(&self) -> &[Role] {
&self.0
}
}
#[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),
Address(Coordinate),
}
impl Pin {
fn tag(&self) -> Tag {
match self {
Pin::Event(id) => Tag::event(*id),
Pin::Address(coordinate) => Tag::coordinate(coordinate.clone(), None),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GroupPins(Vec<Pin>);
impl GroupPins {
pub fn parse(event: &Event) -> Result<Self> {
if event.kind != Kind::Custom(PIN_LIST) {
bail!("not a pinned list event: kind {}", event.kind);
}
let mut pins = Vec::new();
for tag in event.tags.iter() {
match tag.kind() {
"e" => {
let Some(id) = tag.content() else {
continue;
};
pins.push(Pin::Event(EventId::from_hex(id)?));
}
"a" => {
let Some(address) = tag.content() else {
continue;
};
pins.push(Pin::Address(Coordinate::from_str(address)?));
}
_ => {}
}
}
Ok(Self(pins))
}
pub fn pins(&self) -> &[Pin] {
&self.0
}
}
#[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)]
pub struct GroupListEntry {
pub id: GroupId,
pub relay: RelayUrl,
pub name: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GroupList(Vec<GroupListEntry>);
impl GroupList {
pub fn parse(event: &Event) -> Result<Self> {
if event.kind != Kind::SimpleGroups {
bail!("not a group list event: kind {}", event.kind);
}
let mut entries = Vec::new();
for tag in event.tags.iter().filter(|tag| tag.kind() == "group") {
let fields = tag.as_slice();
let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else {
continue;
};
let Ok(id) = GroupId::new(id.as_str()) else {
continue;
};
let Ok(relay) = RelayUrl::parse(relay.as_str()) else {
continue;
};
entries.push(GroupListEntry {
id,
relay,
name: fields.get(3).cloned(),
});
}
Ok(Self(entries))
}
pub fn entries(&self) -> &[GroupListEntry] {
&self.0
}
pub fn upsert(&mut self, entry: GroupListEntry) {
match self
.0
.iter_mut()
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
{
Some(existing) => *existing = entry,
None => self.0.push(entry),
}
}
pub fn remove(&mut self, key: &GroupKey) {
self.0
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
}
pub fn to_builder(&self) -> EventBuilder {
let mut tags: Vec<Tag> = Vec::with_capacity(self.0.len() * 2);
for entry in &self.0 {
let mut values = vec![
entry.id.as_str().to_owned(),
entry.relay.as_str().to_owned(),
];
if let Some(name) = &entry.name {
values.push(name.clone());
}
tags.push(Tag::custom("group", values));
tags.push(Tag::custom("r", [entry.relay.as_str()]));
}
EventBuilder::new(Kind::SimpleGroups, "").tags(tags)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Membership {
Unknown,
Pending { since: Timestamp },
Member,
Refused { reason: Option<String> },
Removed,
}
impl Membership {
pub fn from_events(events: &[Event], me: PublicKey) -> Self {
let mut latest: Option<&Event> = None;
for event in events {
if !MEMBERSHIP_KINDS.contains(&event.kind) {
continue;
}
if !event.tags.public_keys().any(|key| key == me) {
continue;
}
let newer = match latest {
None => true,
Some(current) => {
event.created_at > current.created_at
|| (event.created_at == current.created_at
&& current.kind == Kind::GroupPutUser
&& event.kind == Kind::GroupRemoveUser)
}
};
if newer {
latest = Some(event);
}
}
match latest {
Some(event) if event.kind == Kind::GroupRemoveUser => Membership::Removed,
Some(_) => Membership::Member,
None => Membership::Unknown,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TimelineWindow(Vec<Event>);
impl TimelineWindow {
pub fn new() -> Self {
Self(Vec::new())
}
pub fn push(&mut self, event: Event) {
self.0.retain(|existing| existing.id != event.id);
let position = self.0.partition_point(|e| e.created_at > event.created_at);
self.0.insert(position, event);
self.0.truncate(TIMELINE_WINDOW);
}
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
let references: Vec<String> = self
.0
.iter()
.filter(|event| event.pubkey != me)
.take(PREVIOUS_REFS)
.map(|event| event.id.to_hex().chars().take(PREVIOUS_REF_LEN).collect())
.collect();
if references.is_empty() {
None
} else {
Some(Tag::custom("previous", references))
}
}
}
+1
View File
@@ -36,6 +36,7 @@ state = { path = "../crates/state" }
device = { path = "../crates/device" }
chat = { path = "../crates/chat" }
community = { path = "../crates/community" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" }
person = { path = "../crates/person" }
+3
View File
@@ -81,6 +81,9 @@ fn main() {
// Initialize community registry
community::init(cx);
// Initialize groups registry
nip29::init(cx);
// Initialize auto update
auto_update::init(cx);
-780
View File
@@ -1,780 +0,0 @@
# Using the Concord backend
`crates/concord` is a protocol crate: derivations, envelopes, folds and the local
state document. It has no GPUI dependency and owns no strings a user reads — the
UI layer decides every rendering. This document is the map from a UI action to
the calls it makes.
A community is addressed by a `community_id` (never on the wire) plus three
secrets: `community_root` (read access — holding it *is* membership),
`control_root` (write access to the Control Plane, held by staff), and per-Channel
keys for private channels. Authority is a roster of owner-rooted signed grants,
folded independently by every client.
## Modules
Files follow the CORD documents. The frozen derivations of Appendix A and the id
vocabulary are shared substrate — every document calls them — so they live outside
`cords` in `utils::derive` (re-exported as `derive`) and `types`.
| Module | Owns |
| --- | --- |
| `cord01` | Private Streams: the seal/wrap envelope and the NIP-44 helpers |
| `cord02` | Communities: identity, epochs, metadata, the Control Plane fold and writer |
| `cord02::guestbook` | Joins, leaves, kicks, snapshots, the member list |
| `cord02::list` | The Community List (a member's own memberships, across devices) |
| `cord03` | Channels: Channel metadata and the Chat Plane |
| `cord04` | Roles: chained editions, parse/hash/fold, the roster, permissions, the banlist |
| `cord04::pins` | Pin Lists, and the key disclosure a keyless reader verifies |
| `cord05` | Invite bundles, links, the Direct Invite, the Invite List |
| `cord06` | Key rotations, refounding, compaction, dissolution |
| `derive` | Every frozen HKDF derivation and coordinate |
| `state` | The community state document and its pure folds — no I/O |
`CommunityId`, `ChannelId`, `RoleId`, `Epoch` and `Extra` (crate-internal) come from
the private `types` module and are re-exported at the crate root.
Nothing in `concord` touches a relay or a database. The local rumor cache, the
state documents and the relay paging walk live one crate up, in
`community::cache` and `community::history`, and are what a client actually calls
(read on below).
CORD-07 (audio/video) is unimplemented. CORD-08's timer has no file of its own: it
lives in the metadata it reads (`cord02`) and the fold it filters (`cord03`).
Read `CommunityId` as "this community", `ChannelId` as "this channel", `Epoch` as
"which key generation". Nothing else in the API needs internal state.
## Creating a community
```rust
use concord::cord02::{self, CommunityMetadata};
use concord::state::CommunityState;
use community::cache::save_state;
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?;
// minted.identity — community_id, owner, owner_salt (verify() recomputes it)
// minted.wraps — the two owner-signed genesis editions, already sealed
// minted.channel_id — the #general channel
for wrap in &minted.wraps {
client.send_event(wrap).to(&relays).await?;
}
```
The owner then needs the folded state, which is also what every member does on
join:
```rust
use concord::derive::{control_group_key, control_signer_group_key};
use concord::cord04::ParsedEdition;
let read = control_group_key(&minted.community_root, &minted.identity.community_id, Epoch(0))?;
let signer = control_signer_group_key(&minted.control_root, &minted.identity.community_id, Epoch(0))?;
let editions: Vec<ParsedEdition> = minted
.wraps
.iter()
.map(|wrap| cord02::open_edition(wrap, &read, &signer.pk(), true))
.collect::<Result<_, _>>()?;
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
save_state(&client, &state).await?;
```
Put the community's relay list into `state.relays` and add those relays to the
client explicitly — coop's client is a gossip client with no background refresh.
Creating is not finished until the membership is announced. The two writes are
independent and both best-effort: the genesis wraps go to the community's
relays, and the membership goes to the account's own Community List (below), so a
new device — or another client — can find the community without an invite.
`crates/community`'s `sync::create` performs both.
## Joining
An invite link resolves to a bundle:
```rust
use concord::cord05::{self, BundleState, invite_bundle_key};
let link = cord05::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
// The crate does no I/O: fetch the naddr from the fragment's relays, then:
let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite_bundle_key(&link.token))? {
BundleState::Live(invite) => invite, // validate() already ran
BundleState::Revoked => return Ok(None), // a tombstone at the coordinate
};
```
A Direct Invite arrives as a NIP-59 gift wrap addressed to the member:
```rust
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys).await?;
```
Either way the invite carries `community_id`, `owner`, `owner_salt`,
`community_root`, `root_epoch`, `control_pk`, the granted `channels`
(`ChannelGrant { id, key, epoch, name }`) and the relay set. `invite.expired(now_ms)`
is a preview rule: a past expiry still renders, but the join is refused.
Then publish a join so the member list sees the member before any backfill:
```rust
use concord::derive::guestbook_group_key;
use concord::cord02::guestbook;
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys).await?;
client.send_event(&wrap).to(&relays).await?;
```
## Reading the Control Plane
```rust
use concord::cord02::{self, ControlFold};
let editions: Vec<ParsedEdition> = wraps
.iter()
.filter_map(|wrap| cord02::open_edition(wrap, &read, &control_pk, true).ok())
.collect();
let control: ControlFold =
cord02::fold_control(&owner, &community_id, &editions, &state.floors(), &state.banned);
state.apply_fold(&control);
```
`ControlFold` is everything the community UI needs:
| Field | Use |
| --- | --- |
| `community` | name, description, icon, banner, `message_expiration` |
| `channels` | the channel list; `deleted: true` means drop it |
| `roles` | `role()`, `roles_of()`, `effective_permissions()`, `is_authorized()`, `is_staff()` |
| `banned` | the banlist |
| `registries` / `is_public()` | each invite creator's live link signers |
| `pins` / `pin_content(id, channel)` | Pin List content per channel |
| `floors` | the committed heads the next fold is judged against |
| `gapped` | a chain hole: refetch the Control Plane before trusting what is missing |
`None` on `community` or a channel means "this client saw no authorized edition",
never "the value is gone" — keep what the state already holds rather than walking
the community backwards. Feed `state.floors()` and `state.banned` into the next
fold; they are its memory.
## Sending a message
```rust
use concord::cord03::{self, build_message};
use concord::derive::channel_group_key;
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel
let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await?;
client.send_event(&wrap).to(&relays).await?;
```
- `epoch` is the **root** epoch for a public channel: its plane is
`channel_group_key(community_root, channel, root_epoch)`, so a Refounding moves
it along with the root. A private channel passes its own current channel epoch
(`state.channels` carries it) and derives from its own key instead of
`community_root`.
- `timer` is `control.community.message_expiration`; pass `None` when it is off.
The builder attaches the NIP-40 tag and `seal_rumor` mirrors it onto the wrap,
so relays drop the ciphertext too.
- `ephemeral: true` picks kind `21059` for typing indicators. Keep the returned
wrap key if the message may be deleted later — a kind-5 delete needs it.
- That `send_event` is the whole publish path; there is no optimistic echo. Feed
the wrap through the same ingest path the subscription uses so send-then-read
never waits on a relay round trip.
`build_edit`, `build_reaction` and `build_delete` are the same shape, each a rumor
about an existing `EventId` rather than a mutation.
## Reading a channel
```rust
use concord::cord03::{self, fold, plane_keys};
use community::cache;
let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)]
let mut rumors = Vec::new();
for wrap in &wraps {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) else {
continue;
};
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
continue;
};
cache::cache_rumor(&client, &channel, &opened).await?;
rumors.push(rumor);
}
let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
&& control.roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)
});
```
`ChatMessage` carries `content`, `at_ms`, `edited_at`, `deleted`, `reactions`,
`reply_to` and `thread_root` already resolved. The fold drops expired rumors; a
`deleted` row is still returned so the timeline keeps its shape.
Relay history pages through the local cache:
```rust
use community::history::{self, PageRegistry, Window};
// Every key the client still holds for the channel, newest epoch first: the
// state's current key (`ChannelKeyRef::key` at `epoch`) plus every `priors`
// entry a rotation stepped off. A public channel derives one per held root.
let held: Vec<concord::state::HeldKey> = state.held_keys(&channel);
// The same registry `CommunityRegistry` shares with its notification pump: it is
// what carries each page's EOSE and CLOSED back to the round waiting on it.
let page = history::page(
client,
&pages,
&channel,
&held,
&relays,
Window::opening(cursor),
20,
50,
)
.await?;
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
```
A key's `retired_at` (a `Timestamp`, set when a rotation supersedes it) is a read
cutoff: a wrap at that epoch with a later `created_at` is refused, so a retired
epoch is history and never a live plane an ejected holder can keep writing into.
A relay is only ever *asked*: `history::page` installs **one REQ per page** over
`ReqTarget::manual` for the community's own relays (which is what makes the client
verify a wrap, deduplicate it and persist it), waits for every relay to settle,
and then reads the page back out of the local database — no wrap is ever consumed
straight off the wire, and `fetch_events` is not used anywhere. The wrap lands in
the client's shared event store through that ordinary ingest path; `cache_rumor`
then puts the decrypted rumor beside it, and only the rumor is ever served to a
reader.
EOSE and CLOSED are **not** watched inside the page. The registry's notification
pump is the only consumer of `client.notifications()`; it delivers one
`PageReport` per settling relay into the `PageRegistry` under the page's
subscription id, and the page waits on that channel with `PAGE_TIMEOUT`. The id
is opaque (`concord-history-<n>`) because a NIP-01 id is capped at 64 characters,
and the registry is what maps it back to the waiting round.
A page ends on EOSE. A CLOSED ends it as unanswered, *except* NIP-42's
`auth-required`: the SDK re-issues that REQ under the same subscription id once
the handshake completes, so the page waits for the resubscribed answer rather
than writing off a relay that only wanted to authenticate.
`history::page` walks newest-first across every held epoch, caches what it opens, and
reports what it saw: `oldest`/`newest` feed the caller's `ChannelCursor`,
`exhausted` is earned only by a short page *after* history was seen, and an
all-empty answer sets `failed` so a later round re-asks instead of sealing the
channel at "no more history". `unreadable` counts the wraps the page reached that
no held key could open — sealed past the cutoff their key's rotation set, or bound
to another channel — because those are history the reader is missing, not history
that is not there. Page down with `Window::older_than(seen.oldest)`,
open a channel with `Window::opening(cursor)` (wide cold, `newest - 60s` warm),
and read the region between two cursors with `Window::between(..)`. A `Window`
carries `Timestamp`s, so its bounds go straight into a NIP-01 filter with no unit
conversion; only a reader's millisecond `at_ms` narrows to a second at the query.
`query_rumors` is the read path when the group keys are gone; pass `kinds` to
budget rows apart from the events that only decorate them. `cache::wrapper_index`
reads the cached rows back keyed by the wrap they came from, which is what lets
`sync::fold` observe author and message times without re-opening a wrap it already
cached, and `cache::purge_expired(client, &channel, now)` runs at the top of every
round — the timer is cooperative, so the local store is the artifact that has to
forget.
`sync::fold` counts the same thing over the whole store, per channel, in
`Snapshot.unreadable`. `sync_round` sums a round's pages into
`Progress.unreadable`, and `Community` keeps both per channel: `progress(channel)`
is the last completed round, `unreadable(channel)` is the count, and
`missing_key(channel)`, `channel_removed_at(channel)`, `removed_at()` and
`stranded()` are the rest of the honest read surface a panel needs to tell an
empty room from one it cannot read. `Community::due(channel)` is the other half —
whether a round is worth asking for yet (a round for `Older` and an explicit retry
never ask it), and `Community::tick`, driven by `CommunityRegistry` once per
`community::MIN_ROUND_INTERVAL`, re-folds and re-rounds the active channel of a
community whose last round is older than `STALE_AFTER`.
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
as an inline row only when its author passes
`control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`.
## Membership
```rust
let states = cord02::guestbook::coalesce(&rumors, now_ms, &refounders, |actor, target, citation| {
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
});
let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
```
- `refounders` is the set of npubs whose rotations minted an epoch this client
verified (`CommunityState.refounders`) — a snapshot chunk is honored only from
one of them, and an empty set honors none. A rotation that delivered this
client no key still names its minter, so the set is filled by any base rotation
whose continuity verifies against a root held (`rekey::walk`); a List entry's
`refounder`, read by `list::JoinMaterial::refounder`, is the other source.
- `observed` is npub → ms for every author this client has seen publish anything
usable, which is what makes a member visible before their Join arrives. Only
count it forward.
- `granted` is every npub the roster ranks; they are members with no Guestbook
entry at all.
- `banned_at` is empty today, so a ban is terminal in the fold. Fill it when the
banlist head's timestamp is plumbed through.
- Removal is three separate actions, composed by the caller: strip the grant
(immediate and cheap), then the kick directive, then — for a ban — the rotation
that actually enforces it.
## Moderation writes
Every Control Plane write goes through one writer and one edition shape:
```rust
let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.clone() };
let head = control.floors.get(entity).cloned();
let (wrap, new_head) = writer.set_community_metadata(
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs).await?;
```
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it
from the folded Grant that ranks them (`AuthorityCitation { entity, version, hash }`)
and pass the head from the current fold, so the chain cannot silently fork.
Wrappers: `set_community_metadata`, `set_channel_metadata`, `set_role`,
`set_grant`, `set_banlist`, `set_registry`, `set_pin_list`, plus raw `publish`.
A ban is a `set_banlist` followed by a base rekey; a kick is a `set_grant` with an
empty `role_ids` followed by `cord02::guestbook::build_kick`.
## Pins
```rust
use concord::cord04::pins;
let entry = cord04::pins::build_entry(&opened_message, &plane, &channel)?;
let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
let (wrap, _) = writer.set_pin_list(
&my_keys, &community_id, &channel, &content, head, citation, now_secs).await?;
```
Reading is verification: `read_list` decodes either content form (public, or
sealed under the channel key at the named epoch), and
`cord04::pins::verify_entry(entry, &channel)` returns a `VerifiedPin` with the proven
author, words and time — no history and no old keys needed. `read.sealed` means
the list is sealed under an epoch this client never held: show it as unavailable,
and never write from it (`publishable` refuses). `cord04::pins::killed_by(&pin, &delete)`
answers whether a folded kind-5 erases an entry.
## Invites
```rust
use concord::derive::{invite_bundle_key, TOKEN_LEN};
use concord::cord05::{self, InviteEntry, InviteTombstone};
let token: [u8; TOKEN_LEN] = /* 16 bytes from any CSPRNG */;
let bundle_key = invite_bundle_key(&token);
let link_signer = Keys::generate();
let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?;
let url = cord05::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
```
A link is a coordinate plus a fragment: the naddr fetches the bundle, the token
unlocks it, and the fragment names the relays to fetch from.
`cord05::stock_relays()` is what a fragment with no relays of its own means.
The `link_signer` secret is what lets the creator refresh or retire the link, so
keep it against the token in the member's own Invite List — a local document
encrypted to self, exactly like the Community List:
```rust
let mut list = cord05::parse_invite_list(&my_keys, &event).await?;
list.entries.push(InviteEntry {
token: HEXLOWER.encode(&token),
signer_sk: link_signer.secret_key().to_secret_hex(),
community_id: invite.community_id,
url,
label: None,
created_at: now_ms,
expires_at: None,
extra: Default::default(),
});
let event = cord05::build_invite_list(&my_keys, &list).await?; // kind 13303
// Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
list.tombstones.push(InviteTombstone {
token: HEXLOWER.encode(&token),
community_id: invite.community_id,
extra: Default::default(),
});
```
`merge_invite_lists` merges two devices' copies, `is_live(&token_hex)` answers
whether a link still stands, and `fits()` is the write gate.
## Rekeys, refounding and dissolution
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
gates it, `plan_rotation(scope, epoch)` mints what it delivers, and
`build_rekey_chunks` seals one blob per remaining member:
```rust
use concord::derive::epoch_key_commitment;
use concord::cord06::{self, RekeyScope, RotationPlan};
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
let plan = cord06::plan_rotation(scope, Epoch(epoch + 1))?;
let new_key = plan.new_key();
// A base rotation delivers the new control-plane keys beside the root; a channel
// rotation delivers only that channel's fresh key.
let (control_pk, control_root) = match &plan {
RotationPlan::Base(refounding) => {
let pk = refounding.signer(&community_id)?.pk().to_bytes();
(Some(pk), is_staff.then_some(&refounding.new_control_root))
}
RotationPlan::Channel { .. } => (None, None),
};
let mut blobs = Vec::with_capacity(members.len());
for member in &members {
blobs.push(
cord06::build_blob(
&my_keys,
member,
scope,
plan.epoch(),
&new_key,
control_pk.as_ref(),
control_root,
)
.await?,
);
}
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch())?;
let wraps = cord06::build_rekey_chunks(
&my_keys,
&rekey_group,
scope,
plan.epoch(),
Epoch(epoch),
&epoch_key_commitment(Epoch(epoch), &community_root),
&blobs,
citation,
false,
now_secs,
)
.await?;
```
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then
`collect_rotations(&chunks)`, then `am_i_removed(&rotation, &me)` — which is
`None` until every chunk is held, because an incomplete set is never a removal. A
member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the key
only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
`community::rekey::adopt` is that receiver, run against the local database: it
walks a scope forward one epoch at a time off the key actually held (never
waiving a gap, never adopting a fork), keeps each stepped-off key as a prior
with the rotation's publish time as its read cutoff, and reports a removal or a
strand when a complete rotation carries no blob for the member.
`community::rekey::rotate` is the sender, run against the held state: it resolves
the epoch and key the scope is stepping off, refuses a rotation that would skip or
cut off its own rotator, delivers one blob per recipient (`Rewrite.recipients`,
which the caller names because a private channel's audience is not in the local
state), marks the rotation severed when it excludes somebody, and — for a base
scope — carries the settled Control Plane heads onto the new epoch's groups with
`cord06::compact`. `Community::rotate` checks `Rewrite::authorized` first, the same
`rekey_authorized` under the same permissions `adopt` applies, publishes to the
community's relays, and then adopts what it published through `rekey::adopt`
rather than by construction: a rotation this client wrote and one it received take
one path.
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
text-only, so `build_blob` carries it base64-encoded inside the envelope.
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
Dissolution is owner-only and terminal:
```rust
let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs).await?;
// A receiver seals the community read-only on sight.
if cord06::verify_dissolved(&wrap, &identity) {
state.dissolved = true;
}
```
## The Community List
A member's own memberships, synced across their devices:
```rust
use concord::cord02::list;
let entry = concord::state::list_entry(&state, &metadata.name); // state → §8 material
let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
let mine = held.joined(entry); // community_id-keyed union
let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
client.send_event(&event).to_nip65().await?; // account's own write relays
```
The publish needs no separate database write: `send_event` persists the event
locally *before* it resolves targets, so the fragment is available to the
`concord/list` read path even if every relay is unreachable.
`join_material(&invite, control_root)` makes the §8 material from a CORD-05
invite; `concord::state::list_entry(state, name)` makes it from a `CommunityState`, and is
what a write path uses after a create, join or rename. `joined` and `tombstoned`
are the two mutations: both are `community_id`-keyed unions, so neither an append
nor a leave can lose a membership the other writer has.
The entry is signed by the member's real key and sealed to that same key
(`seal_to_self`), so only the member's devices read it — a stranger's
`parse_list_event` fails rather than returning a partial list. Publishing goes to
the member's **NIP-65 write relays**, the same set the `concord/list`
subscription resolves for its `author` filter.
Kind `33302` is **addressable and fragmented**: one event per fragment, its `d`
tag the fragment index in decimal. `frags` in the payload declares how many the
List has, and `is_complete(held_indices)` answers whether the client has a
fragment at every index below it. `merge` resolves a `frags` disagreement to the
larger value. (`13302`, the single-event List, is retired by the spec — a
replaceable kind cannot fragment.)
A join material may also carry two hex extensions this crate does not write but
does read, because they are the only place a device that never held a rotation can
learn who minted an epoch: `refounder` names the npub whose Refounding minted the
entry's `root_epoch`, and `held_roots` is the retained prior epochs
(`[{epoch, key, refounder?, control_pk?, retired_at?}]`). `JoinMaterial::refounder()`
and `JoinMaterial::held_roots()` decode both; unknown fields are round-tripped
regardless, so a document that carries them keeps them.
The payload's 32-byte values are **unpadded base64url at every depth**, which is
section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on
every serialization, so its output is always the canonical 43-character spelling;
the reader also accepts non-zero trailing bits, because the spec's own worked
example contains them and no reader can tell a mis-encoded named field from a
correct one. The codec is `utils::base64url` and the wire structs behind the
List's `Serialize`/`Deserialize` are the only callers, so no other encoding path
is touched.
Three write-time rules are folded into serialization, so an in-memory document
and its wire form differ:
- an embedded snapshot omits `community_id` and inherits the entry's;
- `seed` is omitted when it equals `current`, and its cosmetic fields (`name`,
`relays`, each channel's `name`) are overwritten from `current` first, so a
rename collapses the snapshots instead of forking them;
- an entry whose `added_at` does not outrun its tombstone is omitted — the
tombstone alone carries the state.
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
strictly newer join outruns it. `fits()` is the write gate: 50 memberships and
the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired
single-event design — §8 has **no membership limit**, its only bound is the
65,536-byte encoded event, and the real fix is to start a new fragment on write.
Until that lands, an append onto a List that already spans more than one fragment
is refused rather than performed against a partial read, because placing a new
membership needs a repack. The community stays local (`load` keeps a membership
the List never mentions) and the write is deferred with a warning.
Discovery is a **subscription, not a fetch**: subscribe with
`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the
fragments back out of `client.database()`. The client persists a relay's event
before it notifies, so a subscription plus a database read loses nothing and
needs no explicit save. Parse each event with `parse_list_event`, keep the newest
per `fragment_index`, and `merge` them — reading an incomplete List is safe, since
a missing fragment is only news not yet heard.
## GPUI integration
`crates/concord` stays GPUI-free; the registry and sync engine live in
`crates/community`. That layer adds a registry global and one
entity per community, and moves every decrypt, verification, fold and I/O off
the foreground thread.
### Entities
Same shape as `ChatRegistry`:
```rust
pub fn init(cx: &mut App) {
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
}
impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCommunityRegistry>().0.clone()
}
}
```
Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and
subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
the account.
- `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
and the channel list. Views render `Entity<Community>`; no protocol state
lives in a view.
- `CommunityState::apply_fold` is one assignment: run it in the task that
produced the fold and send only the result to the foreground.
- Emit an event on every fold so dependents re-read.
### Foreground and background
A background task never touches an entity. It sends results through a bounded
`flume` channel that a foreground `cx.spawn` drains with `this.update(...)`.
```rust
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
let client = client.clone();
// Background: open, verify, fold — no entities.
self.ingress = Some(cx.background_spawn(async move {
for wrap in &wraps {
let Some(plane) = planes.iter().find(|plane| plane.group.pk() == wrap.pubkey) else {
continue;
};
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
cache::cache_rumor(&client, &plane.channel, &opened).await?;
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
}
Ok(())
}));
// Foreground: the only place entities change.
self.consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(signal) = signal_rx.recv_async().await {
this.update(cx, |this, cx| this.apply(signal, cx))?;
}
Ok(())
}));
```
- Every cache function takes the `&Client` and reaches the database through
`client.database()`, so clone the `Client` into the background task.
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None`
to an `Option<Task<_>>` before respawning it; a signer change replaces both
the listener and the consumer.
- `cx.spawn` when the work updates an entity after awaiting, and
`cx.background_spawn` when it only produces a value. A query the foreground awaits can be returned straight out: `fn messages(&self, cx: &App) -> Task<Result<Vec<ChatMessage>, Error>>`.
- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the
first relay request.
- NIP-46 signing is async: call `signer.get_public_key_async()` /
`sign_event_async` inside the background task. Every account-key writer takes
any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await`
it there rather than requiring device keys.
### Subscriptions
A wrap is addressed to a plane, so the plane's public key is the routing key and
one `Filter` per held plane is enough:
```rust
let filter = Filter::new()
.kinds([Kind::from(KIND_WRAP), Kind::from(KIND_WRAP_EPHEMERAL)])
.pubkey(plane.group.pk())
.since(joined_at);
client.subscribe(filter).with_id(sub_id).await?;
```
- `pubkeys([...])` carries every plane of a community on one subscription. Call
`subscribe` again with the new address whenever a join, a channel add or a
rekey fold changes it.
- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by
kind.
- Watch one epoch ahead for the base, and a **window** of channel epochs under
every held root for private channels: `base_rekey_group_key(&root_N, &id,
Epoch(N + 1))`, plus `channel_rekey_group_key(&root, &channel, Epoch(channel_epoch
+ ahead))` for `ahead in 1..=8` and each root in `state.roots()`. A Refounding
seals its channel rekeys under the root current when it was minted, so a member
who adopted the base rotation first must still ask under the prior root; the
window is what lets a member who missed a rotation catch up, or learn they were
cut. This is `community::rekey::watches`, installed as a second standing REQ
whose id resolves back to its community through `rekey::WatchRegistry` (the id
cannot carry a 64-hex community id within the NIP-01 length cap).
- `community::rekey::adopt` then reads those wraps **from the database** and walks
each scope forward one epoch at a time: a complete, authorized rotation whose
`prevcommit` extends the key actually held hands over the next key (raced
rotations settle on the lowest); a gap is fetched, never waived; a fork is never
adopted. An addressed-but-unverifiable rotation is neither adopted nor read as a
removal. Only a complete rotation at/after the join, from a rotator who outranks
the member, with no blob for them, is a removal; one that predates the join is a
strand — a stale invite landed the member on a superseded epoch.
### Tests
`cx.background_executor().timer(..)` for delays, never `smol::Timer`, or
`run_until_parked()` finds nothing left to run. Push a wrap into the channel and
`run_until_parked()` to drive the foreground consumer.
## Not wired up yet
- **`crates/concord` stays protocol-only; the registry lives in
`crates/community`.** `concord` has no subscriptions, no `init`, and no
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
per state document, subscribes when a community's plane set changes, and
re-folds on an inbound wrap. The sidebar subscribes to the registry, surfaces
`CommunityEvent::Error` as a window notification, and its "New community" row
in the Communities tab dispatches `Command::NewCommunity`, whose name prompt
calls `CommunityRegistry::create`. `create` persists the
genesis locally, publishes the wraps to the community's relays, and records the
membership in the account's Community List — all best-effort, so a relay that is
down warns without losing the community. Discovery
subscribes to the account's CORD-02 Community List (`33302`) under the
`concord/list` subscription id and reads the fragments back out of
`client.database()` — the SDK persists a relay's event before notifying, so the
read is always current. A `concord/list` notification re-runs `load`, which
materializes a community from each live List entry (`from_join_material`) and
keeps any state document the List does not mention, so a fresh install — or one
signing in as an account that joined elsewhere — finds its communities. See
`docs/concord-community-discovery-plan.md` (including its note on the retired
`13302` the current reference client still writes).
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
`build_invite_list` / `parse_invite_list`) and `cord06` blob writers
(`build_blob` / `open_blob`) are `async` and generic over the SDK's
`AsyncGetPublicKey` / `AsyncSignEvent` / `AsyncNip44` traits, so a `Keys` and an
app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`,
`unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are.
Group-key and locally-held-secret writers (`cord01` wrap functions,
`cord05::build_bundle_event`, `community::cache`) still take the raw key material they
genuinely need.
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
that handler must route by subscription id before any concord subscription goes
live, or every stream wrap lands in the DM trash and raises a toast.
- **Rotation-delivered plane keys are persisted, and history spans them.**
`CommunityState.held_roots` keeps every root a rotation stepped off (with the
retired root's Control signer and the publish time that retires it), and each
`ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read
side derives planes from all of them, and a retired key's `retired_at` is a hard
read cutoff at both `history::page` and `sync::fold`. The state
`Community::removed_at`/`Community::stranded`/`channel_removed_at` carries is
rendered as a notice **and** enforced at write time: `Community::send` refuses
when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we
never held), because a wrap sealed under a superseded root would reach nobody who
rotated. A rotation this client publishes itself (`Community::rotate`) is adopted
through the same `rekey::adopt` an arriving one goes through, so the held state
and the wire cannot diverge.
+572
View File
@@ -0,0 +1,572 @@
# Using the groups backend
`crates/nip29` is the backend for [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md)
relay-based groups. It is a GPUI crate: it owns one registry entity and one entity
per group, subscribes to the relays, caches everything locally and hands the UI
`chat::Message`s. It owns no strings a user reads — the UI layer decides every
rendering. This document is the map from a UI action to the calls it makes.
A group is a `(relay, id)` pair. The relay enforces membership and signs the
group's state; there is no cross-relay identity, no encryption, and no portable
"create". The same `id` on two relays is two groups (a fork), which the backend
models as two entities.
## Modules
| File | Owns |
| --- | --- |
| `src/protocol.rs` | Pure NIP-29 value types and their `impl` blocks: `GroupId`, `GroupKey`, `GroupReference`, `GroupMetadata`, `GroupAdmins`/`GroupMembers`/`GroupRoles`/`GroupParticipants`, `Pin`/`GroupPins`, `LivekitSession`, `GroupList`/`GroupListEntry`, `Membership`, `TimelineWindow`, plus the filter and event builders on `GroupId`. No I/O. |
| `src/group.rs` | The `Group` entity: one group's state, timeline, membership, writes, the envelope cache helpers and the two LiveKit calls. `GroupEvent`. |
| `src/lib.rs` | The `GroupsRegistry` global, `init`, the notification pump, the account's `10009` list, the fork watch and relay browsing. `GroupsEvent`. |
`protocol` is re-exported at the crate root (`pub use protocol::*`), so callers
write `nip29::GroupKey`, `nip29::GroupMetadata`. `group` is re-exported the same
way (`Group`, `GroupEvent`).
The crate's constants are the protocol's: `RENDER_KINDS` (`1`, `9`, `11`, `1111`,
`7`), `STATE_KINDS` (`39000`–`39004` and `39005`), `MEMBERSHIP_KINDS` (`9000`,
`9001`), `PIN_LIST` (`39005`), `UPDATE_PIN_LIST` (`9010`), `TIMELINE_WINDOW`
(50) and the `previous`-tag shape (3 refs, 8 hex chars).
## Turning it on
One line, after the other registries and gated on nothing — the registry does its
own sign-in check:
```rust
nip29::init(cx);
```
It builds the global entity, subscribes to `NostrRegistry`, and on the first
sign-in (or a signer change) resets its state, restarts the pump and loads the
account's `10009`. No groups exist before a sign-in, and everything is torn down
when the signer changes, so the crate never leaks one account's groups into
another.
`nip29::init(cx)` is already called in `desktop/src/main.rs` and `web/src/lib.rs`.
```rust
use nip29::GroupsRegistry;
let groups = GroupsRegistry::global(cx); // Entity<GroupsRegistry>
groups.read(cx).groups(); // &[Entity<Group>]
groups.read(cx).group(&key, cx); // Option<Entity<Group>>
```
`GroupsEvent` is emitted on the registry; read it back on the event. The sidebar
subscribes to `GroupsEvent` and renders `groups()` — pending and refused groups
are in that list alongside members, and their `Group::membership()` says which.
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupsEvent {
Updated,
Error(String),
}
```
`Updated` fires on any change to `groups()` or to a browse result; `Error` is only
emitted when publishing a `9007` (create) is refused, and carries the relay's
message.
## Value types
```rust
use nip29::{GroupId, GroupKey, GroupReference, GroupMetadata};
let id = GroupId::new("pizza-lovers")?; // trimmed, non-empty
let key = GroupKey::new(relay_url.clone(), id); // the (relay, id) pair
```
A `GroupKey` is the identity of a group *as this backend sees it*: everything
that addresses a group — `group`, `join_key`, `create`, `load_more`, `leave`,
`browse` results — takes one. `Group::key()` returns the one an entity was built
with, and `GroupReference::key()` returns the one a link resolves to.
A reference is a link, and only a link:
```rust
let reference = GroupReference::parse("naddr1…?invite=CODE")?; // or .parse::<GroupReference>()
```
`GroupReference::parse` (and `FromStr`) accepts only a kind-`39000` `naddr` with a
relay hint. The `?invite=` suffix the NIP defines is split off before bech32
decoding and lives on `reference.invite()`; a missing relay hint is an error,
because there is nowhere to send the join request. `id@relay` is deliberately not
accepted.
`GroupMetadata` is both the read model and the `9002`/`9007` payload, and it is
`Default`:
```rust
let metadata = GroupMetadata {
name: Some("Pizza Lovers".into()),
private: true,
..Default::default()
};
```
`parse(event)` fills it from a relay-signed `39000` (and keeps the event's id and
author privately, which is what `naddr` is built from). `name()`, `supports(kind)`
and the public flag fields (`private`, `restricted`, `hidden`, `closed`,
`livekit`, `parent`, `children`, `supported_kinds`) are what a group header reads.
`supports` distinguishes "tag absent" (all kinds) from "tag present with no
values" (no kinds) — that is the difference between a normal group and an AV-only
one.
## The account's group list
`10009` ("simple groups") is the member's own list of memberships, synced across
devices. The registry keeps it in `list: GroupList` and owns its whole lifecycle:
it is loaded at sign-in, `upsert`ed on join/create, `remove`d on leave, and
re-published to `to_nip65()` after every change. A caller never touches it.
```rust
// `10009` tag shape: ["group", id, relay, name?] plus ["r", relay]
```
Loading is a subscription plus a database read, the same read model as everything
else: subscribe to our own `10009` under `nip29-list`, then read the newest from
`client.database()` and merge it. A newer list arriving from another device is
merged union-only — the backend never diffs, so a removal another client made is
not detected, and the local state is never walked backwards. `GroupList::parse`
requires `["group", id, relay]` and takes the third value as the display name.
## Joining
```rust
GroupsRegistry::global(cx).update(cx, |registry, cx| {
registry.join(reference, cx); // or join_key(key, invite, cx)
});
```
`join` is `join_key(reference.key(), reference.invite().to_owned())`, and
`join_key` is what every other path calls too, because a browse result has a key
and no `naddr`. The sequence is:
1. `ensure` the group: create the entity, register it in `groups()`, subscribe to
its relay (the standing `grp-<hash>` REQ, below) and reload it from the local
cache.
2. Add the entry to `10009` optimistically and publish the list, so the group is
listed the moment the user asks for it.
3. Publish `key.id().join_request(invite, "")` — the `h` tag plus, when the link
had one, a `code` tag. `Group::mark_pending(now)` runs immediately, so
`Group::membership()` is `Pending { since }` while the relay decides.
The relay either emits `9000` (the standing subscription folds it into
`Membership::Member`), or answers the `9021` with `OK false` — the registry
catches that and sets `Membership::Refused { reason }` from the relay's message.
The NIP promises that message explains whether the rejection is final, pending
review or payment-required, so show it verbatim and offer a retry. A join to a
`closed` group is refused the same way, unless the invite code is honoured.
Join needs no `39000` first: nothing is fetched, the `9021` is the whole request.
A hidden group needs no special handling either — the state simply never arrives.
## Reading a group
`Group` is an entity with no protocol state in the view. A panel reads it and
subscribes to `GroupEvent`:
```rust
use nip29::{Group, GroupEvent};
let Some(group) = registry.read(cx).group(&key, cx) else {
return;
};
let messages = group.read(cx).messages(); // &[chat::Message]
```
```rust
cx.subscribe(&group, |this, group, event, cx| match event {
GroupEvent::Incoming(..) | GroupEvent::Updated => cx.notify(),
GroupEvent::Refused(reason) => this.show_error(reason, cx),
GroupEvent::Eose => this.stop_spinner(cx),
GroupEvent::Sent(_) => {}
})
```
`GroupEvent` is the full surface:
| Variant | Means |
| --- | --- |
| `Incoming(Event)` | a fresh renderable message was folded in (use it for system notifications when the window is inactive) |
| `Sent(Event)` | our own event was accepted by the relay; the registry re-reads the group, the panel normally ignores it |
| `Updated` | metadata, state, membership, pins or a finished page changed |
| `Eose` | the standing subscription settled |
| `Refused(String)` | the relay `CLOSED` the standing subscription, with its message |
The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`,
`participants`, `pins`, `membership`, `elsewhere`, `messages`, `loading`,
`loading_more`, `oldest`, `refused`.
### The read model
There is **no `fetch_events` anywhere** in the crate. A group is read by
subscribing, then querying the local database — the same model as `crates/chat`
and `crates/community`:
```
subscribe grp-<hash> the global pump observes every REQ on the client
│ (timeline + state + membership filters, one relay)
▼
save_envelope 30078, d = event id, r = "<relay>|<id>", k = kind
│ content = the raw event JSON, signed by crate-local keys
▼
load_envelopes query by the r tag, parse, sort by created_at
│
▼
Group::hydrate the only place entity state changes
```
The standing REQ carries three filters under one id: the timeline
(`GroupId::timeline_filter(100)`), the relay state (`state_filter`) and our own
membership (`membership_filter(me)`). Its id is `grp-` plus 8 hex of a hash of the
key — NIP-01 caps ids at 64 characters, and the pump needs a prefix to recognise
its own subscriptions, so the real key is held in the registry's `routes`, not
encoded in the id.
The envelope cache is what makes the timeline fork-safe and restart-safe without
teaching the nostr database about relays (a stored event carries no origin): the
`r` tag *is* the relay scoping. It is also why every event is re-read instead of
consumed off the wire — the pipeline dedups, sorts and survives a restart, and a
page, a live tail and a local send all take the same path.
`hydrate` partitions an envelope run:
- `STATE_KINDS` → `metadata`, `admins`, `members`, `roles`, `participants`,
`pins` (relay-signed, read-only, addressable by `d`).
- `MEMBERSHIP_KINDS` → our membership only (see below).
- `RENDER_KINDS` → a `chat::Message`, inserted in `created_at` order.
- everything else is kept only for the `previous` window.
Every `h`-tagged event feeds the window regardless of kind. The window is the
NIP's timeline reference: the last 50 events seen on that relay, excluding our
own, first 8 hex characters of their ids, up to 3 per send. The write path
attaches it automatically via `Group::previous_tag(me)`, so callers never build
`previous` themselves.
`Group::loading()` is true until EOSE; `Group::refused()` carries the relay's
`CLOSED` message when it never settles. A relay may answer a restricted or
private group with `restricted:` / `auth-required:` — the state stays, the group
just reads empty, and that is not an error toast.
### Older messages
```rust
GroupsRegistry::global(cx).update(cx, |registry, cx| {
registry.load_more(&key, cx);
});
```
It is a no-op while a page is in flight (`Group::loading_more()`) or before
anything is cached. Otherwise it sends a `pg-<hash>` REQ (`timeline_page(50,
until)`) alongside the standing one — paging must not replace the live tail — and
the pump caches each page event and reloads the group exactly like a live event.
The cursor is `Group::oldest()`, the oldest `h`-tagged event ever hydrated; `until`
is inclusive, so a page repeats its first event, which the dedup absorbs for free.
EOSE, a page `CLOSED` or the 10s auto-close all clear the spinner.
One relay behaviour is worth a spinner that can be cancelled: a relay that accepts
the REQ but never EOSEs leaves `loading_more` true, because the auto-close closes
the subscription locally with no notification at all.
## Sending
Every write goes through one path — `Group::dispatch` builds the event with
`key.id()` and the current `previous` tag, `Group::publish` adds and connects the
relay, signs with the app signer and sends to that one relay:
```rust
let sent = group.update(cx, |group, cx| group.send_message(&text, replies, cx));
cx.spawn(async move |_, _| {
if let Err(error) = sent.await {
// the relay's OK-false message, verbatim: "invalid: ...", "restricted: ...", "duplicate: ..."
}
});
```
| Call | Wire |
| --- | --- |
| `send_message(content, replies: Vec<EventId>, cx)` | kind `9` + `h` + `e` per reply |
| `send_reaction(target: EventId, emoji: String, cx)` | kind `7` + `h` + `e` |
| `put_user(public_key, roles, cx)` | `9000` |
| `remove_user(public_key, cx)` | `9001` |
| `edit_metadata(&GroupMetadata, cx)` | `9002` |
| `delete_event(EventId, cx)` | `9005` |
| `create_invite(code, cx)` | `9009` |
| `update_pins(Vec<Pin>, cx)` | `9010` |
Each returns `Task<Result<()>>` and each resolves only once the relay has
answered: `OK true` emits `GroupEvent::Sent` and the registry reloads the event
through the normal cache path, so the timeline shows a send without waiting for
the live tail; `OK false` is an `Err` carrying the relay's message. There is no
offline queue — NIP-29 relays reject late publication, so a send either goes now
or fails visibly.
Kind 9 is the default message kind. When `metadata().supports(Kind::ChatMessage)`
is false — an AV-only group, or one whose `supported_kinds` omits it — disable the
composer instead of sending a kind the relay does not take.
`replies` are plain `EventId`s; they become `e` tags. There is no thread model in
the backend beyond that: `chat::Message::replies_to` already reads them back.
## Membership
Membership is derived from the relay's own events, never guessed:
```rust
pub enum Membership {
Unknown,
Pending { since: Timestamp },
Member,
Refused { reason: Option<String> },
Removed,
}
```
`Membership::from_events(&[...], me)` takes the newest `9000`/`9001` whose `p`
tags name us — on an exact timestamp tie it prefers `9001` — and `Unknown` when
there is none, which the NIP says means "not a member". The `Group` keeps only
the membership events that mention us, so `refresh_membership` costs nothing.
`Pending` and `Refused` are the two local states, because the relay does not fold
them into an event: the join path sets them from the `9021` send result. A
`Member` is never overwritten by `mark_pending` or `mark_refused`, so a stale
refusal cannot demote a member.
Count a member who is `Unknown`, `Pending` or `Refused` as "not in the room" for
writes, but keep the row visible: pending and refused groups stay in `groups()`
with their state on the row.
## Moderation
The `9000`–`9010` writes are on `Group` and share `dispatch`, so they are the
same shape as sending a message. The backend does not enforce authority — the
relay owns roles and its own policy — it only surfaces the refusal. Gate the UI
on the caller appearing in `Group::admins()` (each entry is a pubkey plus its role
labels), read the relay's supported roles from `Group::roles()`, and let the relay
reject anything it does not want.
`edit_metadata` takes a `GroupMetadata`, so a form can start from
`Group::metadata().cloned().unwrap_or_default()`, mutate, and send. `children`
must carry every child tag, because a `9002` that drops one is rejected by the
relay; the builder re-emits them from the struct.
`delete_event` is a **relay** instruction only. The relay stops serving the event,
but the local timeline keeps it: the backend does not evict the envelope or the
message, so a deletion the relay accepted is still visible locally until the UI
phase drops it. That is the one moderation write left incomplete (see below).
## Pins
```rust
use nip29::Pin;
let pins: Vec<Pin> = vec![Pin::Event(event_id), Pin::Address(coordinate)];
group.update(cx, |group, cx| group.update_pins(pins, cx));
```
`9010` carries the whole list, so pinning, unpinning, reordering and clearing are
all one call. The relay mirrors it into `39005`, which is in `STATE_KINDS` — so
`Group::pins()` reflects the accepted list on the next reload.
## Subgroups
`GroupMetadata` carries `parent: Option<GroupId>` and `children: Vec<GroupId>`,
and the metadata builder re-emits both, so a create or edit can declare the link.
There is no tree helper: the tree is scoped to one relay, so assemble it from
`Group::metadata()` across `groups()` where all the children have the same relay.
Membership and admin roles do **not** cascade across the link — each group's
`9000`/`9001` and `39001` are authoritative for its own scope — so never infer
access from a parent.
## LiveKit
Two calls, both off the foreground thread and both `&App`:
```rust
if group.read(cx).metadata().is_some_and(|m| m.livekit) {
let available = group.read(cx).livekit_supported(cx).await?; // GET /.well-known/nip29/livekit
let session = group.read(cx).livekit_session(cx).await?; // LivekitSession { token, server_url }
}
```
`livekit_supported` is an unauthenticated GET that answers `true` only for `204`.
`livekit_session` signs a NIP-98 `Authorization` header (`27235`, with the `u` and
`method` tags the SDK's verifier demands) and returns the JWT and server URL; the
token is a per-join artifact, returned not stored. Both requests carry an explicit
`RedirectPolicy::NoFollow` and a 10s timeout, because the header is signed for
exactly one URL and GPUI applies no timeout unless asked. The backend stops at the
JWT — no LiveKit client library is involved — so the AV room itself is UI work.
Check `metadata().livekit` before offering the button, and read
`Group::participants()` (kind `39004`, `participant` tags, relay-published) for
who is live. Absent and empty are the same value there: nobody is in the room.
## Forks and migration
A `10009` on the *admins'* relays says where each of them thinks the group is. The
registry keeps one standing `nip29-forks` subscription over
`{authors: every admin of every group, kinds: [10009]}` on `ReqTarget::auto` —
which is why it still works when the group's own relay is down — folds each event
into a per-author list, and recomputes every group's `elsewhere`.
```rust
if let Some(moved) = group.read(cx).elsewhere() {
// the group may have moved or been forked; offer:
registry.update(cx, |registry, cx| registry.join_key(moved.clone(), None, cx));
}
```
`elsewhere()` is `Some(key)` when an admin of the group lists the same group id at
a different relay. The data cannot distinguish a move from a fork, hence the
neutral name; nothing is auto-joined, because the NIP asks the client to *offer*.
Evaluation is incremental on each event rather than batched on EOSE, since
`ReqTarget::auto` fans out and an early EOSE would evaluate against one relay's
partial view. A 15-minute background tick re-subscribes regardless, which is the
recovery path for a subscription that died or a client that was offline.
`Monitor` is not adopted: it is only reachable through `ClientBuilder::monitor`,
so nothing can read per-relay status without changing `crates/state`. The fork
check does not need it — the admin pubkeys come from the cached `39001`, so the
check runs with the relay gone, just at the next tick.
## Browsing a relay
```rust
use nip29::Browse;
registry.update(cx, |registry, cx| registry.browse(relay_url.clone(), cx));
let browse: Option<&Browse> = registry.read(cx).browsed(&relay_url);
```
`Browse` is `{ relay, loading, groups: Vec<GroupCandidate> }`, where a candidate
is a `GroupKey` plus its parsed `GroupMetadata`. `browse` connects the relay,
sends one auto-closing `39000` REQ (limit 500, EOSE or 10s) and returns
immediately; the pump folds each relay-signed metadata event into the result. Read
it back in a `GroupsEvent::Updated` handler, which also fires when the browse
settles (`loading` flips false on EOSE or a refusal). Join a result with
`join_key(candidate.key, None, cx)`.
One caveat: `Browse::loading` can stick if the relay accepts the REQ and never
EOSEs, because the auto-close timeout closes the subscription locally with no
notification. Treat it as "still trying", not as an error.
## Creating a group
```rust
let metadata = GroupMetadata { name: Some(name), ..Default::default() };
registry.update(cx, |registry, cx| registry.create(key, metadata, cx));
```
Creation mirrors join: `ensure` the entity, add it to `10009` and publish optimistically,
then send the `9007`. There is no NIP-11 check to lean on, so "where the relay
supports it" is enforced by the relay refusing the event — on refusal the registry
forgets the group and emits `GroupsEvent::Error(reason)`. On success the relay
republishes `39000`, which the standing subscription picks up like any other state.
## Leaving
```rust
registry.update(cx, |registry, cx| registry.leave(&key, cx));
```
`leave` sends `9022`; once the relay accepts, the registry forgets the group:
unsubscribe both REQs, drop the entity and its observer, remove the `10009` entry,
delete every cached envelope for that group's `r` tag, and publish the shortened
list. A failed leave leaves everything untouched, so the group stays. There is no
separate local removal — a group someone else removed us from stays listed until
we leave it ourselves.
## GPUI integration
`init` builds the global; the registry and its groups are the only entities, and
`protocol.rs` has no GPUI dependency at all.
```rust
pub fn init(cx: &mut App) {
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
}
impl GroupsRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalGroupsRegistry>().0.clone()
}
}
```
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`, the
live `browse` results, `routes` (subscription id → `GroupKey`), one
`Subscription` per group, the fork state, and its tasks.
- `Group` owns everything the panel draws: metadata, admins/members/roles,
participants, pins, membership, the `Vec<Message>` timeline, the `previous`
window, `loading`/`loading_more`/`refused`.
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
lives in a view.
### Foreground and background
One background task drains `client.notifications()` and never touches an entity;
it sends signals through an unbounded `flume` channel that one foreground
`cx.spawn` consumes, which is the only place entities change:
```
notifications() ──sig──► flume ──recv──► this.update(cx, …) ──► handle_event → reload → hydrate
```
- `handle_notifications` runs on `cx.background_spawn`; it matches
`ClientNotification::Message` and routes the message by subscription id.
- The consumer runs on `cx.spawn`; every arm is one `this.update(cx, ..)`.
- The listener and the consumer are replaced (`= None` first) on a signer change,
because a dropped `Task` cancels; that is what makes the reset clean.
- Both `save_envelope` and `load_envelopes` are `cx.background_spawn` work that
clones the `Client` in, so no database call ever runs on the foreground thread.
- NIP-46 signing is async, so all signing happens inside the background task.
### Subscriptions and routing
One `Client` and one notification stream are shared with `chat` and `community`,
so every subscription the crate owns is identified by a prefix and nothing else:
| Id | Meaning |
| --- | --- |
| `nip29-list` | the account's `10009` |
| `nip29-forks` | the admins' `10009` |
| `grp-<hash>` | a group's standing REQ (timeline + state + membership) |
| `pg-<hash>` | a "load more" page |
| `brw-<hash>` | a relay browse |
Inbound events are routed by `subscription_id` from `RelayMessage::Event`, never
by kind. That matters because `crates/chat` treats every kind-1059 event as a
NIP-59 gift wrap — anything a new subscription adds must be recognisable by id, and
the pump drops ids it did not mint. Duplicate event ids are filtered in the pump
before they reach the consumer, so a replay cannot double-hydrate.
## Not wired up yet
- **No screen consumes this crate yet.** The only references outside
`crates/nip29` are the two `nip29::init(cx)` calls, so the sidebar section, the
group panel and the join dialog are still to be built. The panel needs
`chat::Message` rendering lifted (or copied) out of `ChatPanel`: the data type
is already shared, only the row rendering is not. `crates/workspace/src/panels/browse.rs`
is the stub for browsing.
- **`9005` delete does not touch local state.** The relay stops serving the
event, but the envelope and the row stay until a reload. Dropping it needs a
filter on the target's `d` tag plus the group's `r` cache tag, and an eviction
from `messages` and the id index; nothing does that today.
- **The LiveKit response shape is a guess.** The NIP names no fields, so
`LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) and
fails loudly on anything else. Re-check it against the relay actually targeted
before building the AV UI, or the join button fails silently.
- **A relay that never EOSEs sticks a spinner.** `Browse::loading` and
`Group::loading_more` both rely on EOSE or CLOSED, and the auto-close timeout
closes the subscription with no notification at all. Bind "load more" to
`Group::loading_more()` and give the user a way out.
- **Subgroups are parsed, not assembled.** `parent`/`children` are on
`GroupMetadata` and the writes carry them; there is no tree builder, because
the tree is scoped to a relay and that is a view concern.
- **`GroupMetadata::naddr` needs a parsed metadata.** It builds from the `id` and
author the `39000` left on the struct, so a hand-built `Default` value errors
rather than minting a link that points nowhere. A group whose relay publishes
no `39000` cannot be referenced or joined at all.
+1
View File
@@ -17,6 +17,7 @@ state = { path = "../crates/state" }
device = { path = "../crates/device" }
chat = { path = "../crates/chat" }
community = { path = "../crates/community" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
person = { path = "../crates/person" }
+3
View File
@@ -73,6 +73,9 @@ pub fn run() -> Result<(), JsValue> {
// Initialize community registry
community::init(cx);
// Initialize groups registry
nip29::init(cx);
cx.open_window(WindowOptions::default(), |window, cx| {
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
})