724 lines
24 KiB
Rust
724 lines
24 KiB
Rust
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<DockArea>, window: &mut Window, cx: &mut App) {
|
|
open_with(dock, None, window, cx);
|
|
}
|
|
|
|
pub fn open_channel(
|
|
dock: WeakEntity<DockArea>,
|
|
parent: GroupKey,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) {
|
|
open_with(dock, Some(parent), window, cx);
|
|
}
|
|
|
|
fn open_with(
|
|
dock: WeakEntity<DockArea>,
|
|
parent: Option<GroupKey>,
|
|
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<DockArea>,
|
|
/// The parent group when creating a channel; the relay is locked to its own.
|
|
parent: Option<GroupKey>,
|
|
name_input: Entity<InputState>,
|
|
about_input: Entity<TextareaState>,
|
|
custom_relay_input: Entity<InputState>,
|
|
/// The relays offered in the relay select.
|
|
relays: Vec<RelayUrl>,
|
|
/// The selected relay; `None` selects the custom relay field.
|
|
selected_relay: Option<RelayUrl>,
|
|
/// 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<SharedString>,
|
|
/// Whether a picture upload is in flight.
|
|
uploading: 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>,
|
|
parent: Option<GroupKey>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> 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<RelayUrl> = 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<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 || 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<RelayUrl>, cx: &mut Context<Self>) {
|
|
self.selected_relay = relay;
|
|
self.error = None;
|
|
cx.notify();
|
|
}
|
|
|
|
fn set_relay_select_open(&mut self, open: bool, cx: &mut Context<Self>) {
|
|
self.relay_select_open = open;
|
|
cx.notify();
|
|
}
|
|
|
|
fn activate_relay_option(&mut self, index: usize, cx: &mut Context<Self>) {
|
|
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>) {
|
|
self.uploading = uploading;
|
|
cx.notify();
|
|
}
|
|
|
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
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<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 {
|
|
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<Self>) -> 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<Self>) -> 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<Self>,
|
|
) -> 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<Self>) -> 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()
|
|
}
|
|
}
|