use anyhow::Error; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, Task, WeakEntity, Window, div, px, rems, }; use gpui_base::{Popup, Select}; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::form::field; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use gpui_kit::component::separator::Separator; use gpui_kit::component::switch::Switch; use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use settings::AppSettings; use state::upload; use ui::Avatar; const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"]; 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"; const LIST_PRIVATE_DESC: &str = "Kept encrypted in your group list so others can't see you joined"; pub fn open(dock: WeakEntity, window: &mut Window, cx: &mut App) { open_with(dock, None, window, cx); } pub fn open_channel( dock: WeakEntity, parent: GroupKey, window: &mut Window, cx: &mut App, ) { open_with(dock, Some(parent), window, cx); } fn open_with( dock: WeakEntity, parent: Option, window: &mut Window, cx: &mut App, ) { let is_channel = parent.is_some(); let view = cx.new(|cx| NewGroup::new(dock, parent, window, cx)); window.open_dialog(cx, { let view = view.clone(); move |this, _window, _cx| { let title = if is_channel { "Create Channel" } else { "Create a Group" }; this.width(px(460.)).title(title).child(view.clone()) } }); } struct NewGroup { dock: WeakEntity, /// The parent group when creating a channel; the relay is locked to its own. parent: Option, name_input: Entity, about_input: Entity, custom_relay_input: Entity, /// The relays offered in the relay select. relays: Vec, /// The selected relay; `None` selects the custom relay field. selected_relay: Option, /// Whether the relay select popup is open. relay_select_open: bool, relay_options_focus: FocusHandle, /// Keep the group in the NIP-44 encrypted section of the user's group list. list_private: 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, hidden: bool, creating: bool, error: Option, tasks: Vec>>, _subscriptions: Vec, } impl NewGroup { fn new( dock: WeakEntity, parent: Option, window: &mut Window, cx: &mut Context, ) -> Self { let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("#example")); 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 relay_options_focus = cx.focus_handle(); let relays: Vec = DEFAULT_RELAYS .iter() .filter_map(|url| match RelayUrl::parse(url) { Ok(relay) => Some(relay), Err(error) => { log::warn!("the default group relay {url} is invalid: {error}"); None } }) .collect(); 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); } }, )); } Self { dock, parent, name_input, about_input, custom_relay_input, relays, selected_relay, relay_select_open: false, relay_options_focus, list_private: false, picture: None, uploading: false, private: false, closed: false, restricted: false, hidden: false, creating: false, error: None, tasks: vec![], _subscriptions: subscriptions, } } /// 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 || self.uploading { 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.parent { Some(parent) => parent.relay().clone(), None => match self.effective_relay(cx) { Ok(relay) => relay, Err(error) => { self.set_error(error, cx); return; } }, }; let about = self.about_input.read(cx).value().trim().to_owned(); 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, hidden: self.hidden, list_private: self.list_private, custom_id: None, parent: self.parent.as_ref().map(|parent| parent.id().clone()), }; 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)); crate::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_relay_select_open(&mut self, open: bool, cx: &mut Context) { self.relay_select_open = open; cx.notify(); } fn activate_relay_option(&mut self, index: usize, cx: &mut Context) { if index < self.relays.len() { self.select_relay(Some(self.relays[index].clone()), cx); } else { self.select_relay(None, cx); } self.set_relay_select_open(false, cx); } 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(); } 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 { if let Some(parent) = &self.parent { return v_flex().gap_1p5().child(field_label("Relay", cx)).child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(format!( "Created on {} (same relay as the parent group).", relay_label(parent.relay()) )), ); } let this = cx.entity().downgrade(); let open = self.relay_select_open; let trigger = h_flex() .id("relay-trigger") .justify_between() .w_full() .h_8() .px_3() .rounded(cx.theme().radius) .text_xs() .cursor_default() .bg(if open { cx.theme().list_active } else { cx.theme().secondary }) .when(!open, |this| { this.hover(|this| this.bg(cx.theme().primary)) .active(|this| this.bg(cx.theme().list_active)) }) .on_click(cx.listener(move |this, _, _window, cx| { this.relay_select_open = !open; cx.notify(); })) .child(selected.to_owned()) .child( Icon::new(IconName::ChevronDown) .small() .text_color(cx.theme().muted_foreground), ); let select = Select::new("relay-select") .open(open) .on_open_change({ let this = this.clone(); move |open, _, cx| { let _ = this.update(cx, |this, cx| { this.set_relay_select_open(open, cx); }); } }) .content_focus_handle(&self.relay_options_focus) .accessibility_label("Relay") .accessibility_value(selected.to_owned()) .w_full() .child(trigger); let options = v_flex() .id("relay-options") .occlude() .track_focus(&self.relay_options_focus) .key_context("Select") .on_mouse_down_out(cx.listener(|this, _, _, cx| { this.set_relay_select_open(false, cx); })) .bg(cx.theme().popover) .border_1() .border_color(cx.theme().border) .shadow_md() .rounded(cx.theme().radius_lg) .p_1() .gap_y_1() .min_w(rems(8.)) .text_color(cx.theme().foreground) .children(self.relays.iter().enumerate().map(|(index, relay)| { h_flex() .id(SharedString::from(format!("option-{relay}"))) .px_1p5() .py_1() .rounded(cx.theme().radius) .text_xs() .cursor_default() .hover(|this| this.bg(cx.theme().primary)) .child(relay_label(relay)) .on_click(cx.listener(move |this, _, _, cx| { this.activate_relay_option(index, cx); })) })) .child(Separator::horizontal()) .child( h_flex() .id("relay-add") .px_1p5() .py_1() .rounded(cx.theme().radius) .text_xs() .cursor_default() .child("Custom relay") .hover(|this| this.bg(cx.theme().primary)) .on_click(cx.listener(|this, _, _, cx| { this.activate_relay_option(this.relays.len(), cx); })), ); v_flex() .gap_1p5() .child(field_label("Relay", cx)) .when(self.selected_relay.is_none(), |this| { this.child(Input::new(&self.custom_relay_input)) }) .child(Popup::new("relay-popup", select).when(open, |this| this.content(options))) } fn render_access(&self, cx: &mut Context) -> impl IntoElement { let private = field().label("Private").description(PRIVATE_DESC).child( Switch::new("private") .checked(self.private) .on_click(cx.listener(|this, checked: &bool, _window, cx| { this.private = *checked; cx.notify(); })), ); let closed = field().label("Closed").description(CLOSED_DESC).child( Switch::new("closed") .checked(self.closed) .on_click(cx.listener(|this, checked: &bool, _window, cx| { this.closed = *checked; cx.notify(); })), ); let restricted = field() .label("Restricted") .description(RESTRICTED_DESC) .child( Switch::new("restricted") .checked(self.restricted) .on_click(cx.listener(|this, checked: &bool, _window, cx| { this.restricted = *checked; cx.notify(); })), ); let hidden = field().label("Hidden").description(HIDDEN_DESC).child( Switch::new("hidden") .checked(self.hidden) .on_click(cx.listener(|this, checked: &bool, _window, cx| { this.hidden = *checked; cx.notify(); })), ); v_flex() .gap_2() .child(field_label("Access settings", cx)) .child(switch_row(private, cx)) .child(switch_row(closed, cx)) .child(switch_row(restricted, cx)) .child(switch_row(hidden, cx)) } fn render_list_setting(&self, cx: &mut Context) -> impl IntoElement { let list_private = field() .label("Private on my list") .description(LIST_PRIVATE_DESC) .child( Switch::new("list-private") .checked(self.list_private) .on_click(cx.listener(|this, checked: &bool, _window, cx| { this.list_private = *checked; cx.notify(); })), ); v_flex() .gap_2() .child(field_label("List", cx)) .child(switch_row(list_private, cx)) } 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_xs() .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_xs() .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(|| "Add relay".into()); let web_url = self .effective_relay(cx) .ok() .map(|relay| relay_web_url(&relay)); v_flex() .gap_4() .child( v_flex() .id("group-dialog-content") .h(px(500.)) .gap_3() .when_some(self.error.clone(), |this, error| { this.child(self.render_error(&error, web_url.as_deref(), cx)) }) .child( h_flex() .items_center() .gap_3() .child( Avatar::new(self.picture.clone()) .seed(self.name_input.read(cx).value().trim().to_owned()) .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(input_field("Name", Input::new(&self.name_input), cx)) .child(self.render_relay(&selected, cx)) .child(input_field("About", Textarea::new(&self.about_input), cx)) .child(self.render_access(cx)) .child(self.render_list_setting(cx)) .overflow_y_scroll(), ) .child( h_flex() .gap_1() .justify_end() .child( Button::new("cancel") .label("Cancel") .ghost() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("create") .label(if self.parent.is_some() { "Create Channel" } else { "Create Group" }) .primary() .font_weight(gpui::FontWeight::SEMIBOLD) .loading(self.creating) .disabled(self.creating || self.uploading) .on_click(cx.listener(|this, _event, _window, cx| this.create(cx))), ), ) } } fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div { v_flex().gap_1p5().child(field_label(text, cx)).child(input) } fn field_label(text: &'static str, cx: &App) -> Div { div() .text_xs() .text_color(cx.theme().muted_foreground) .font_weight(gpui::FontWeight::SEMIBOLD) .child(text) } fn switch_row(switch: impl IntoElement, cx: &App) -> Div { div() .w_full() .p_2() .rounded(cx.theme().radius) .bg(cx.theme().secondary) .child(switch) } fn relay_label(relay: &RelayUrl) -> SharedString { relay .as_str() .trim_start_matches("wss://") .trim_start_matches("ws://") .to_owned() .into() } 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() } }