add NIP-29 moderation, pins, browse, and create-group support

This commit is contained in:
2026-09-30 12:38:02 +07:00
parent ee9a852a8f
commit de862637f3
4 changed files with 452 additions and 41 deletions
+58 -19
View File
@@ -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<Event>,
state_ids: HashMap<Kind, EventId>,
@@ -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<EventId>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
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<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx)
}
pub fn put_user(
&mut self,
public_key: PublicKey,
roles: Vec<String>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
}
pub fn remove_user(
&mut self,
public_key: PublicKey,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
}
pub fn edit_metadata(
&mut self,
metadata: &GroupMetadata,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx)
}
pub fn delete_event(&mut self, id: EventId, cx: &mut Context<Self>) -> Task<Result<()>> {
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
}
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
}
pub fn update_pins(&mut self, pins: Vec<Pin>, cx: &mut Context<Self>) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx)
}
fn dispatch<F>(&mut self, build: F, cx: &mut Context<Self>) -> Task<Result<()>>
where
F: FnOnce(&GroupId, Option<Tag>) -> EventBuilder,
{
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return Task::ready(Err(anyhow!("nip29: no signed-in account")));
};
let 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<Self>) -> Task<Result<()>> {
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,
}
+157 -9
View File
@@ -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<Keys> = LazyLock::new(Keys::generate);
const LIST_SUBSCRIPTION: &str = "nip29-list";
const GROUP_PREFIX: &str = "grp-";
const BROWSE_PREFIX: &str = "brw-";
const BROWSE_LIMIT: usize = 500;
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
pub fn init(cx: &mut App) {
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
@@ -32,6 +38,13 @@ pub enum GroupsEvent {
Error(String),
}
#[derive(Debug, Clone)]
pub struct Browse {
pub relay: RelayUrl,
pub loading: bool,
pub groups: Vec<GroupCandidate>,
}
#[derive(Debug)]
enum Signal {
Event(SubscriptionId, Event),
@@ -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<GroupsEvent> for GroupsRegistry {}
@@ -57,6 +77,7 @@ impl EventEmitter<GroupsEvent> for GroupsRegistry {}
pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
list: GroupList,
browse: HashMap<SubscriptionId, Browse>,
routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>,
signal_tx: flume::Sender<Signal>,
@@ -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<Self>) {
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<String>, cx: &mut Context<Self>) {
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>) {
self.ensure(key.clone(), cx);
let Some(group) = self.group(&key, cx) else {
return;
};
self.upsert_entry(&key, metadata.name().map(str::to_owned));
self.publish_list(cx);
cx.emit(GroupsEvent::Updated);
cx.notify();
let builder = key.id().create_group(&metadata, None);
let published = group.read(cx).publish(builder, cx);
let logged = key.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = published.await {
this.update(cx, |this, cx| {
this.forget(logged, cx);
cx.emit(GroupsEvent::Error(e.to_string()));
})
.ok();
}
}));
}
pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let id = browse_subscription(&relay);
self.browse.insert(
id.clone(),
Browse {
relay: relay.clone(),
loading: true,
groups: Vec::new(),
},
);
cx.emit(GroupsEvent::Updated);
cx.notify();
self.tasks.push(cx.background_spawn(async move {
client.add_relay(relay.clone()).and_connect().await.ok();
let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT);
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(BROWSE_TIMEOUT));
if let Err(error) = client
.subscribe(ReqTarget::single(relay, [filter]))
.with_id(id)
.close_on(opts)
.await
{
log::warn!("nip29: browsing failed: {error}");
}
}));
}
pub fn browsed(&self, relay: &RelayUrl) -> Option<&Browse> {
self.browse.get(&browse_subscription(relay))
}
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(key, cx) else {
return;
@@ -205,6 +296,9 @@ impl GroupsRegistry {
let mut ids: Vec<SubscriptionId> = routes.into_keys().collect();
ids.push(list_subscription_id());
ids.extend(self.browse.keys().cloned());
self.browse.clear();
self.tasks.push(cx.background_spawn(async move {
for id in ids {
@@ -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<Self>) {
let Some(browse) = self.browse.get_mut(id) else {
return;
};
let Ok(metadata) = GroupMetadata::parse(event) else {
log::warn!(
"nip29: ignoring malformed group metadata from {}",
browse.relay
);
return;
};
let Some(group) = metadata.id().cloned() else {
return;
};
let candidate = GroupCandidate {
key: GroupKey::new(browse.relay.clone(), group),
metadata,
};
match browse.groups.iter_mut().find(|e| e.key == candidate.key) {
Some(existing) => *existing = candidate,
None => browse.groups.push(candidate),
}
cx.emit(GroupsEvent::Updated);
cx.notify();
}
fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
if let Some(browse) = self.browse.get_mut(&id) {
browse.loading = false;
cx.emit(GroupsEvent::Updated);
cx.notify();
return;
}
let Some(key) = self.routes.get(&id).cloned() else {
return;
};
let Some(group) = self.group(&key, cx) else {
return;
};
@@ -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<Self>) {
if let Some(browse) = self.browse.get_mut(&id) {
log::warn!("nip29: browsing {} was refused: {reason}", browse.relay);
browse.loading = false;
cx.emit(GroupsEvent::Updated);
cx.notify();
return;
}
let Some(key) = self.routes.get(&id).cloned() else {
return;
};
let Some(group) = self.group(&key, cx) else {
return;
};
@@ -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,
};
+188 -8
View File
@@ -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<Tag>,
) -> EventBuilder {
let mut values = Vec::with_capacity(1 + roles.len());
values.push(public_key.to_hex());
values.extend(roles.iter().cloned());
EventBuilder::new(Kind::GroupPutUser, "")
.tag(self.h_tag())
.tag(Tag::custom("p", values))
.tag_maybe(previous)
}
pub fn remove_user(&self, public_key: PublicKey, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupRemoveUser, "")
.tag(self.h_tag())
.tag(Tag::public_key(public_key))
.tag_maybe(previous)
}
pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupEditMetadata, "")
.tags(self.metadata_tags(metadata))
.tag_maybe(previous)
}
pub fn create_group(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupCreateGroup, "")
.tags(self.metadata_tags(metadata))
.tag_maybe(previous)
}
fn metadata_tags(&self, metadata: &GroupMetadata) -> Vec<Tag> {
let mut tags = vec![self.h_tag()];
for (name, value) in [
("name", &metadata.name),
("picture", &metadata.picture),
("banner", &metadata.banner),
("about", &metadata.about),
] {
if let Some(value) = value {
tags.push(Tag::custom(name, [value.clone()]));
}
}
for (name, set) in [
("private", metadata.private),
("restricted", metadata.restricted),
("hidden", metadata.hidden),
("closed", metadata.closed),
("livekit", metadata.livekit),
] {
if set {
tags.push(Tag::custom(name, Vec::<String>::new()));
}
}
if let Some(parent) = &metadata.parent {
tags.push(Tag::custom("parent", [parent.as_str()]));
}
for child in &metadata.children {
tags.push(Tag::custom("child", [child.as_str()]));
}
if let Some(kinds) = &metadata.supported_kinds {
tags.push(Tag::custom(
"supported_kinds",
kinds.iter().map(ToString::to_string),
));
}
tags
}
pub fn delete_event(&self, id: EventId, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupDeleteEvent, "")
.tag(self.h_tag())
.tag(Tag::event(id))
.tag_maybe(previous)
}
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupCreateInvite, "")
.tag(self.h_tag())
.tag(Tag::custom("code", [code]))
.tag_maybe(previous)
}
pub fn update_pin_list(&self, pins: &[Pin], previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::Custom(UPDATE_PIN_LIST), "")
.tag(self.h_tag())
.tags(pins.iter().map(Pin::tag))
.tag_maybe(previous)
}
}
impl fmt::Display for GroupId {
@@ -210,7 +314,7 @@ impl FromStr for GroupReference {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct GroupMetadata {
pub name: Option<String>,
pub picture: Option<String>,
@@ -222,9 +326,10 @@ pub struct GroupMetadata {
pub closed: bool,
pub livekit: bool,
pub parent: Option<GroupId>,
pub children: Vec<GroupId>,
pub supported_kinds: Option<Vec<Kind>>,
id: GroupId,
author: PublicKey,
id: Option<GroupId>,
author: Option<PublicKey>,
}
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<String> {
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<String>)>);
@@ -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<Pin>);
impl GroupPins {
pub fn parse(event: &Event) -> Result<Self> {
if event.kind != Kind::Custom(PIN_LIST) {
bail!("not a pinned list event: kind {}", event.kind);
}
let mut pins = Vec::new();
for tag in event.tags.iter() {
match tag.kind() {
"e" => {
let Some(id) = tag.content() else {
continue;
};
pins.push(Pin::Event(EventId::from_hex(id)?));
}
"a" => {
let Some(address) = tag.content() else {
continue;
};
pins.push(Pin::Address(Coordinate::from_str(address)?));
}
_ => {}
}
}
Ok(Self(pins))
}
pub fn pins(&self) -> &[Pin] {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupListEntry {
pub id: GroupId,