From 1236f40d83bd8ef42df3fd238e77c8bace86393f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 10 Oct 2026 09:02:17 +0700 Subject: [PATCH] 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);