From f164e869672afadc01c328649499bb67bb2b1a86 Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 2 Feb 2026 15:23:02 +0700 Subject: [PATCH] wip: command bar --- Cargo.lock | 2 + crates/chat/src/lib.rs | 115 ++++++++-- crates/common/src/lib.rs | 2 - crates/common/src/nip05.rs | 31 --- crates/coop/src/command_bar.rs | 309 +++++++++++++++++++++++++++ crates/coop/src/dialogs/compose.rs | 9 +- crates/coop/src/dialogs/profile.rs | 20 +- crates/coop/src/dialogs/screening.rs | 21 +- crates/coop/src/main.rs | 4 +- crates/coop/src/sidebar/mod.rs | 308 +------------------------- crates/coop/src/workspace.rs | 101 ++------- crates/person/src/lib.rs | 1 - crates/state/Cargo.toml | 2 + crates/state/src/lib.rs | 51 ++++- crates/state/src/nip05.rs | 60 ++++++ crates/titlebar/src/lib.rs | 95 ++++---- crates/ui/src/button.rs | 10 +- crates/ui/src/lib.rs | 2 +- crates/ui/src/root.rs | 2 +- 19 files changed, 627 insertions(+), 518 deletions(-) delete mode 100644 crates/common/src/nip05.rs create mode 100644 crates/coop/src/command_bar.rs create mode 100644 crates/state/src/nip05.rs diff --git a/Cargo.lock b/Cargo.lock index d81ea63..34b11d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6069,10 +6069,12 @@ dependencies = [ "common", "flume", "gpui", + "gpui_tokio", "log", "nostr-connect", "nostr-lmdb", "nostr-sdk", + "reqwest", "rustls", "smol", "webbrowser", diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index d4eebf0..cccdd3c 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; -use common::EventUtils; +use common::{EventUtils, BOOTSTRAP_RELAYS}; use device::DeviceRegistry; use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; @@ -16,7 +16,7 @@ use gpui::{ }; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{tracker, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP}; +use state::{tracker, NostrAddress, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP}; mod message; mod room; @@ -336,6 +336,7 @@ impl ChatRegistry { } /// Emit an open room event. + /// /// If the room is new, add it to the registry. pub fn emit_room(&mut self, room: WeakEntity, cx: &mut Context) { if let Some(room) = room.upgrade() { @@ -364,28 +365,96 @@ impl ChatRegistry { cx.notify(); } - /// Search rooms by their name. - pub fn search(&self, query: &str, cx: &App) -> Vec> { - let matcher = SkimMatcherV2::default(); + /// Find for rooms that match the query. + pub fn find(&self, query: &str, cx: &App) -> Task>, Error>> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let http_client = cx.http_client(); + let query = query.to_string(); - self.rooms - .iter() - .filter(|room| { - matcher - .fuzzy_match(room.read(cx).display_name(cx).as_ref(), query) - .is_some() + if let Ok(addr) = Nip05Address::parse(&query) { + let task: Task> = cx.background_spawn(async move { + let profile = addr.profile(&http_client).await?; + let public_key = profile.public_key; + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(Duration::from_secs(3))); + + // Construct the filter for the metadata event + let filter = Filter::new() + .kind(Kind::Metadata) + .author(public_key) + .limit(1); + + // Subscribe to bootstrap relays + client + .subscribe_to(BOOTSTRAP_RELAYS, vec![filter], Some(opts)) + .await?; + + Ok(public_key) + }); + + cx.spawn(async move |cx| { + let public_key = task.await?; + let results = cx.read_global::(|this, cx| { + this.0 + .read_with(cx, |this, cx| this.find_rooms(&public_key.to_hex(), cx)) + }); + Ok(results) }) - .cloned() - .collect() + } else { + cx.spawn(async move |cx| { + let results = cx.read_global::(|this, cx| { + this.0.read_with(cx, |this, cx| this.find_rooms(&query, cx)) + }); + Ok(results) + }) + } } - /// Search rooms by public keys. - pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec> { - self.rooms - .iter() - .filter(|room| room.read(cx).members.contains(&public_key)) - .cloned() - .collect() + /// Internal find function for finding rooms based on a query. + fn find_rooms(&self, query: &str, cx: &App) -> Vec> { + let matcher = SkimMatcherV2::default(); + + if let Ok(public_key) = PublicKey::parse(query) { + self.rooms + .iter() + .filter(|room| room.read(cx).members.contains(&public_key)) + .cloned() + .collect() + } else { + self.rooms + .iter() + .filter(|room| { + matcher + .fuzzy_match(room.read(cx).display_name(cx).as_ref(), query) + .is_some() + }) + .cloned() + .collect() + } + } + + /// Construct a chat room based on NIP-05 address. + pub fn address_to_room(&self, addr: Nip05Address, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let http_client = cx.http_client(); + + cx.background_spawn(async move { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + // Get the profile belonging to the address + let profile = addr.profile(&http_client).await?; + + // Construct the room + let receivers = vec![profile.public_key]; + let room = Room::new(None, public_key, receivers); + + Ok(room) + }) } /// Reset the registry. @@ -531,7 +600,7 @@ impl ChatRegistry { } } - /// Parse a Nostr event into a Coop Message and push it to the belonging room + /// Parse a nostr event into a message and push it to the belonging room /// /// If the room doesn't exist, it will be created. /// Updates room ordering based on the most recent messages. @@ -578,7 +647,7 @@ impl ChatRegistry { } } - // Unwraps a gift-wrapped event and processes its contents. + /// Unwraps a gift-wrapped event and processes its contents. async fn extract_rumor( client: &Client, device_signer: &Option>, @@ -602,7 +671,7 @@ impl ChatRegistry { Ok(rumor_unsigned) } - // Helper method to try unwrapping with different signers + /// Helper method to try unwrapping with different signers async fn try_unwrap( client: &Client, device_signer: &Option>, diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index bbfb448..653d1dc 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -4,7 +4,6 @@ pub use constants::*; pub use debounced_delay::*; pub use display::*; pub use event::*; -pub use nip05::*; pub use nip96::*; use nostr_sdk::prelude::*; pub use paths::*; @@ -13,7 +12,6 @@ mod constants; mod debounced_delay; mod display; mod event; -mod nip05; mod nip96; mod paths; diff --git a/crates/common/src/nip05.rs b/crates/common/src/nip05.rs deleted file mode 100644 index 1286033..0000000 --- a/crates/common/src/nip05.rs +++ /dev/null @@ -1,31 +0,0 @@ -use anyhow::anyhow; -use nostr::prelude::*; -use reqwest::Client as ReqClient; - -pub async fn nip05_verify(public_key: PublicKey, address: &str) -> Result { - let req_client = ReqClient::new(); - let address = Nip05Address::parse(address)?; - - // Get NIP-05 response - let res = req_client.get(address.url().to_string()).send().await?; - let json: Value = res.json().await?; - - let verify = nip05::verify_from_json(&public_key, &address, &json); - - Ok(verify) -} - -pub async fn nip05_profile(address: &str) -> Result { - let req_client = ReqClient::new(); - let address = Nip05Address::parse(address)?; - - // Get NIP-05 response - let res = req_client.get(address.url().to_string()).send().await?; - let json: Value = res.json().await?; - - if let Ok(profile) = Nip05Profile::from_json(&address, &json) { - Ok(profile) - } else { - Err(anyhow!("Failed to get NIP-05 profile")) - } -} diff --git a/crates/coop/src/command_bar.rs b/crates/coop/src/command_bar.rs new file mode 100644 index 0000000..bf66d2b --- /dev/null +++ b/crates/coop/src/command_bar.rs @@ -0,0 +1,309 @@ +use std::ops::Range; +use std::time::Duration; + +use anyhow::Error; +use chat::{ChatRegistry, Room}; +use common::DebouncedDelay; +use gpui::prelude::FluentBuilder; +use gpui::{ + anchored, deferred, div, point, px, uniform_list, AppContext, Bounds, Context, Entity, + Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Point, Render, Styled, + Subscription, Task, Window, +}; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; +use state::{NostrRegistry, FIND_DELAY}; +use theme::{ActiveTheme, TITLEBAR_HEIGHT}; +use ui::button::{Button, ButtonVariants}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::{v_flex, window_paddings, IconName, Sizable, WindowExtension}; + +/// Command bar for searching conversations. +pub struct CommandBar { + /// Find input state + find_input: Entity, + + /// Debounced delay for find input + find_debouncer: DebouncedDelay, + + /// Whether a search is in progress + finding: bool, + + /// Find results + find_results: Entity>>>, + + /// Async find operation + find_task: Option>>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl CommandBar { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let find_results = cx.new(|_| None); + let find_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Find or start a conversation") + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Subscribe to find input events + cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { + let delay = Duration::from_millis(FIND_DELAY); + + match event { + InputEvent::PressEnter { .. } => { + this.search(window, cx); + } + InputEvent::Change => { + if state.read(cx).value().is_empty() { + // Clear results when input is empty + this.reset(window, cx); + } else { + // Run debounced search + this.find_debouncer + .fire_new(delay, window, cx, |this, window, cx| { + this.debounced_search(window, cx) + }); + } + } + _ => {} + }; + }), + ); + + Self { + find_debouncer: DebouncedDelay::new(), + finding: false, + find_input, + find_results, + find_task: None, + _subscriptions: subscriptions, + } + } + + fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, window, cx| { + this.search(window, cx); + }) + .ok(); + }) + } + + fn search(&mut self, window: &mut Window, cx: &mut Context) { + let chat = ChatRegistry::global(cx); + let query = self.find_input.read(cx).value(); + + let nostr = NostrRegistry::global(cx); + let identity = nostr.read(cx).identity().read(cx).public_key(); + + // Return if the query is empty + if query.is_empty() { + log::warn!("Empty query"); + return; + } + + // Return if a search is already in progress + if self.finding { + if self.find_task.is_none() { + window.push_notification("There is another search in progress", cx); + return; + } else { + // Cancel the ongoing search request + self.find_task = None; + } + } + + // Block the input until the search completes + self.set_finding(true, window, cx); + + // Perform a local search + let find_rooms = chat.read(cx).find(&query, cx); + // Perform a global search + let find_users = nostr.read(cx).search(&query, cx); + + // Run task in the main thread + self.find_task = Some(cx.spawn_in(window, async move |this, cx| { + let local_rooms = find_rooms.await.ok().filter(|rooms| !rooms.is_empty()); + let rooms = match local_rooms { + Some(rooms) => rooms, + None => find_users + .await? + .into_iter() + .map(|event| cx.new(|_| Room::new(None, identity, vec![event.pubkey]))) + .collect(), + }; + + this.update_in(cx, |this, window, cx| { + this.set_results(rooms, cx); + this.set_finding(false, window, cx); + }) + .ok(); + + Ok(()) + })); + } + + fn set_results(&mut self, rooms: Vec>, cx: &mut Context) { + self.find_results.update(cx, |this, cx| { + *this = Some(rooms); + cx.notify(); + }); + } + + fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { + // Disable the input to prevent duplicate requests + self.find_input.update(cx, |this, cx| { + this.set_disabled(status, cx); + this.set_loading(status, cx); + }); + // Set the search status + self.finding = status; + cx.notify(); + } + + fn reset(&mut self, window: &mut Window, cx: &mut Context) { + // Clear all search results + self.find_results.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); + + // Reset the search status + self.set_finding(false, window, cx); + + // Cancel the current search task + self.find_task = None; + cx.notify(); + } + + fn render_list_items(&self, range: Range, cx: &Context) -> Vec { + let Some(rooms) = self.find_results.read(cx) else { + return vec![div().into_any_element()]; + }; + + rooms + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| { + let room = item.read(cx); + + div() + .id(range.start + ix) + .child(room.display_name(cx)) + .into_any_element() + }) + .collect() + } +} + +impl Render for CommandBar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let window_paddings = window_paddings(window, cx); + let view_size = window.viewport_size() + - gpui::size( + window_paddings.left + window_paddings.right, + window_paddings.top + window_paddings.bottom, + ); + + let bounds = Bounds { + origin: Point::default(), + size: view_size, + }; + + let x = bounds.center().x - px(320.) / 2.; + let y = TITLEBAR_HEIGHT; + + let input_focus_handle = self.find_input.read(cx).focus_handle(cx); + let input_focused = input_focus_handle.is_focused(window); + + let results = self.find_results.read(cx).as_ref(); + + div() + .w_full() + .child( + TextInput::new(&self.find_input) + .cleanable() + .appearance(true) + .bordered(false) + .xsmall() + .text_xs() + .when(!self.find_input.read(cx).loading, |this| { + this.suffix( + Button::new("find") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + }), + ) + .when(input_focused, |this| { + this.child(deferred( + anchored() + .position(point(window_paddings.left, window_paddings.top)) + .snap_to_window() + .child( + div() + .occlude() + .w(view_size.width) + .h(view_size.height) + .on_mouse_down(MouseButton::Left, move |_ev, window, cx| { + window.focus_prev(cx); + }) + .child( + v_flex() + .absolute() + .occlude() + .relative() + .left(x) + .top(y) + .w(px(320.)) + .min_h_24() + .p_1() + .gap_1() + .justify_between() + .border_1() + .border_color(cx.theme().border.alpha(0.4)) + .bg(cx.theme().surface_background) + .shadow_md() + .rounded(cx.theme().radius_lg) + .when_some(results, |this, results| { + this.child( + uniform_list( + "rooms", + results.len(), + cx.processor(|this, range, _window, cx| { + this.render_list_items(range, cx) + }), + ) + .h_32(), + ) + }) + .child( + v_flex() + .pt_1() + .border_t_1() + .border_color(cx.theme().border_variant) + .child( + Button::new("directory") + .icon(IconName::Door) + .label("Nostr Directory") + .ghost() + .xsmall() + .no_center(), + ), + ), + ), + ), + )) + }) + } +} diff --git a/crates/coop/src/dialogs/compose.rs b/crates/coop/src/dialogs/compose.rs index 4fcec5a..f7af756 100644 --- a/crates/coop/src/dialogs/compose.rs +++ b/crates/coop/src/dialogs/compose.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{anyhow, Error}; use chat::{ChatRegistry, Room}; -use common::{nip05_profile, TextUtils, BOOTSTRAP_RELAYS}; +use common::{TextUtils, BOOTSTRAP_RELAYS}; use gpui::prelude::FluentBuilder; use gpui::{ div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement, @@ -14,7 +14,7 @@ use gpui_tokio::Tokio; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; +use state::{NostrAddress, NostrRegistry}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -250,6 +250,7 @@ impl Compose { fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context) { let content = self.user_input.read(cx).value().to_string(); + let http_client = cx.http_client(); // Show loading indicator in the input self.user_input.update(cx, |this, cx| { @@ -259,9 +260,9 @@ impl Compose { if let Ok(public_key) = content.to_public_key() { let contact = Contact::new(public_key).selected(); self.push_contact(contact, window, cx); - } else if content.contains("@") { + } else if let Ok(addr) = Nip05Address::parse(&content) { let task = Tokio::spawn(cx, async move { - if let Ok(profile) = nip05_profile(&content).await { + if let Ok(profile) = addr.profile(&http_client).await { let public_key = profile.public_key; let contact = Contact::new(public_key).selected(); diff --git a/crates/coop/src/dialogs/profile.rs b/crates/coop/src/dialogs/profile.rs index 27d784a..4cace7a 100644 --- a/crates/coop/src/dialogs/profile.rs +++ b/crates/coop/src/dialogs/profile.rs @@ -1,17 +1,16 @@ use std::time::Duration; use anyhow::Error; -use common::{nip05_verify, shorten_pubkey}; +use common::shorten_pubkey; use gpui::prelude::FluentBuilder; use gpui::{ div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, }; -use gpui_tokio::Tokio; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; +use state::{NostrAddress, NostrRegistry}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -40,6 +39,7 @@ pub struct ProfileDialog { impl ProfileDialog { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let http_client = cx.http_client(); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -48,6 +48,7 @@ impl ProfileDialog { let mut tasks = smallvec![]; + // Check if the user is following let check_follow: Task> = cx.background_spawn(async move { let signer = client.signer().await?; let public_key = signer.get_public_key().await?; @@ -56,13 +57,12 @@ impl ProfileDialog { Ok(contact_list.contains(&public_key)) }); - let verify_nip05 = if let Some(address) = profile.metadata().nip05 { - Some(Tokio::spawn(cx, async move { - nip05_verify(public_key, &address).await.unwrap_or(false) - })) - } else { - None - }; + // Verify the NIP05 address if available + let verify_nip05 = profile.metadata().nip05.and_then(|address| { + Nip05Address::parse(&address).ok().map(|addr| { + cx.background_spawn(async move { addr.verify(&http_client, &public_key).await }) + }) + }); tasks.push( // Load user profile data diff --git a/crates/coop/src/dialogs/screening.rs b/crates/coop/src/dialogs/screening.rs index d5364c4..31b7629 100644 --- a/crates/coop/src/dialogs/screening.rs +++ b/crates/coop/src/dialogs/screening.rs @@ -1,17 +1,16 @@ use std::time::Duration; use anyhow::Error; -use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS}; +use common::{shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS}; use gpui::prelude::FluentBuilder; use gpui::{ div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, }; -use gpui_tokio::Tokio; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; +use state::{NostrAddress, NostrRegistry}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -33,6 +32,7 @@ pub struct Screening { impl Screening { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let http_client = cx.http_client(); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -41,6 +41,7 @@ impl Screening { let mut tasks = smallvec![]; + // Check WOT let contact_check: Task), Error>> = cx.background_spawn({ let client = nostr.read(cx).client(); async move { @@ -68,6 +69,7 @@ impl Screening { } }); + // Check the last activity let activity_check = cx.background_spawn(async move { let filter = Filter::new().author(public_key).limit(1); let mut activity: Option = None; @@ -86,13 +88,12 @@ impl Screening { activity }); - let addr_check = if let Some(address) = profile.metadata().nip05 { - Some(Tokio::spawn(cx, async move { - nip05_verify(public_key, &address).await.unwrap_or(false) - })) - } else { - None - }; + // Verify the NIP05 address if available + let addr_check = profile.metadata().nip05.and_then(|address| { + Nip05Address::parse(&address).ok().map(|addr| { + cx.background_spawn(async move { addr.verify(&http_client, &public_key).await }) + }) + }); tasks.push( // Run the contact check in the background diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 139bba6..6a40e92 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -12,6 +12,7 @@ use ui::Root; use crate::actions::Quit; mod actions; +mod command_bar; mod dialogs; mod panels; mod sidebar; @@ -73,9 +74,6 @@ fn main() { cx.activate(true); cx.new(|cx| { - // Initialize the tokio runtime - gpui_tokio::init(cx); - // Initialize components ui::init(cx); diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index 487a3ec..072b0b6 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -67,281 +67,6 @@ impl Sidebar { } } - /* - async fn nip50(client: &Client, query: &str) -> Result, Error> { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::Metadata) - .search(query.to_lowercase()) - .limit(FIND_LIMIT); - - let mut stream = client - .stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3)) - .await?; - - let mut results: Vec = Vec::with_capacity(FIND_LIMIT); - - while let Some((_url, event)) = stream.next().await { - if let Ok(event) = event { - // Skip if author is match current user - if event.pubkey == public_key { - continue; - } - - // Skip if the event has already been added - if results.iter().any(|this| this.pubkey == event.pubkey) { - continue; - } - - results.push(event); - } - } - - if results.is_empty() { - return Err(anyhow!("No results for query {query}")); - } - - // Get all public keys - let public_keys: Vec = results.iter().map(|event| event.pubkey).collect(); - - // Fetch metadata and contact lists if public keys is not empty - if !public_keys.is_empty() { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new() - .kinds(vec![Kind::Metadata, Kind::ContactList]) - .limit(public_keys.len() * 2) - .authors(public_keys); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - } - - Ok(results) - } - - fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, window, cx| { - this.search(window, cx); - }) - .ok(); - }) - } - - fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); - - let query = query.to_owned(); - - self.search_task = Some(cx.spawn_in(window, async move |this, cx| { - let result = Self::nip50(&client, &query).await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(results) => { - let rooms = results - .into_iter() - .map(|event| { - cx.new(|_| Room::new(None, public_key, vec![event.pubkey])) - }) - .collect(); - - this.set_results(rooms, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - this.set_finding(false, window, cx); - }) - .ok(); - })); - } - - fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let address = query.to_owned(); - - let task = Tokio::spawn(cx, async move { - match common::nip05_profile(&address).await { - Ok(profile) => { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let receivers = vec![profile.public_key]; - let room = Room::new(None, public_key, receivers); - - Ok(room) - } - Err(e) => Err(anyhow!(e)), - } - }); - - self.search_task = Some(cx.spawn_in(window, async move |this, cx| { - let result = task.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(Ok(room)) => { - this.set_results(vec![cx.new(|_| room)], cx); - } - Ok(Err(e)) => { - window.push_notification(e.to_string(), cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - } - this.set_finding(false, window, cx); - }) - .ok(); - })); - } - - fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let Ok(public_key) = query.to_public_key() else { - window.push_notification("Public Key is invalid", cx); - self.set_finding(false, window, cx); - return; - }; - - let task: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; - let author = signer.get_public_key().await?; - - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let receivers = vec![public_key]; - let room = Room::new(None, author, receivers); - - let filter = Filter::new() - .kinds(vec![Kind::Metadata, Kind::ContactList]) - .author(public_key) - .limit(2); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(room) - }); - - self.search_task = Some(cx.spawn_in(window, async move |this, cx| { - let result = task.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(room) => { - let chat = ChatRegistry::global(cx); - let local_results = chat.read(cx).search_by_public_key(public_key, cx); - - if !local_results.is_empty() { - this.set_results(local_results, cx); - } else { - this.set_results(vec![cx.new(|_| room)], cx); - } - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - this.set_finding(false, window, cx); - }) - .ok(); - })); - } - - fn search(&mut self, window: &mut Window, cx: &mut Context) { - // Return if the query is empty - if self.find_input.read(cx).value().is_empty() { - return; - } - - // Return if search is in progress - if self.finding { - if self.search_task.is_none() { - window.push_notification("There is another search in progress", cx); - return; - } else { - // Cancel ongoing search request - self.search_task = None; - } - } - - let input = self.find_input.read(cx).value(); - let query = input.to_string(); - - // Block the input until the search process completes - self.set_finding(true, window, cx); - - // Process to search by pubkey if query starts with npub or nprofile - if query.starts_with("npub1") || query.starts_with("nprofile1") { - self.search_by_pubkey(&query, window, cx); - return; - }; - - // Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld) - if query.split('@').count() == 2 { - let parts: Vec<&str> = query.split('@').collect(); - if !parts[0].is_empty() && !parts[1].is_empty() && parts[1].contains('.') { - self.search_by_nip05(&query, window, cx); - return; - } - } - - // Get all local results with current query - let chat = ChatRegistry::global(cx); - let local_results = chat.read(cx).search(&query, cx); - - // Try to update with local results first - if !local_results.is_empty() { - self.set_results(local_results, cx); - return; - }; - - // If no local results, try global search via NIP-50 - self.search_by_nip50(&query, window, cx); - } - - fn set_results(&mut self, rooms: Vec>, cx: &mut Context) { - self.search_results.update(cx, |this, cx| { - *this = Some(rooms); - cx.notify(); - }); - } - - fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { - // Disable the input to prevent duplicate requests - self.find_input.update(cx, |this, cx| { - this.set_disabled(status, cx); - this.set_loading(status, cx); - }); - // Set the finding status - self.finding = status; - cx.notify(); - } - - fn clear(&mut self, window: &mut Window, cx: &mut Context) { - // Reset the input state - if self.finding { - self.set_finding(false, window, cx); - } - - // Clear all local results - self.search_results.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - } - */ - /// Get the active filter. fn current_filter(&self, kind: &RoomKind, cx: &Context) -> bool { self.filter.read(cx) == kind @@ -356,23 +81,7 @@ impl Sidebar { self.new_requests = false; } - /// Open a room. - fn open(&mut self, id: u64, _window: &mut Window, cx: &mut Context) { - let chat = ChatRegistry::global(cx); - - if let Some(room) = chat.read(cx).room(&id, cx) { - chat.update(cx, |this, cx| { - this.emit_room(room, cx); - }); - } - } - - fn render_list_items( - &self, - range: Range, - _window: &Window, - cx: &Context, - ) -> Vec { + fn render_list_items(&self, range: Range, cx: &Context) -> Vec { let chat = ChatRegistry::global(cx); let rooms = chat.read(cx).rooms(self.filter.read(cx), cx); @@ -383,13 +92,12 @@ impl Sidebar { .enumerate() .map(|(ix, item)| { let room = item.read(cx); + let weak_room = item.downgrade(); let public_key = room.display_member(cx).public_key(); - let id = room.id; - - let handler = cx.listener({ - move |this, _ev, window, cx| { - this.open(id, window, cx); - } + let handler = cx.listener(move |_this, _ev, _window, cx| { + ChatRegistry::global(cx).update(cx, |s, cx| { + s.emit_room(weak_room.clone(), cx); + }); }); RoomListItem::new(range.start + ix) @@ -544,8 +252,8 @@ impl Render for Sidebar { uniform_list( "rooms", total_rooms, - cx.processor(|this, range, window, cx| { - this.render_list_items(range, window, cx) + cx.processor(|this, range, _window, cx| { + this.render_list_items(range, cx) }), ) .h_full(), diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index 949dbeb..2d3cc0b 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -1,15 +1,13 @@ use std::sync::Arc; -use std::time::Duration; -use chat::{ChatEvent, ChatRegistry, Room}; -use common::DebouncedDelay; +use chat::{ChatEvent, ChatRegistry}; use dock::dock::DockPlacement; use dock::panel::PanelView; use dock::{ClosePanel, DockArea, DockItem}; use gpui::prelude::FluentBuilder; use gpui::{ div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, Task, Window, + ParentElement, Render, SharedString, Styled, Subscription, Window, }; use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; @@ -17,16 +15,12 @@ use state::NostrRegistry; use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; use titlebar::TitleBar; use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::input::{InputEvent, InputState, TextInput}; use ui::{h_flex, v_flex, Icon, IconName, Root, Sizable, WindowExtension}; +use crate::command_bar::CommandBar; use crate::panels::greeter; use crate::sidebar; -const FIND_DELAY: u64 = 600; -const FIND_LIMIT: usize = 20; - pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Workspace::new(window, cx)) } @@ -38,46 +32,21 @@ pub struct Workspace { /// App's Dock Area dock: Entity, - /// Search results - search_results: Entity>>>, - - /// Async search operation - search_task: Option>, - - /// Search input state - find_input: Entity, - - /// Debounced delay for search input - find_debouncer: DebouncedDelay, - - /// Whether searching is in progress - finding: bool, + /// App's Command Bar + command_bar: Entity, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 4]>, + _subscriptions: SmallVec<[Subscription; 3]>, } impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); - - // App's titlebar let titlebar = cx.new(|_| TitleBar::new()); - - // App's dock area + let command_bar = cx.new(|cx| CommandBar::new(window, cx)); let dock = cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar)); - // Define the find input state - let find_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Find or start a conversation") - .clean_on_escape() - }); - - // Define the search results states - let search_results = cx.new(|_| None); - let mut subscriptions = smallvec![]; subscriptions.push( @@ -131,32 +100,6 @@ impl Workspace { }), ); - subscriptions.push( - // Subscribe for find input events - cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { - let delay = Duration::from_millis(FIND_DELAY); - - match event { - InputEvent::PressEnter { .. } => { - // this.search(window, cx); - } - InputEvent::Change => { - if state.read(cx).value().is_empty() { - // Clear the result when input is empty - // this.clear(window, cx); - } else { - // Run debounced search - //this.find_debouncer - // .fire_new(delay, window, cx, |this, window, cx| { - // this.debounced_search(window, cx) - // }); - } - } - _ => {} - }; - }), - ); - // Set the default layout for app's dock cx.defer_in(window, |this, window, cx| { this.set_layout(window, cx); @@ -165,15 +108,12 @@ impl Workspace { Self { titlebar, dock, - find_debouncer: DebouncedDelay::new(), - finding: false, - find_input, - search_results, - search_task: None, + command_bar, _subscriptions: subscriptions, } } + /// Add panel to the dock pub fn add_panel

(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App) where P: PanelView, @@ -189,6 +129,7 @@ impl Workspace { } } + /// Get all panel ids fn panel_ids(&self, cx: &App) -> Option> { let ids: Vec = self .dock @@ -202,6 +143,7 @@ impl Workspace { Some(ids) } + /// Set the dock layout fn set_layout(&mut self, window: &mut Window, cx: &mut Context) { let weak_dock = self.dock.downgrade(); @@ -257,24 +199,8 @@ impl Workspace { }) } - fn titlebar_center(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { - h_flex().flex_1().child( - TextInput::new(&self.find_input) - .xsmall() - .cleanable() - .appearance(true) - .bordered(false) - .text_xs() - .when(!self.find_input.read(cx).loading, |this| { - this.prefix( - Button::new("find") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .transparent() - .small(), - ) - }), - ) + fn titlebar_center(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { + h_flex().flex_1().w_full().child(self.command_bar.clone()) } fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { @@ -303,6 +229,7 @@ impl Render for Workspace { .size_full() .child( v_flex() + .relative() .size_full() // Title Bar .child(self.titlebar.clone()) diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index f1b8424..ab20aec 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -190,7 +190,6 @@ impl PersonRegistry { .wait_timeout(Duration::from_secs(2)) { Ok(Some(public_key)) => { - log::info!("Received public key: {}", public_key); batch.insert(public_key); // Process the batch if it's full if batch.len() >= 20 { diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 64dcec8..cda5d26 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -12,7 +12,9 @@ nostr-lmdb.workspace = true nostr-connect.workspace = true gpui.workspace = true +gpui_tokio.workspace = true smol.workspace = true +reqwest.workspace = true flume.workspace = true log.workspace = true anyhow.workspace = true diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 5ef174f..8cac077 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -13,20 +13,22 @@ mod device; mod event; mod gossip; mod identity; +mod nip05; pub use device::*; pub use event::*; pub use gossip::*; pub use identity::*; +pub use nip05::*; use crate::identity::Identity; -pub fn init(cx: &mut App) { - NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); -} - /// Default timeout for subscription pub const TIMEOUT: u64 = 3; +/// Default delay for searching +pub const FIND_DELAY: u64 = 600; +/// Default limit for searching +pub const FIND_LIMIT: usize = 20; /// Default timeout for Nostr Connect pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; /// Default Nostr Connect relay @@ -36,6 +38,13 @@ pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; /// Default subscription id for user gift wrap events pub const USER_GIFTWRAP: &str = "user-gift-wraps"; +pub fn init(cx: &mut App) { + // Initialize the tokio runtime + gpui_tokio::init(cx); + + NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); +} + struct GlobalNostrRegistry(Entity); impl Global for GlobalNostrRegistry {} @@ -796,6 +805,40 @@ impl NostrRegistry { (signer, uri) } + + /// Perform a NIP-50 global search for user profiles based on a given query + pub fn search(&self, query: &str, cx: &App) -> Task, Error>> { + let client = self.client(); + let query = query.to_string(); + + cx.background_spawn(async move { + let mut results: Vec = Vec::with_capacity(FIND_LIMIT); + + // Construct the filter for the search query + let filter = Filter::new() + .search(query.to_lowercase()) + .kind(Kind::Metadata) + .limit(FIND_LIMIT); + + // Stream events from the search relays + let mut stream = client + .stream_events_from(SEARCH_RELAYS, vec![filter], Duration::from_secs(3)) + .await?; + + // Collect the results + while let Some((_url, res)) = stream.next().await { + if let Ok(event) = res { + results.push(event); + } + } + + if results.is_empty() { + return Err(anyhow!("No results for query {query}")); + } + + Ok(results) + }) + } } #[derive(Debug, Clone)] diff --git a/crates/state/src/nip05.rs b/crates/state/src/nip05.rs new file mode 100644 index 0000000..afd3d65 --- /dev/null +++ b/crates/state/src/nip05.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use anyhow::Error; +use gpui::http_client::{AsyncBody, HttpClient}; +use nostr_sdk::prelude::*; +use smol::io::AsyncReadExt; + +#[allow(async_fn_in_trait)] +pub trait NostrAddress { + /// Get the NIP-05 profile + async fn profile(&self, client: &Arc) -> Result; + + /// Verify the NIP-05 address + async fn verify( + &self, + client: &Arc, + public_key: &PublicKey, + ) -> Result; +} + +impl NostrAddress for Nip05Address { + async fn profile(&self, client: &Arc) -> Result { + let mut body = Vec::new(); + let mut res = client + .get(self.url().as_str(), AsyncBody::default(), false) + .await?; + + // Read the response body into a vector + res.body_mut().read_to_end(&mut body).await?; + + // Parse the JSON response + let json: Value = serde_json::from_slice(&body)?; + + let profile = Nip05Profile::from_json(self, &json)?; + + Ok(profile) + } + + async fn verify( + &self, + client: &Arc, + public_key: &PublicKey, + ) -> Result { + let mut body = Vec::new(); + let mut res = client + .get(self.url().as_str(), AsyncBody::default(), false) + .await?; + + // Read the response body into a vector + res.body_mut().read_to_end(&mut body).await?; + + // Parse the JSON response + let json: Value = serde_json::from_slice(&body)?; + + // Verify the NIP-05 address + let verified = nip05::verify_from_json(public_key, self, &json); + + Ok(verified) + } +} diff --git a/crates/titlebar/src/lib.rs b/crates/titlebar/src/lib.rs index 4a3a583..eb67bf3 100644 --- a/crates/titlebar/src/lib.rs +++ b/crates/titlebar/src/lib.rs @@ -4,7 +4,7 @@ use gpui::MouseButton; #[cfg(not(target_os = "windows"))] use gpui::Pixels; use gpui::{ - px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, + div, px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, }; use smallvec::{smallvec, SmallVec}; @@ -129,45 +129,60 @@ impl Render for TitleBar { }) .children(children), ) - .when(!window.is_fullscreen(), |this| match cx.theme().platform { - PlatformKind::Linux => { - #[cfg(target_os = "linux")] - if matches!(decorations, Decorations::Client { .. }) { - this.child(LinuxWindowControls::new(None)) - .when(supported_controls.window_menu, |this| { - this.on_mouse_down(MouseButton::Right, move |ev, window, _| { - window.show_window_menu(ev.position) - }) - }) - .on_mouse_move(cx.listener(move |this, _ev, window, _| { - if this.should_move { - this.should_move = false; - window.start_window_move(); + .child( + h_flex() + .absolute() + .top_0() + .right_0() + .pr_2() + .h(height) + .child( + div().when(!window.is_fullscreen(), |this| match cx.theme().platform { + PlatformKind::Linux => { + #[cfg(target_os = "linux")] + if matches!(decorations, Decorations::Client { .. }) { + this.child(LinuxWindowControls::new(None)) + .when(supported_controls.window_menu, |this| { + this.on_mouse_down( + MouseButton::Right, + move |ev, window, _| { + window.show_window_menu(ev.position) + }, + ) + }) + .on_mouse_move(cx.listener(move |this, _ev, window, _| { + if this.should_move { + this.should_move = false; + window.start_window_move(); + } + })) + .on_mouse_down_out(cx.listener( + move |this, _ev, _window, _cx| { + this.should_move = false; + }, + )) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |this, _ev, _window, _cx| { + this.should_move = false; + }), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev, _window, _cx| { + this.should_move = true; + }), + ) + } else { + this } - })) - .on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| { - this.should_move = false; - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(move |this, _ev, _window, _cx| { - this.should_move = false; - }), - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _ev, _window, _cx| { - this.should_move = true; - }), - ) - } else { - this - } - #[cfg(not(target_os = "linux"))] - this - } - PlatformKind::Windows => this.child(WindowsWindowControls::new(height)), - PlatformKind::Mac => this, - }) + #[cfg(not(target_os = "linux"))] + this + } + PlatformKind::Windows => this.child(WindowsWindowControls::new(height)), + PlatformKind::Mac => this, + }), + ), + ) } } diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 90d7bef..0c151fe 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -124,6 +124,7 @@ pub struct Button { children: Vec, variant: ButtonVariant, + center: bool, rounded: bool, size: Size, @@ -170,6 +171,7 @@ impl Button { on_hover: None, loading: false, reverse: false, + center: true, bold: false, cta: false, children: Vec::new(), @@ -221,6 +223,12 @@ impl Button { self } + /// Disable centering the button's content. + pub fn no_center(mut self) -> Self { + self.center = false; + self + } + /// Set the cta style of the button. pub fn cta(mut self) -> Self { self.cta = true; @@ -353,7 +361,7 @@ impl RenderOnce for Button { .flex_shrink_0() .flex() .items_center() - .justify_center() + .when(self.center, |this| this.justify_center()) .cursor_default() .overflow_hidden() .refine_style(&self.style) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 561702f..dce8d13 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -4,7 +4,7 @@ pub use focusable::FocusableCycle; pub use icon::*; pub use kbd::*; pub use menu::{context_menu, popup_menu}; -pub use root::Root; +pub use root::{window_paddings, Root}; pub use styled::*; pub use window_ext::*; diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 476afce..7c6e0ca 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -381,7 +381,7 @@ impl Render for Root { } /// Get the window paddings. -pub(crate) fn window_paddings(window: &Window, _cx: &App) -> Edges { +pub fn window_paddings(window: &Window, _cx: &App) -> Edges { match window.window_decorations() { Decorations::Server => Edges::all(px(0.0)), Decorations::Client { tiling } => {