From de862637f3867c91b9f01f0011b80f034ffd351b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 12:38:02 +0700 Subject: [PATCH] add NIP-29 moderation, pins, browse, and create-group support --- crates/nip29/src/group.rs | 77 ++++++++++---- crates/nip29/src/lib.rs | 166 +++++++++++++++++++++++++++-- crates/nip29/src/protocol.rs | 196 +++++++++++++++++++++++++++++++++-- docs/nip29-backend-plan.md | 54 +++++++++- 4 files changed, 452 insertions(+), 41 deletions(-) diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 8b107da6..c0f64d07 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -9,8 +9,8 @@ use state::NostrRegistry; use crate::LOCAL_KEYS; use crate::protocol::{ - GroupAdmins, GroupKey, GroupMembers, GroupMetadata, GroupRoles, MEMBERSHIP_KINDS, Membership, - RENDER_KINDS, STATE_KINDS, TimelineWindow, + GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupPins, GroupRoles, + MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow, }; const REPLAY_LIMIT: usize = 100; @@ -37,6 +37,7 @@ pub struct Group { members: GroupMembers, admins: GroupAdmins, roles: GroupRoles, + pins: GroupPins, membership: Membership, membership_events: Vec, state_ids: HashMap, @@ -58,6 +59,7 @@ impl Group { members: GroupMembers::default(), admins: GroupAdmins::default(), roles: GroupRoles::default(), + pins: GroupPins::default(), membership: Membership::Unknown, membership_events: Vec::new(), state_ids: HashMap::new(), @@ -90,6 +92,10 @@ impl Group { &self.roles } + pub fn pins(&self) -> &GroupPins { + &self.pins + } + pub fn membership(&self) -> &Membership { &self.membership } @@ -116,16 +122,7 @@ impl Group { replies: Vec, cx: &mut Context, ) -> Task> { - let nostr = NostrRegistry::global(cx); - - let Some(me) = nostr.read(cx).current_user() else { - return Task::ready(Err(anyhow!("nip29: no signed-in account"))); - }; - - let previous = self.window.previous_tag(me); - let builder = self.key.id().message(content, &replies, previous); - - self.dispatch(builder, cx) + self.dispatch(move |id, prev| id.message(content, &replies, prev), cx) } pub fn send_reaction( @@ -134,19 +131,57 @@ impl Group { emoji: String, cx: &mut Context, ) -> Task> { + self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx) + } + + pub fn put_user( + &mut self, + public_key: PublicKey, + roles: Vec, + cx: &mut Context, + ) -> Task> { + self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx) + } + + pub fn remove_user( + &mut self, + public_key: PublicKey, + cx: &mut Context, + ) -> Task> { + self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx) + } + + pub fn edit_metadata( + &mut self, + metadata: &GroupMetadata, + cx: &mut Context, + ) -> Task> { + self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx) + } + + pub fn delete_event(&mut self, id: EventId, cx: &mut Context) -> Task> { + self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx) + } + + pub fn create_invite(&mut self, code: &str, cx: &mut Context) -> Task> { + self.dispatch(move |id, prev| id.create_invite(code, prev), cx) + } + + pub fn update_pins(&mut self, pins: Vec, cx: &mut Context) -> Task> { + self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx) + } + + fn dispatch(&mut self, build: F, cx: &mut Context) -> Task> + where + F: FnOnce(&GroupId, Option) -> EventBuilder, + { let nostr = NostrRegistry::global(cx); let Some(me) = nostr.read(cx).current_user() else { return Task::ready(Err(anyhow!("nip29: no signed-in account"))); }; - let previous = self.window.previous_tag(me); - let builder = self.key.id().reaction(target, &emoji, previous); - - self.dispatch(builder, cx) - } - - fn dispatch(&mut self, builder: EventBuilder, cx: &mut Context) -> Task> { + let builder = build(self.key.id(), self.window.previous_tag(me)); let published = self.publish(builder, cx); cx.spawn(async move |this, cx| { @@ -381,6 +416,10 @@ impl Group { Ok(roles) => self.roles = roles, Err(_) => return false, }, + Kind::Custom(PIN_LIST) => match GroupPins::parse(event) { + Ok(pins) => self.pins = pins, + Err(_) => return false, + }, _ => return false, } diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index f10735fd..2585aebc 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -1,5 +1,8 @@ +use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::sync::LazyLock; +use std::time::Duration; use anyhow::{Error, Result}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; @@ -17,6 +20,9 @@ pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const LIST_SUBSCRIPTION: &str = "nip29-list"; const GROUP_PREFIX: &str = "grp-"; +const BROWSE_PREFIX: &str = "brw-"; +const BROWSE_LIMIT: usize = 500; +const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); pub fn init(cx: &mut App) { GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); @@ -32,6 +38,13 @@ pub enum GroupsEvent { Error(String), } +#[derive(Debug, Clone)] +pub struct Browse { + pub relay: RelayUrl, + pub loading: bool, + pub groups: Vec, +} + #[derive(Debug)] enum Signal { Event(SubscriptionId, Event), @@ -47,9 +60,16 @@ fn subscription_id(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) } -fn is_group_subscription(id: &SubscriptionId) -> bool { +fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { + let mut hasher = DefaultHasher::new(); + relay.hash(&mut hasher); + + SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) +} + +fn is_own_subscription(id: &SubscriptionId) -> bool { let id = id.as_str(); - id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) + id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) } impl EventEmitter for GroupsRegistry {} @@ -57,6 +77,7 @@ impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { groups: Vec>, list: GroupList, + browse: HashMap, routes: HashMap, observers: HashMap, signal_tx: flume::Sender, @@ -106,6 +127,7 @@ impl GroupsRegistry { Self { groups: Vec::new(), list: GroupList::default(), + browse: HashMap::new(), routes: HashMap::new(), observers: HashMap::new(), signal_tx: tx, @@ -129,9 +151,10 @@ impl GroupsRegistry { } pub fn join(&mut self, reference: GroupReference, cx: &mut Context) { - let key = reference.key(); - let invite = reference.invite().map(str::to_owned); + self.join_key(reference.key(), reference.invite().map(str::to_owned), cx); + } + pub fn join_key(&mut self, key: GroupKey, invite: Option, cx: &mut Context) { self.ensure(key.clone(), cx); let Some(group) = self.group(&key, cx) else { @@ -168,6 +191,74 @@ impl GroupsRegistry { })); } + pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context) { + self.ensure(key.clone(), cx); + + let Some(group) = self.group(&key, cx) else { + return; + }; + + self.upsert_entry(&key, metadata.name().map(str::to_owned)); + self.publish_list(cx); + + cx.emit(GroupsEvent::Updated); + cx.notify(); + + let builder = key.id().create_group(&metadata, None); + let published = group.read(cx).publish(builder, cx); + let logged = key.clone(); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = published.await { + this.update(cx, |this, cx| { + this.forget(logged, cx); + cx.emit(GroupsEvent::Error(e.to_string())); + }) + .ok(); + } + })); + } + + pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let id = browse_subscription(&relay); + + self.browse.insert( + id.clone(), + Browse { + relay: relay.clone(), + loading: true, + groups: Vec::new(), + }, + ); + + cx.emit(GroupsEvent::Updated); + cx.notify(); + + self.tasks.push(cx.background_spawn(async move { + client.add_relay(relay.clone()).and_connect().await.ok(); + + let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(BROWSE_TIMEOUT)); + + if let Err(error) = client + .subscribe(ReqTarget::single(relay, [filter])) + .with_id(id) + .close_on(opts) + .await + { + log::warn!("nip29: browsing failed: {error}"); + } + })); + } + + pub fn browsed(&self, relay: &RelayUrl) -> Option<&Browse> { + self.browse.get(&browse_subscription(relay)) + } + pub fn leave(&mut self, key: &GroupKey, cx: &mut Context) { let Some(group) = self.group(key, cx) else { return; @@ -205,6 +296,9 @@ impl GroupsRegistry { let mut ids: Vec = routes.into_keys().collect(); ids.push(list_subscription_id()); + ids.extend(self.browse.keys().cloned()); + + self.browse.clear(); self.tasks.push(cx.background_spawn(async move { for id in ids { @@ -446,6 +540,11 @@ impl GroupsRegistry { return; } + if self.browse.contains_key(&id) { + self.absorb_candidate(&id, &event, cx); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -453,10 +552,49 @@ impl GroupsRegistry { self.reload(key, Some(event), cx); } + fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { + let Some(browse) = self.browse.get_mut(id) else { + return; + }; + + let Ok(metadata) = GroupMetadata::parse(event) else { + log::warn!( + "nip29: ignoring malformed group metadata from {}", + browse.relay + ); + return; + }; + + let Some(group) = metadata.id().cloned() else { + return; + }; + + let candidate = GroupCandidate { + key: GroupKey::new(browse.relay.clone(), group), + metadata, + }; + + match browse.groups.iter_mut().find(|e| e.key == candidate.key) { + Some(existing) => *existing = candidate, + None => browse.groups.push(candidate), + } + + cx.emit(GroupsEvent::Updated); + cx.notify(); + } + fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context) { + if let Some(browse) = self.browse.get_mut(&id) { + browse.loading = false; + cx.emit(GroupsEvent::Updated); + cx.notify(); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; + let Some(group) = self.group(&key, cx) else { return; }; @@ -464,13 +602,24 @@ impl GroupsRegistry { group.update(cx, |group, cx| { group.eose(cx); }); + cx.notify(); } fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { + if let Some(browse) = self.browse.get_mut(&id) { + log::warn!("nip29: browsing {} was refused: {reason}", browse.relay); + browse.loading = false; + + cx.emit(GroupsEvent::Updated); + cx.notify(); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; + let Some(group) = self.group(&key, cx) else { return; }; @@ -478,6 +627,7 @@ impl GroupsRegistry { group.update(cx, |group, cx| { group.refuse(reason, cx); }); + cx.notify(); } @@ -508,13 +658,12 @@ impl GroupsRegistry { } => { let id = subscription_id.into_owned(); - if !is_group_subscription(&id) { + if !is_own_subscription(&id) { None } else { if processed.len() >= MAX_PROCESSED { processed.clear(); } - processed .insert(event.id) .then(|| Signal::Event(id, event.into_owned())) @@ -522,15 +671,14 @@ impl GroupsRegistry { } RelayMessage::EndOfStoredEvents(id) => { let id = id.into_owned(); - is_group_subscription(&id).then_some(Signal::Eose(id)) + is_own_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())) + is_own_subscription(&id).then_some(Signal::Closed(id, message.into_owned())) } _ => None, }; diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 45bf54f5..2e1bbcc4 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -19,11 +19,15 @@ pub const RENDER_KINDS: [Kind; 5] = [ Kind::Reaction, ]; -pub const STATE_KINDS: [Kind; 4] = [ +pub const PIN_LIST: u16 = 39_005; +pub const UPDATE_PIN_LIST: u16 = 9_010; + +pub const STATE_KINDS: [Kind; 5] = [ Kind::GroupMetadata, Kind::GroupAdmins, Kind::GroupMembers, Kind::GroupRoles, + Kind::Custom(PIN_LIST), ]; pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser]; @@ -106,6 +110,106 @@ impl GroupId { pub fn leave_request(&self, reason: &str) -> EventBuilder { EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag()) } + + pub fn put_user( + &self, + public_key: PublicKey, + roles: &[String], + previous: Option, + ) -> EventBuilder { + let mut values = Vec::with_capacity(1 + roles.len()); + values.push(public_key.to_hex()); + values.extend(roles.iter().cloned()); + + EventBuilder::new(Kind::GroupPutUser, "") + .tag(self.h_tag()) + .tag(Tag::custom("p", values)) + .tag_maybe(previous) + } + + pub fn remove_user(&self, public_key: PublicKey, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupRemoveUser, "") + .tag(self.h_tag()) + .tag(Tag::public_key(public_key)) + .tag_maybe(previous) + } + + pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupEditMetadata, "") + .tags(self.metadata_tags(metadata)) + .tag_maybe(previous) + } + + pub fn create_group(&self, metadata: &GroupMetadata, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupCreateGroup, "") + .tags(self.metadata_tags(metadata)) + .tag_maybe(previous) + } + + fn metadata_tags(&self, metadata: &GroupMetadata) -> Vec { + let mut tags = vec![self.h_tag()]; + + for (name, value) in [ + ("name", &metadata.name), + ("picture", &metadata.picture), + ("banner", &metadata.banner), + ("about", &metadata.about), + ] { + if let Some(value) = value { + tags.push(Tag::custom(name, [value.clone()])); + } + } + + for (name, set) in [ + ("private", metadata.private), + ("restricted", metadata.restricted), + ("hidden", metadata.hidden), + ("closed", metadata.closed), + ("livekit", metadata.livekit), + ] { + if set { + tags.push(Tag::custom(name, Vec::::new())); + } + } + + if let Some(parent) = &metadata.parent { + tags.push(Tag::custom("parent", [parent.as_str()])); + } + + for child in &metadata.children { + tags.push(Tag::custom("child", [child.as_str()])); + } + + if let Some(kinds) = &metadata.supported_kinds { + tags.push(Tag::custom( + "supported_kinds", + kinds.iter().map(ToString::to_string), + )); + } + + tags + } + + pub fn delete_event(&self, id: EventId, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupDeleteEvent, "") + .tag(self.h_tag()) + .tag(Tag::event(id)) + .tag_maybe(previous) + } + + pub fn create_invite(&self, code: &str, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupCreateInvite, "") + .tag(self.h_tag()) + .tag(Tag::custom("code", [code])) + .tag_maybe(previous) + } + + pub fn update_pin_list(&self, pins: &[Pin], previous: Option) -> EventBuilder { + EventBuilder::new(Kind::Custom(UPDATE_PIN_LIST), "") + .tag(self.h_tag()) + .tags(pins.iter().map(Pin::tag)) + .tag_maybe(previous) + } } impl fmt::Display for GroupId { @@ -210,7 +314,7 @@ impl FromStr for GroupReference { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct GroupMetadata { pub name: Option, pub picture: Option, @@ -222,9 +326,10 @@ pub struct GroupMetadata { pub closed: bool, pub livekit: bool, pub parent: Option, + pub children: Vec, pub supported_kinds: Option>, - id: GroupId, - author: PublicKey, + id: Option, + author: Option, } impl GroupMetadata { @@ -257,6 +362,14 @@ impl GroupMetadata { None => None, }; + let mut children = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "child") { + if let Some(child) = tag.content() { + children.push(GroupId::new(child)?); + } + } + Ok(Self { name: tag_value(&event.tags, "name").map(str::to_owned), picture: tag_value(&event.tags, "picture").map(str::to_owned), @@ -268,12 +381,17 @@ impl GroupMetadata { closed: has_tag(&event.tags, "closed"), livekit: has_tag(&event.tags, "livekit"), parent, + children, supported_kinds, - id: GroupId::new(identifier)?, - author: event.pubkey, + id: Some(GroupId::new(identifier)?), + author: Some(event.pubkey), }) } + pub(crate) fn id(&self) -> Option<&GroupId> { + self.id.as_ref() + } + pub fn name(&self) -> Option<&str> { self.name.as_deref() } @@ -286,8 +404,10 @@ impl GroupMetadata { } pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result { - let coordinate = - Coordinate::new(Kind::GroupMetadata, self.author).identifier(self.id.as_str()); + let id = self.id.as_ref().ok_or_else(|| anyhow!("no id"))?; + let author = self.author.ok_or_else(|| anyhow!("no author"))?; + + let coordinate = Coordinate::new(Kind::GroupMetadata, author).identifier(id.as_str()); let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?; Ok(match invite { @@ -297,6 +417,12 @@ impl GroupMetadata { } } +#[derive(Debug, Clone)] +pub struct GroupCandidate { + pub key: GroupKey, + pub metadata: GroupMetadata, +} + #[derive(Debug, Clone, Default)] pub struct GroupAdmins(Vec<(PublicKey, Vec)>); @@ -389,6 +515,60 @@ impl GroupRoles { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pin { + Event(EventId), + Address(Coordinate), +} + +impl Pin { + fn tag(&self) -> Tag { + match self { + Pin::Event(id) => Tag::event(*id), + Pin::Address(coordinate) => Tag::coordinate(coordinate.clone(), None), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct GroupPins(Vec); + +impl GroupPins { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::Custom(PIN_LIST) { + bail!("not a pinned list event: kind {}", event.kind); + } + + let mut pins = Vec::new(); + + for tag in event.tags.iter() { + match tag.kind() { + "e" => { + let Some(id) = tag.content() else { + continue; + }; + + pins.push(Pin::Event(EventId::from_hex(id)?)); + } + "a" => { + let Some(address) = tag.content() else { + continue; + }; + + pins.push(Pin::Address(Coordinate::from_str(address)?)); + } + _ => {} + } + } + + Ok(Self(pins)) + } + + pub fn pins(&self) -> &[Pin] { + &self.0 + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct GroupListEntry { pub id: GroupId, diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md index c967da00..be8bbee8 100644 --- a/docs/nip29-backend-plan.md +++ b/docs/nip29-backend-plan.md @@ -393,11 +393,50 @@ group's relay only, and treat the relay's `OK` as the truth. 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. + caller appearing in `39001`, let the relay be the enforcer. **Done** as pure + writes: builders on `GroupId`, senders on `Group` sharing `dispatch`. + `edit_metadata` takes a `GroupMetadata`, which is now `Default`-able (its + private `id`/`author` origin are `Option`) so the UI can start an edit from + scratch or from `Group::metadata()`. **`9005` is write-only for now**: the + relay stops serving the event but nothing is removed locally, so the deleted + message lingers in the cache and timeline. The UI phase must drop the target + envelope (filter on its `d` = target id and the group's `r` cache tag) and + evict it from `messages`/`index`, otherwise only a reload clears it. +2. Group creation (`9007`) where the relay supports it, and relay browsing. + **Done.** `GroupId::create_group` reuses the `9002` metadata tags, so the + proposed id (`h`) and the initial name/flags go in one event; the relay + republishes `39000`, which the standing subscription picks up. + `GroupsRegistry::create` mirrors `join`: `ensure` → optimistic `10009` entry + + `publish_list` → publish, and on `OK false` it `forget`s the group and emits + `GroupsEvent::Error(reason)` (that variant was previously unused). There is + no NIP-11 support check available, so "where the relay supports it" is + enforced by the relay refusing the `9007`. + `GroupsRegistry::browse(relay, cx)` follows the same pipeline as every + other read: it registers a `brw-` subscription (an opaque id, like + `grp-`; the relay is keyed in state, not encoded in the id, because the + pump needs a prefix to recognise its own ids and a bare + `SubscriptionId::generate()` would be indistinguishable from the other + crates' ids on the shared client). `add_relay` + + `subscribe` (auto-close on EOSE, 10s cap), and returns immediately. The + **global pump** observes the events, and `handle_event`/`handle_eose`/ + `handle_closed` fold each relay-signed `39000` into `self.browse`, keyed by + the subscription id, so no DB scoping is needed. `GroupsRegistry::browsed(relay)` + returns `Browse { relay, loading, groups: Vec }` for the UI, and + `GroupsEvent::Updated` says when to re-read it. No `fetch_events` anywhere: + the DB cannot be scoped to a relay (a stored event carries no origin), so the + subscription's own events are the source. `join` now delegates to a new + `join_key(key, invite)`, which is what a browse result needs (a candidate has + a key, not a `GroupReference`). +3. Pins and subgroups. **Pins done:** `Pin::{Event, Address}` and + `GroupPins` in `protocol.rs`, with `PIN_LIST` (39005) and `UPDATE_PIN_LIST` + (9010) as `u16` consts used through `Kind::Custom`. `39005` joins + `STATE_KINDS`, so the standing subscription fetches it, `absorb_state` folds + it into `Group::pins()`, and `Group::update_pins(pins)` writes `9010` through + the shared `dispatch`. **Subgroups parsed:** `GroupMetadata` gained + `pub children: Vec` (order preserved, parsed from `child` tags) and + `metadata_tags` re-emits them — `9002` requires it, an edit that drops a + child is rejected. `parent`/`children` are per-relay, so assembling the tree + from `Group::metadata()` is UI work; there is no backend tree helper. 4. Range backfill: page the timeline with `until` for "load more". 5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token endpoint with a NIP-98 `27235` authorization event) and fork/migration @@ -415,6 +454,11 @@ group's relay only, and treat the relay's `OK` as the truth. | Membership badge (member, pending, refused, removed) | `Group::membership()` | | Copy an invite link | `GroupMetadata::naddr(relay, invite)` | | Leave | `GroupsRegistry::leave` | +| Create a group | `GroupMetadata { ..Default::default() }` → `GroupsRegistry::create(key, metadata, cx)`; failure surfaces as `GroupsEvent::Error` | +| Browse a relay's groups | `GroupsRegistry::browse(relay, cx)` → re-read `GroupsRegistry::browsed(relay)` on `GroupsEvent::Updated`; join a result with `GroupsRegistry::join_key(candidate.key, None, cx)` | +| Moderate (put/remove user, edit metadata, delete event, invite) | `Group::put_user/remove_user/edit_metadata/delete_event/create_invite` | +| Pins | `Group::pins()` to read, `Group::update_pins(pins)` to replace the list | +| Subgroup tree | `Group::metadata()` → `parent`/`children`, assembled per relay | Where it lands: the sidebar gains a section reading `GroupsRegistry::groups()` (the same shape as its Communities section) with pending and refused groups