Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f7956dff | ||
|
|
c477246bb8 | ||
|
|
338783f394 |
Generated
+16
@@ -1458,6 +1458,7 @@ dependencies = [
|
|||||||
"gpui-pre-reqwest-client",
|
"gpui-pre-reqwest-client",
|
||||||
"gpui-pre-windows",
|
"gpui-pre-windows",
|
||||||
"log",
|
"log",
|
||||||
|
"nip29",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"person",
|
"person",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -1487,6 +1488,7 @@ dependencies = [
|
|||||||
"gpui-pre-web",
|
"gpui-pre-web",
|
||||||
"instant",
|
"instant",
|
||||||
"log",
|
"log",
|
||||||
|
"nip29",
|
||||||
"person",
|
"person",
|
||||||
"settings",
|
"settings",
|
||||||
"state",
|
"state",
|
||||||
@@ -4593,6 +4595,20 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nip29"
|
||||||
|
version = "1.0.2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chat",
|
||||||
|
"flume 0.11.1",
|
||||||
|
"gpui-pre",
|
||||||
|
"log",
|
||||||
|
"nostr-sdk",
|
||||||
|
"smallvec",
|
||||||
|
"state",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "no_std_io2"
|
name = "no_std_io2"
|
||||||
version = "0.9.4"
|
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" }
|
device = { path = "../crates/device" }
|
||||||
chat = { path = "../crates/chat" }
|
chat = { path = "../crates/chat" }
|
||||||
community = { path = "../crates/community" }
|
community = { path = "../crates/community" }
|
||||||
|
nip29 = { path = "../crates/nip29" }
|
||||||
settings = { path = "../crates/settings" }
|
settings = { path = "../crates/settings" }
|
||||||
auto_update = { path = "../crates/auto_update" }
|
auto_update = { path = "../crates/auto_update" }
|
||||||
person = { path = "../crates/person" }
|
person = { path = "../crates/person" }
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ fn main() {
|
|||||||
// Initialize community registry
|
// Initialize community registry
|
||||||
community::init(cx);
|
community::init(cx);
|
||||||
|
|
||||||
|
// Initialize groups registry
|
||||||
|
nip29::init(cx);
|
||||||
|
|
||||||
// Initialize auto update
|
// Initialize auto update
|
||||||
auto_update::init(cx);
|
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" }
|
device = { path = "../crates/device" }
|
||||||
chat = { path = "../crates/chat" }
|
chat = { path = "../crates/chat" }
|
||||||
community = { path = "../crates/community" }
|
community = { path = "../crates/community" }
|
||||||
|
nip29 = { path = "../crates/nip29" }
|
||||||
settings = { path = "../crates/settings" }
|
settings = { path = "../crates/settings" }
|
||||||
person = { path = "../crates/person" }
|
person = { path = "../crates/person" }
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ pub fn run() -> Result<(), JsValue> {
|
|||||||
// Initialize community registry
|
// Initialize community registry
|
||||||
community::init(cx);
|
community::init(cx);
|
||||||
|
|
||||||
|
// Initialize groups registry
|
||||||
|
nip29::init(cx);
|
||||||
|
|
||||||
cx.open_window(WindowOptions::default(), |window, cx| {
|
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||||
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
|
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user