feat: refine the search bar (#207)

* update deps

* refactor the search cancellation

* .

* .
This commit is contained in:
reya
2025-11-22 07:25:08 +07:00
committed by GitHub
parent a6e00b47d8
commit 14c36e4731
11 changed files with 271 additions and 344 deletions

View File

@@ -11,7 +11,11 @@ pub const BOOTSTRAP_RELAYS: [&str; 5] = [
];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
pub const SEARCH_RELAYS: [&str; 3] = [
"wss://relay.nostr.band",
"wss://search.nos.today",
"wss://relay.noswhere.com",
];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";

View File

@@ -35,8 +35,8 @@ use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt};
use crate::actions::{reset, DarkMode, KeyringPopup, Logout, Settings, ViewProfile, ViewRelays};
use crate::user::viewer;
use crate::views::compose::compose_button;
use crate::views::{onboarding, preferences, setup_relay, sidebar, startup, welcome};
use crate::{login, new_identity, user};
use crate::views::{onboarding, preferences, setup_relay, startup, welcome};
use crate::{login, new_identity, sidebar, user};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
cx.new(|cx| ChatSpace::new(window, cx))

View File

@@ -111,7 +111,7 @@ impl Login {
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectURI::parse(content) else {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error(t!("login.bunker_invalid"), cx);
return;
};
@@ -165,7 +165,7 @@ impl Login {
fn save_connection(
&mut self,
keys: &Keys,
uri: &NostrConnectURI,
uri: &NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {

View File

@@ -15,6 +15,7 @@ mod actions;
mod chatspace;
mod login;
mod new_identity;
mod sidebar;
mod user;
mod views;

View File

@@ -1,7 +1,7 @@
use std::collections::BTreeSet;
use std::ops::Range;
use std::time::Duration;
use account::Account;
use anyhow::{anyhow, Error};
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
@@ -13,7 +13,6 @@ use gpui::{
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
use itertools::Itertools;
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use settings::AppSettings;
@@ -31,42 +30,43 @@ use crate::actions::{RelayStatus, Reload};
mod list_item;
const FIND_DELAY: u64 = 600;
const FIND_LIMIT: usize = 10;
const FIND_LIMIT: usize = 20;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
Sidebar::new(window, cx)
cx.new(|cx| Sidebar::new(window, cx))
}
pub struct Sidebar {
name: SharedString,
// Search
/// Focus handle for the sidebar
focus_handle: FocusHandle,
/// Image cache
image_cache: Entity<RetainAllImageCache>,
/// Search results
search_results: Entity<Option<Vec<Entity<Room>>>>,
/// Async search operation
search_task: Option<Task<()>>,
find_input: Entity<InputState>,
find_debouncer: DebouncedDelay<Self>,
finding: bool,
cancel_handle: Entity<Option<smol::channel::Sender<()>>>,
local_result: Entity<Option<Vec<Entity<Room>>>>,
global_result: Entity<Option<Vec<Entity<Room>>>>,
// Rooms
indicator: Entity<Option<RoomKind>>,
active_filter: Entity<RoomKind>,
// GPUI
focus_handle: FocusHandle,
image_cache: Entity<RetainAllImageCache>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 3]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>,
}
impl Sidebar {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let active_filter = cx.new(|_| RoomKind::Ongoing);
let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None);
let cancel_handle = cx.new(|_| None);
let search_results = cx.new(|_| None);
let find_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("sidebar.search_label")));
@@ -99,11 +99,13 @@ impl Sidebar {
// Subscribe for find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => this.search(window, cx),
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
// Clear the result when input is empty
if state.read(cx).value().is_empty() {
this.clear_search_results(window, cx);
this.clear(window, cx);
} else {
// Run debounced search
this.find_debouncer.fire_new(
@@ -125,62 +127,65 @@ impl Sidebar {
image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(),
finding: false,
cancel_handle,
indicator,
active_filter,
find_input,
local_result,
global_result,
subscriptions,
search_results,
search_task: None,
_subscriptions: subscriptions,
}
}
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];
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(())
}
async fn nip50(client: &Client, query: &str) -> Result<BTreeSet<Room>, Error> {
async fn nip50(client: &Client, query: &str) -> Result<Vec<Event>, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let timeout = Duration::from_secs(2);
let mut rooms: BTreeSet<Room> = 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().unique_by(|event| event.pubkey) {
// Skip if author is match current user
if event.pubkey == public_key {
continue;
}
let mut stream = client
.stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
.await?;
// Request metadata event's author
Self::request_metadata(client, event.pubkey).await?;
let mut results: Vec<Event> = Vec::with_capacity(FIND_LIMIT);
// Construct room
let room = Room::new(None, public_key, vec![event.pubkey]);
rooms.insert(room);
while let Some(event) = stream.next().await {
// Skip if author is match current user
if event.pubkey == public_key {
continue;
}
// Skip if the event has already been added
if results.iter().any(|this| this.pubkey == event.pubkey) {
continue;
}
results.push(event);
}
Ok(rooms)
if results.is_empty() {
return Err(anyhow!("No results for query {query}"));
}
// Get all public keys
let public_keys: Vec<PublicKey> = results.iter().map(|event| event.pubkey).collect();
// Fetch metadata and contact lists if public keys is not empty
if !public_keys.is_empty() {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList])
.limit(public_keys.len() * 2)
.authors(public_keys);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
}
Ok(results)
}
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
@@ -192,63 +197,38 @@ impl Sidebar {
})
}
fn search_by_nip50(
&mut self,
query: &str,
rx: smol::channel::Receiver<()>,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let account = Account::global(cx);
let public_key = account.read(cx).public_key();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let query = query.to_owned();
let query_cloned = query.clone();
let task = smol::future::or(
Tokio::spawn(cx, async move { Self::nip50(&client, &query).await.ok() }),
Tokio::spawn(cx, async move {
let _ = rx.recv().await.is_ok();
None
}),
);
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = Self::nip50(&client, &query).await;
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(Some(results)) => {
this.update_in(cx, |this, window, cx| {
let msg = t!("sidebar.empty", query = query_cloned);
let rooms = results.into_iter().map(|r| cx.new(|_| r)).collect_vec();
this.update_in(cx, |this, window, cx| {
match result {
Ok(results) => {
let rooms = results
.into_iter()
.map(|event| {
cx.new(|_| Room::new(None, public_key, vec![event.pubkey]))
})
.collect();
if rooms.is_empty() {
window.push_notification(msg, cx);
}
this.results(rooms, true, window, cx);
})
.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) => {
this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
this.set_finding(false, window, cx);
this.set_cancel_handle(None, cx);
})
.ok();
}
};
})
.detach();
}
};
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -261,7 +241,8 @@ impl Sidebar {
Ok(profile) => {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let room = Room::new(None, public_key, vec![profile.public_key]);
let receivers = vec![profile.public_key];
let room = Room::new(None, public_key, receivers);
Ok(room)
}
@@ -269,40 +250,25 @@ impl Sidebar {
}
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(Ok(room)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.results(vec![cx.new(|_| room)], true, window, cx);
})
.ok();
})
.ok();
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(room)) => {
this.set_results(vec![cx.new(|_| room)], cx);
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
}
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| {
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();
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -310,7 +276,7 @@ impl Sidebar {
let client = nostr.read(cx).client();
let Ok(public_key) = query.to_public_key() else {
window.push_notification(t!("common.pubkey_invalid"), cx);
window.push_notification("Public Key is invalid", cx);
self.set_finding(false, window, cx);
return;
};
@@ -318,46 +284,49 @@ impl Sidebar {
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let author = signer.get_public_key().await?;
let room = Room::new(None, author, vec![public_key]);
Self::request_metadata(&client, public_key).await?;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let receivers = vec![public_key];
let room = Room::new(None, author, receivers);
let filter = Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList])
.author(public_key)
.limit(2);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(room)
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(room) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
let chat = ChatRegistry::global(cx);
let result = chat.read(cx).search_by_public_key(public_key, cx);
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
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| {
this.update_in(cx, |this, window, cx| {
match result {
Ok(room) => {
let chat = ChatRegistry::global(cx);
let local_results = chat.read(cx).search_by_public_key(public_key, cx);
if !local_results.is_empty() {
this.set_results(local_results, cx);
} else {
this.set_results(vec![cx.new(|_| room)], cx);
}
}
Err(e) => {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
})
.detach();
}
};
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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;
@@ -365,15 +334,12 @@ impl Sidebar {
// Return if search is in progress
if self.finding {
if self.cancel_handle.read(cx).is_none() {
window.push_notification(t!("sidebar.search_in_progress"), cx);
if self.search_task.is_none() {
window.push_notification("There is another 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();
// Cancel ongoing search request
self.search_task = None;
}
}
@@ -398,48 +364,25 @@ impl Sidebar {
}
}
let chat = ChatRegistry::global(cx);
// Get all local results with current query
let chat = ChatRegistry::global(cx);
let local_results = chat.read(cx).search(&query, cx);
// Try to update with local results first
if !local_results.is_empty() {
// Try to update with local results first
self.results(local_results, false, window, cx);
} else {
// 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);
}
self.set_results(local_results, cx);
return;
};
// If no local results, try global search via NIP-50
self.search_by_nip50(&query, window, cx);
}
fn results(
&mut self,
rooms: Vec<Entity<Room>>,
global: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.finding {
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| {
*this = Some(rooms);
cx.notify();
});
} else {
self.local_result.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
}
}
fn set_results(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
self.search_results.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
}
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
@@ -453,31 +396,14 @@ impl Sidebar {
cx.notify();
}
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>) {
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Reset the input state
if self.finding {
self.set_finding(false, window, 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| {
self.search_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
@@ -499,10 +425,11 @@ impl Sidebar {
}
fn open_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
let room = if let Some(room) = ChatRegistry::global(cx).read(cx).room(&id, cx) {
let chat = ChatRegistry::global(cx);
let room = if let Some(room) = chat.read(cx).room(&id, cx) {
room
} else {
let Some(result) = self.global_result.read(cx).as_ref() else {
let Some(result) = self.search_results.read(cx).as_ref() else {
window.push_notification(t!("common.room_error"), cx);
return;
};
@@ -513,12 +440,12 @@ impl Sidebar {
};
// Clear all search results
self.clear_search_results(window, cx);
self.clear(window, cx);
room
};
ChatRegistry::global(cx).update(cx, |this, cx| {
chat.update(cx, |this, cx| {
this.push_room(room, cx);
});
}
@@ -673,12 +600,10 @@ impl Render for Sidebar {
let loading = chat.read(cx).loading;
// Get rooms from either search results or the chat registry
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() {
let rooms = if let Some(results) = self.search_results.read(cx).as_ref() {
results.to_owned()
} else {
#[allow(clippy::collapsible_else_if)]
// Filter rooms based on the active filter
if self.active_filter.read(cx) == &RoomKind::Ongoing {
chat.read(cx).ongoing_rooms(cx)
} else {
@@ -717,13 +642,19 @@ impl Render for Sidebar {
.cleanable()
.appearance(true)
.text_xs()
.suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip(t!("sidebar.search_tooltip"))
.transparent()
.small(),
),
.map(|this| {
if !self.find_input.read(cx).loading {
this.suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip(t!("sidebar.search_tooltip"))
.transparent()
.small(),
)
} else {
this
}
}),
),
)
// Chat Rooms

View File

@@ -3,6 +3,5 @@ pub mod onboarding;
pub mod preferences;
pub mod screening;
pub mod setup_relay;
pub mod sidebar;
pub mod startup;
pub mod welcome;

View File

@@ -78,7 +78,7 @@ impl Onboarding {
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let uri = NostrConnectURI::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let qr_code = uri.to_string().to_qr();
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
@@ -119,7 +119,7 @@ impl Onboarding {
fn save_connection(
&mut self,
uri: &NostrConnectURI,
uri: &NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {

View File

@@ -76,7 +76,7 @@ impl Startup {
// Try to login with bunker
if secret.starts_with("bunker://") {
match NostrConnectURI::parse(secret) {
match NostrConnectUri::parse(secret) {
Ok(uri) => {
self.login_with_bunker(uri, window, cx);
}
@@ -102,7 +102,7 @@ impl Startup {
fn login_with_bunker(
&mut self,
uri: NostrConnectURI,
uri: NostrConnectUri,
window: &mut Window,
cx: &mut Context<Self>,
) {

View File

@@ -69,9 +69,15 @@ impl NostrRegistry {
timeout: Duration::from_secs(600),
});
// Construct the lmdb
let lmdb = cx.background_executor().block(async move {
let path = config_dir().join("nostr");
NostrLMDB::open(path)
.await
.expect("Failed to initialize database")
});
// Construct the nostr client
let path = config_dir().join("nostr");
let lmdb = NostrLMDB::open(path).expect("Failed to initialize database");
let client = ClientBuilder::default().database(lmdb).opts(opts).build();
let tracker = Arc::new(RwLock::new(EventTracker::default()));