From 2a077738489d2aa414bd088e7f02e6969755253a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 10 Oct 2026 08:40:10 +0700 Subject: [PATCH 1/4] adopt two-step NIP-29 group creation with confirmation --- crates/nip29/src/lib.rs | 95 ++++++++++++-- crates/nip29/src/protocol.rs | 34 ++++- crates/nip29/src/utils.rs | 41 ++++++ crates/workspace/src/dialogs/new_group.rs | 46 ++----- crates/workspace/src/sidebar/mod.rs | 6 + docs/group-creation-plan.md | 144 ++++++++++++++++++++++ 6 files changed, 317 insertions(+), 49 deletions(-) create mode 100644 docs/group-creation-plan.md diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 10420191..38f22f1c 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -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)] @@ -191,31 +200,93 @@ 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 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)); + this.publish_list(cx); + + cx.emit(GroupsEvent::Updated); + cx.emit(GroupsEvent::Created { + key: created, + warning, + }); + cx.notify(); + }) + .ok(); })); } diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 596ec1f1..257d8caa 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,36 @@ 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, + 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, 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/workspace/src/dialogs/new_group.rs b/crates/workspace/src/dialogs/new_group.rs index 42f5646c..e63ef083 100644 --- a/crates/workspace/src/dialogs/new_group.rs +++ b/crates/workspace/src/dialogs/new_group.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px}; -use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry}; +use nip29::{GroupCreateOptions, GroupsRegistry}; use nostr_sdk::prelude::*; use theme::{ActiveTheme, TextStyle, Typeset as _}; use ui::button::{Button, ButtonVariants, IconPosition}; @@ -90,20 +90,6 @@ fn create( return false; } - let id = if raw_id.is_empty() { - slug(&name_value) - } else { - raw_id - }; - - let id = match GroupId::new(id) { - Ok(id) => id, - Err(error) => { - window.push_notification(Notification::error(error.to_string()), cx); - return false; - } - }; - let relay = match RelayUrl::parse(&raw_relay) { Ok(relay) => relay, Err(error) => { @@ -112,30 +98,20 @@ fn create( } }; - let mut metadata = GroupMetadata::default(); - metadata.name = Some(name_value); + let options = GroupCreateOptions { + name: name_value, + custom_id: if raw_id.is_empty() { + None + } else { + Some(raw_id) + }, + ..Default::default() + }; let registry = GroupsRegistry::global(cx); registry.update(cx, |registry, cx| { - registry.create(GroupKey::new(relay, id), metadata, cx); + registry.create(relay, options, cx); }); true } - -fn slug(name: &str) -> String { - let mut slug = String::with_capacity(name.len()); - let mut separator = false; - - for character in name.chars() { - if character.is_ascii_alphanumeric() { - slug.push(character.to_ascii_lowercase()); - separator = false; - } else if !separator && !slug.is_empty() { - slug.push('-'); - separator = true; - } - } - - slug.trim_end_matches('-').to_owned() -} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 5fa0f8e2..45d7d471 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -127,6 +127,12 @@ impl Sidebar { GroupsEvent::Error(error) => { window.push_notification(Notification::error(error.clone()), cx); } + GroupsEvent::Created { + warning: Some(warning), + .. + } => { + window.push_notification(Notification::warning(warning.clone()), cx); + } _ => {} }; }), diff --git a/docs/group-creation-plan.md b/docs/group-creation-plan.md new file mode 100644 index 00000000..749369cb --- /dev/null +++ b/docs/group-creation-plan.md @@ -0,0 +1,144 @@ +# Group creation flow (nostrord parity) + +Plan to bring the group creation flow in line with nostrord +(https://github.com/nostrord/nostrord), a NIP-29 client. Analysis of both +implementations, then a phased plan. + +## Current state (coop) + +- **Dialog** (`crates/workspace/src/dialogs/new_group.rs`): three plain text + inputs (Name, Group id, Relay). Slug is generated from the name when the id + is blank. Metadata is `GroupMetadata::default()` with only the name set. + Errors surface as toast notifications only. +- **Protocol** (`crates/nip29/src/protocol.rs`): + `GroupId::create_group` sends kind 9007 with the full metadata in one event. + No confirmed-group-id handling and no OK-retry on metadata. +- **Registry** (`GroupsRegistry::create` in `crates/nip29/src/lib.rs`): ensures + and subscribes the group, upserts the list entry, publishes kind 10009, and + on publish failure forgets the group and emits `GroupsEvent::Error`. + +## Reference (nostrord) + +### UI (`CreateGroupModal.kt`) + +- Title "Create a Group" ("Create Channel" for subgroups). +- Avatar upload row -> `picture`. +- Name (required, placeholder `#example`). +- Group ID optional, hidden behind a "Set a custom ID" disclosure; filtered to + lowercase `a-z0-9-_`; hint "Leave empty for a random ID. The relay may + override your choice." +- Relay dropdown: user's relays + current relay + suggested NIP-29 relays + + "Custom relay..." revealing a URL field. Locked to the parent's relay for + subgroups. +- Description, multi-line (3-5 rows) -> `about`. +- ACCESS SETTINGS: switch rows with NIP-29 copy — Private (only members can + read), Closed (join requests ignored), Restricted (only members can post), + Hidden (not discoverable). +- YOUR LIST: "Private on my list" — keeps the membership in the NIP-44 + encrypted section of kind 10009. +- Errors: friendly mapping (`blocked:` -> creation must happen on the relay's + website, `auth-required` -> authorization required, timeouts, ...) plus an + "Open relay website ->" link (wss -> https). +- Submit: spinner while in flight, cancelable; validation (name required, + valid relay URL). + +### Protocol (`GroupManager.createGroup`) + +Two-step flow: + +1. Group ID: custom (trimmed, lowercased) or a random readable id + (`ReadableId.generate()`). +2. Kind 9007 (create-group) with only the `h` tag -> await relay OK -> await + the relay's kind 39000 to learn the confirmed group id (relays may override + the suggested id). +3. Kind 9002 (edit-metadata) with `h` + name + access flags (presence-only + tags) + about + picture + parent -> await OK (7s timeout, one retry after + 1s to cover the admin-grant race). If still rejected, keep the group and + warn that the relay did not accept its name. +4. Bookkeeping: persist membership, discard the self-invite race, set the + private-list flag before publishing kind 10009, publish the list, request + group messages and metadata. + +### Existing coop infrastructure to reuse + +- `Group::publish` already awaits the relay OK and surfaces the rejection + reason (`crates/nip29/src/group.rs`). +- `GroupId::edit_metadata` / `Group::edit_metadata` for kind 9002 + (`crates/nip29/src/protocol.rs`, `group.rs`). +- `ui::Switch` (label + description), `PopupMenu` dropdown, `TextareaState`, + `Avatar` (`crates/ui`). +- Blossom upload via `state::upload` with `AppSettings::get_file_server`. +- NIP-65 relay-list extraction, as in `dialogs/relays.rs` + (`GossipRelays::load`). +- Group panel open flow used by the Browse panel + (`crates/workspace/src/panels/browse.rs` `open_panel`). + +## Plan + +### Phase 1 — Protocol layer (`crates/nip29`) + +- Adopt the two-step creation in `protocol.rs`: `GroupId::create_group` emits + kind 9007 with only the `h` tag; metadata moves to the existing + `edit_metadata` (kind 9002) builder. +- Add `GroupCreateOptions` (name, about, picture, private/closed/restricted/ + hidden, optional custom id, optional parent) passed to + `GroupsRegistry::create`. +- Rework `GroupsRegistry::create`: + - ID: custom (trim/lowercase/validate) or a random readable id (small port + of nostrord's syllable-based `ReadableId`). + - `ensure()` -> publish 9007 via `Group::publish` (relay OK) -> wait briefly + (with timeout) for the relay's kind 39000 to confirm the group id (the + group is already subscribed via `ensure`). + - Publish 9002 with the full metadata; retry once after 1s; on persistent + failure keep the group and emit a warning event (nostrord parity). + - On 9007 failure: `forget()` + emit `GroupsEvent::Error` with the relay's + reason. + - On success: upsert entry + `publish_list` + emit a new + `GroupsEvent::Created(GroupKey)` so the UI can navigate. + +### Phase 2 — New dialog (`crates/workspace/src/dialogs/new_group.rs`) + +Rebuild the modal to nostrord's layout and copy: + +- Header + subtitle; name field; "Set a custom ID" disclosure with filtered + input. +- Relay dropdown: user's NIP-65 relays (loaded like `GossipRelays::load`) + + suggested group relay + "Custom relay..." revealing a URL field with + validation. +- Description via `TextareaState` (3-5 lines). +- ACCESS SETTINGS: four `ui::Switch` rows with nostrord's NIP-29 + label/description copy. +- In-flight state on the Create button (spinner, disabled), inline error row + with the friendly error mapping + "Open relay website ->" link (wss -> + https). +- Subscribe to `GroupsEvent::Created` / `GroupsEvent::Error` while open -> + close the dialog and open the new group panel (same flow as + `browse.rs::open_panel`). + +### Phase 3 — Avatar upload + +- Picture row using `ui::Avatar` preview + file picker -> `state::upload` + (existing blossom upload), wiring upload errors into the dialog's error row. + +### Phase 4 — Optional extensions + +- **"Private on my list"**: requires NIP-44 encrypted-section support in + `GroupList` (kind 10009 parse/build/load) — meaningful extra scope; candidate + for a follow-up. +- **"Create Channel" subgroup mode**: protocol support (parent tag) already + exists; needs a trigger point in the group details/subgroups UI and + relay-locking in the dialog. + +### Validation + +- `cargo check` / `cargo clippy` after each phase. +- Manual run of the dialog against a NIP-29 relay. +- Unit tests for the readable-id and input-filter helpers. + +## Open questions + +1. Include "Private on my list" now, or defer the encrypted kind 10009 work? +2. Blank id behavior: nostrord generates a random readable id, coop currently + slugs the name. Match nostrord? +3. Avatar upload in scope for the first pass? +4. Subgroup / "Create Channel" mode now, or group creation only? -- 2.54.0 From 1236f40d83bd8ef42df3fd238e77c8bace86393f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 10 Oct 2026 09:02:17 +0700 Subject: [PATCH 2/4] rewrite new group dialog as a full view --- crates/nip29_ui/src/metadata.rs | 8 +- crates/workspace/src/dialogs/new_group.rs | 648 +++++++++++++++++++--- crates/workspace/src/lib.rs | 4 +- 3 files changed, 567 insertions(+), 93 deletions(-) diff --git a/crates/nip29_ui/src/metadata.rs b/crates/nip29_ui/src/metadata.rs index a4086ace..d8850386 100644 --- a/crates/nip29_ui/src/metadata.rs +++ b/crates/nip29_ui/src/metadata.rs @@ -77,28 +77,28 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) .child( Switch::new("private") .label("Private") - .description("Only members can read messages.") + .description("Only members can read group messages") .checked(current.private) .on_click(toggle(&flags, |flags| &mut flags.private)), ) .child( Switch::new("restricted") .label("Restricted") - .description("Only members can send messages.") + .description("Only members can post messages") .checked(current.restricted) .on_click(toggle(&flags, |flags| &mut flags.restricted)), ) .child( Switch::new("hidden") .label("Hidden") - .description("Hide the group from non-members.") + .description("Hidden from non-members, not discoverable") .checked(current.hidden) .on_click(toggle(&flags, |flags| &mut flags.hidden)), ) .child( Switch::new("closed") .label("Closed") - .description("Ignore join requests.") + .description("Join requests are ignored (invite-only)") .checked(current.closed) .on_click(toggle(&flags, |flags| &mut flags.closed)), ) diff --git a/crates/workspace/src/dialogs/new_group.rs b/crates/workspace/src/dialogs/new_group.rs index e63ef083..bd2a856d 100644 --- a/crates/workspace/src/dialogs/new_group.rs +++ b/crates/workspace/src/dialogs/new_group.rs @@ -1,40 +1,509 @@ -use std::rc::Rc; - -use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px}; -use nip29::{GroupCreateOptions, GroupsRegistry}; +use anyhow::Error; +use gpui::prelude::FluentBuilder; +use gpui::{ + Anchor, App, AppContext, Context, Div, Entity, InteractiveElement as _, IntoElement, + ParentElement, Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, + Task, WeakEntity, Window, div, px, +}; +use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; -use theme::{ActiveTheme, TextStyle, Typeset as _}; -use ui::button::{Button, ButtonVariants, IconPosition}; -use ui::input::{Input, InputState}; -use ui::notification::Notification; -use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex}; +use state::NostrRegistry; +use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius}; +use ui::button::{Button, ButtonVariants}; +use ui::dock::{DockArea, DockPlacement, PanelHandle}; +use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::switch::Switch; +use ui::{Disableable, StyledExt, WindowExtension, h_flex, v_flex}; -type SubmitFn = Rc bool>; +const SUGGESTED_RELAY: &str = "wss://relay.ditto.pub"; +const PRIVATE_DESC: &str = "Only members can read group messages"; +const CLOSED_DESC: &str = "Join requests are ignored (invite-only)"; +const RESTRICTED_DESC: &str = "Only members can post messages"; +const HIDDEN_DESC: &str = "Hidden from non-members, not discoverable"; -pub fn open(window: &mut Window, cx: &mut App) { - let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name")); - let id = cx.new(|cx| InputState::new(window, cx).placeholder("Group id")); - let relay = cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com")); +pub fn open(dock: WeakEntity, window: &mut Window, cx: &mut App) { + let view = cx.new(|cx| NewGroup::new(dock, window, cx)); - let submit: SubmitFn = { - let (name, id, relay) = (name.clone(), id.clone(), relay.clone()); - Rc::new(move |_, window, cx| create(&name, &id, &relay, window, cx)) - }; + window.open_dialog(cx, { + let view = view.clone(); + move |this, _window, _cx| { + this.width(px(450.)) + .title("Create a Group") + .child(view.clone()) + } + }); +} - window.open_dialog(cx, move |this, _window, cx| { - this.width(px(380.)) - .title("New group") - .on_ok({ - let submit = submit.clone(); - move |event, window, cx| submit(event, window, cx) +struct NewGroup { + dock: WeakEntity, + name_input: Entity, + id_input: Entity, + about_input: Entity, + custom_relay_input: Entity, + /// The relays offered in the dropdown. + relays: Vec, + /// The selected relay; `None` selects the custom relay field. + selected_relay: Option, + /// Whether the optional custom group id input is shown. + show_custom_id: bool, + private: bool, + closed: bool, + restricted: bool, + hidden: bool, + creating: bool, + error: Option, + tasks: Vec>>, + _subscriptions: Vec, +} + +impl NewGroup { + fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("#example")); + let id_input = cx.new(|cx| InputState::new(window, cx).placeholder("my-group")); + + let custom_relay_input = + cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com")); + + let about_input = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(3, 5) + .placeholder("What is this group about?") + }); + + let relays = match RelayUrl::parse(SUGGESTED_RELAY) { + Ok(relay) => vec![relay], + Err(error) => { + log::warn!("the suggested group relay is invalid: {error}"); + Vec::new() + } + }; + let selected_relay = relays.first().cloned(); + + let registry = GroupsRegistry::global(cx); + let mut subscriptions = vec![]; + + subscriptions.push(cx.subscribe_in( + ®istry, + window, + |this, _registry, event, window, cx| match event { + GroupsEvent::Created { key, .. } => { + this.creating = false; + this.open_group(key.clone(), window, cx); + } + GroupsEvent::Error(error) if this.creating => { + this.creating = false; + this.set_error(friendly_error(error), cx); + } + _ => {} + }, + )); + + // Clear stale errors as the user edits the failing fields. + for input in [&name_input, &custom_relay_input] { + subscriptions.push(cx.subscribe_in( + input, + window, + |this, _input, event, _window, cx| { + if let InputEvent::Change = event { + this.clear_error(cx); + } + }, + )); + } + + // Load the user's relays once the view is mounted. + cx.defer_in(window, |this, window, cx| { + this.load_relays(window, cx); + }); + + Self { + dock, + name_input, + id_input, + about_input, + custom_relay_input, + relays, + selected_relay, + show_custom_id: false, + private: false, + closed: false, + restricted: false, + hidden: false, + creating: false, + error: None, + tasks: vec![], + _subscriptions: subscriptions, + } + } + + /// Load the signed-in user's relay list. + fn load_relays(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.into_iter().next() { + Ok(nip65::extract_relay_list(&event) + .map(|(url, _)| url) + .collect()) + } else { + Ok(Vec::new()) + } + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let relays = task.await?; + + this.update(cx, |this, cx| { + if !relays.is_empty() { + this.relays = relays; + } + + this.selected_relay = this + .selected_relay + .take() + .filter(|selected| this.relays.contains(selected)) + .or_else(|| this.relays.first().cloned()); + + cx.notify(); + })?; + + Ok(()) + })); + } + + /// The relay the group will be created on. + fn effective_relay(&self, cx: &App) -> Result { + let Some(selected) = &self.selected_relay else { + let raw = self.custom_relay_input.read(cx).value().trim().to_owned(); + + if raw.is_empty() { + return Err("Enter a relay to create the group on.".into()); + } + + let url = if raw.starts_with("wss://") || raw.starts_with("ws://") { + raw + } else { + format!("wss://{raw}") + }; + + return RelayUrl::parse(&url).map_err(|_| { + SharedString::from("Enter a valid relay URL (e.g. relay.example.com).") + }); + }; + + Ok(selected.clone()) + } + + fn create(&mut self, cx: &mut Context) { + if self.creating { + return; + } + + let registry = GroupsRegistry::global(cx); + let name = self.name_input.read(cx).value().trim().to_owned(); + + if name.is_empty() { + self.set_error("Group name is required.", cx); + return; + } + + let relay = match self.effective_relay(cx) { + Ok(relay) => relay, + Err(error) => { + self.set_error(error, cx); + return; + } + }; + + let custom_id = self.id_input.read(cx).value().trim().to_lowercase(); + if !custom_id.is_empty() + && !custom_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + self.set_error( + "The group id can only contain letters, numbers, '-' and '_'.", + cx, + ); + return; + } + + let about = self.about_input.read(cx).value().trim().to_owned(); + + let options = GroupCreateOptions { + name, + about: (!about.is_empty()).then_some(about), + private: self.private, + closed: self.closed, + restricted: self.restricted, + hidden: self.hidden, + custom_id: (!custom_id.is_empty()).then_some(custom_id), + ..Default::default() + }; + + self.creating = true; + self.error = None; + cx.notify(); + + registry.update(cx, |registry, cx| { + registry.create(relay, options, cx); + }); + } + + fn open_group(&self, key: GroupKey, window: &mut Window, cx: &mut App) { + let registry = GroupsRegistry::global(cx); + let group = registry.update(cx, |registry, cx| registry.open(key, cx)); + + ui::dock::add_panel_to( + &self.dock, + PanelHandle::new(nip29_ui::init(group, window, cx)), + DockPlacement::Center, + window, + cx, + ); + + window.close_dialog(cx); + } + + fn select_relay(&mut self, relay: Option, cx: &mut Context) { + self.selected_relay = relay; + self.error = None; + cx.notify(); + } + + fn set_error(&mut self, message: impl Into, cx: &mut Context) { + self.error = Some(message.into()); + cx.notify(); + } + + fn clear_error(&mut self, cx: &mut Context) { + if self.error.take().is_some() { + cx.notify(); + } + } + + fn render_relay(&self, selected: &str, cx: &mut Context) -> impl IntoElement { + let this = cx.entity().downgrade(); + let relays = self.relays.clone(); + + v_flex() + .gap_1() + .child(field_label("Relay", cx)) + .when(self.selected_relay.is_none(), |this| { + this.child(Input::new(&self.custom_relay_input)) }) .child( - v_flex() - .gap_3() - .child(field("Name", &name, cx)) - .child(field("Group id", &id, cx)) - .child(field("Relay", &relay, cx)), + Button::new("relay") + .label(selected.to_owned()) + .ghost_alt() + .caret() + .dropdown_menu_with_anchor(Anchor::BottomLeft, move |menu, _window, _cx| { + let menu = relays.iter().fold(menu, |menu, relay| { + let this = this.clone(); + let relay = relay.clone(); + + menu.item(PopupMenuItem::new(relay_label(&relay)).on_click( + move |_, _window, cx| { + this.update(cx, |this, cx| { + this.select_relay(Some(relay.clone()), cx); + }) + .ok(); + }, + )) + }); + + let this = this.clone(); + + menu.item(PopupMenuItem::new("Custom relay…").on_click( + move |_, _window, cx| { + this.update(cx, |this, cx| this.select_relay(None, cx)).ok(); + }, + )) + }), ) + } + + fn render_access(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_2() + .child(field_label("ACCESS SETTINGS", cx)) + .child( + div() + .p_2() + .rounded(px(control_radius())) + .bg(cx.theme().surface_raised) + .child( + Switch::new("private") + .label("Private") + .description(PRIVATE_DESC) + .checked(self.private) + .on_click(cx.listener(|this, checked: &bool, _window, cx| { + this.private = *checked; + cx.notify(); + })), + ), + ) + .child( + div() + .p_2() + .rounded(px(control_radius())) + .bg(cx.theme().surface_raised) + .child( + Switch::new("closed") + .label("Closed") + .description(CLOSED_DESC) + .checked(self.closed) + .on_click(cx.listener(|this, checked: &bool, _window, cx| { + this.closed = *checked; + cx.notify(); + })), + ), + ) + .child( + div() + .p_2() + .rounded(px(control_radius())) + .bg(cx.theme().surface_raised) + .child( + Switch::new("restricted") + .label("Restricted") + .description(RESTRICTED_DESC) + .checked(self.restricted) + .on_click(cx.listener(|this, checked: &bool, _window, cx| { + this.restricted = *checked; + cx.notify(); + })), + ), + ) + .child( + div() + .p_2() + .rounded(px(control_radius())) + .bg(cx.theme().surface_raised) + .child( + Switch::new("hidden") + .label("Hidden") + .description(HIDDEN_DESC) + .checked(self.hidden) + .on_click(cx.listener(|this, checked: &bool, _window, cx| { + this.hidden = *checked; + cx.notify(); + })), + ), + ) + } + + fn render_error( + &self, + error: &str, + web_url: Option<&str>, + cx: &Context, + ) -> impl IntoElement { + h_flex() + .items_start() + .gap_2() + .child( + div() + .flex_1() + .text_style(TextStyle::Caption) + .text_color(cx.theme().danger) + .child(error.to_owned()), + ) + .when(shows_relay_website(error), |this| { + this.child( + div() + .id("relay-website") + .cursor_pointer() + .flex_shrink_0() + .underline() + .text_style(TextStyle::Caption) + .text_color(cx.theme().danger) + .child("Open relay website →") + .on_click({ + let web_url = web_url.map(str::to_owned); + + move |_, _window, cx| { + if let Some(url) = &web_url { + cx.open_url(url); + } + } + }), + ) + }) + } +} + +impl Render for NewGroup { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let selected = self + .selected_relay + .as_ref() + .map(relay_label) + .unwrap_or_else(|| "Custom relay...".into()); + + let web_url = self + .effective_relay(cx) + .ok() + .map(|relay| relay_web_url(&relay)); + + v_flex() + .gap_3() + .child( + div() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_muted) + .child( + "Give your new group a name and description. You can always change these later.", + ), + ) + .child(field("Group Name", Input::new(&self.name_input), cx)) + .when(self.show_custom_id, |this| { + this.child( + v_flex() + .gap_1() + .child(field("Group ID (optional)", Input::new(&self.id_input), cx)) + .child( + div() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child( + "Leave empty for a random ID. The relay may override your choice.", + ), + ), + ) + }) + .when(!self.show_custom_id, |this| { + this.child( + div() + .id("show-custom-id") + .cursor_pointer() + .underline() + .text_style(TextStyle::Caption) + .text_color(cx.theme().accent) + .child("Set a custom ID") + .on_click(cx.listener(|this, _event, _window, cx| { + this.show_custom_id = true; + cx.notify(); + })), + ) + }) + .child(self.render_relay(&selected, cx)) + .child(field("Description", Textarea::new(&self.about_input), cx)) + .child(self.render_access(cx)) + .when_some(self.error.clone(), |this, error| { + this.child(self.render_error(&error, web_url.as_deref(), cx)) + }) .child( h_flex() .gap_1() @@ -47,71 +516,76 @@ pub fn open(window: &mut Window, cx: &mut App) { ) .child( Button::new("create") - .icon(IconName::ArrowRight) - .icon_position(IconPosition::End) - .label("Create") + .label("Create Group") .primary() .font_semibold() - .on_click({ - let submit = submit.clone(); - move |event, window, cx| { - if submit(event, window, cx) { - window.close_dialog(cx); - } - } - }), + .loading(self.creating) + .disabled(self.creating) + .on_click(cx.listener(|this, _event, _window, cx| this.create(cx))), ), ) - }); -} - -fn field(label: &'static str, input: &Entity, cx: &App) -> Div { - v_flex() - .gap_1() - .text_style(TextStyle::Callout) - .text_color(cx.theme().text_muted) - .child(label) - .child(Input::new(input)) -} - -fn create( - name: &Entity, - id: &Entity, - relay: &Entity, - window: &mut Window, - cx: &mut App, -) -> bool { - let name_value = name.read(cx).value().trim().to_owned(); - let raw_id = id.read(cx).value().trim().to_owned(); - let raw_relay = relay.read(cx).value().trim().to_owned(); - - if name_value.is_empty() { - window.push_notification(Notification::error("Enter a group name"), cx); - return false; } +} - let relay = match RelayUrl::parse(&raw_relay) { - Ok(relay) => relay, - Err(error) => { - window.push_notification(Notification::error(error.to_string()), cx); - return false; - } - }; +fn field(text: &'static str, input: impl IntoElement, cx: &App) -> Div { + v_flex().gap_1().child(field_label(text, cx)).child(input) +} - let options = GroupCreateOptions { - name: name_value, - custom_id: if raw_id.is_empty() { - None - } else { - Some(raw_id) - }, - ..Default::default() - }; +fn field_label(text: &'static str, cx: &App) -> Div { + div() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_muted) + .font_semibold() + .child(text) +} - let registry = GroupsRegistry::global(cx); - registry.update(cx, |registry, cx| { - registry.create(relay, options, cx); - }); +fn relay_label(relay: &RelayUrl) -> SharedString { + relay + .as_str() + .trim_start_matches("wss://") + .trim_start_matches("ws://") + .to_owned() + .into() +} - true +fn relay_web_url(relay: &RelayUrl) -> String { + relay + .as_str() + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1) +} + +/// Whether the relay's website likely resolves the rejection. +fn shows_relay_website(error: &str) -> bool { + let error = error.to_lowercase(); + [ + "website", + "not allowed", + "restricted", + "authorization", + "auth-required", + "blocked", + ] + .iter() + .any(|hint| error.contains(hint)) +} + +/// Map raw relay rejection reasons to user-facing copy (nostrord parity). +fn friendly_error(raw: &str) -> SharedString { + let lowered = raw.to_lowercase(); + + if lowered.contains("blocked:") { + "Group creation on this relay must be done via the relay's website.".into() + } else if lowered.contains("auth-required") + || lowered.contains("not allowed") + || lowered.contains("restricted") + { + "This relay requires authorization to create groups.".into() + } else if lowered.contains("did not respond") || lowered.contains("timeout") { + "Relay did not respond. Try again.".into() + } else if lowered.contains("not connected") { + "Not connected to relay. Try again.".into() + } else { + raw.into() + } } diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 0fc22453..e61e5611 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -14,7 +14,7 @@ use nostr_sdk::prelude::*; use serde::Deserialize; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, StateEvent}; -use theme::{ActiveTheme, TextStyle, Typeset as _, SIDEBAR_WIDTH, Theme}; +use theme::{ActiveTheme, SIDEBAR_WIDTH, TextStyle, Theme, Typeset as _}; use ui::button::Button; use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use ui::notification::{Notification, NotificationKind}; @@ -298,7 +298,7 @@ impl Workspace { new_chat::open(self.dock.downgrade(), window, cx); } Command::NewGroup => { - new_group::open(window, cx); + new_group::open(self.dock.downgrade(), window, cx); } Command::JoinGroup => { join_group::open(window, cx); -- 2.54.0 From ea95756637820949afe2fef62519c6924c795043 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 10 Oct 2026 09:50:56 +0700 Subject: [PATCH 3/4] add group picture upload to new group dialog --- crates/workspace/src/dialogs/new_group.rs | 99 +++++++++++++++++++++-- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/crates/workspace/src/dialogs/new_group.rs b/crates/workspace/src/dialogs/new_group.rs index bd2a856d..4b8703c8 100644 --- a/crates/workspace/src/dialogs/new_group.rs +++ b/crates/workspace/src/dialogs/new_group.rs @@ -2,19 +2,21 @@ use anyhow::Error; use gpui::prelude::FluentBuilder; use gpui::{ Anchor, App, AppContext, Context, Div, Entity, InteractiveElement as _, IntoElement, - ParentElement, Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, - Task, WeakEntity, Window, div, px, + ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _, + Styled, Subscription, Task, WeakEntity, Window, div, px, }; use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; -use state::NostrRegistry; +use settings::AppSettings; +use state::{NostrRegistry, upload}; use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius}; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{DockArea, DockPlacement, PanelHandle}; use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::switch::Switch; -use ui::{Disableable, StyledExt, WindowExtension, h_flex, v_flex}; +use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; const SUGGESTED_RELAY: &str = "wss://relay.ditto.pub"; const PRIVATE_DESC: &str = "Only members can read group messages"; @@ -47,6 +49,10 @@ struct NewGroup { selected_relay: Option, /// Whether the optional custom group id input is shown. show_custom_id: bool, + /// The uploaded group picture, ready to be published with the metadata. + picture: Option, + /// Whether a picture upload is in flight. + uploading: bool, private: bool, closed: bool, restricted: bool, @@ -126,6 +132,8 @@ impl NewGroup { relays, selected_relay, show_custom_id: false, + picture: None, + uploading: false, private: false, closed: false, restricted: false, @@ -206,7 +214,7 @@ impl NewGroup { } fn create(&mut self, cx: &mut Context) { - if self.creating { + if self.creating || self.uploading { return; } @@ -244,6 +252,7 @@ impl NewGroup { let options = GroupCreateOptions { name, about: (!about.is_empty()).then_some(about), + picture: self.picture.as_ref().map(|picture| picture.to_string()), private: self.private, closed: self.closed, restricted: self.restricted, @@ -282,6 +291,50 @@ impl NewGroup { cx.notify(); } + fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { + self.uploading = uploading; + cx.notify(); + } + + fn upload(&mut self, window: &mut Window, cx: &mut Context) { + let server = AppSettings::get_file_server(cx); + + let path = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + // Selecting no file means the prompt was cancelled + let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + return Ok(()); + }; + + this.update(cx, |this, cx| { + this.set_uploading(true, cx); + })?; + + match upload(server, path, cx).await { + Ok(url) => { + this.update(cx, |this, cx| { + this.picture = Some(url.to_string().into()); + this.set_uploading(false, cx); + })?; + } + Err(error) => { + this.update(cx, |this, cx| { + this.set_uploading(false, cx); + this.set_error(friendly_error(&error.to_string()), cx); + })?; + } + } + + Ok(()) + })); + } + fn set_error(&mut self, message: impl Into, cx: &mut Context) { self.error = Some(message.into()); cx.notify(); @@ -450,13 +503,24 @@ impl Render for NewGroup { .selected_relay .as_ref() .map(relay_label) - .unwrap_or_else(|| "Custom relay...".into()); + .unwrap_or_else(|| "Custom relay…".into()); let web_url = self .effective_relay(cx) .ok() .map(|relay| relay_web_url(&relay)); + // The avatar fallback is seeded with the id, falling back to the name. + let id = self.id_input.read(cx).value().trim().to_lowercase(); + let name = self.name_input.read(cx).value().trim().to_owned(); + let seed: String = if !id.is_empty() { + id + } else if !name.is_empty() { + name + } else { + "group".to_owned() + }; + v_flex() .gap_3() .child( @@ -467,6 +531,27 @@ impl Render for NewGroup { "Give your new group a name and description. You can always change these later.", ), ) + .child( + h_flex() + .items_center() + .gap_3() + .child(Avatar::new(self.picture.clone()).seed(seed).large()) + .child( + Button::new("upload-picture") + .label(if self.picture.is_some() { + "Change picture" + } else { + "Add a picture" + }) + .xsmall() + .ghost() + .disabled(self.uploading) + .loading(self.uploading) + .on_click(cx.listener(|this, _event, window, cx| { + this.upload(window, cx); + })), + ), + ) .child(field("Group Name", Input::new(&self.name_input), cx)) .when(self.show_custom_id, |this| { this.child( @@ -520,7 +605,7 @@ impl Render for NewGroup { .primary() .font_semibold() .loading(self.creating) - .disabled(self.creating) + .disabled(self.creating || self.uploading) .on_click(cx.listener(|this, _event, _window, cx| this.create(cx))), ), ) -- 2.54.0 From f8a987003d9bb7c2c26fe0a50169529226e2e2e0 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 10 Oct 2026 21:07:25 +0700 Subject: [PATCH 4/4] add encrypted private section to group lists and channels --- crates/nip29/src/lib.rs | 104 ++++++++-- crates/nip29/src/protocol.rs | 149 +++++++++++++- crates/nip29_ui/src/lib.rs | 37 +++- crates/state/src/signer.rs | 20 ++ crates/workspace/src/dialogs/new_group.rs | 225 ++++++++++++++-------- crates/workspace/src/lib.rs | 20 ++ docs/group-creation-plan.md | 144 -------------- 7 files changed, 451 insertions(+), 248 deletions(-) delete mode 100644 docs/group-creation-plan.md diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 38f22f1c..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; @@ -170,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); @@ -231,6 +231,7 @@ impl GroupsRegistry { }; 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 created = key; @@ -276,7 +277,7 @@ impl GroupsRegistry { } this.update(cx, |this, cx| { - this.upsert_entry(&created, Some(name)); + this.upsert_entry(&created, Some(name), list_private); this.publish_list(cx); cx.emit(GroupsEvent::Updated); @@ -463,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()), } }); @@ -506,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 { @@ -517,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(); @@ -733,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, }); } @@ -761,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); } @@ -769,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) => { @@ -929,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 257d8caa..46d91c0e 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -445,6 +445,8 @@ pub struct GroupCreateOptions { 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, } @@ -641,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 { @@ -672,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(), @@ -715,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_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