From 3ada21b7280c14304ca3f27cb4fb0f5a8694f498 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 6 Oct 2026 16:40:27 +0700 Subject: [PATCH] chore: replace relay panels with relays dialog --- crates/workspace/src/dialogs/mod.rs | 1 + crates/workspace/src/dialogs/relays.rs | 689 ++++++++++++++++++ crates/workspace/src/lib.rs | 22 +- .../workspace/src/panels/messaging_relays.rs | 372 ---------- crates/workspace/src/panels/mod.rs | 2 - crates/workspace/src/panels/relay_list.rs | 441 ----------- 6 files changed, 693 insertions(+), 834 deletions(-) create mode 100644 crates/workspace/src/dialogs/relays.rs delete mode 100644 crates/workspace/src/panels/messaging_relays.rs delete mode 100644 crates/workspace/src/panels/relay_list.rs diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index b34b5c16..f104a3bb 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -3,6 +3,7 @@ pub mod join_group; pub mod new_chat; pub mod new_group; pub mod profile; +pub mod relays; pub mod restore; pub mod screening; pub mod settings; diff --git a/crates/workspace/src/dialogs/relays.rs b/crates/workspace/src/dialogs/relays.rs new file mode 100644 index 00000000..6223c026 --- /dev/null +++ b/crates/workspace/src/dialogs/relays.rs @@ -0,0 +1,689 @@ +use std::collections::HashSet; + +use anyhow::{Error, anyhow}; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, AppContext, ClickEvent, Context, Div, Entity, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign, + WeakEntity, Window, div, px, rems, +}; +use instant::Duration; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::input::{Input, InputEvent, InputState}; +use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::scroll::ScrollableElement; +use ui::tab::Tab; +use ui::tab::tab_bar::TabBar; +use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +const GOSSIP_MSG: &str = "Gossip Relays are the relays where you publish all your events and discover events from others."; +const INBOX_MSG: &str = "Messaging Relays host your private messages. Others find these relays and send you messages there."; + +pub fn open(window: &mut Window, cx: &mut App) { + let view = cx.new(|cx| RelayManager::new(window, cx)); + + window.open_dialog(cx, move |this, _window, _cx| { + this.title("Relays").child(view.clone()) + }); +} + +/// State of the NIP-65 relay list (gossip relays). +struct GossipRelays { + /// Relay URL input + input: Entity, + /// Metadata assigned to relays added afterwards + metadata: Option, + /// All relays + relays: HashSet<(RelayUrl, Option)>, + /// Whether the relay list is updating + updating: bool, + error: Option, + tasks: Vec>>, +} + +/// State of the NIP-17 inbox relays (messaging relays). +struct InboxRelays { + /// Relay URL input + input: Entity, + /// All relays + relays: HashSet, + /// Whether the relays are updating + updating: bool, + error: Option, + tasks: Vec>>, +} + +pub struct RelayManager { + /// Index of the active tab, `0` for gossip relays and `1` for inbox relays + active_tab: usize, + gossip: GossipRelays, + inbox: InboxRelays, + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl RelayManager { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let gossip = GossipRelays::new(window, cx); + let inbox = InboxRelays::new(window, cx); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Add a relay when the user presses enter in the gossip input + cx.subscribe_in(&gossip.input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.gossip.add(window, cx); + } + }), + ); + + subscriptions.push( + // Add a relay when the user presses enter in the inbox input + cx.subscribe_in(&inbox.input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.inbox.add(window, cx); + } + }), + ); + + // Run at the end of current cycle + cx.defer_in(window, |this, window, cx| { + this.gossip.load(window, cx); + this.inbox.load(window, cx); + }); + + Self { + active_tab: 0, + gossip, + inbox, + _subscriptions: subscriptions, + } + } + + fn render_gossip(&mut self, cx: &mut Context) -> impl IntoElement { + let manager = cx.entity().downgrade(); + + v_flex() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(GOSSIP_MSG)), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.gossip.input).small().cleanable(true)) + .child( + Button::new("metadata") + .map(|this| { + if let Some(metadata) = self.gossip.metadata { + this.label(metadata.to_string()) + } else { + this.label("R & W") + } + }) + .tooltip("Relay metadata") + .ghost() + .h(rems(2.)) + .text_xs() + .dropdown_menu(move |menu, _window, _cx| { + menu.item(metadata_item(&manager, RelayMetadata::Read)) + .item(metadata_item(&manager, RelayMetadata::Write)) + }), + ) + .child( + Button::new("add-gossip") + .icon(IconName::Plus) + .tooltip("Add relay") + .ghost() + .size(rems(2.)) + .on_click(cx.listener(move |this, _, window, cx| { + this.gossip.add(window, cx); + })), + ), + ) + .when_some(self.gossip.error.as_ref(), |this, error| { + this.child( + div() + .italic() + .text_xs() + .text_color(cx.theme().text_danger) + .child(error.clone()), + ) + }) + .map(|this| { + if self.gossip.relays.is_empty() { + this.child(empty_relays(cx)) + } else { + this.child( + v_flex() + .gap_1() + .w_full() + .max_h(rems(16.)) + .overflow_y_scrollbar() + .children(self.gossip_rows(cx)), + ) + } + }) + } + + fn gossip_rows(&self, cx: &mut Context) -> Vec> { + let mut rows = Vec::new(); + + for (relay, metadata) in self.gossip.relays.iter() { + let url = relay.to_owned(); + let badge = match metadata { + Some(metadata) => SharedString::from(metadata.to_string()), + None => SharedString::from("Read and Write"), + }; + + rows.push(relay_row( + url.clone(), + Some(badge), + cx.listener(move |this, _event, _window, cx| this.gossip.remove(&url, cx)), + cx, + )); + } + + rows + } + + fn render_inbox(&mut self, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(INBOX_MSG)), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.inbox.input).small().cleanable(true)) + .child( + Button::new("add-inbox") + .icon(IconName::Plus) + .tooltip("Add relay") + .ghost() + .size(rems(2.)) + .on_click(cx.listener(move |this, _, window, cx| { + this.inbox.add(window, cx); + })), + ), + ) + .when_some(self.inbox.error.as_ref(), |this, error| { + this.child( + div() + .italic() + .text_xs() + .text_color(cx.theme().text_danger) + .child(error.clone()), + ) + }) + .map(|this| { + if self.inbox.relays.is_empty() { + this.child(empty_relays(cx)) + } else { + this.child( + v_flex() + .gap_1() + .w_full() + .max_h(rems(16.)) + .overflow_y_scrollbar() + .children(self.inbox_rows(cx)), + ) + } + }) + } + + fn inbox_rows(&self, cx: &mut Context) -> Vec> { + let mut rows = Vec::new(); + + for relay in self.inbox.relays.iter() { + let url = relay.to_owned(); + + rows.push(relay_row( + url.clone(), + None, + cx.listener(move |this, _event, _window, cx| this.inbox.remove(&url, cx)), + cx, + )); + } + + rows + } + + fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { + TabBar::new("relays-tabs") + .segmented(true) + .selected_index(self.active_tab) + .on_click(cx.listener(|this, ix: &usize, _window, cx| { + this.active_tab = *ix; + cx.notify(); + })) + .child(Tab::new().label("Gossip Relays")) + .child(Tab::new().label("Messaging Relays")) + } + + fn render_actions(&self, cx: &mut Context) -> impl IntoElement { + let updating = if self.active_tab == 0 { + self.gossip.updating + } else { + self.inbox.updating + }; + + h_flex().gap_1().justify_end().child( + Button::new("update") + .icon(IconName::CheckCircle) + .label("Update") + .primary() + .font_semibold() + .loading(updating) + .disabled(updating) + .on_click(cx.listener(move |this, _ev, window, cx| { + if this.active_tab == 0 { + this.gossip.publish(window, cx); + } else { + this.inbox.publish(window, cx); + } + })), + ) + } +} + +impl GossipRelays { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); + + Self { + input, + metadata: None, + relays: HashSet::new(), + updating: false, + error: None, + tasks: vec![], + } + } + + #[allow(clippy::type_complexity)] + fn load(&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).collect()) + } else { + Err(anyhow!("Not found.")) + } + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let relays = task.await?; + + this.update(cx, |this, cx| { + this.gossip.relays.extend(relays); + cx.notify(); + })?; + + Ok(()) + })); + } + + fn add(&mut self, window: &mut Window, cx: &mut Context) { + let value = self.input.read(cx).value().to_string(); + let metadata = self.metadata; + + if !value.starts_with("ws") { + self.set_error("Relay URl is invalid", window, cx); + return; + } + + if let Ok(url) = RelayUrl::parse(&value) { + if self.relays.insert((url, metadata)) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + cx.notify(); + } + } else { + self.set_error("Relay URl is invalid", window, cx); + } + } + + fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { + self.relays.retain(|(relay, _)| relay != url); + cx.notify(); + } + + fn publish(&mut self, window: &mut Window, cx: &mut Context) { + if self.relays.is_empty() { + self.set_error("You need to add at least 1 relay", window, cx); + return; + } + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + let relays = self.relays.clone(); + + self.set_updating(true, cx); + + let task: Task> = cx.background_spawn(async move { + let event = nip65::RelayList::new(relays) + .finalize_async(&signer) + .await?; + + // Set relay list for current user + client.send_event(&event).await?; + + Ok(()) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + this.update_in(cx, |this, window, cx| { + this.gossip.set_updating(false, cx); + this.gossip.load(window, cx); + + window.push_notification("Update successful", cx); + })?; + } + Err(error) => { + this.update_in(cx, |this, window, cx| { + this.gossip.set_updating(false, cx); + this.gossip.set_error(error.to_string(), window, cx); + })?; + } + } + + Ok(()) + })); + } + + fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) + where + E: Into, + { + self.error = Some(error.into()); + cx.notify(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + // Clear the error message after a delay + this.update(cx, |this, cx| { + this.gossip.error = None; + cx.notify(); + })?; + + Ok(()) + })); + } + + fn set_updating(&mut self, updating: bool, cx: &mut Context) { + self.updating = updating; + cx.notify(); + } +} + +impl InboxRelays { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); + + Self { + input, + relays: HashSet::new(), + updating: false, + error: None, + tasks: vec![], + } + } + + fn load(&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::InboxRelays) + .author(public_key) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.into_iter().next() { + Ok(nip17::extract_relay_list(&event).collect()) + } else { + Err(anyhow!("Not found.")) + } + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let relays = task.await?; + + this.update(cx, |this, cx| { + this.inbox.relays.extend(relays); + cx.notify(); + })?; + + Ok(()) + })); + } + + fn add(&mut self, window: &mut Window, cx: &mut Context) { + let value = self.input.read(cx).value().to_string(); + + if !value.starts_with("ws") { + self.set_error("Relay URl is invalid", window, cx); + return; + } + + if let Ok(url) = RelayUrl::parse(&value) { + if self.relays.insert(url) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + cx.notify(); + } + } else { + self.set_error("Relay URl is invalid", window, cx); + } + } + + fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { + self.relays.remove(url); + cx.notify(); + } + + fn publish(&mut self, window: &mut Window, cx: &mut Context) { + if self.relays.is_empty() { + self.set_error("You need to add at least 1 relay", window, cx); + return; + } + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + let tags: Vec = self + .relays + .iter() + .map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned()))) + .collect(); + + self.set_updating(true, cx); + + let task: Task> = cx.background_spawn(async move { + let event = EventBuilder::new(Kind::InboxRelays, "") + .tags(tags) + .finalize_async(&signer) + .await?; + + // Set messaging relays + client.send_event(&event).to_nip65().await?; + + Ok(()) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + this.update_in(cx, |this, window, cx| { + this.inbox.set_updating(false, cx); + this.inbox.load(window, cx); + + window.push_notification("Update successful", cx); + })?; + } + Err(error) => { + this.update_in(cx, |this, window, cx| { + this.inbox.set_updating(false, cx); + this.inbox.set_error(error.to_string(), window, cx); + })?; + } + } + + Ok(()) + })); + } + + fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) + where + E: Into, + { + self.error = Some(error.into()); + cx.notify(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + // Clear the error message after a delay + this.update(cx, |this, cx| { + this.inbox.error = None; + cx.notify(); + })?; + + Ok(()) + })); + } + + fn set_updating(&mut self, updating: bool, cx: &mut Context) { + self.updating = updating; + cx.notify(); + } +} + +impl Render for RelayManager { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if self.active_tab == 0 { + self.render_gossip(cx).into_any_element() + } else { + self.render_inbox(cx).into_any_element() + }; + + v_flex() + .gap_4() + .text_sm() + .child(self.render_tab_bar(cx)) + .child(content) + .child(self.render_actions(cx)) + } +} + +fn metadata_item(manager: &WeakEntity, metadata: RelayMetadata) -> PopupMenuItem { + let manager = manager.clone(); + let label = match metadata { + RelayMetadata::Read => "Read", + RelayMetadata::Write => "Write", + }; + + PopupMenuItem::new(label).on_click(move |_, _, cx| { + if let Err(error) = manager.update(cx, |this, cx| { + this.gossip.metadata = Some(metadata); + cx.notify(); + }) { + log::error!("Failed to set relay metadata: {error}"); + } + }) +} + +fn relay_row( + url: RelayUrl, + badge: Option, + on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> Stateful
{ + let label = SharedString::from(url.to_string()); + + h_flex() + .id(label.clone()) + .group("") + .w_full() + .h_8() + .px_2() + .justify_between() + .rounded(cx.theme().radius) + .bg(cx.theme().surface_background) + .child( + h_flex() + .gap_1() + .text_sm() + .child(label) + .when_some(badge, |this, badge| { + this.child( + div() + .p_0p5() + .rounded_xs() + .font_semibold() + .text_size(px(8.)) + .child(badge), + ) + }), + ) + .child( + Button::new("remove") + .icon(IconName::Close) + .xsmall() + .ghost() + .invisible() + .group_hover("", |this| this.visible()) + .on_click(on_remove), + ) +} + +fn empty_relays(cx: &App) -> impl IntoElement { + h_flex() + .h_20() + .justify_center() + .border_2() + .border_dashed() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .text_sm() + .text_align(TextAlign::Center) + .child(SharedString::from("Please add some relays.")) +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 30d32ecb..bf6d7f0e 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -22,10 +22,8 @@ use ui::notification::{Notification, NotificationKind}; use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::restore::RestoreEncryption; -use crate::dialogs::{join_group, new_chat, new_group, profile, settings}; -use crate::panels::{ - backup, browse, contact_list, greeter, inbox, messaging_relays, relay_list, requests, search, -}; +use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings}; +use crate::panels::{backup, browse, contact_list, greeter, inbox, requests, search}; use crate::sidebar::Sidebar; mod dialogs; @@ -50,7 +48,6 @@ enum Command { RefreshEncryption, ResetEncryption, ShowRelayList, - ShowMessaging, ShowProfile, ShowSettings, ShowBackup, @@ -325,14 +322,6 @@ impl Workspace { Command::ShowBackup => { self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); } - Command::ShowMessaging => { - self.add_panel_to_dock( - messaging_relays::init(window, cx), - DockPlacement::Left, - window, - cx, - ); - } Command::RefreshMessagingRelays => { let chat = ChatRegistry::global(cx); // Trigger a refresh of the chat registry @@ -341,12 +330,7 @@ impl Workspace { }); } Command::ShowRelayList => { - self.add_panel_to_dock( - relay_list::init(window, cx), - DockPlacement::Right, - window, - cx, - ); + relays::open(window, cx); } Command::RefreshEncryption => { let device = DeviceRegistry::global(cx); diff --git a/crates/workspace/src/panels/messaging_relays.rs b/crates/workspace/src/panels/messaging_relays.rs deleted file mode 100644 index 93cde120..00000000 --- a/crates/workspace/src/panels/messaging_relays.rs +++ /dev/null @@ -1,372 +0,0 @@ -use std::collections::HashSet; - -use anyhow::{Error, anyhow}; -use gpui::prelude::FluentBuilder; -use gpui::{ - AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - Task, TextAlign, Window, div, rems, -}; -use instant::Duration; -use nostr_sdk::prelude::*; -use smallvec::{SmallVec, smallvec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::dock::{Panel, PanelEvent}; -use ui::input::{Input, InputEvent, InputState}; -use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex}; - -const MSG: &str = "Messaging Relays are relays that hosted all your messages. \ - Other users will find your relays and send messages to it."; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| MessagingRelayPanel::new(window, cx)) -} - -#[derive(Debug)] -pub struct MessagingRelayPanel { - name: SharedString, - focus_handle: FocusHandle, - - /// Relay URL input - input: Entity, - - /// Whether the panel is updating - updating: bool, - - /// Error message - error: Option, - - /// All relays - relays: HashSet, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, - - /// Background tasks - tasks: Vec>>, -} - -impl MessagingRelayPanel { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Subscribe to user's input events - cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.add(window, cx); - } - }), - ); - - // Run at the end of current cycle - cx.defer_in(window, |this, window, cx| { - this.load(window, cx); - }); - - Self { - name: "Update Messaging Relays".into(), - focus_handle: cx.focus_handle(), - input, - updating: false, - relays: HashSet::new(), - error: None, - _subscriptions: subscriptions, - tasks: vec![], - } - } - - fn load(&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::InboxRelays) - .author(public_key) - .limit(1); - - if let Some(event) = client.database().query(filter).await?.into_iter().next() { - Ok(nip17::extract_relay_list(&event).collect()) - } else { - Err(anyhow!("Not found.")) - } - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let relays = task.await?; - - // Update state - this.update(cx, |this, cx| { - this.relays.extend(relays); - cx.notify(); - })?; - - Ok(()) - })); - } - - fn add(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.input.read(cx).value().to_string(); - - if !value.starts_with("ws") { - self.set_error("Relay URl is invalid", window, cx); - return; - } - - if let Ok(url) = RelayUrl::parse(&value) { - if self.relays.insert(url) { - self.input.update(cx, |this, cx| { - this.set_value("", window, cx); - }); - cx.notify(); - } - } else { - self.set_error("Relay URl is invalid", window, cx); - } - } - - fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { - self.relays.remove(url); - cx.notify(); - } - - fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) - where - E: Into, - { - self.error = Some(error.into()); - cx.notify(); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - // Clear the error message after a delay - this.update(cx, |this, cx| { - this.error = None; - cx.notify(); - })?; - - Ok(()) - })); - } - - fn set_updating(&mut self, updating: bool, cx: &mut Context) { - self.updating = updating; - cx.notify(); - } - - pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context) { - if self.relays.is_empty() { - self.set_error("You need to add at least 1 relay", window, cx); - return; - }; - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - - // Construct event tags - let tags: Vec = self - .relays - .iter() - .map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned()))) - .collect(); - - // Set updating state - self.set_updating(true, cx); - - let task: Task> = cx.background_spawn(async move { - // Construct nip17 event builder - let event = EventBuilder::new(Kind::InboxRelays, "") - .tags(tags) - .finalize_async(&signer) - .await?; - - // Set messaging relays - client.send_event(&event).to_nip65().await?; - - Ok(()) - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(_) => { - this.update_in(cx, |this, window, cx| { - this.set_updating(false, cx); - this.load(window, cx); - - window.push_notification("Update successful", cx); - })?; - } - Err(e) => { - this.update_in(cx, |this, window, cx| { - this.set_updating(false, cx); - this.set_error(e.to_string(), window, cx); - })?; - } - }; - - Ok(()) - })); - } - - fn render_list_items(&mut self, cx: &mut Context) -> Vec { - let mut items = Vec::new(); - - for url in self.relays.iter() { - items.push( - h_flex() - .id(SharedString::from(url.to_string())) - .group("") - .flex_1() - .w_full() - .h_8() - .px_2() - .justify_between() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary_background) - .text_color(cx.theme().secondary_foreground) - .child(div().text_sm().child(SharedString::from(url.to_string()))) - .child( - Button::new("remove_{ix}") - .icon(IconName::Close) - .xsmall() - .ghost() - .invisible() - .group_hover("", |this| this.visible()) - .on_click({ - let url = url.to_owned(); - cx.listener(move |this, _ev, _window, cx| { - this.remove(&url, cx); - }) - }), - ), - ) - } - - items - } - - fn render_empty(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .h_20() - .justify_center() - .border_2() - .border_dashed() - .border_color(cx.theme().border) - .rounded(cx.theme().radius_lg) - .text_sm() - .text_align(TextAlign::Center) - .child(SharedString::from("Please add some relays.")) - } -} - -impl Panel for MessagingRelayPanel { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for MessagingRelayPanel {} - -impl Focusable for MessagingRelayPanel { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for MessagingRelayPanel { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .p_3() - .gap_3() - .w_full() - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(MSG)), - ) - .child(divider(cx)) - .child( - v_flex() - .gap_2() - .flex_1() - .w_full() - .text_sm() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Relays:")), - ) - .child( - v_flex() - .gap_1() - .child( - h_flex() - .gap_1() - .w_full() - .child(Input::new(&self.input).small().cleanable(true)) - .child( - Button::new("add") - .icon(IconName::Plus) - .tooltip("Add relay") - .ghost() - .size(rems(2.)) - .on_click(cx.listener(move |this, _, window, cx| { - this.add(window, cx); - })), - ), - ) - .when_some(self.error.as_ref(), |this, error| { - this.child( - div() - .italic() - .text_xs() - .text_color(cx.theme().text_danger) - .child(error.clone()), - ) - }), - ) - .map(|this| { - if self.relays.is_empty() { - this.child(self.render_empty(window, cx)) - } else { - this.child( - v_flex() - .gap_1() - .flex_1() - .w_full() - .children(self.render_list_items(cx)), - ) - } - }) - .child( - Button::new("submit") - .icon(IconName::CheckCircle) - .label("Update") - .primary() - .font_semibold() - .loading(self.updating) - .disabled(self.updating) - .on_click(cx.listener(move |this, _ev, window, cx| { - this.set_relays(window, cx); - })), - ), - ) - } -} diff --git a/crates/workspace/src/panels/mod.rs b/crates/workspace/src/panels/mod.rs index 7b6602e6..ebbcf76e 100644 --- a/crates/workspace/src/panels/mod.rs +++ b/crates/workspace/src/panels/mod.rs @@ -3,7 +3,5 @@ pub mod browse; pub mod contact_list; pub mod greeter; pub mod inbox; -pub mod messaging_relays; -pub mod relay_list; pub mod requests; pub mod search; diff --git a/crates/workspace/src/panels/relay_list.rs b/crates/workspace/src/panels/relay_list.rs deleted file mode 100644 index 4e76bd4f..00000000 --- a/crates/workspace/src/panels/relay_list.rs +++ /dev/null @@ -1,441 +0,0 @@ -use std::collections::HashSet; - -use anyhow::{Error, anyhow}; -use gpui::prelude::FluentBuilder; -use gpui::{ - Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - Task, TextAlign, Window, div, px, rems, -}; -use instant::Duration; -use nostr_sdk::prelude::*; -use serde::Deserialize; -use smallvec::{SmallVec, smallvec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::dock::{Panel, PanelEvent}; -use ui::input::{Input, InputEvent, InputState}; -use ui::menu::DropdownMenu; -use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex}; - -const MSG: &str = "Relay List (or Gossip Relays) are a set of relays \ - where you will publish all your events. Others also publish events \ - related to you here."; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| RelayListPanel::new(window, cx)) -} - -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = relay, no_json)] -enum SetMetadata { - Read, - Write, -} - -#[derive(Debug)] -pub struct RelayListPanel { - name: SharedString, - focus_handle: FocusHandle, - - /// Relay URL input - input: Entity, - - /// Whether the panel is updating - updating: bool, - - /// Relay metadata input - metadata: Entity>, - - /// Error message - error: Option, - - // All relays - relays: HashSet<(RelayUrl, Option)>, - - // Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, - - // Background tasks - tasks: Vec>>, -} - -impl RelayListPanel { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); - let metadata = cx.new(|_| None); - - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Subscribe to user's input events - cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.add(window, cx); - } - }), - ); - - // Run at the end of current cycle - cx.defer_in(window, |this, window, cx| { - this.load(window, cx); - }); - - Self { - name: "Update Relay List".into(), - focus_handle: cx.focus_handle(), - input, - updating: false, - metadata, - relays: HashSet::new(), - error: None, - _subscriptions: subscriptions, - tasks: vec![], - } - } - - #[allow(clippy::type_complexity)] - fn load(&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).collect()) - } else { - Err(anyhow!("Not found.")) - } - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let relays = task.await?; - - // Update state - this.update(cx, |this, cx| { - this.relays.extend(relays); - cx.notify(); - })?; - - Ok(()) - })); - } - - fn add(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.input.read(cx).value().to_string(); - let metadata = self.metadata.read(cx); - - if !value.starts_with("ws") { - self.set_error("Relay URl is invalid", window, cx); - return; - } - - if let Ok(url) = RelayUrl::parse(&value) { - if self.relays.insert((url, metadata.to_owned())) { - self.input.update(cx, |this, cx| { - this.set_value("", window, cx); - }); - cx.notify(); - } - } else { - self.set_error("Relay URl is invalid", window, cx); - } - } - - fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { - self.relays.retain(|(relay, _)| relay != url); - cx.notify(); - } - - fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) - where - E: Into, - { - self.error = Some(error.into()); - cx.notify(); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - // Clear the error message after a delay - this.update(cx, |this, cx| { - this.error = None; - cx.notify(); - })?; - - Ok(()) - })); - } - - fn set_updating(&mut self, updating: bool, cx: &mut Context) { - self.updating = updating; - cx.notify(); - } - - fn set_metadata(&mut self, ev: &SetMetadata, _window: &mut Window, cx: &mut Context) { - match ev { - SetMetadata::Read => { - self.metadata.update(cx, |this, cx| { - *this = Some(RelayMetadata::Read); - cx.notify(); - }); - } - SetMetadata::Write => { - self.metadata.update(cx, |this, cx| { - *this = Some(RelayMetadata::Write); - cx.notify(); - }); - } - } - } - - fn set_relays(&mut self, window: &mut Window, cx: &mut Context) { - if self.relays.is_empty() { - self.set_error("You need to add at least 1 relay", window, cx); - return; - }; - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - - // Get all relays - let relays = self.relays.clone(); - - // Set updating state - self.set_updating(true, cx); - - let task: Task> = cx.background_spawn(async move { - let event = nip65::RelayList::new(relays) - .finalize_async(&signer) - .await?; - - // Set relay list for current user - client.send_event(&event).await?; - - Ok(()) - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(_) => { - this.update_in(cx, |this, window, cx| { - this.set_updating(false, cx); - this.load(window, cx); - - window.push_notification("Update successful", cx); - })?; - } - Err(e) => { - this.update_in(cx, |this, window, cx| { - this.set_updating(false, cx); - this.set_error(e.to_string(), window, cx); - })?; - } - }; - - Ok(()) - })); - } - - fn render_list_items(&mut self, cx: &mut Context) -> Vec { - let mut items = Vec::new(); - - for (url, metadata) in self.relays.iter() { - items.push( - h_flex() - .id(SharedString::from(url.to_string())) - .group("") - .flex_1() - .w_full() - .h_8() - .px_2() - .justify_between() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary_background) - .text_color(cx.theme().secondary_foreground) - .child( - h_flex() - .gap_1() - .text_sm() - .child(SharedString::from(url.to_string())) - .child( - div() - .p_0p5() - .rounded_xs() - .font_semibold() - .text_size(px(8.)) - .text_color(cx.theme().secondary_foreground) - .map(|this| { - if let Some(metadata) = metadata { - this.child(SharedString::from(metadata.to_string())) - } else { - this.child("Read and Write") - } - }), - ), - ) - .child( - Button::new("remove_{ix}") - .icon(IconName::Close) - .xsmall() - .ghost() - .invisible() - .group_hover("", |this| this.visible()) - .on_click({ - let url = url.to_owned(); - cx.listener(move |this, _ev, _window, cx| { - this.remove(&url, cx); - }) - }), - ), - ) - } - - items - } - - fn render_empty(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .h_20() - .justify_center() - .border_2() - .border_dashed() - .border_color(cx.theme().border) - .rounded(cx.theme().radius_lg) - .text_sm() - .text_align(TextAlign::Center) - .child(SharedString::from("Please add some relays.")) - } -} - -impl Panel for RelayListPanel { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for RelayListPanel {} - -impl Focusable for RelayListPanel { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for RelayListPanel { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .on_action(cx.listener(Self::set_metadata)) - .p_3() - .gap_3() - .w_full() - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(MSG)), - ) - .child(divider(cx)) - .child( - v_flex() - .gap_2() - .flex_1() - .w_full() - .text_sm() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Relays:")), - ) - .child( - v_flex() - .gap_1() - .child( - h_flex() - .gap_1() - .w_full() - .child(Input::new(&self.input).small().cleanable(true)) - .child( - Button::new("metadata") - .map(|this| { - if let Some(metadata) = self.metadata.read(cx) { - this.label(metadata.to_string()) - } else { - this.label("R & W") - } - }) - .tooltip("Relay metadata") - .ghost() - .h(rems(2.)) - .text_xs() - .dropdown_menu(|this, _window, _cx| { - this.menu("Read", Box::new(SetMetadata::Read)) - .menu("Write", Box::new(SetMetadata::Write)) - }), - ) - .child( - Button::new("add") - .icon(IconName::Plus) - .tooltip("Add relay") - .ghost() - .size(rems(2.)) - .on_click(cx.listener(move |this, _, window, cx| { - this.add(window, cx); - })), - ), - ) - .when_some(self.error.as_ref(), |this, error| { - this.child( - div() - .italic() - .text_xs() - .text_color(cx.theme().text_danger) - .child(error.clone()), - ) - }), - ) - .map(|this| { - if self.relays.is_empty() { - this.child(self.render_empty(window, cx)) - } else { - this.child( - v_flex() - .gap_1() - .flex_1() - .w_full() - .children(self.render_list_items(cx)), - ) - } - }) - .child( - Button::new("submit") - .icon(IconName::CheckCircle) - .label("Update") - .primary() - .font_semibold() - .loading(self.updating) - .disabled(self.updating) - .on_click(cx.listener(move |this, _ev, window, cx| { - this.set_relays(window, cx); - })), - ), - ) - } -}