rewrite new group dialog as a full view

This commit is contained in:
2026-10-10 09:02:17 +07:00
parent 2a07773848
commit 1236f40d83
3 changed files with 567 additions and 93 deletions
+4 -4
View File
@@ -77,28 +77,28 @@ pub(crate) fn open(group: WeakEntity<Group>, 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)),
)
+553 -79
View File
@@ -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<dyn Fn(&ClickEvent, &mut Window, &mut App) -> 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<DockArea>, 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())
}
});
}
struct NewGroup {
dock: WeakEntity<DockArea>,
name_input: Entity<InputState>,
id_input: Entity<InputState>,
about_input: Entity<TextareaState>,
custom_relay_input: Entity<InputState>,
/// The relays offered in the dropdown.
relays: Vec<RelayUrl>,
/// The selected relay; `None` selects the custom relay field.
selected_relay: Option<RelayUrl>,
/// 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<SharedString>,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
}
impl NewGroup {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> 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(
&registry,
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<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
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)
let task: Task<Result<Vec<RelayUrl>, 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<RelayUrl, SharedString> {
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<Self>) {
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<RelayUrl>, cx: &mut Context<Self>) {
self.selected_relay = relay;
self.error = None;
cx.notify();
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
fn clear_error(&mut self, cx: &mut Context<Self>) {
if self.error.take().is_some() {
cx.notify();
}
}
fn render_relay(&self, selected: &str, cx: &mut Context<Self>) -> 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(
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<Self>) -> 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<Self>,
) -> 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<Self>) -> 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(field("Name", &name, cx))
.child(field("Group id", &id, cx))
.child(field("Relay", &relay, cx)),
.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<InputState>, cx: &App) -> Div {
v_flex()
.gap_1()
.text_style(TextStyle::Callout)
fn field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
v_flex().gap_1().child(field_label(text, cx)).child(input)
}
fn field_label(text: &'static str, cx: &App) -> Div {
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(label)
.child(Input::new(input))
.font_semibold()
.child(text)
}
fn create(
name: &Entity<InputState>,
id: &Entity<InputState>,
relay: &Entity<InputState>,
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;
fn relay_label(relay: &RelayUrl) -> SharedString {
relay
.as_str()
.trim_start_matches("wss://")
.trim_start_matches("ws://")
.to_owned()
.into()
}
let relay = match RelayUrl::parse(&raw_relay) {
Ok(relay) => relay,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
fn relay_web_url(relay: &RelayUrl) -> String {
relay
.as_str()
.replacen("wss://", "https://", 1)
.replacen("ws://", "http://", 1)
}
};
let options = GroupCreateOptions {
name: name_value,
custom_id: if raw_id.is_empty() {
None
/// 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 {
Some(raw_id)
},
..Default::default()
};
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.create(relay, options, cx);
});
true
raw.into()
}
}
+2 -2
View File
@@ -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);