5 Commits
Author SHA1 Message Date
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
11 changed files with 2463 additions and 3 deletions
Generated
+16
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,20 @@ 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",
"gpui-pre",
"log",
"nostr-sdk",
"smallvec",
"state",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
+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;
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "nip29"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
nostr-sdk.workspace = true
anyhow.workspace = true
state = { path = "../state" }
chat = { path = "../chat" }
gpui.workspace = true
flume.workspace = true
log.workspace = true
smallvec.workspace = true
+454
View File
@@ -0,0 +1,454 @@
use std::collections::{HashMap, HashSet};
use anyhow::{Result, anyhow, bail};
use chat::Message;
use gpui::{App, AppContext, Context, EventEmitter, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use crate::LOCAL_KEYS;
use crate::protocol::{
GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupPins, GroupRoles,
MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow,
};
const REPLAY_LIMIT: usize = 100;
#[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,
pins: GroupPins,
membership: Membership,
membership_events: Vec<Event>,
state_ids: HashMap<Kind, EventId>,
messages: Vec<Message>,
index: HashSet<EventId>,
window: TimelineWindow,
loading: 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(),
pins: GroupPins::default(),
membership: Membership::Unknown,
membership_events: Vec::new(),
state_ids: HashMap::new(),
messages: Vec::new(),
index: HashSet::new(),
window: TimelineWindow::new(),
loading: true,
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 pins(&self) -> &GroupPins {
&self.pins
}
pub fn membership(&self) -> &Membership {
&self.membership
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn loading(&self) -> bool {
self.loading
}
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 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 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.window.push(event.clone());
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.window.push(event.clone());
if RENDER_KINDS.contains(&event.kind) {
Absorbed::Rendered {
fresh: self.push_message(event),
}
} else {
Absorbed::Ignored
}
}
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::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
}
}
+709
View File
@@ -0,0 +1,709 @@
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 GROUP_PREFIX: &str = "grp-";
const BROWSE_PREFIX: &str = "brw-";
const BROWSE_LIMIT: usize = 500;
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
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),
}
fn list_subscription_id() -> SubscriptionId {
SubscriptionId::new(LIST_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 is_own_subscription(id: &SubscriptionId) -> bool {
let id = id.as_str();
id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_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>,
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(),
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 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.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.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}"),
}));
}
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 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);
}
_ => {}
}),
);
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 cache_tag = key.cache_tag();
self.routes.remove(&id);
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 {
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 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;
};
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;
}
let Some(key) = self.routes.get(&id).cloned() else {
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);
}
})
.ok();
}
}));
}
}
+731
View File
@@ -0,0 +1,731 @@
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; 5] = [
Kind::GroupMetadata,
Kind::GroupAdmins,
Kind::GroupMembers,
Kind::GroupRoles,
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 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()
}
}
#[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, 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 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);
+520
View File
@@ -0,0 +1,520 @@
# NIP-29 groups backend
Coop has two relay-backed backends today: `crates/chat` (NIP-17 gift-wrap DMs,
rendered by `crates/chat_ui`) and `crates/community` (Concord). This document is a
plan, not a description of shipped code; nothing below is wired up yet. It covers
the backend only — reading, writing, joining and leaving relay-based groups as
defined by [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) —
and ends with the hooks a UI will use later.
## The spec in backend terms
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 plan
models as two entities.
| Concern | Wire | Backend rule |
| --- | --- | --- |
| Group id | random string, relay-chosen | `h` tag on everything a user sends; `d` tag on relay state events |
| Timeline | any event kind + `h` | v1 renders kinds `1`, `9`, `11`, `1111` and `7`; every other kind is cached for the `previous` window but not rendered, except the membership kinds below |
| Timeline refs | `previous` tag, first 8 hex chars of event ids | built from the last 50 events seen on that relay (any kind — NIP-29 timeline references), excluding our own; send up to 3 |
| Relay state | `39000` metadata, `39001` admins, `39002` members, `39003` roles | relay-signed, addressable by `d`; read, never written by the client |
| Membership | `9021` join, `9022` leave (user-signed); relay answers with `9000` put-user / `9001` remove-user | membership = latest `9000`/`9001` naming our pubkey; no event means not a member, and `Pending`/`Refused` are tracked locally for private groups |
| Own group list | `10009` (NIP-51 simple groups) | `["group", id, relay, name?]` plus `["r", relay]`; replaceable, published to NIP-65 relays |
| Reference | `naddr1…` of kind `39000` with the relay self pubkey and a relay hint, optionally `?invite=<code>` | the self pubkey is the author of the fetched `39000`; no `39000` means no canonical naddr |
Two behaviours that shape the code:
- **Late publication is rejected.** Relays drop messages with old timestamps, so
there is no offline send queue in v1: a send either goes now or fails visibly.
- **`previous` must reference events the relay already has.** Relays may enforce
at least 3 references; a group we have little history for may have its sends
rejected until the backlog loads. The relay's `OK false` message is surfaced
as-is.
### Not in v1
Moderation writes (`9000`–`9010`), group creation (`9007`, relay-dependent),
pinned events (`39005`/`9010`), subgroups (`parent`/`child` — parsed but inert),
LiveKit audio/video (`livekit` tag, `39004`, the token endpoint), fork/migration
detection through admins' `10009`, and relay browsing (`{"kinds":[39000]}` to
list a relay's public groups). All are additive; none change the model above.
## What the local rust-nostr checkout gives us
Cross-checked against the pinned checkout
(`~/.cargo/git/checkouts/nostr-619b808bb247a9ed/01f1950`):
- **Kinds exist** for every NIP-29 event in v1: `Kind::GroupPutUser` (9000),
`GroupRemoveUser` (9001), `GroupEditMetadata` (9002), `GroupDeleteEvent`
(9005), `GroupCreateGroup` (9007), `GroupDeleteGroup` (9008),
`GroupCreateInvite` (9009), `GroupJoinRequest` (9021), `GroupLeaveRequest`
(9022), `GroupMetadata` (39000), `GroupAdmins` (39001), `GroupMembers`
(39002), `GroupRoles` (39003), `GroupLivekitParticipants` (39004), and
`Kind::SimpleGroups` (10009). `Kind::ChatMessage` is kind 9.
- **`9010` and `39005` are missing** from this revision (`Kind::Custom(9010)`,
`Kind::Custom(39005)` if they are ever needed — they are not, in v1).
- **There is no NIP-29 module.** `nostr/src/nips/` has no `nip29.rs`, and no
`GroupId`, metadata type, parser or builder exists. Everything in `protocol.rs`
below is ours; it is thin — tags and filters, no crypto.
- **The APIs to lean on** are all present and already used in the app:
- `Tag::custom`, `Tag::event`, `Tag::identifier`, `Tag::public_key`,
`Tag::content`, `Tag::as_slice`.
- `Filter::custom_tag(SingleLetterTag::LOWERCASE_H, id)`,
`.identifier(id)`, `.author`, `.kinds`, `.limit`, `.since`.
- `ReqTarget::single(url, filters)` for a group-scoped REQ,
`client.subscribe(..).with_id(..)`, `client.fetch_events(..)`,
`client.send_event(&event).to([url])`.
- `EventBuilder::new(kind, content).tags(tags).finalize_async(&signer)` — the
app's `UniversalSigner` works here (NIP-46 included), as it does in
`crates/chat/src/room.rs`.
- `client.database().query/save_event/delete`.
- `Nip19Coordinate { coordinate, relays }` with `ToBech32`/`FromBech32` for
`naddr`.
- **NIP-11 is not exposed.** `Relay` in this SDK has no document accessor, so the
relay self pubkey is taken from the author of the group's `39000` event; a
group whose relay publishes no `39000` therefore has no canonical naddr and
cannot be shared or joined in v1.
## What the app already does (and we copy)
The NIP-17 backend is the template. `crates/chat` has a global `ChatRegistry`
entity, one `Room` entity per conversation, a background task over
`client.notifications()` that sends signals through a `flume` channel, and a
foreground `cx.spawn` consumer that is the only place entities change. Local
messages are cached as `ApplicationSpecificData` (30078) events with a `d` tag
for the target id and an `r` tag for the room key (`set_rumor`/`get_rumor`,
`crates/chat/src/lib.rs`). `crates/chat_ui` renders `chat::Message` into a
`ListState`, resolving names through `PersonRegistry`.
`crates/community` shows the same pattern for relay-scoped state: routing
notifications by subscription id (`route_of`), bringing relays into the pool
before use (`connect_relays`), and treating a `CLOSED` message as a refusal.
Replacing a list is done in `crates/workspace/src/panels/contact_list.rs`:
sign the builder, `client.send_event(&event).to_nip65().await` — the client saves
the event locally even when the publish fails.
NIP-29 reuses all of this and drops what only NIP-17 needs: no encryption, no
per-member fan-out, no gift wraps, no `SendReport` per receiver — one relay, one
event, one `OK`.
## Where this lands
A new crate `crates/nip29`, because NIP-29 is a different transport (plain relay
events), a different state authority (relay-signed `39000`s) and a different UI
later. Making `chat::Room` polymorphic over gift-wrapped DMs and relay groups
would put two protocols in one entity. The alternative — modules inside
`crates/chat` — saves a `Cargo.toml` but was set aside (see Decisions).
| File | Owns |
| --- | --- |
| `crates/nip29/src/protocol.rs` | Pure NIP-29 types and their `impl` blocks: `GroupId`, `GroupKey`, `GroupReference`, `GroupMetadata`, admins/members/roles, `GroupList`, `Membership`, `TimelineWindow`. No I/O. |
| `crates/nip29/src/group.rs` | `Group` entity: state, timeline, membership, `connect`, `ingest`, `send_message`, `send_reaction`, `join`, `leave`, `GroupEvent`. |
| `crates/nip29/src/lib.rs` | `GroupsRegistry` global, `init`, standing subscriptions and the signal pump, the envelope cache, `load`/`publish` of the account's `10009`. |
| `desktop/src/main.rs`, `web/src/lib.rs` | `nip29::init(cx)` after `chat::init(cx)`. |
| UI later | `crates/chat_ui` (reuse `chat::Message` rendering) or a new `crates/nip29_ui`; sidebar section; a join dialog. |
`protocol` is re-exported at the crate root (`pub use protocol::*`) so callers
write `nip29::GroupId`, `nip29::GroupMetadata`. The crate's dependencies are the
ones `chat` already carries: `state` (client and signer), `chat` (`Message`),
`gpui`, `nostr-sdk`, `anyhow`, `flume`, `futures`, `smallvec`, `log`;
dev-dependencies `nostr-memory` and `nostr-sdk` with the `local-relay` feature
for the tests below.
## Data flow
```
own 10009 (NIP-65 relays) group relay (one per group)
│ load / subscribe │ REQ #h=<id> (timeline + 9000/9001)
▼ │ REQ #d=<id> (39000..39003)
GroupsRegistry ──ensure relay──► Group entity ◄──ingest── background listener
│ index: GroupKey │ (saves envelope first)
│ │
└── flume Signal ◄──────────────────────┘
▲
UI later: GroupPanel (Vec<Message>), JoinDialog (naddr)
```
## Phase 0 — `crates/nip29/src/protocol.rs` (pure, no I/O)
The protocol is expressed as types with `impl` blocks: a parsed thing is a struct
and every operation on it is a method, so call sites read `id.message(...)`,
`metadata.naddr(...)`, `admins.contains(&pk)` instead of handing the pieces to
free builder functions.
```rust
/// The relay-chosen group id.
pub struct GroupId(String);
impl GroupId {
pub fn new(id: impl Into<String>) -> Result<Self, Error>; // trimmed, non-empty
pub fn as_str(&self) -> &str;
pub fn h_tag(&self) -> Tag; // ["h", id]
pub fn timeline_filter(&self, limit: usize) -> Filter; // #h = id
pub fn state_filter(&self) -> Filter; // #d = id, kinds 39000..39003
pub fn membership_filter(&self, me: PublicKey) -> Filter; // #h = id, #p = me, kinds 9000/9001
pub fn message(&self, content: String, replies: &[EventId], previous: Option<Tag>) -> EventBuilder; // kind 9
pub fn reaction(&self, target: EventId, emoji: String, previous: Option<Tag>) -> EventBuilder; // kind 7 + e
pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder; // 9021
pub fn leave_request(&self, reason: &str) -> EventBuilder; // 9022
}
/// Entity identity: the same id on another relay is another group (a fork).
pub struct GroupKey { relay: RelayUrl, id: GroupId }
impl GroupKey {
pub fn new(relay: RelayUrl, id: GroupId) -> Self;
pub fn relay(&self) -> &RelayUrl;
pub fn id(&self) -> &GroupId;
pub fn cache_tag(&self) -> String; // "<relay>|<id>"
pub fn uniq_id(&self) -> u64; // hashed, for panel and element ids
}
/// What a user pastes to join: naddr1…(+"?invite=<code>").
pub struct GroupReference { id: GroupId, relay: RelayUrl, invite: Option<String> }
impl GroupReference {
pub fn parse(input: &str) -> Result<Self, Error>;
pub fn id(&self) -> &GroupId;
pub fn relay(&self) -> &RelayUrl;
pub fn invite(&self) -> Option<&str>;
pub fn key(&self) -> GroupKey;
}
impl FromStr for GroupReference { .. } // so a pasted naddr can use `.parse()` too
/// kind:39000, relay-signed.
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 supported_kinds: Option<Vec<Kind>>, // None = unspecified (all); Some(vec![]) = none
}
impl GroupMetadata {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn name(&self) -> Option<&str>;
pub fn supports(&self, kind: Kind) -> bool;
pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result<String, Error>;
// Coordinate { kind: 39000, public_key: <the event author>, identifier: <the d tag> }
}
/// kind:39001 — each pubkey with its role labels.
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
impl GroupAdmins {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn entries(&self) -> &[(PublicKey, Vec<String>)];
pub fn contains(&self, public_key: &PublicKey) -> bool;
}
/// kind:39002 — may be absent or a subset; absence means "not published".
pub struct GroupMembers(Vec<PublicKey>);
impl GroupMembers {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn contains(&self, public_key: &PublicKey) -> bool;
pub fn iter(&self) -> impl Iterator<Item = &PublicKey>;
}
/// kind:39003 — role names are relay-defined.
pub struct GroupRoles(Vec<Role>);
pub struct Role { pub name: String, pub description: Option<String> }
impl GroupRoles {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn roles(&self) -> &[Role];
}
/// kind:10009 — the account's own list.
pub struct GroupList(Vec<GroupListEntry>);
pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option<String> }
impl GroupList {
pub fn parse(event: &Event) -> Result<Self, Error>;
pub fn entries(&self) -> &[GroupListEntry];
pub fn upsert(&mut self, entry: GroupListEntry);
pub fn remove(&mut self, key: &GroupKey);
pub fn to_builder(&self) -> EventBuilder; // kind 10009, group + r tags
}
pub enum Membership {
Unknown,
Pending { since: Timestamp }, // our 9021 went out; no answer yet
Member,
Refused { reason: Option<String> }, // the relay's OK false / CLOSED message
Removed,
}
impl Membership {
/// Folds 9000/9001 only; `Pending` and `Refused` are set by the join path.
pub fn from_events(events: &[Event], me: PublicKey) -> Self;
}
/// The newest-first window (≤50) that `previous` references are drawn from.
pub struct TimelineWindow(Vec<Event>);
impl TimelineWindow {
pub fn new() -> Self;
pub fn push(&mut self, event: Event); // dedups, caps at 50
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag>; // excludes `me`, ≤3 distinct 8-hex
}
```
Details that must be right:
- Every user event carries `["h", id]` (from `GroupId::h_tag`); `previous` is
added only when we have at least one eligible event, and its values are the
first 8 characters of the hex ids.
- `GroupReference::parse` splits `?invite=` before bech32 decoding; a missing
relay hint is an error (there is nowhere to send the join request).
- `GroupList::parse` accepts `group` tags with 1–3 values (id, relay, name).
- `Membership::from_events` takes the newest `9000`/`9001` whose `p` tags name
`me`; on an exact tie it prefers `9001` (removal). `Pending` and `Refused` are
not things the relay folds into an event: the join path sets them from the
`9021` send and from the relay's refusal message.
- `GroupMetadata::supports` distinguishes "tag absent" from "tag present with no
values": the first means all kinds, the second means no text messages.
- The builders return `EventBuilder`s so the caller decides when to sign; nothing
in this file does I/O.
## Phase 1 — read path (`group.rs` + `lib.rs`)
`GroupsRegistry` (global entity, mirroring `CommunityRegistry`):
```rust
pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
index: HashMap<GroupKey, Entity<Group>>,
/// The account's 10009, loaded at start and mutated by join/leave
list: GroupList,
/// subscription id → group, rebuilt whenever a group resubscribes
routes: Arc<RwLock<HashMap<SubscriptionId, GroupKey>>>,
signal_tx: flume::Sender<Signal>, signal_rx: flume::Receiver<Signal>,
notification_listener: Option<Task<Result<()>>>, // cancelled on signer change
signal_consumer: Option<Task<Result<()>>>,
tasks: SmallVec<[Task<Result<()>>; 2]>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl GroupsRegistry {
pub fn global(cx: &App) -> Entity<Self>;
pub fn groups(&self) -> &[Entity<Group>];
pub fn group(&self, key: &GroupKey) -> Option<Entity<Group>>;
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>);
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>);
fn load(&mut self, cx); // own 10009 from the database into `self.list`
fn publish_list(&mut self, cx); // sign `self.list.to_builder()`, to_nip65, best-effort
}
```
- **Subscriptions.** One standing REQ per group, two filters in one id:
`key.id().timeline_filter(100)` and `key.id().state_filter()`. The id is
`grp-<8 hex of the group key hash>` (NIP-01 caps ids at 64 chars; the real key
lives in `routes`). The relay is added and connected first
(`add_relay(url).and_connect()`), like `community::sync::connect_relays`. On
open, also issue a one-shot `key.id().membership_filter(me)` fetch so a restart
learns `9000`/`9001` immediately; the state filter is fetched once (limit 1 per
kind) so the sidebar has names before the live REQ settles. A dead relay costs
only its own group.
- **Pump.** The listener matches `ClientNotification::Message` and routes
`RelayMessage::Event`/`EndOfStoredEvents`/`Closed` by subscription id through
`routes`. For `h`-tagged events it saves the envelope first, then sends
`Signal::Event(key, event)`; state events send `Signal::State(key, event)`;
`Closed` sends `Signal::Refused(key, message)`. The consumer calls
`Group::ingest`, which is the only place entity state changes.
- **Envelope cache.** Same shape as `set_rumor`/`get_rumor` in `crates/chat`:
`ApplicationSpecificData` (30078, addressable) with `d` = event id (dedup on
replay), `r` = `"<relay>|<id>"` (per-fork history), `k` = kind, content = the
raw event JSON, signed by a crate-local `LOCAL_KEYS`. Every `h`-tagged event
the standing subscription delivers is enveloped — any kind, because the
`previous` window is literally "the last 50 events seen" (NIP-29 timeline
references) — while the `d`-tagged state events (`39000`–`39003`) are not.
Reading is `Filter::new().custom_tag(LOWERCASE_R, key)`, parsed and sorted by
`created_at`, then partitioned: the render set (`1`, `7`, `9`, `11`, `1111`)
becomes messages, `9000`/`9001` fold membership, the rest only feed the
window. This is what makes the timeline fork-safe and restart-safe without
teaching the database about relays.
- **`Group` state.** `GroupKey`, `metadata: Option<GroupMetadata>`,
`metadata_event: Option<Event>` (what `GroupMetadata::naddr` is built from),
`members`, `admins`, `roles`, `membership: Membership` (including the local
`Pending`/`Refused` access states), `messages: Vec<Message>` for the render set
(reusing `chat::Message`, which already parses any event into display fields),
an id index, a `TimelineWindow` for `previous`, `loading`, `refused`. It emits
`GroupEvent::{Incoming(Event), Updated, Refused(String), Eose}` — the panel
subscribes to these the way `ChatPanel` subscribes to `RoomEvent`.
- **`load()`.** Subscribe to our own `10009` (`ReqTarget::auto`, limit 1), then
read the newest one from the database into `self.list`, and add one group per
entry. A newer `10009` arriving from another device is merged
(`GroupList::upsert`) and the new groups are subscribed; removals by another
client cannot be detected in a replaceable list and are not guessed at.
## Phase 2 — write path
All writes sign with the app signer (`finalize_async(&signer)`), send to the
group's relay only, and treat the relay's `OK` as the truth.
- **Send a message.** Build with
`key.id().message(content, &replies, window.previous_tag(me))`, sign, then
`client.send_event(&event).to([relay])`. Kind 9 is the default message kind;
when `metadata.supports(Kind::ChatMessage)` is false the composer is disabled
rather than sending a kind the relay does not take. On acceptance: save the
envelope, insert into `messages`, emit `GroupEvent::Incoming`. On `OK false`:
return the relay's message (it already carries `invalid:` / `restricted:` /
`duplicate:` prefixes) and let the panel show it. Reactions go through the same
path with `key.id().reaction(..)`.
- **Join.** `GroupReference::parse` → error if no relay hint → add/connect the
relay → fetch `39000` (`limit 1`) to confirm the group and learn the self
pubkey; `GroupMetadata::parse` it; a hidden group may refuse this — proceed
without metadata rather than failing → create the entity, fetch state +
membership, subscribe → send `key.id().join_request(code, ..)` — the `h` tag
plus the `code` tag when an invite code was parsed → mark
`Membership::Pending { since }` and `self.list.upsert(..)`, so the group stays
listed while it waits. The relay either emits `9000` (member), refuses the
`9021` with `OK false` (set `Refused { reason }` from its message — the NIP
says the message explains whether the rejection is final, pending review or
payment-required), or refuses the timeline REQ with `restricted:` /
`auth-required:` in a `CLOSED` (keep the state, do not raise an error toast).
All three are observed on the standing subscription or the send result.
- **Leave.** Send `key.id().leave_request(..)`; on `OK`, forget the group's
envelopes (`client.database().delete(filter)` by `r` tag), drop the entity and
`self.list.remove(key)`, then publish. The relay also emits `9001`, which the
(now gone) subscription would have seen.
- **Publish `10009`.** Sign `self.list.to_builder()` and publish with
`to_nip65()`. The registry's `GroupList` is the one loaded at start, join
`upsert`s an entry and leave `remove`s one, so a rewrite can never drop a group
we loaded. The client saves the event locally even when the publish fails, so
the list survives offline.
## Phase 3 — later, in rough order
1. Moderation writes: `9000` put-user (roles), `9001` remove-user, `9002`
edit-metadata, `9005` delete-event, `9009` create-invite; gate the UI on the
caller appearing in `39001`, let the relay be the enforcer. **Done** as pure
writes: builders on `GroupId`, senders on `Group` sharing `dispatch`.
`edit_metadata` takes a `GroupMetadata`, which is now `Default`-able (its
private `id`/`author` origin are `Option`) so the UI can start an edit from
scratch or from `Group::metadata()`. **`9005` is write-only for now**: the
relay stops serving the event but nothing is removed locally, so the deleted
message lingers in the cache and timeline. The UI phase must drop the target
envelope (filter on its `d` = target id and the group's `r` cache tag) and
evict it from `messages`/`index`, otherwise only a reload clears it.
2. Group creation (`9007`) where the relay supports it, and relay browsing.
**Done.** `GroupId::create_group` reuses the `9002` metadata tags, so the
proposed id (`h`) and the initial name/flags go in one event; the relay
republishes `39000`, which the standing subscription picks up.
`GroupsRegistry::create` mirrors `join`: `ensure` → optimistic `10009` entry
+ `publish_list` → publish, and on `OK false` it `forget`s the group and emits
`GroupsEvent::Error(reason)` (that variant was previously unused). There is
no NIP-11 support check available, so "where the relay supports it" is
enforced by the relay refusing the `9007`.
`GroupsRegistry::browse(relay, cx)` follows the same pipeline as every
other read: it registers a `brw-<hash>` subscription (an opaque id, like
`grp-<hash>`; the relay is keyed in state, not encoded in the id, because the
pump needs a prefix to recognise its own ids and a bare
`SubscriptionId::generate()` would be indistinguishable from the other
crates' ids on the shared client). `add_relay` +
`subscribe` (auto-close on EOSE, 10s cap), and returns immediately. The
**global pump** observes the events, and `handle_event`/`handle_eose`/
`handle_closed` fold each relay-signed `39000` into `self.browse`, keyed by
the subscription id, so no DB scoping is needed. `GroupsRegistry::browsed(relay)`
returns `Browse { relay, loading, groups: Vec<GroupCandidate> }` for the UI, and
`GroupsEvent::Updated` says when to re-read it. No `fetch_events` anywhere:
the DB cannot be scoped to a relay (a stored event carries no origin), so the
subscription's own events are the source. `join` now delegates to a new
`join_key(key, invite)`, which is what a browse result needs (a candidate has
a key, not a `GroupReference`).
3. Pins and subgroups. **Pins done:** `Pin::{Event, Address}` and
`GroupPins` in `protocol.rs`, with `PIN_LIST` (39005) and `UPDATE_PIN_LIST`
(9010) as `u16` consts used through `Kind::Custom`. `39005` joins
`STATE_KINDS`, so the standing subscription fetches it, `absorb_state` folds
it into `Group::pins()`, and `Group::update_pins(pins)` writes `9010` through
the shared `dispatch`. **Subgroups parsed:** `GroupMetadata` gained
`pub children: Vec<GroupId>` (order preserved, parsed from `child` tags) and
`metadata_tags` re-emits them — `9002` requires it, an edit that drops a
child is rejected. `parent`/`children` are per-relay, so assembling the tree
from `Group::metadata()` is UI work; there is no backend tree helper.
4. Range backfill: page the timeline with `until` for "load more".
5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token
endpoint with a NIP-98 `27235` authorization event) and fork/migration
detection through the admins' `10009`.
## UI later — the hooks this backend leaves
| UI action | Call |
| --- | --- |
| Paste an naddr and join (or retry a refused join) | `GroupReference::parse` → `GroupsRegistry::join` |
| Open a group | `GroupsRegistry::group(&key, cx)` → panel subscribes to `GroupEvent` |
| Timeline | `Group::messages()` (already `chat::Message`s) |
| Send / react | `Group::send_message` / `send_reaction` |
| Members, admins, roles, flags | `Group::members/admins/roles/metadata` |
| Membership badge (member, pending, refused, removed) | `Group::membership()` |
| Copy an invite link | `GroupMetadata::naddr(relay, invite)` |
| Leave | `GroupsRegistry::leave` |
| Create a group | `GroupMetadata { ..Default::default() }` → `GroupsRegistry::create(key, metadata, cx)`; failure surfaces as `GroupsEvent::Error` |
| Browse a relay's groups | `GroupsRegistry::browse(relay, cx)` → re-read `GroupsRegistry::browsed(relay)` on `GroupsEvent::Updated`; join a result with `GroupsRegistry::join_key(candidate.key, None, cx)` |
| Moderate (put/remove user, edit metadata, delete event, invite) | `Group::put_user/remove_user/edit_metadata/delete_event/create_invite` |
| Pins | `Group::pins()` to read, `Group::update_pins(pins)` to replace the list |
| Subgroup tree | `Group::metadata()` → `parent`/`children`, assembled per relay |
Where it lands: the sidebar gains a section reading `GroupsRegistry::groups()`
(the same shape as its Communities section) with pending and refused groups
listed alongside members; a new panel renders `Vec<Message>` with the existing
`ListState` + `ui::message::MessageRow` and `PersonRegistry` for authors. The
message *data* is already shared (`chat::Message`), so only `ChatPanel`'s
row-rendering methods need lifting into `ui` (or copying) rather than the type. A
join dialog accepts a pasted naddr (with or without `?invite=`). System
notifications can hang off `GroupEvent::Incoming` when the window is inactive.
Nothing new is needed from GPUI to support this backend. The local checkouts
are `gpui-pre` 0.3.5 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui`
styles; the only GPUI APIs the backend uses are the ones `chat` and `community`
already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`,
`EventEmitter`, `Subscription` — and the panel's components (`ListState`,
`MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the app.
## Tests
- `protocol.rs` unit tests: `GroupReference::parse` (naddr, `?invite=`, garbage,
missing relay hint, an naddr whose kind is not `39000`); `GroupMetadata::parse`
flags and `supports` with `supported_kinds` absent vs empty; admins with roles;
members; roles; `GroupList` round trip (`parse` → `upsert` → `to_builder`);
`TimelineWindow::previous_tag` (excludes self, caps at 3, 8-char prefixes,
any-kind window, `None` when empty); `Membership::from_events` (`9000` vs
`9001`, newest wins, tie prefers removal; `Pending`/`Refused` are exercised
through the join path); `GroupId::message` carries `h` and the reply `e` tags;
`GroupMetadata::naddr` round trip with and without an invite code.
- Cache round trip with `nostr-memory` as a dev-dependency (the pattern
`crates/community` already uses): save two envelopes (a kind 9 and a kind
`9000`), read them back sorted, assert the partition — the kind 9 renders, the
`9000` only folds membership — re-save one and confirm no duplicate, `delete`
clears the group.
- Routing: subscription id ↔ `GroupKey` mapping survives a resubscribe.
- One integration test against the SDK's `LocalRelay`
(`nostr-sdk` dev-dependency with the `local-relay` feature): seed a `39000`
and a kind 9 with an `h` tag, run `Group::connect`, assert metadata and the
timeline land. Timeouts in GPUI-driven tests use
`cx.background_executor().timer(..)`, never `smol::Timer`, per `AGENTS.md`.
## Decisions
1. **Crate.** `crates/nip29` (the body above is written for it).
2. **Timeline kinds.** Render `1`, `9`, `11`, `1111` and `7`; every other kind
is still cached and feeds the `previous` window, per the NIP's timeline
references rule.
3. **Default message kind.** Kind 9; the composer is disabled when
`supported_kinds` is present and lacks it.
4. **Pending joins.** Pending (and refused) groups are listed in the sidebar,
with an access state on the row.
5. **`10009` merge.** Union-only; removals made by another client are not
detected and no diffing is done.
6. **Leave and the cache.** Leaving wipes the group's envelopes.
7. **Restricted/private groups.** A visible access state is required:
`Membership::{Unknown, Pending, Member, Refused, Removed}` with the relay's
refusal reason shown and a retry, while the client's NIP-42 authenticator
handles the reads.
8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group
whose relay publishes no `39000` cannot be referenced or joined in v1.
+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))
})