3 Commits
Author SHA1 Message Date
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
10 changed files with 1771 additions and 0 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"
+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
+323
View File
@@ -0,0 +1,323 @@
use std::collections::{HashMap, HashSet};
use anyhow::Result;
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, GroupKey, GroupMembers, GroupMetadata, GroupRoles, MEMBERSHIP_KINDS, Membership,
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),
Updated,
Eose,
Refused(String),
}
pub struct Group {
key: GroupKey,
metadata: Option<GroupMetadata>,
members: GroupMembers,
admins: GroupAdmins,
roles: GroupRoles,
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(),
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 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(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).read(cx);
let client = nostr.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).read(cx);
let client = nostr.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,
},
_ => 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
}
}
+380
View File
@@ -0,0 +1,380 @@
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
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-";
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)]
enum Signal {
Event(SubscriptionId, Event),
Eose(SubscriptionId),
Closed(SubscriptionId, String),
}
fn list_subscription_id() -> SubscriptionId {
SubscriptionId::new(LIST_SUBSCRIPTION)
}
pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
}
fn is_group_subscription(id: &SubscriptionId) -> bool {
let id = id.as_str();
id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX)
}
impl EventEmitter<GroupsEvent> for GroupsRegistry {}
pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
list: GroupList,
routes: HashMap<SubscriptionId, GroupKey>,
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(),
routes: 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 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.list = GroupList::default();
let client = NostrRegistry::global(cx).read(cx).client();
let mut ids: Vec<SubscriptionId> = routes.into_keys().collect();
ids.push(list_subscription_id());
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);
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 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;
}
let Some(key) = self.routes.get(&id).cloned() else {
return;
};
self.reload(key, Some(event), cx);
}
fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
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>) {
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_group_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_group_subscription(&id).then_some(Signal::Eose(id))
}
RelayMessage::Closed {
subscription_id,
message,
} => {
let id = subscription_id.into_owned();
is_group_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();
}
}));
}
}
+551
View File
@@ -0,0 +1,551 @@
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 STATE_KINDS: [Kind; 4] = [
Kind::GroupMetadata,
Kind::GroupAdmins,
Kind::GroupMembers,
Kind::GroupRoles,
];
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())
}
}
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)]
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>>,
id: GroupId,
author: 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,
};
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,
supported_kinds,
id: GroupId::new(identifier)?,
author: event.pubkey,
})
}
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 coordinate =
Coordinate::new(Kind::GroupMetadata, self.author).identifier(self.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, 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 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);
+476
View File
@@ -0,0 +1,476 @@
# 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.
2. Group creation (`9007`) where the relay supports it, and relay browsing
(`fetch_events(ReqTarget::single(relay, [Filter::new().kind(Kind::GroupMetadata).limit(..)]))`).
3. Pins: `9010` write, `39005` read (`Kind::Custom`); subgroups: parse
`parent`/`child` and lay groups out as a tree.
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` |
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))
})