diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 10420191..1f7722ae 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -6,7 +6,7 @@ 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; +use state::{NostrRegistry, UniversalSigner}; mod discovery; mod group; @@ -23,6 +23,10 @@ pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const BROWSE_LIMIT: usize = 500; const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60); +const CONFIRM_POLLS: usize = 20; +const CONFIRM_INTERVAL: Duration = Duration::from_millis(250); +const METADATA_WARNING: &str = + "Group created, but the relay did not accept its name. Edit the group to set it."; pub fn init(cx: &mut App) { GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); @@ -46,6 +50,11 @@ pub enum GroupsEvent { }, /// The discovery synced new groups into the local database. Synced, + /// A relay accepted a new group; the warning carries a non-fatal issue. + Created { + key: GroupKey, + warning: Option, + }, } #[derive(Debug)] @@ -161,7 +170,7 @@ impl GroupsRegistry { return; }; - self.upsert_entry(&key, None); + self.upsert_entry(&key, None, false); self.publish_list(cx); cx.emit(GroupsEvent::Updated); @@ -191,31 +200,94 @@ impl GroupsRegistry { })); } - pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context) { + pub fn create(&mut self, relay: RelayUrl, options: GroupCreateOptions, cx: &mut Context) { + let name = options.name.trim().to_owned(); + + if name.is_empty() { + cx.emit(GroupsEvent::Error("group name is empty".to_owned())); + return; + } + + let raw = options.custom_id.as_deref().unwrap_or_default(); + let id = if raw.is_empty() { + readable_id() + } else { + raw.trim().to_lowercase() + }; + + let id = match GroupId::new(id) { + Ok(id) => id, + Err(error) => { + cx.emit(GroupsEvent::Error(error.to_string())); + return; + } + }; + + let key = GroupKey::new(relay, id); 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 metadata = options.metadata(name.clone()); + let list_private = options.list_private; + let builder = key.id().create_group(None); let published = group.read(cx).publish(builder, cx); - let logged = key.clone(); + let created = key; self.tasks.push(cx.spawn(async move |this, cx| { - if let Err(e) = published.await { + if let Err(error) = published.await { this.update(cx, |this, cx| { - this.forget(logged, cx); - cx.emit(GroupsEvent::Error(e.to_string())); + this.forget(created, cx); + cx.emit(GroupsEvent::Error(error.to_string())); }) .ok(); + return; } + + // Give the relay time to publish the group's metadata event, + // which settles the group id (the relay may override the suggested one). + for _ in 0..CONFIRM_POLLS { + if group.read_with(cx, |group, _cx| group.metadata().is_some()) { + break; + } + cx.background_executor().timer(CONFIRM_INTERVAL).await; + } + + // The relay can reject the metadata while the admin grant from the + // create is still settling, one delayed retry covers that race. + let mut warning = None; + + for attempt in 0..2 { + if attempt > 0 { + cx.background_executor().timer(Duration::from_secs(1)).await; + } + + let published = group.update(cx, |group, cx| group.edit_metadata(&metadata, cx)); + let accepted = published.await.is_ok(); + + if accepted { + break; + } + + if attempt == 1 { + warning = Some(METADATA_WARNING.to_owned()); + } + } + + this.update(cx, |this, cx| { + this.upsert_entry(&created, Some(name), list_private); + this.publish_list(cx); + + cx.emit(GroupsEvent::Updated); + cx.emit(GroupsEvent::Created { + key: created, + warning, + }); + cx.notify(); + }) + .ok(); })); } @@ -392,7 +464,10 @@ impl GroupsRegistry { let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); match client.database().query(filter).await?.into_iter().next() { - Some(event) => GroupList::parse(&event), + Some(event) => { + let list = GroupList::parse(&event)?; + Ok(Self::hydrate_list(list, &signer, &me).await) + } None => Ok(GroupList::default()), } }); @@ -435,6 +510,10 @@ impl GroupsRegistry { } fn merge(&mut self, list: GroupList, cx: &mut Context) { + // Carry the event's private section so a later publish can preserve it. + self.list + .set_content(list.content().map(str::to_owned), list.content_readable()); + let entries = list.entries().to_vec(); for entry in entries { @@ -446,6 +525,58 @@ impl GroupsRegistry { cx.notify(); } + /// Decrypt a list event's private section into its entries. + async fn hydrate_list( + mut list: GroupList, + signer: &UniversalSigner, + me: &PublicKey, + ) -> GroupList { + let Some(content) = list.content().map(str::to_owned) else { + return list; + }; + + match signer.nip44_decrypt(me, &content).await { + Ok(plaintext) => match parse_private_section(&plaintext) { + Some(entries) => { + list.merge_private(entries); + list.mark_readable(); + } + None => log::warn!("ignoring a malformed private group list section"), + }, + Err(error) => log::warn!("decrypting the private group list failed: {error}"), + } + + list + } + + /// Absorb a list event, decrypting its private section off the hot path. + fn absorb_list(&mut self, event: Event, cx: &mut Context) { + let list = match GroupList::parse(&event) { + Ok(list) => list, + Err(error) => { + log::warn!("ignoring a malformed group list: {error}"); + return; + } + }; + + if list.content().is_none() { + self.merge(list, cx); + return; + } + + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + let Some(me) = nostr.read(cx).current_user() else { + self.merge(list, cx); + return; + }; + + self.tasks.push(cx.spawn(async move |this, cx| { + let list = Self::hydrate_list(list, &signer, &me).await; + this.update(cx, |this, cx| this.merge(list, cx)).ok(); + })); + } + fn watch_forks(&mut self, cx: &mut Context) { let executor = cx.background_executor().clone(); let tx = self.signal_tx.clone(); @@ -662,13 +793,14 @@ impl GroupsRegistry { .and_then(|entry| entry.name.clone()) } - fn upsert_entry(&mut self, key: &GroupKey, name: Option) { + fn upsert_entry(&mut self, key: &GroupKey, name: Option, private: bool) { let name = name.or_else(|| self.entry_name(key)); self.list.upsert(GroupListEntry { id: key.id().clone(), relay: key.relay().clone(), name, + private, }); } @@ -690,7 +822,9 @@ impl GroupsRegistry { return; } - self.upsert_entry(&key, Some(name)); + let private = self.list.entry(&key).is_some_and(|entry| entry.private); + + self.upsert_entry(&key, Some(name), private); self.publish_list(cx); } @@ -698,10 +832,30 @@ impl GroupsRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - - let builder = self.list.to_builder(); + let me = nostr.read(cx).current_user(); + let list = self.list.clone(); self.tasks.push(cx.spawn(async move |_this, _cx| { + // Re-encrypt the private section when it carries local entries; a + // section this session could not read is carried verbatim. + let content = match (list.has_private(), list.content_readable()) { + (true, _) => match list.encode_private().zip(me) { + Some((plaintext, me)) => match signer.nip44_encrypt(&me, &plaintext).await { + Ok(content) => Some(content), + Err(error) => { + log::warn!("encrypting the private group list failed: {error}"); + list.content().map(str::to_owned) + } + }, + None => None, + }, + (false, false) => list.content().map(str::to_owned), + (false, true) => None, + }; + + let builder = EventBuilder::new(Kind::SimpleGroups, content.unwrap_or_default()) + .tags(list.public_tags()); + let event = match builder.finalize_async(&signer).await { Ok(event) => event, Err(error) => { @@ -858,10 +1012,7 @@ impl GroupsRegistry { fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context) { if id == list_subscription_id() { - match GroupList::parse(&event) { - Ok(list) => self.merge(list, cx), - Err(error) => log::warn!("ignoring a malformed group list: {error}"), - } + self.absorb_list(event, cx); return; } diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 596ec1f1..46d91c0e 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -155,9 +155,9 @@ impl GroupId { .tag_maybe(previous) } - pub fn create_group(&self, metadata: &GroupMetadata, previous: Option) -> EventBuilder { + pub fn create_group(&self, previous: Option) -> EventBuilder { EventBuilder::new(Kind::GroupCreateGroup, "") - .tags(self.metadata_tags(metadata)) + .tag(self.h_tag()) .tag_maybe(previous) } @@ -435,6 +435,38 @@ impl GroupMetadata { } } +/// Input for creating a group: access flags are presence-only per NIP-29. +#[derive(Debug, Clone, Default)] +pub struct GroupCreateOptions { + pub name: String, + pub about: Option, + pub picture: Option, + pub private: bool, + pub closed: bool, + pub restricted: bool, + pub hidden: bool, + /// List the group in the NIP-44 encrypted section of the user's group list. + pub list_private: bool, + pub custom_id: Option, + pub parent: Option, +} + +impl GroupCreateOptions { + pub(crate) fn metadata(&self, name: String) -> GroupMetadata { + GroupMetadata { + name: Some(name), + picture: self.picture.clone(), + about: self.about.clone(), + private: self.private, + restricted: self.restricted, + hidden: self.hidden, + closed: self.closed, + parent: self.parent.clone(), + ..Default::default() + } + } +} + #[derive(Debug, Clone)] pub struct GroupCandidate { pub key: GroupKey, @@ -611,10 +643,19 @@ pub(crate) struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option, + /// Kept in the encrypted section of the list event, not in public tags. + pub private: bool, } #[derive(Debug, Clone, Default)] -pub(crate) struct GroupList(Vec); +pub(crate) struct GroupList { + entries: Vec, + /// The raw private section of the last seen list event. + content: Option, + /// Whether the content was decrypted in this session; an opaque section + /// (another client's encryption, or a refused decrypt) is carried verbatim. + readable: bool, +} impl GroupList { pub fn parse(event: &Event) -> Result { @@ -642,36 +683,94 @@ impl GroupList { id, relay, name: fields.get(3).cloned(), + private: false, }); } - Ok(Self(entries)) + let content = if event.content.is_empty() { + None + } else { + Some(event.content.clone()) + }; + + Ok(Self { + entries, + readable: content.is_none(), + content, + }) } pub fn entries(&self) -> &[GroupListEntry] { - &self.0 + &self.entries + } + + pub fn entry(&self, key: &GroupKey) -> Option<&GroupListEntry> { + self.entries + .iter() + .find(|entry| entry.id == *key.id() && entry.relay == *key.relay()) } pub fn upsert(&mut self, entry: GroupListEntry) { match self - .0 + .entries .iter_mut() .find(|existing| existing.id == entry.id && existing.relay == entry.relay) { Some(existing) => *existing = entry, - None => self.0.push(entry), + None => self.entries.push(entry), } } pub fn remove(&mut self, key: &GroupKey) { - self.0 + self.entries .retain(|entry| entry.id != *key.id() || entry.relay != *key.relay()); } - pub fn to_builder(&self) -> EventBuilder { - let mut tags: Vec = Vec::with_capacity(self.0.len() * 2); + /// The raw private section of the list event, if any. + pub(crate) fn content(&self) -> Option<&str> { + self.content.as_deref() + } + + pub(crate) fn content_readable(&self) -> bool { + self.readable + } + + pub(crate) fn set_content(&mut self, content: Option, readable: bool) { + self.content = content; + self.readable = readable; + } + + pub(crate) fn has_private(&self) -> bool { + self.entries.iter().any(|entry| entry.private) + } + + /// Mark the private section as readable after a successful decrypt. + pub(crate) fn mark_readable(&mut self) { + self.readable = true; + } + + /// Add decrypted private entries; the public listing wins on collision. + pub(crate) fn merge_private(&mut self, entries: Vec) { + for entry in entries { + let public = self.entries.iter().any(|existing| { + !existing.private && existing.id == entry.id && existing.relay == entry.relay + }); + + if !public { + self.upsert(entry); + } + } + } + + /// The tags of the public section of the list event. + pub(crate) fn public_tags(&self) -> Vec { + let mut tags = Vec::with_capacity(self.entries.len() * 2); + + for entry in &self.entries { + if entry.private { + continue; + } - for entry in &self.0 { let mut values = vec![ entry.id.as_str().to_owned(), entry.relay.as_str().to_owned(), @@ -685,8 +784,68 @@ impl GroupList { tags.push(Tag::custom("r", [entry.relay.as_str()])); } - EventBuilder::new(Kind::SimpleGroups, "").tags(tags) + tags } + + /// The private entries encoded as a tag array, ready for encryption. + pub(crate) fn encode_private(&self) -> Option { + let tags: Vec> = self + .entries + .iter() + .filter(|entry| entry.private) + .map(|entry| { + 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()); + } + + values + }) + .collect(); + + if tags.is_empty() { + None + } else { + serde_json::to_string(&tags).ok() + } + } +} + +/// Parse a decrypted private section; `None` when it isn't a tag array. +pub(crate) fn parse_private_section(plaintext: &str) -> Option> { + let tags = serde_json::from_str::>>(plaintext).ok()?; + + let mut entries = Vec::new(); + + for tag in tags { + if tag.first().map(String::as_str) != Some("group") { + continue; + } + + let (Some(id), Some(relay)) = (tag.get(1), tag.get(2)) else { + continue; + }; + + let Ok(id) = GroupId::new(id) else { + continue; + }; + let Ok(relay) = RelayUrl::parse(relay) else { + continue; + }; + + entries.push(GroupListEntry { + id, + relay, + name: tag.get(3).cloned(), + private: true, + }); + } + + Some(entries) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/nip29/src/utils.rs b/crates/nip29/src/utils.rs index f66d4bc5..153540ed 100644 --- a/crates/nip29/src/utils.rs +++ b/crates/nip29/src/utils.rs @@ -58,3 +58,44 @@ pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize) Some((id, event.created_at, members)) } + +const ONSETS: [&str; 34] = [ + "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", "br", "cr", + "dr", "fr", "gr", "pr", "tr", "bl", "cl", "fl", "gl", "pl", "sl", "sh", "ch", "th", "st", "sk", +]; +const NUCLEI: [&str; 9] = ["a", "e", "i", "o", "u", "ae", "ei", "io", "ou"]; +const CODAS: [&str; 11] = ["", "", "", "n", "r", "s", "t", "d", "l", "m", "k"]; + +/// A short pronounceable id for new groups, seeded from a fresh key. +pub(crate) fn readable_id() -> String { + let mut picks = SecretKey::generate().secret_bytes().into_iter(); + + let mut id = String::with_capacity(18); + + for _ in 0..3 { + let onset = ONSETS[usize::from(picks.next().unwrap_or(0)) % ONSETS.len()]; + let nucleus = NUCLEI[usize::from(picks.next().unwrap_or(0)) % NUCLEI.len()]; + let coda = CODAS[usize::from(picks.next().unwrap_or(0)) % CODAS.len()]; + + id.push_str(onset); + id.push_str(nucleus); + id.push_str(coda); + } + + id +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_readable_id() { + for _ in 0..32 { + let id = readable_id(); + + assert!((6..=15).contains(&id.len())); + assert!(id.chars().all(|character| character.is_ascii_lowercase())); + } + } +} diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index a6b653f1..b1ec01ce 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -51,6 +51,14 @@ pub struct HideGroupMeta { pub group: String, } +/// Ask the shell to open the channel creation dialog under a group. +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = nip29_ui, no_json)] +pub struct CreateChannel { + pub relay: RelayUrl, + pub parent: String, +} + const ADMIN_ROLE: &str = "admin"; const LOAD_OLDER_THRESHOLD: usize = 20; @@ -96,6 +104,7 @@ pub fn actions(group: WeakEntity, cx: &App) -> AnyElement { let info = group.clone(); let edit = group.clone(); let invite = group.clone(); + let channel = group.clone(); let leave = group; h_flex() @@ -131,6 +140,27 @@ pub fn actions(group: WeakEntity, cx: &App) -> AnyElement { invite::open(invite.clone(), window, cx); }), ) + .child( + Button::new("group-channel") + .icon(IconName::Plus) + .tooltip("Create channel") + .ghost() + .small() + .on_click(move |_, window, cx| { + let Ok(key) = channel.read_with(cx, |group, _cx| group.key().clone()) + else { + return; + }; + + window.dispatch_action( + Box::new(CreateChannel { + relay: key.relay().clone(), + parent: key.id().as_str().to_owned(), + }), + cx, + ); + }), + ) }) .child( Button::new("leave") @@ -927,7 +957,12 @@ impl Panel for GroupPanel { .child(display_name(group)) .into_any_element() }) - .unwrap_or_else(|| div().text_style(TextStyle::Caption).child("Unknown").into_any_element()) + .unwrap_or_else(|| { + div() + .text_style(TextStyle::Caption) + .child("Unknown") + .into_any_element() + }) } fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec