From 2e3a4b3634b73dfc6f9d2b8049e4a17015f702f3 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Thu, 10 Jul 2025 09:03:54 +0700 Subject: [PATCH] chore: improve search (#83) * . * . * wip: add nip05 search * add nip05 search * . * support cancel search * . --- Cargo.lock | 40 ++- Cargo.toml | 1 + crates/common/src/lib.rs | 11 + crates/common/src/profile.rs | 48 --- crates/coop/Cargo.toml | 1 + crates/coop/src/main.rs | 2 + crates/coop/src/views/compose.rs | 12 +- crates/coop/src/views/sidebar/mod.rs | 507 ++++++++++++++++----------- crates/ui/src/input/clear_button.rs | 6 +- locales/app.yml | 40 +-- 10 files changed, 377 insertions(+), 291 deletions(-) delete mode 100644 crates/common/src/profile.rs diff --git a/Cargo.lock b/Cargo.lock index 8ccf699..f41888c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.11.0", "lazy_static", "lazycell", "log", @@ -544,7 +544,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -1075,7 +1075,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1176,6 +1176,7 @@ dependencies = [ "futures", "global", "gpui", + "gpui_tokio", "i18n", "identity", "itertools 0.13.0", @@ -1462,7 +1463,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "proc-macro2", "quote", @@ -2308,7 +2309,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2400,7 +2401,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2409,6 +2410,17 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "gpui_tokio" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" +dependencies = [ + "gpui", + "tokio", + "util", + "workspace-hack", +] + [[package]] name = "grid" version = "0.13.0" @@ -2623,7 +2635,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "anyhow", "bytes", @@ -2640,7 +2652,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3417,7 +3429,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -4722,7 +4734,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "derive_refineable", "workspace-hack", @@ -4875,7 +4887,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "anyhow", "bytes", @@ -5357,7 +5369,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "anyhow", "serde", @@ -5733,7 +5745,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "arrayvec", "log", @@ -6642,7 +6654,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" +source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff" dependencies = [ "anyhow", "async-fs", diff --git a/Cargo.toml b/Cargo.toml index 001ffe1..da30676 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ i18n = { path = "crates/i18n" } # GPUI gpui = { git = "https://github.com/zed-industries/zed" } +gpui_tokio = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } # Nostr diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 81e0919..8076ed7 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::Arc; +use anyhow::anyhow; use gpui::{Image, ImageFormat}; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -32,6 +33,16 @@ pub fn room_hash(event: &Event) -> u64 { hasher.finish() } +pub fn parse_pubkey_from_str(content: &str) -> Result { + if content.starts_with("nprofile1") { + Ok(Nip19Profile::from_bech32(content)?.public_key) + } else if content.starts_with("npub1") { + Ok(PublicKey::parse(content)?) + } else { + Err(anyhow!("Invalid public key")) + } +} + pub fn string_to_qr(data: &str) -> Option> { let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256) else { return None; diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs deleted file mode 100644 index d08b61c..0000000 --- a/crates/common/src/profile.rs +++ /dev/null @@ -1,48 +0,0 @@ -use global::constants::IMAGE_RESIZE_SERVICE; -use gpui::SharedString; -use nostr_sdk::prelude::*; - -const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png"; - -pub trait RenderProfile { - fn render_avatar(&self, proxy: bool) -> SharedString; - fn render_name(&self) -> SharedString; -} - -impl RenderProfile for Profile { - fn render_avatar(&self, proxy: bool) -> SharedString { - self.metadata() - .picture - .as_ref() - .filter(|picture| !picture.is_empty()) - .map(|picture| { - if proxy { - format!( - "{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1" - ) - .into() - } else { - picture.into() - } - }) - .unwrap_or_else(|| "brand/avatar.png".into()) - } - - fn render_name(&self) -> SharedString { - if let Some(display_name) = self.metadata().display_name.as_ref() { - if !display_name.is_empty() { - return display_name.into(); - } - } - - if let Some(name) = self.metadata().name.as_ref() { - if !name.is_empty() { - return name.into(); - } - } - - let pubkey = self.public_key().to_hex(); - - format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into() - } -} diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index c24d628..f13359a 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -22,6 +22,7 @@ auto_update = { path = "../auto_update" } rust-i18n.workspace = true i18n.workspace = true gpui.workspace = true +gpui_tokio.workspace = true reqwest_client.workspace = true nostr-connect.workspace = true diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index ffb4205..d443cdb 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -234,6 +234,8 @@ fn main() { // Root Entity cx.new(|cx| { cx.activate(true); + // Initialize the tokio runtime + gpui_tokio::init(cx); // Initialize components ui::init(cx); // Initialize app registry diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 6b9841a..2478120 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -147,16 +147,6 @@ impl Compose { Ok(()) } - fn parse_pubkey(content: &str) -> Result { - if content.starts_with("nprofile1") { - Ok(Nip19Profile::from_bech32(content)?.public_key) - } else if content.starts_with("npub1") { - Ok(PublicKey::parse(content)?) - } else { - Err(anyhow!(t!("common.pubkey_invalid"))) - } - } - pub fn compose(&mut self, window: &mut Window, cx: &mut Context) { let public_keys: Vec = self.selected(cx); @@ -288,7 +278,7 @@ impl Compose { Err(anyhow!(t!("common.not_found"))) } }) - } else if let Ok(public_key) = Self::parse_pubkey(&content) { + } else if let Ok(public_key) = common::parse_pubkey_from_str(&content) { cx.background_spawn(async move { let client = nostr_client(); let contact = Contact::new(public_key).select(); diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index 3865a4e..235e5bf 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -2,12 +2,12 @@ use std::collections::BTreeSet; use std::ops::Range; use std::time::Duration; -use anyhow::Error; +use anyhow::{anyhow, Error}; use common::debounced_delay::DebouncedDelay; use common::display::DisplayProfile; use common::nip05::nip05_verify; use element::DisplayRoom; -use global::constants::{DEFAULT_MODAL_WIDTH, SEARCH_RELAYS}; +use global::constants::{BOOTSTRAP_RELAYS, DEFAULT_MODAL_WIDTH, SEARCH_RELAYS}; use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ @@ -16,6 +16,7 @@ use gpui::{ Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, }; +use gpui_tokio::Tokio; use i18n::t; use identity::Identity; use itertools::Itertools; @@ -30,7 +31,6 @@ 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}; @@ -52,6 +52,7 @@ pub struct Sidebar { find_input: Entity, find_debouncer: DebouncedDelay, finding: bool, + cancel_handle: Entity>>, local_result: Entity>>>, global_result: Entity>>>, // Rooms @@ -75,6 +76,7 @@ impl Sidebar { let indicator = cx.new(|_| None); let local_result = cx.new(|_| None); let global_result = cx.new(|_| None); + let cancel_handle = cx.new(|_| None); let find_input = cx.new(|cx| { InputState::new(window, cx).placeholder(t!("sidebar.find_or_start_conversation")) @@ -105,7 +107,7 @@ impl Sidebar { InputEvent::Change(text) => { // Clear the result when input is empty if text.is_empty() { - this.clear_search_results(cx); + this.clear_search_results(window, cx); } else { // Run debounced search this.find_debouncer.fire_new( @@ -128,6 +130,7 @@ impl Sidebar { find_debouncer: DebouncedDelay::new(), finding: false, trusted_only: false, + cancel_handle, indicator, active_filter, find_input, @@ -137,6 +140,70 @@ impl Sidebar { } } + async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; + let filter = Filter::new().author(public_key).kinds(kinds).limit(10); + + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await?; + + Ok(()) + } + + async fn create_temp_room(identity: PublicKey, public_key: PublicKey) -> Result { + let keys = Keys::generate(); + let builder = EventBuilder::private_msg_rumor(public_key, ""); + let event = builder.build(identity).sign(&keys).await?; + let room = Room::new(&event).kind(RoomKind::Ongoing); + + Ok(room) + } + + async fn nip50(identity: PublicKey, query: &str) -> BTreeSet { + let client = nostr_client(); + let timeout = Duration::from_secs(2); + let mut rooms: BTreeSet = BTreeSet::new(); + let mut processed: BTreeSet = BTreeSet::new(); + + let filter = Filter::new() + .kind(Kind::Metadata) + .search(query.to_lowercase()) + .limit(FIND_LIMIT); + + if let Ok(events) = client + .fetch_events_from(SEARCH_RELAYS, filter, timeout) + .await + { + // Process to verify the search results + for event in events.into_iter() { + if processed.contains(&event.pubkey) { + continue; + } + processed.insert(event.pubkey); + + let metadata = Metadata::from_json(event.content).unwrap_or_default(); + + // Skip if NIP-05 is not found + let Some(target) = metadata.nip05.as_ref() else { + continue; + }; + + // Skip if NIP-05 is not valid or failed to verify + if !nip05_verify(event.pubkey, target).await.unwrap_or(false) { + continue; + }; + + if let Ok(room) = Self::create_temp_room(identity, event.pubkey).await { + rooms.insert(room); + } + } + } + + rooms + } + fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { cx.spawn_in(window, async move |this, cx| { cx.update(|window, cx| { @@ -149,97 +216,70 @@ impl Sidebar { }) } - fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context) { + fn search_by_nip50( + &mut self, + query: &str, + rx: smol::channel::Receiver<()>, + window: &mut Window, + cx: &mut Context, + ) { + let Some(identity) = Identity::read_global(cx).public_key() else { + // User is not logged in. Stop searching + self.set_finding(false, window, cx); + self.set_cancel_handle(None, cx); + return; + }; + let query = query.to_owned(); let query_cloned = query.clone(); - let task: Task, Error>> = cx.background_spawn(async move { - let client = nostr_client(); - - let filter = Filter::new() - .kind(Kind::Metadata) - .search(query.to_lowercase()) - .limit(FIND_LIMIT); - - let events = client - .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(); - - // Process to verify the search results - if !events.is_empty() { - let (tx, rx) = smol::channel::bounded::(events.len()); - - nostr_sdk::async_utility::task::spawn(async move { - let signer = client.signer().await.unwrap(); - let public_key = signer.get_public_key().await.unwrap(); - - for event in events.into_iter() { - let metadata = Metadata::from_json(event.content).unwrap_or_default(); - - let Some(target) = metadata.nip05.as_ref() else { - // Skip if NIP-05 is not found - continue; - }; - - if !nip05_verify(event.pubkey, target).await.unwrap_or(false) { - // Skip if NIP-05 is not valid or failed to verify - 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); - } - } - - Ok(rooms) - }); + let task = smol::future::or( + Tokio::spawn(cx, async move { + let rooms = Self::nip50(identity, &query).await; + Some(rooms) + }), + Tokio::spawn(cx, async move { + let _ = rx.recv().await.is_ok(); + None + }), + ); cx.spawn_in(window, async move |this, cx| { match task.await { - Ok(result) => { + Ok(Some(results)) => { cx.update(|window, cx| { this.update(cx, |this, cx| { - if result.is_empty() { - window.push_notification( - Notification::info(t!("sidebar.empty", query = query_cloned)), - cx, - ); - this.set_finding(false, cx); - } else { - this.global_result( - result - .into_iter() - .map(|room| cx.new(|_| room)) - .collect_vec(), - cx, - ); + let msg = t!("sidebar.empty", query = query_cloned); + let rooms = results.into_iter().map(|r| cx.new(|_| r)).collect_vec(); + + if rooms.is_empty() { + window.push_notification(msg, cx); } + + this.results(rooms, true, window, cx); }) .ok(); }) .ok(); } + // User cancelled the search + Ok(None) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.set_finding(false, window, cx); + this.set_cancel_handle(None, cx); + }) + }) + .ok(); + } + // Async task failed Err(e) => { cx.update(|window, cx| { - window.push_notification(Notification::error(e.to_string()), cx); + this.update(cx, |this, cx| { + window.push_notification(e.to_string(), cx); + this.set_finding(false, window, cx); + this.set_cancel_handle(None, cx); + }) }) .ok(); } @@ -248,63 +288,110 @@ impl Sidebar { .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(t!("common.pubkey_invalid"), cx); - self.set_finding(false, cx); + fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context) { + let Some(identity) = Identity::read_global(cx).public_key() else { + // User is not logged in. Stop searching + self.set_finding(false, window, cx); + self.set_cancel_handle(None, cx); return; }; - let task: Task> = cx.background_spawn(async move { + let address = query.to_owned(); + + let task = Tokio::spawn(cx, async move { let client = nostr_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)) + if let Ok(profile) = common::nip05::nip05_profile(&address).await { + let public_key = profile.public_key; + // Request for user metadata + Self::request_metadata(client, public_key).await.ok(); + // Return a temporary room + Self::create_temp_room(identity, public_key).await + } else { + Err(anyhow!(t!("sidebar.addr_error"))) + } }); cx.spawn_in(window, async move |this, cx| { match task.await { - Ok((profile, room)) => { - this.update(cx, |this, cx| { - let chats = Registry::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(Ok(room)) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.results(vec![cx.new(|_| room)], true, window, cx); + }) + .ok(); + }) + .ok(); + } + Ok(Err(e)) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + window.push_notification(e.to_string(), cx); + this.set_cancel_handle(None, cx); + this.set_finding(false, window, cx); + }) }) .ok(); } Err(e) => { cx.update(|window, cx| { - window.push_notification(Notification::error(e.to_string()), cx); + this.update(cx, |this, cx| { + window.push_notification(e.to_string(), cx); + this.set_cancel_handle(None, cx); + this.set_finding(false, window, cx); + }) + }) + .ok(); + } + }; + }) + .detach(); + } + + fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context) { + let Ok(public_key) = common::parse_pubkey_from_str(query) else { + window.push_notification(t!("common.pubkey_invalid"), cx); + self.set_finding(false, window, cx); + return; + }; + + let Some(identity) = Identity::read_global(cx).public_key() else { + // User is not logged in. Stop searching + self.set_finding(false, window, cx); + return; + }; + + let task: Task> = cx.background_spawn(async move { + let client = nostr_client(); + + // Request metadata for this user + Self::request_metadata(client, public_key).await?; + + // Create a gift wrap event to represent as room + Self::create_temp_room(identity, public_key).await + }); + + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(room) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + let registry = Registry::read_global(cx); + let result = registry.search_by_public_key(public_key, cx); + + if !result.is_empty() { + this.results(result, false, window, cx); + } else { + this.results(vec![cx.new(|_| room)], true, window, cx); + } + }) + .ok(); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(e.to_string(), cx); }) .ok(); } @@ -314,82 +401,119 @@ impl Sidebar { } fn search(&mut self, window: &mut Window, cx: &mut Context) { - let query = self.find_input.read(cx).value().to_string(); + let (tx, rx) = smol::channel::bounded::<()>(1); + let tx_clone = tx.clone(); + + // 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 { - window.push_notification(t!("sidebar.search_in_progress"), cx); - return; + if self.cancel_handle.read(cx).is_none() { + window.push_notification(t!("sidebar.search_in_progress"), cx); + return; + } else { + // This is a hack to cancel ongoing search request + cx.background_spawn(async move { + tx.send(()).await.ok(); + }) + .detach(); + } } - // Return if the query is empty - if query.is_empty() { - window.push_notification(t!("sidebar.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(t!("sidebar.not_support"), cx); - return; - } + 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, cx); + self.set_finding(true, window, cx); - // Process to search by user if query starts with npub or nprofile + // Process to search by pubkey if query starts with npub or nprofile if query.starts_with("npub1") || query.starts_with("nprofile1") { - self.search_by_user(&query, window, cx); + self.search_by_pubkey(&query, window, cx); return; }; - let chats = Registry::global(cx); - let result = chats.read(cx).search(&query, cx); + // 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; + } + } - 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); + let chats = Registry::read_global(cx); + // Get all local results with current query + let local_results = chats.search(&query, cx); + + if !local_results.is_empty() { + // Try to update with local results first + self.results(local_results, false, window, cx); } else { - self.local_result(result, cx); + // If no local results, try global search via NIP-50 + self.set_cancel_handle(Some(tx_clone), cx); + self.search_by_nip50(&query, rx, window, cx); } } - fn global_result(&mut self, rooms: Vec>, cx: &mut Context) { + fn results( + &mut self, + rooms: Vec>, + global: bool, + window: &mut Window, + cx: &mut Context, + ) { if self.finding { - self.set_finding(false, cx); + self.set_finding(false, window, 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); + if self.cancel_handle.read(cx).is_some() { + self.set_cancel_handle(None, cx); } - self.local_result.update(cx, |this, cx| { - *this = Some(rooms); - cx.notify(); - }); + if !rooms.is_empty() { + if global { + self.global_result.update(cx, |this, cx| { + *this = Some(rooms); + cx.notify(); + }); + } else { + self.local_result.update(cx, |this, cx| { + *this = Some(rooms); + cx.notify(); + }); + } + } } - fn set_finding(&mut self, status: bool, cx: &mut Context) { + fn set_finding(&mut self, status: bool, _window: &mut Window, 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); }); + + cx.notify(); } - fn clear_search_results(&mut self, cx: &mut Context) { + fn set_cancel_handle( + &mut self, + handle: Option>, + cx: &mut Context, + ) { + self.cancel_handle.update(cx, |this, cx| { + *this = handle; + cx.notify(); + }); + } + + fn clear_search_results(&mut self, window: &mut Window, cx: &mut Context) { // Reset the input state if self.finding { - self.set_finding(false, cx); + self.set_finding(false, window, cx); } // Clear all local results @@ -440,7 +564,7 @@ impl Sidebar { }; // Clear all search results - self.clear_search_results(cx); + self.clear_search_results(window, cx); room }; @@ -617,13 +741,14 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let registry = Registry::read_global(cx); - let profile = Identity::read_global(cx) .public_key() .map(|pk| registry.get_person(&pk, cx)); // Get rooms from either search results or the chat registry - let rooms = if let Some(results) = self.local_result.read(cx) { + let rooms = if let Some(results) = self.local_result.read(cx).as_ref() { + results.to_owned() + } else if let Some(results) = self.global_result.read(cx).as_ref() { results.to_owned() } else { #[allow(clippy::collapsible_else_if)] @@ -647,37 +772,27 @@ impl Render for Sidebar { }) // Search Input .child( - div().px_3().w_full().h_7().flex_none().child( - TextInput::new(&self.find_input).small().suffix( - Button::new("find") - .icon(IconName::Search) - .tooltip(t!("sidebar.press_enter_to_search")) - .transparent() - .small(), + div() + .relative() + .px_3() + .w_full() + .h_7() + .flex_none() + .flex() + .child( + TextInput::new(&self.find_input) + .small() + .cleanable() + .appearance(true) + .suffix( + Button::new("find") + .icon(IconName::Search) + .tooltip(t!("sidebar.press_enter_to_search")) + .transparent() + .small(), + ), ), - ), ) - // Global Search Results - .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.iter().enumerate() { - let this = room.read(cx); - let id = this.id; - let label = this.display_name(cx); - let img = this.display_image(cx); - - let handler = cx.listener(move |this, _, window, cx| { - this.open_room(id, window, cx); - }); - - items.push(DisplayRoom::new(ix).img(img).label(label).on_click(handler)) - } - - items - })) - }) // Chat Rooms .child( div() diff --git a/crates/ui/src/input/clear_button.rs b/crates/ui/src/input/clear_button.rs index cb1cfb6..7874a82 100644 --- a/crates/ui/src/input/clear_button.rs +++ b/crates/ui/src/input/clear_button.rs @@ -1,4 +1,5 @@ use gpui::{App, Styled}; +use i18n::t; use theme::ActiveTheme; use crate::button::{Button, ButtonVariants as _}; @@ -8,7 +9,8 @@ use crate::{Icon, IconName, Sizable as _}; pub(crate) fn clear_button(cx: &App) -> Button { Button::new("clean") .icon(Icon::new(IconName::CloseCircle)) - .ghost() - .xsmall() + .tooltip(t!("common.clear")) + .small() .text_color(cx.theme().text_muted) + .transparent() } diff --git a/locales/app.yml b/locales/app.yml index 5dd9828..019b899 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -101,6 +101,16 @@ common: es: "Copiado" pt: "Copiado" ko: "복사됨" + clear: + en: "Clear" + zh-CN: "清除" + zh-TW: "清除" + ru: "Очистить" + vi: "Xóa" + ja: "クリア" + es: "Limpiar" + pt: "Limpar" + ko: "지우기" welcome: title: @@ -977,26 +987,16 @@ sidebar: es: "Hay otra búsqueda en curso" pt: "Outra pesquisa está em andamento" ko: "다른 검색이 진행 중입니다" - empty_query: - en: "Cannot search with an empty query" - zh-CN: "无法使用空查询进行搜索" - zh-TW: "無法使用空查詢進行搜尋" - ru: "Невозможно выполнить поиск с пустым запросом" - vi: "Không thể tìm kiếm với truy vấn rỗng" - ja: "空のクエリで検索することはできません" - es: "No se puede buscar con una consulta vacía" - pt: "Não é possível pesquisar com uma consulta vazia" - ko: "빈 쿼리로 검색할 수 없습니다" - not_support: - en: "Coop does not support searching with this query" - zh-CN: "Coop 不支持使用此查询进行搜索" - zh-TW: "Coop 不支援使用此查詢進行搜尋" - ru: "Coop не поддерживает поиск с этим запросом" - vi: "Coop không hỗ trợ tìm kiếm với truy vấn này" - ja: "Coopはこのクエリで検索をサポートしていません" - es: "Coop no admite buscar con esta consulta" - pt: "Coop não suporta pesquisar com esta consulta" - ko: "Coop은 이 쿼리로 검색을 지원하지 않습니다" + addr_error: + en: "Failed to get profile via address" + zh-CN: "通过地址获取资料失败" + zh-TW: "透過地址獲取資料失敗" + ru: "Не удалось получить профиль по адресу" + vi: "Không thể lấy thông tin theo địa chỉ" + ja: "アドレスからプロフィールを取得できませんでした" + es: "No se pudo obtener el perfil a través de la dirección" + pt: "Falha ao obter perfil pelo endereço" + ko: "주소를 통해 프로필을 가져오지 못했습니다" direct_messages: en: "Direct Messages" zh-CN: "私信"