chore: improve search (#83)

* .

* .

* wip: add nip05 search

* add nip05 search

* .

* support cancel search

* .
This commit is contained in:
reya
2025-07-10 09:03:54 +07:00
committed by GitHub
parent 8bfad30a99
commit 2e3a4b3634
10 changed files with 377 additions and 291 deletions

40
Cargo.lock generated
View File

@@ -521,7 +521,7 @@ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"cexpr", "cexpr",
"clang-sys", "clang-sys",
"itertools 0.12.1", "itertools 0.11.0",
"lazy_static", "lazy_static",
"lazycell", "lazycell",
"log", "log",
@@ -544,7 +544,7 @@ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"cexpr", "cexpr",
"clang-sys", "clang-sys",
"itertools 0.13.0", "itertools 0.11.0",
"log", "log",
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
@@ -1075,7 +1075,7 @@ dependencies = [
[[package]] [[package]]
name = "collections" name = "collections"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
@@ -1176,6 +1176,7 @@ dependencies = [
"futures", "futures",
"global", "global",
"gpui", "gpui",
"gpui_tokio",
"i18n", "i18n",
"identity", "identity",
"itertools 0.13.0", "itertools 0.13.0",
@@ -1462,7 +1463,7 @@ dependencies = [
[[package]] [[package]]
name = "derive_refineable" name = "derive_refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2308,7 +2309,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
@@ -2400,7 +2401,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macros" name = "gpui_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -2409,6 +2410,17 @@ dependencies = [
"workspace-hack", "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]] [[package]]
name = "grid" name = "grid"
version = "0.13.0" version = "0.13.0"
@@ -2623,7 +2635,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client" name = "http_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2640,7 +2652,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client_tls" name = "http_client_tls"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"rustls", "rustls",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -3417,7 +3429,7 @@ dependencies = [
[[package]] [[package]]
name = "media" name = "media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen 0.71.1", "bindgen 0.71.1",
@@ -4722,7 +4734,7 @@ dependencies = [
[[package]] [[package]]
name = "refineable" name = "refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"derive_refineable", "derive_refineable",
"workspace-hack", "workspace-hack",
@@ -4875,7 +4887,7 @@ dependencies = [
[[package]] [[package]]
name = "reqwest_client" name = "reqwest_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -5357,7 +5369,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
[[package]] [[package]]
name = "semantic_version" name = "semantic_version"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@@ -5733,7 +5745,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sum_tree" name = "sum_tree"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"log", "log",
@@ -6642,7 +6654,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "util" name = "util"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" source = "git+https://github.com/zed-industries/zed#df57754baf64b118972ee3c42f68a0acc23989ff"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-fs", "async-fs",

View File

@@ -18,6 +18,7 @@ i18n = { path = "crates/i18n" }
# GPUI # GPUI
gpui = { git = "https://github.com/zed-industries/zed" } 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" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr # Nostr

View File

@@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow;
use gpui::{Image, ImageFormat}; use gpui::{Image, ImageFormat};
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -32,6 +33,16 @@ pub fn room_hash(event: &Event) -> u64 {
hasher.finish() hasher.finish()
} }
pub fn parse_pubkey_from_str(content: &str) -> Result<PublicKey, anyhow::Error> {
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<Arc<Image>> { pub fn string_to_qr(data: &str) -> Option<Arc<Image>> {
let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256) else { let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256) else {
return None; return None;

View File

@@ -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()
}
}

View File

@@ -22,6 +22,7 @@ auto_update = { path = "../auto_update" }
rust-i18n.workspace = true rust-i18n.workspace = true
i18n.workspace = true i18n.workspace = true
gpui.workspace = true gpui.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true reqwest_client.workspace = true
nostr-connect.workspace = true nostr-connect.workspace = true

View File

@@ -234,6 +234,8 @@ fn main() {
// Root Entity // Root Entity
cx.new(|cx| { cx.new(|cx| {
cx.activate(true); cx.activate(true);
// Initialize the tokio runtime
gpui_tokio::init(cx);
// Initialize components // Initialize components
ui::init(cx); ui::init(cx);
// Initialize app registry // Initialize app registry

View File

@@ -147,16 +147,6 @@ impl Compose {
Ok(()) Ok(())
} }
fn parse_pubkey(content: &str) -> Result<PublicKey, Error> {
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<Self>) { pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let public_keys: Vec<PublicKey> = self.selected(cx); let public_keys: Vec<PublicKey> = self.selected(cx);
@@ -288,7 +278,7 @@ impl Compose {
Err(anyhow!(t!("common.not_found"))) 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 { cx.background_spawn(async move {
let client = nostr_client(); let client = nostr_client();
let contact = Contact::new(public_key).select(); let contact = Contact::new(public_key).select();

View File

@@ -2,12 +2,12 @@ use std::collections::BTreeSet;
use std::ops::Range; use std::ops::Range;
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::{anyhow, Error};
use common::debounced_delay::DebouncedDelay; use common::debounced_delay::DebouncedDelay;
use common::display::DisplayProfile; use common::display::DisplayProfile;
use common::nip05::nip05_verify; use common::nip05::nip05_verify;
use element::DisplayRoom; 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 global::nostr_client;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -16,6 +16,7 @@ use gpui::{
Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription,
Task, Window, Task, Window,
}; };
use gpui_tokio::Tokio;
use i18n::t; use i18n::t;
use identity::Identity; use identity::Identity;
use itertools::Itertools; use itertools::Itertools;
@@ -30,7 +31,6 @@ use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::popup_menu::PopupMenu; use ui::popup_menu::PopupMenu;
use ui::skeleton::Skeleton; use ui::skeleton::Skeleton;
use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt}; use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt};
@@ -52,6 +52,7 @@ pub struct Sidebar {
find_input: Entity<InputState>, find_input: Entity<InputState>,
find_debouncer: DebouncedDelay<Self>, find_debouncer: DebouncedDelay<Self>,
finding: bool, finding: bool,
cancel_handle: Entity<Option<smol::channel::Sender<()>>>,
local_result: Entity<Option<Vec<Entity<Room>>>>, local_result: Entity<Option<Vec<Entity<Room>>>>,
global_result: Entity<Option<Vec<Entity<Room>>>>, global_result: Entity<Option<Vec<Entity<Room>>>>,
// Rooms // Rooms
@@ -75,6 +76,7 @@ impl Sidebar {
let indicator = cx.new(|_| None); let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None); let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None); let global_result = cx.new(|_| None);
let cancel_handle = cx.new(|_| None);
let find_input = cx.new(|cx| { let find_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("sidebar.find_or_start_conversation")) InputState::new(window, cx).placeholder(t!("sidebar.find_or_start_conversation"))
@@ -105,7 +107,7 @@ impl Sidebar {
InputEvent::Change(text) => { InputEvent::Change(text) => {
// Clear the result when input is empty // Clear the result when input is empty
if text.is_empty() { if text.is_empty() {
this.clear_search_results(cx); this.clear_search_results(window, cx);
} else { } else {
// Run debounced search // Run debounced search
this.find_debouncer.fire_new( this.find_debouncer.fire_new(
@@ -128,6 +130,7 @@ impl Sidebar {
find_debouncer: DebouncedDelay::new(), find_debouncer: DebouncedDelay::new(),
finding: false, finding: false,
trusted_only: false, trusted_only: false,
cancel_handle,
indicator, indicator,
active_filter, active_filter,
find_input, 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<Room, Error> {
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<Room> {
let client = nostr_client();
let timeout = Duration::from_secs(2);
let mut rooms: BTreeSet<Room> = BTreeSet::new();
let mut processed: BTreeSet<PublicKey> = 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<Self>) -> Task<()> { fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
cx.update(|window, 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<Self>) { fn search_by_nip50(
&mut self,
query: &str,
rx: smol::channel::Receiver<()>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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 = query.to_owned();
let query_cloned = query.clone(); let query_cloned = query.clone();
let task: Task<Result<BTreeSet<Room>, Error>> = cx.background_spawn(async move { let task = smol::future::or(
let client = nostr_client(); Tokio::spawn(cx, async move {
let rooms = Self::nip50(identity, &query).await;
let filter = Filter::new() Some(rooms)
.kind(Kind::Metadata) }),
.search(query.to_lowercase()) Tokio::spawn(cx, async move {
.limit(FIND_LIMIT); let _ = rx.recv().await.is_ok();
None
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::<Room>(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)
});
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
match task.await { match task.await {
Ok(result) => { Ok(Some(results)) => {
cx.update(|window, cx| { cx.update(|window, cx| {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
if result.is_empty() { let msg = t!("sidebar.empty", query = query_cloned);
window.push_notification( let rooms = results.into_iter().map(|r| cx.new(|_| r)).collect_vec();
Notification::info(t!("sidebar.empty", query = query_cloned)),
cx, if rooms.is_empty() {
); window.push_notification(msg, cx);
this.set_finding(false, cx);
} else {
this.global_result(
result
.into_iter()
.map(|room| cx.new(|_| room))
.collect_vec(),
cx,
);
} }
this.results(rooms, true, window, cx);
}) })
.ok(); .ok();
}) })
.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) => { Err(e) => {
cx.update(|window, cx| { 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(); .ok();
} }
@@ -248,63 +288,110 @@ impl Sidebar {
.detach(); .detach();
} }
fn search_by_user(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) { fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let public_key = if query.starts_with("npub1") { let Some(identity) = Identity::read_global(cx).public_key() else {
PublicKey::parse(query).ok() // User is not logged in. Stop searching
} else if query.starts_with("nprofile1") { self.set_finding(false, window, cx);
Nip19Profile::from_bech32(query) self.set_cancel_handle(None, cx);
.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);
return; return;
}; };
let task: Task<Result<(Profile, Room), Error>> = cx.background_spawn(async move { let address = query.to_owned();
let task = Tokio::spawn(cx, async move {
let client = nostr_client(); let client = nostr_client();
let signer = client.signer().await.unwrap();
let user_pubkey = signer.get_public_key().await.unwrap();
let metadata = client if let Ok(profile) = common::nip05::nip05_profile(&address).await {
.fetch_metadata(public_key, Duration::from_secs(3)) let public_key = profile.public_key;
.await? // Request for user metadata
.unwrap_or_default(); Self::request_metadata(client, public_key).await.ok();
// Return a temporary room
let event = EventBuilder::private_msg_rumor(public_key, "") Self::create_temp_room(identity, public_key).await
.build(user_pubkey) } else {
.sign(&Keys::generate()) Err(anyhow!(t!("sidebar.addr_error")))
.await?; }
let profile = Profile::new(public_key, metadata);
let room = Room::new(&event);
Ok((profile, room))
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
match task.await { match task.await {
Ok((profile, room)) => { Ok(Ok(room)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let chats = Registry::global(cx); this.results(vec![cx.new(|_| room)], true, window, cx);
let result = chats })
.read(cx) .ok();
.search_by_public_key(profile.public_key(), cx); })
.ok();
if !result.is_empty() {
this.local_result(result, cx);
} }
this.global_result(vec![cx.new(|_| room)], cx); 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(); .ok();
} }
Err(e) => { Err(e) => {
cx.update(|window, cx| { 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<Self>) {
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<Result<Room, Error>> = 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(); .ok();
} }
@@ -314,82 +401,119 @@ impl Sidebar {
} }
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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 // Return if search is in progress
if self.finding { if self.finding {
if self.cancel_handle.read(cx).is_none() {
window.push_notification(t!("sidebar.search_in_progress"), cx); window.push_notification(t!("sidebar.search_in_progress"), cx);
return; 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 let input = self.find_input.read(cx).value();
if query.is_empty() { let query = input.to_string();
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;
}
// Block the input until the search process completes // 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") { if query.starts_with("npub1") || query.starts_with("nprofile1") {
self.search_by_user(&query, window, cx); self.search_by_pubkey(&query, window, cx);
return; return;
}; };
let chats = Registry::global(cx); // Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld)
let result = chats.read(cx).search(&query, cx); 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() { let chats = Registry::read_global(cx);
// There are no current rooms matching this query, so proceed with global search via NIP-50 // Get all local results with current query
self.search_by_nip50(&query, window, cx); 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 { } 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<Entity<Room>>, cx: &mut Context<Self>) { fn results(
&mut self,
rooms: Vec<Entity<Room>>,
global: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.finding { if self.finding {
self.set_finding(false, cx); self.set_finding(false, window, cx);
} }
if self.cancel_handle.read(cx).is_some() {
self.set_cancel_handle(None, cx);
}
if !rooms.is_empty() {
if global {
self.global_result.update(cx, |this, cx| { self.global_result.update(cx, |this, cx| {
*this = Some(rooms); *this = Some(rooms);
cx.notify(); cx.notify();
}); });
} } else {
fn local_result(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
if self.finding {
self.set_finding(false, cx);
}
self.local_result.update(cx, |this, cx| { self.local_result.update(cx, |this, cx| {
*this = Some(rooms); *this = Some(rooms);
cx.notify(); cx.notify();
}); });
} }
}
}
fn set_finding(&mut self, status: bool, cx: &mut Context<Self>) { fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
self.finding = status; self.finding = status;
cx.notify();
// Disable the input to prevent duplicate requests // Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| { self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx); this.set_disabled(status, cx);
this.set_loading(status, cx); this.set_loading(status, cx);
}); });
cx.notify();
} }
fn clear_search_results(&mut self, cx: &mut Context<Self>) { fn set_cancel_handle(
&mut self,
handle: Option<smol::channel::Sender<()>>,
cx: &mut Context<Self>,
) {
self.cancel_handle.update(cx, |this, cx| {
*this = handle;
cx.notify();
});
}
fn clear_search_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Reset the input state // Reset the input state
if self.finding { if self.finding {
self.set_finding(false, cx); self.set_finding(false, window, cx);
} }
// Clear all local results // Clear all local results
@@ -440,7 +564,7 @@ impl Sidebar {
}; };
// Clear all search results // Clear all search results
self.clear_search_results(cx); self.clear_search_results(window, cx);
room room
}; };
@@ -617,13 +741,14 @@ impl Focusable for Sidebar {
impl Render for Sidebar { impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx); let registry = Registry::read_global(cx);
let profile = Identity::read_global(cx) let profile = Identity::read_global(cx)
.public_key() .public_key()
.map(|pk| registry.get_person(&pk, cx)); .map(|pk| registry.get_person(&pk, cx));
// Get rooms from either search results or the chat registry // 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() results.to_owned()
} else { } else {
#[allow(clippy::collapsible_else_if)] #[allow(clippy::collapsible_else_if)]
@@ -647,8 +772,19 @@ impl Render for Sidebar {
}) })
// Search Input // Search Input
.child( .child(
div().px_3().w_full().h_7().flex_none().child( div()
TextInput::new(&self.find_input).small().suffix( .relative()
.px_3()
.w_full()
.h_7()
.flex_none()
.flex()
.child(
TextInput::new(&self.find_input)
.small()
.cleanable()
.appearance(true)
.suffix(
Button::new("find") Button::new("find")
.icon(IconName::Search) .icon(IconName::Search)
.tooltip(t!("sidebar.press_enter_to_search")) .tooltip(t!("sidebar.press_enter_to_search"))
@@ -657,27 +793,6 @@ impl Render for Sidebar {
), ),
), ),
) )
// 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 // Chat Rooms
.child( .child(
div() div()

View File

@@ -1,4 +1,5 @@
use gpui::{App, Styled}; use gpui::{App, Styled};
use i18n::t;
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
@@ -8,7 +9,8 @@ use crate::{Icon, IconName, Sizable as _};
pub(crate) fn clear_button(cx: &App) -> Button { pub(crate) fn clear_button(cx: &App) -> Button {
Button::new("clean") Button::new("clean")
.icon(Icon::new(IconName::CloseCircle)) .icon(Icon::new(IconName::CloseCircle))
.ghost() .tooltip(t!("common.clear"))
.xsmall() .small()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.transparent()
} }

View File

@@ -101,6 +101,16 @@ common:
es: "Copiado" es: "Copiado"
pt: "Copiado" pt: "Copiado"
ko: "복사됨" ko: "복사됨"
clear:
en: "Clear"
zh-CN: "清除"
zh-TW: "清除"
ru: "Очистить"
vi: "Xóa"
ja: "クリア"
es: "Limpiar"
pt: "Limpar"
ko: "지우기"
welcome: welcome:
title: title:
@@ -977,26 +987,16 @@ sidebar:
es: "Hay otra búsqueda en curso" es: "Hay otra búsqueda en curso"
pt: "Outra pesquisa está em andamento" pt: "Outra pesquisa está em andamento"
ko: "다른 검색이 진행 중입니다" ko: "다른 검색이 진행 중입니다"
empty_query: addr_error:
en: "Cannot search with an empty query" en: "Failed to get profile via address"
zh-CN: "无法使用空查询进行搜索" zh-CN: "通过地址获取资料失败"
zh-TW: "無法使用空查詢進行搜尋" zh-TW: "透過地址獲取資料失敗"
ru: "Невозможно выполнить поиск с пустым запросом" ru: "Не удалось получить профиль по адресу"
vi: "Không thể tìm kiếm với truy vấn rỗng" vi: "Không thể lấy thông tin theo địa chỉ"
ja: "空のクエリで検索することはできません" ja: "アドレスからプロフィールを取得できませんでした"
es: "No se puede buscar con una consulta vacía" es: "No se pudo obtener el perfil a través de la dirección"
pt: "Não é possível pesquisar com uma consulta vazia" pt: "Falha ao obter perfil pelo endereço"
ko: "빈 쿼리로 검색할 수 없습니다" 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은 이 쿼리로 검색을 지원하지 않습니다"
direct_messages: direct_messages:
en: "Direct Messages" en: "Direct Messages"
zh-CN: "私信" zh-CN: "私信"