From edee9305ccd830b9a426b3fa7c30ca9eb891b828 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:03:05 +0700 Subject: [PATCH] feat: improve search and handle input in compose (#67) * feat: support search by npub or nprofile * . * . * . * chore: prevent update local search with empty result * clean up * . --- crates/chats/src/lib.rs | 9 + crates/common/src/debounced_delay.rs | 20 +- crates/coop/src/views/compose.rs | 485 ++++++++++++++++----------- crates/coop/src/views/sidebar/mod.rs | 336 +++++++++++++------ crates/global/src/constants.rs | 4 +- crates/global/src/lib.rs | 6 + crates/ui/src/title_bar.rs | 4 + 7 files changed, 550 insertions(+), 314 deletions(-) diff --git a/crates/chats/src/lib.rs b/crates/chats/src/lib.rs index b86a944..c07f10c 100644 --- a/crates/chats/src/lib.rs +++ b/crates/chats/src/lib.rs @@ -144,6 +144,15 @@ impl ChatRegistry { .collect() } + /// 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() + } + /// Load all rooms from the lmdb. /// /// This method: diff --git a/crates/common/src/debounced_delay.rs b/crates/common/src/debounced_delay.rs index 2b9d301..995e983 100644 --- a/crates/common/src/debounced_delay.rs +++ b/crates/common/src/debounced_delay.rs @@ -3,7 +3,7 @@ use std::time::Duration; use futures::channel::oneshot; use futures::FutureExt; -use gpui::{Context, Task}; +use gpui::{Context, Task, Window}; pub struct DebouncedDelay { task: Option>, @@ -26,9 +26,14 @@ impl DebouncedDelay { } } - pub fn fire_new(&mut self, delay: Duration, cx: &mut Context, func: F) - where - F: 'static + Send + FnOnce(&mut E, &mut Context) -> Task<()>, + pub fn fire_new( + &mut self, + delay: Duration, + window: &mut Window, + cx: &mut Context, + func: F, + ) where + F: 'static + Send + FnOnce(&mut E, &mut Window, &mut Context) -> Task<()>, { if let Some(channel) = self.cancel_channel.take() { _ = channel.send(()); @@ -38,7 +43,8 @@ impl DebouncedDelay { self.cancel_channel = Some(sender); let previous_task = self.task.take(); - self.task = Some(cx.spawn(async move |entity, cx| { + + self.task = Some(cx.spawn_in(window, async move |entity, cx| { let mut timer = cx.background_executor().timer(delay).fuse(); if let Some(previous_task) = previous_task { @@ -50,7 +56,9 @@ impl DebouncedDelay { _ = timer => {} } - if let Ok(task) = entity.update(cx, |project, cx| (func)(project, cx)) { + if let Ok(Ok(task)) = + cx.update(|window, cx| entity.update(cx, |project, cx| (func)(project, window, cx))) + { task.await; } })); diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index aab2717..0d137fc 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -1,17 +1,18 @@ -use std::collections::{BTreeSet, HashSet}; +use std::ops::Range; use std::time::Duration; -use anyhow::Error; -use chats::room::Room; +use anyhow::{anyhow, Error}; +use chats::room::{Room, RoomKind}; use chats::ChatRegistry; use common::profile::RenderProfile; use global::shared_state; use gpui::prelude::FluentBuilder; use gpui::{ div, img, impl_internal_actions, px, red, relative, uniform_list, App, AppContext, Context, - Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextAlign, Window, }; +use itertools::Itertools; use nostr_sdk::prelude::*; use serde::Deserialize; use settings::AppSettings; @@ -20,6 +21,7 @@ use smol::Timer; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{InputEvent, InputState, TextInput}; +use ui::notification::Notification; use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -31,82 +33,117 @@ struct SelectContact(PublicKey); impl_internal_actions!(contacts, [SelectContact]); +#[derive(Debug, Clone)] +struct Contact { + profile: Profile, + select: bool, +} + +impl AsRef for Contact { + fn as_ref(&self) -> &Profile { + &self.profile + } +} + +impl Contact { + pub fn new(profile: Profile) -> Self { + Self { + profile, + select: false, + } + } + + pub fn select(mut self) -> Self { + self.select = true; + self + } +} + pub struct Compose { + /// Input for the room's subject title_input: Entity, + /// Input for the room's members user_input: Entity, - contacts: Entity>, - selected: Entity>, - focus_handle: FocusHandle, - is_loading: bool, - is_submitting: bool, + /// The current user's contacts + contacts: Vec>, + /// Input error message error_message: Entity>, + adding: bool, + submitting: bool, #[allow(dead_code)] subscriptions: SmallVec<[Subscription; 1]>, } impl Compose { pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { - let contacts = cx.new(|_| Vec::new()); - let selected = cx.new(|_| HashSet::new()); - let error_message = cx.new(|_| None); + let user_input = + cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile...")); - let user_input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1...")); let title_input = cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)")); + let error_message = cx.new(|_| None); let mut subscriptions = smallvec![]; // Handle Enter event for user input subscriptions.push(cx.subscribe_in( &user_input, window, - move |this, _, input_event, window, cx| { - if let InputEvent::PressEnter { .. } = input_event { - this.add(window, cx); - } + move |this, _input, event, window, cx| { + match event { + InputEvent::PressEnter { .. } => this.add_and_select_contact(window, cx), + InputEvent::Change(_) => {} + _ => {} + }; }, )); - cx.spawn(async move |this, cx| { - let task: Task, Error>> = cx.background_spawn(async move { - let client = shared_state().client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let profiles = client.database().contacts(public_key).await?; + let get_contacts: Task, Error>> = cx.background_spawn(async move { + let client = shared_state().client(); + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let profiles = client.database().contacts(public_key).await?; + let contacts = profiles.into_iter().map(Contact::new).collect_vec(); - Ok(profiles) - }); + Ok(contacts) + }); - if let Ok(contacts) = task.await { - cx.update(|cx| { + cx.spawn_in(window, async move |this, cx| { + match get_contacts.await { + Ok(contacts) => { this.update(cx, |this, cx| { - this.contacts.update(cx, |this, cx| { - this.extend(contacts); - cx.notify(); - }); + this.contacts(contacts, cx); }) - .ok() - }) - .ok(); - } + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(e.to_string()).title("Contacts"), + cx, + ); + }) + .ok(); + } + }; }) .detach(); Self { + adding: false, + submitting: false, + contacts: vec![], title_input, user_input, - contacts, - selected, error_message, subscriptions, - is_loading: false, - is_submitting: false, - focus_handle: cx.focus_handle(), } } pub fn compose(&mut self, window: &mut Window, cx: &mut Context) { - if self.selected.read(cx).is_empty() { + let public_keys: Vec = self.selected(cx); + + if public_keys.is_empty() { self.set_error(Some("You need to add at least 1 receiver".into()), cx); return; } @@ -114,11 +151,8 @@ impl Compose { // Show loading spinner self.set_submitting(true, cx); - // Get all pubkeys - let pubkeys: Vec = self.selected.read(cx).iter().copied().collect(); - // Convert selected pubkeys into Nostr tags - let mut tag_list: Vec = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect(); + let mut tag_list: Vec = public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(); // Add subject if it is present if !self.title_input.read(cx).value().is_empty() { @@ -128,31 +162,30 @@ impl Compose { )); } - let tags = Tags::from_list(tag_list); - - let event: Task> = cx.background_spawn(async move { + let event: Task> = cx.background_spawn(async move { let signer = shared_state().client().signer().await?; let public_key = signer.get_public_key().await?; - // [IMPORTANT] - // Make sure this event is never send, - // this event existed just use for convert to Coop's Room later. - let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "") - .tags(tags) + let room = EventBuilder::private_msg_rumor(public_keys[0], "") + .tags(Tags::from_list(tag_list)) .build(public_key) .sign(&Keys::generate()) - .await?; + .await + .map(|event| Room::new(&event).kind(RoomKind::Ongoing))?; - Ok(event) + Ok(room) }); cx.spawn_in(window, async move |this, cx| match event.await { - Ok(event) => { + Ok(room) => { cx.update(|window, cx| { - let room = cx.new(|_| Room::new(&event).kind(chats::room::RoomKind::Ongoing)); + this.update(cx, |this, cx| { + this.set_submitting(false, cx); + }) + .ok(); ChatRegistry::global(cx).update(cx, |this, cx| { - this.push_room(room, cx); + this.push_room(cx.new(|_| room), cx); }); window.close_modal(cx); @@ -169,28 +202,77 @@ impl Compose { .detach(); } - fn add(&mut self, window: &mut Window, cx: &mut Context) { + fn contacts(&mut self, contacts: impl IntoIterator, cx: &mut Context) { + self.contacts + .extend(contacts.into_iter().map(|contact| cx.new(|_| contact))); + cx.notify(); + } + + fn push_contact(&mut self, contact: Contact, cx: &mut Context) { + if !self + .contacts + .iter() + .any(|e| e.read(cx).profile.public_key() == contact.profile.public_key()) + { + self.contacts.insert(0, cx.new(|_| contact)); + cx.notify(); + } + } + + fn selected(&self, cx: &Context) -> Vec { + self.contacts + .iter() + .filter_map(|contact| { + if contact.read(cx).select { + Some(contact.read(cx).profile.public_key()) + } else { + None + } + }) + .collect() + } + + fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context) { let content = self.user_input.read(cx).value().to_string(); - // Show loading spinner - self.set_loading(true, cx); + // Prevent multiple requests + self.set_adding(true, cx); - let task: Task> = if content.contains("@") { + // Show loading indicator in the input + self.user_input.update(cx, |this, cx| { + this.set_loading(true, cx); + }); + + let task: Task> = if content.contains("@") { cx.background_spawn(async move { - let profile = nip05::profile(&content, None).await?; - let public_key = profile.public_key; + let (tx, rx) = oneshot::channel::(); - let metadata = shared_state() - .client() - .fetch_metadata(public_key, Duration::from_secs(2)) - .await? - .unwrap_or_default(); + nostr_sdk::async_utility::task::spawn(async move { + if let Ok(profile) = nip05::profile(&content, None).await { + tx.send(profile).ok(); + } + }); - Ok(Profile::new(public_key, metadata)) + if let Ok(profile) = rx.await { + let public_key = profile.public_key; + let metadata = shared_state() + .client() + .fetch_metadata(public_key, Duration::from_secs(2)) + .await? + .unwrap_or_default(); + let profile = Profile::new(public_key, metadata); + let contact = Contact::new(profile).select(); + + Ok(contact) + } else { + Err(anyhow!("Profile not found")) + } }) - } else { - let Ok(public_key) = PublicKey::parse(&content) else { - self.set_loading(false, cx); + } else if content.starts_with("nprofile1") { + let Some(public_key) = Nip19Profile::from_bech32(&content) + .map(|nip19| nip19.public_key) + .ok() + else { self.set_error(Some("Public Key is not valid".into()), cx); return; }; @@ -202,55 +284,67 @@ impl Compose { .await? .unwrap_or_default(); - Ok(Profile::new(public_key, metadata)) + let profile = Profile::new(public_key, metadata); + let contact = Contact::new(profile).select(); + + Ok(contact) + }) + } else { + let Ok(public_key) = PublicKey::parse(&content) else { + self.set_error(Some("Public Key is not valid".into()), cx); + return; + }; + + cx.background_spawn(async move { + let metadata = shared_state() + .client() + .fetch_metadata(public_key, Duration::from_secs(2)) + .await? + .unwrap_or_default(); + + let profile = Profile::new(public_key, metadata); + let contact = Contact::new(profile).select(); + + Ok(contact) }) }; - cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(profile) => { - cx.update(|window, cx| { - this.update(cx, |this, cx| { - let public_key = profile.public_key(); - - this.contacts.update(cx, |this, cx| { - this.insert(0, profile); - cx.notify(); - }); - - this.selected.update(cx, |this, cx| { - this.insert(public_key); - cx.notify(); - }); - - // Stop loading indicator + cx.spawn_in(window, async move |this, cx| match task.await { + Ok(contact) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.push_contact(contact, cx); + this.set_adding(false, cx); + this.user_input.update(cx, |this, cx| { + this.set_value("", window, cx); this.set_loading(false, cx); - - // Clear input - this.user_input.update(cx, |this, cx| { - this.set_value("", window, cx); - }); - }) - .ok(); + }); }) .ok(); - } - Err(e) => { - cx.update(|_, cx| { - this.update(cx, |this, cx| { - this.set_loading(false, cx); - this.set_error(Some(e.to_string().into()), cx); - }) - .ok(); - }) - .ok(); - } + }) + .ok(); + } + Err(e) => { + this.update(cx, |this, cx| { + this.set_error(Some(e.to_string().into()), cx); + }) + .ok(); } }) .detach(); } fn set_error(&mut self, error: Option, cx: &mut Context) { + if self.adding { + self.set_adding(false, cx); + } + + // Unlock the user input + self.user_input.update(cx, |this, cx| { + this.set_loading(false, cx); + }); + + // Update error message self.error_message.update(cx, |this, cx| { *this = error; cx.notify(); @@ -259,42 +353,72 @@ impl Compose { // Dismiss error after 2 seconds cx.spawn(async move |this, cx| { Timer::after(Duration::from_secs(2)).await; - - cx.update(|cx| { - this.update(cx, |this, cx| { - this.set_error(None, cx); - }) - .ok(); + this.update(cx, |this, cx| { + this.set_error(None, cx); }) .ok(); }) .detach(); } - fn set_loading(&mut self, status: bool, cx: &mut Context) { - self.is_loading = status; + fn set_adding(&mut self, status: bool, cx: &mut Context) { + self.adding = status; cx.notify(); } fn set_submitting(&mut self, status: bool, cx: &mut Context) { - self.is_submitting = status; + self.submitting = status; cx.notify(); } - fn on_action_select( - &mut self, - action: &SelectContact, - _window: &mut Window, - cx: &mut Context, - ) { - self.selected.update(cx, |this, cx| { - if this.contains(&action.0) { - this.remove(&action.0); - } else { - this.insert(action.0); + fn list_items(&self, range: Range, cx: &Context) -> Vec { + let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let mut items = Vec::with_capacity(self.contacts.len()); + + for ix in range { + let Some(entity) = self.contacts.get(ix).cloned() else { + continue; }; - cx.notify(); - }); + + let profile = entity.read(cx).as_ref(); + let selected = entity.read(cx).select; + + items.push( + div() + .id(ix) + .w_full() + .h_10() + .px_3() + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .items_center() + .gap_3() + .text_sm() + .child(img(profile.render_avatar(proxy)).size_7().flex_shrink_0()) + .child(profile.render_name()), + ) + .when(selected, |this| { + this.child( + Icon::new(IconName::CheckCircleFill) + .small() + .text_color(cx.theme().ring), + ) + }) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click(cx.listener(move |_this, _event, _window, cx| { + entity.update(cx, |this, cx| { + this.select = !this.select; + cx.notify(); + }); + })), + ); + } + + items } } @@ -303,17 +427,13 @@ impl Render for Compose { const DESCRIPTION: &str = "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; - - let label: SharedString = if self.selected.read(cx).len() > 1 { + let label: SharedString = if self.contacts.len() > 1 { "Create Group DM".into() } else { "Create DM".into() }; div() - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::on_action_select)) .flex() .flex_col() .gap_1() @@ -353,12 +473,30 @@ impl Render for Compose { .flex_col() .gap_2() .child(div().text_sm().font_semibold().child("To:")) - .child(TextInput::new(&self.user_input).small()), + .child( + div() + .flex() + .items_center() + .gap_1() + .child( + TextInput::new(&self.user_input) + .small() + .disabled(self.adding), + ) + .child( + Button::new("add") + .icon(IconName::PlusCircleFill) + .small() + .ghost() + .disabled(self.adding) + .on_click(cx.listener(move |this, _, window, cx| { + this.add_and_select_contact(window, cx); + })), + ), + ), ) .map(|this| { - let contacts = self.contacts.read(cx).clone(); - - if contacts.is_empty() { + if self.contacts.is_empty() { this.child( div() .w_full() @@ -386,62 +524,9 @@ impl Render for Compose { this.child( uniform_list( "contacts", - contacts.len(), + self.contacts.len(), cx.processor(move |this, range, _window, cx| { - let selected = this.selected.read(cx); - let mut items = Vec::new(); - - for ix in range { - let profile: &Profile = contacts.get(ix).unwrap(); - let item = profile.clone(); - let is_select = selected.contains(&item.public_key()); - - items.push( - div() - .id(ix) - .w_full() - .h_10() - .px_3() - .flex() - .items_center() - .justify_between() - .child( - div() - .flex() - .items_center() - .gap_3() - .text_sm() - .child( - img(item.render_avatar(proxy)) - .size_7() - .flex_shrink_0(), - ) - .child(item.render_name()), - ) - .when(is_select, |this| { - this.child( - Icon::new(IconName::CheckCircleFill) - .small() - .text_color(cx.theme().icon_accent), - ) - }) - .hover(|this| { - this.bg(cx - .theme() - .elevated_surface_background) - }) - .on_click(move |_, window, cx| { - window.dispatch_action( - Box::new(SelectContact( - item.public_key(), - )), - cx, - ); - }), - ); - } - - items + this.list_items(range, cx) }), ) .pb_4() @@ -456,9 +541,11 @@ impl Render for Compose { .label(label) .primary() .w_full() - .loading(self.is_submitting) - .disabled(self.is_submitting) - .on_click(cx.listener(|this, _, window, cx| this.compose(window, cx))), + .loading(self.submitting) + .disabled(self.submitting || self.adding) + .on_click(cx.listener(move |this, _event, window, cx| { + this.compose(window, cx); + })), ), ) } diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index 4169886..b13f8a0 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -2,6 +2,7 @@ use std::collections::BTreeSet; use std::ops::Range; use std::time::Duration; +use anyhow::Error; use chats::room::{Room, RoomKind}; use chats::{ChatRegistry, RoomEmitter}; use common::debounced_delay::DebouncedDelay; @@ -27,6 +28,7 @@ use ui::button::{Button, ButtonRounded, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::indicator::Indicator; use ui::input::{InputEvent, InputState, TextInput}; +use ui::notification::Notification; use ui::popup_menu::PopupMenu; use ui::skeleton::Skeleton; use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt}; @@ -95,9 +97,9 @@ impl Sidebar { subscriptions.push(cx.subscribe_in( &find_input, window, - |this, _state, event, _window, cx| { + |this, _state, event, window, cx| { match event { - InputEvent::PressEnter { .. } => this.search(cx), + InputEvent::PressEnter { .. } => this.search(window, cx), InputEvent::Change(text) => { // Clear the result when input is empty if text.is_empty() { @@ -106,8 +108,9 @@ impl Sidebar { // Run debounced search this.find_debouncer.fire_new( Duration::from_millis(FIND_DELAY), + window, cx, - |this, cx| this.debounced_search(cx), + |this, window, cx| this.debounced_search(window, cx), ); } } @@ -132,19 +135,23 @@ impl Sidebar { } } - fn debounced_search(&self, cx: &mut Context) -> Task<()> { - cx.spawn(async move |this, cx| { - this.update(cx, |this, cx| { - this.search(cx); + fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.search(window, cx); + }) + .ok(); }) .ok(); }) } - fn nip50_search(&self, cx: &App) -> Task, Error>> { - let query = self.find_input.read(cx).value().clone(); + fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context) { + let query = query.to_owned(); + let query_cloned = query.clone(); - cx.background_spawn(async move { + let task: Task, Error>> = cx.background_spawn(async move { let client = shared_state().client(); let filter = Filter::new() @@ -153,137 +160,251 @@ impl Sidebar { .limit(FIND_LIMIT); let events = client - .fetch_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3)) + .fetch_events_from(SEARCH_RELAYS, filter, Duration::from_secs(5)) .await? .into_iter() .unique_by(|event| event.pubkey) .collect_vec(); let mut rooms = BTreeSet::new(); - let (tx, rx) = smol::channel::bounded::(10); - nostr_sdk::async_utility::task::spawn(async move { - let signer = client.signer().await.expect("signer is required"); - let public_key = signer.get_public_key().await.expect("error"); + // Process to verify the search results + if !events.is_empty() { + let (tx, rx) = smol::channel::bounded::(events.len()); - for event in events.into_iter() { - let metadata = Metadata::from_json(event.content).unwrap_or_default(); + nostr_sdk::async_utility::task::spawn(async move { + let signer = client.signer().await.unwrap(); + let public_key = signer.get_public_key().await.unwrap(); - let Some(target) = metadata.nip05.as_ref() else { - continue; - }; + for event in events.into_iter() { + let metadata = Metadata::from_json(event.content).unwrap_or_default(); - let Ok(verified) = nip05::verify(&event.pubkey, target, None).await else { - continue; - }; + let Some(target) = metadata.nip05.as_ref() else { + // Skip if NIP-05 is not found + continue; + }; - if !verified { - continue; - }; + let Ok(verified) = nip05::verify(&event.pubkey, target, None).await else { + // Skip if NIP-05 verification fails + continue; + }; - if let Ok(event) = EventBuilder::private_msg_rumor(event.pubkey, "") - .build(public_key) - .sign(&Keys::generate()) - .await - { - if let Err(e) = tx.send(Room::new(&event).kind(RoomKind::Ongoing)).await { - log::error!("{e}") + if !verified { + // Skip if NIP-05 is not valid + continue; + }; + + if let Ok(event) = EventBuilder::private_msg_rumor(event.pubkey, "") + .build(public_key) + .sign(&Keys::generate()) + .await + { + if let Err(e) = tx.send(Room::new(&event).kind(RoomKind::Ongoing)).await + { + log::error!("Send error: {e}") + } } } - } - }); + }); - while let Ok(room) = rx.recv().await { - rooms.insert(room); + while let Ok(room) = rx.recv().await { + rooms.insert(room); + } } Ok(rooms) - }) - } - - fn search(&mut self, cx: &mut Context) { - let query = self.find_input.read(cx).value(); - let result = ChatRegistry::get_global(cx).search(query.as_ref(), cx); - - // Return if query is empty - if query.is_empty() { - return; - } - - if query.starts_with("nevent1") - || query.starts_with("naddr") - || query.starts_with("nsec1") - || query.starts_with("note1") - { - return; - } - - // Return if search is in progress - if self.finding { - return; - } - - // Block the UI until the search process completes - self.set_finding(true, cx); - - // Disable the search input to prevent duplicate requests - self.find_input.update(cx, |this, cx| { - this.set_disabled(true, cx); - this.set_loading(true, cx); }); - if !result.is_empty() { - self.set_finding(false, cx); - - self.find_input.update(cx, |this, cx| { - this.set_disabled(false, cx); - this.set_loading(false, cx); - }); - - self.local_result.update(cx, |this, cx| { - *this = Some(result); - cx.notify(); - }); - } else { - let task = self.nip50_search(cx); - - cx.spawn(async move |this, cx| { - if let Ok(result) = task.await { - this.update(cx, |this, cx| { - let result = result - .into_iter() - .map(|room| cx.new(|_| room)) - .collect_vec(); - - this.set_finding(false, cx); - - this.find_input.update(cx, |this, cx| { - this.set_disabled(false, cx); - this.set_loading(false, cx); - }); - - this.global_result.update(cx, |this, cx| { - *this = Some(result); - cx.notify(); - }); + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(result) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + if result.is_empty() { + let msg = + format!("There are no users matching query {}", query_cloned); + window.push_notification(Notification::info(msg), cx); + this.set_finding(false, cx); + } else { + let result = result + .into_iter() + .map(|room| cx.new(|_| room)) + .collect_vec(); + this.global_result(result, cx); + } + }) + .ok(); }) .ok(); } - }) - .detach(); + Err(e) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(e.to_string()).title("Search Error"), + cx, + ); + }) + .ok(); + } + }; + }) + .detach(); + } + + fn search_by_user(&mut self, query: &str, window: &mut Window, cx: &mut Context) { + let public_key = if query.starts_with("npub1") { + PublicKey::parse(query).ok() + } else if query.starts_with("nprofile1") { + Nip19Profile::from_bech32(query) + .map(|nip19| nip19.public_key) + .ok() + } else { + None + }; + + let Some(public_key) = public_key else { + window.push_notification("Public Key is not valid", cx); + self.set_finding(false, cx); + return; + }; + + let task: Task> = cx.background_spawn(async move { + let client = shared_state().client(); + let signer = client.signer().await.unwrap(); + let user_pubkey = signer.get_public_key().await.unwrap(); + + let metadata = client + .fetch_metadata(public_key, Duration::from_secs(3)) + .await? + .unwrap_or_default(); + + let event = EventBuilder::private_msg_rumor(public_key, "") + .build(user_pubkey) + .sign(&Keys::generate()) + .await?; + + let profile = Profile::new(public_key, metadata); + let room = Room::new(&event); + + Ok((profile, room)) + }); + + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok((profile, room)) => { + this.update(cx, |this, cx| { + let chats = ChatRegistry::global(cx); + let result = chats + .read(cx) + .search_by_public_key(profile.public_key(), cx); + + if !result.is_empty() { + this.local_result(result, cx); + } + this.global_result(vec![cx.new(|_| room)], cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(e.to_string()).title("Search Error"), + cx, + ); + }) + .ok(); + } + }; + }) + .detach(); + } + + fn search(&mut self, window: &mut Window, cx: &mut Context) { + let query = self.find_input.read(cx).value().to_string(); + + // Return if search is in progress + if self.finding { + window.push_notification("There is another search in progress", cx); + return; } + + // Return if the query is empty + if query.is_empty() { + window.push_notification("Cannot search with an empty query", cx); + return; + } + + // Return if the query starts with "nsec1" or "note1" + if query.starts_with("nsec1") || query.starts_with("note1") { + window.push_notification("Coop does not support searching with this query", cx); + return; + } + + // Block the input until the search process completes + self.set_finding(true, cx); + + // Process to search by user if query starts with npub or nprofile + if query.starts_with("npub1") || query.starts_with("nprofile1") { + self.search_by_user(&query, window, cx); + return; + }; + + let chats = ChatRegistry::global(cx); + let result = chats.read(cx).search(&query, cx); + + if result.is_empty() { + // There are no current rooms matching this query, so proceed with global search via NIP-50 + self.search_by_nip50(&query, window, cx); + } else { + self.local_result(result, cx); + } + } + + fn global_result(&mut self, rooms: Vec>, cx: &mut Context) { + if self.finding { + self.set_finding(false, cx); + } + + self.global_result.update(cx, |this, cx| { + *this = Some(rooms); + cx.notify(); + }); + } + + fn local_result(&mut self, rooms: Vec>, cx: &mut Context) { + if self.finding { + self.set_finding(false, cx); + } + + self.local_result.update(cx, |this, cx| { + *this = Some(rooms); + cx.notify(); + }); } fn set_finding(&mut self, status: bool, cx: &mut Context) { self.finding = status; cx.notify(); + // Disable the input to prevent duplicate requests + self.find_input.update(cx, |this, cx| { + this.set_disabled(status, cx); + this.set_loading(status, cx); + }); } fn clear_search_results(&mut self, cx: &mut Context) { + // Reset the input state + if self.finding { + self.set_finding(false, cx); + } + + // Clear all local results self.local_result.update(cx, |this, cx| { *this = None; cx.notify(); }); + + // Clear all global results self.global_result.update(cx, |this, cx| { *this = None; cx.notify(); @@ -516,6 +637,7 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let chats = ChatRegistry::get_global(cx); + // Get rooms from either search results or the chat registry let rooms = if let Some(results) = self.local_result.read(cx) { results.to_owned() @@ -552,11 +674,11 @@ impl Render for Sidebar { ), ) // Global Search Results - .when_some(self.global_result.read(cx).clone(), |this, rooms| { + .when_some(self.global_result.read(cx).as_ref(), |this, rooms| { this.child(div().px_2().w_full().flex().flex_col().gap_1().children({ let mut items = Vec::with_capacity(rooms.len()); - for (ix, room) in rooms.into_iter().enumerate() { + for (ix, room) in rooms.iter().enumerate() { let this = room.read(cx); let id = this.id; let label = this.display_name(cx); diff --git a/crates/global/src/constants.rs b/crates/global/src/constants.rs index 0412d5c..414046a 100644 --- a/crates/global/src/constants.rs +++ b/crates/global/src/constants.rs @@ -11,7 +11,7 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ "wss://relay.damus.io", "wss://relay.primal.net", "wss://user.kindpag.es", - "wss://relaydiscovery.com", + "wss://purplepag.es", ]; /// NIP65 Relays. Used for new account @@ -26,7 +26,7 @@ pub const NIP65_RELAYS: [&str; 4] = [ pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"]; /// Search Relays. -pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"]; +pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"]; /// Default relay for Nostr Connect pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; diff --git a/crates/global/src/lib.rs b/crates/global/src/lib.rs index 3886a9f..73cf0f0 100644 --- a/crates/global/src/lib.rs +++ b/crates/global/src/lib.rs @@ -302,6 +302,12 @@ impl Globals { }) } + pub async fn request_metadata(&self, public_key: PublicKey) { + if let Err(e) = self.batch_sender.send(public_key).await { + log::error!("Failed to request metadata: {e}") + } + } + /// Gets a person's profile from cache or creates default (blocking) pub fn person(&self, public_key: &PublicKey) -> Profile { let metadata = if let Some(metadata) = self.persons.read_blocking().get(public_key) { diff --git a/crates/ui/src/title_bar.rs b/crates/ui/src/title_bar.rs index b943a5a..ddb0288 100644 --- a/crates/ui/src/title_bar.rs +++ b/crates/ui/src/title_bar.rs @@ -257,6 +257,7 @@ impl RenderOnce for TitleBar { const HEIGHT: Pixels = px(34.); let is_linux = cfg!(target_os = "linux"); + let is_macos = cfg!(target_os = "macos"); div().flex_shrink_0().child( self.base @@ -270,6 +271,9 @@ impl RenderOnce for TitleBar { .when(is_linux, |this| { this.on_double_click(|_, window, _| window.zoom_window()) }) + .when(is_macos, |this| { + this.on_double_click(|_, window, _| window.titlebar_double_click()) + }) .child( h_flex() .id("bar")