Compare commits
3
Commits
21f96c7bbe
...
c6f7956dff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f7956dff | ||
|
|
c477246bb8 | ||
|
|
338783f394 |
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user