diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index f5a05c89..dc6fcd97 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -6,7 +6,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::TimestampExt; +use common::{DebouncedDelay, TimestampExt}; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, @@ -14,12 +14,13 @@ use gpui::{ Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px, relative, retain_all, uniform_list, }; +use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use state::{FIND_LIMIT, NostrRegistry, StateEvent}; +use state::{FIND_DELAY, FIND_LIMIT, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; @@ -69,6 +70,10 @@ pub enum SidebarRow { Contact { public_key: PublicKey, }, + /// A profile found by global search. + Profile { + public_key: PublicKey, + }, Message { hit: MessageHit, }, @@ -216,10 +221,20 @@ pub struct Sidebar { /// Whether the search bar is shown below the header. search_open: bool, + /// Whether searches also cover global (relay) profiles. + global_open: bool, + /// Debounced delay for the global search while typing. + find_debouncer: DebouncedDelay, /// Search input state. find_input: Entity, /// Search results: rooms and groups by name, then messages by content. results: Vec, + /// Whether a global (relay) search is in progress. + global_finding: bool, + /// Profiles found by the last global search, if one was triggered. + global_results: Option>, + /// The async global profile search. + global_task: Option>, /// Scroll state of the search results list. search_scroll: UniformListScrollHandle, @@ -235,7 +250,7 @@ impl Sidebar { let find_input = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Search chats, groups and messages") + .placeholder("Type to search") .clean_on_escape() }); @@ -280,13 +295,28 @@ impl Sidebar { subscriptions.push(cx.subscribe_in( &find_input, window, - |this, state, event, _window, cx| match event { - InputEvent::PressEnter { .. } => this.search(cx), + |this, state, event, window, cx| match event { + InputEvent::PressEnter { .. } => { + this.search(cx); + + if this.global_open { + this.global_search(cx); + } + } InputEvent::Change => { if state.read(cx).value().is_empty() { this.clear_results(cx); } else { this.search(cx); + + if this.global_open { + this.find_debouncer.fire_new( + Duration::from_millis(FIND_DELAY), + window, + cx, + |this, window, cx| this.debounced_global(window, cx), + ); + } } } _ => {} @@ -314,8 +344,13 @@ impl Sidebar { requests_scroll: UniformListScrollHandle::new(), new_requests: false, search_open: false, + global_open: false, + find_debouncer: DebouncedDelay::new(), find_input, results: Vec::new(), + global_finding: false, + global_results: None, + global_task: None, search_scroll: UniformListScrollHandle::new(), tasks: smallvec![], _subscriptions: subscriptions, @@ -518,6 +553,7 @@ impl Sidebar { self.find_input.update(cx, |input, cx| { input.set_value("", window, cx); }); + self.global_open = false; self.clear_results(cx); } @@ -566,9 +602,81 @@ impl Sidebar { cx.notify(); } + /// Search profiles on the indexer relays for the current query. + fn global_search(&mut self, cx: &mut Context) { + if !self.global_open { + return; + } + + let query = self.find_input.read(cx).value().trim().to_string(); + + if query.is_empty() { + return; + } + + let nostr = NostrRegistry::global(cx); + let find_profiles = nostr.read(cx).search(&query, cx); + + self.global_finding = true; + self.global_results = None; + cx.notify(); + + self.global_task = Some(cx.spawn(async move |this, cx| { + let result = find_profiles.await; + + this.update(cx, |this, cx| { + this.global_finding = false; + this.global_results = Some(match result { + Ok(profiles) => { + // Relays may return the same profile more than once + let mut seen = HashSet::new(); + profiles + .into_iter() + .filter(|key| seen.insert(*key)) + .collect() + } + Err(error) => { + log::debug!("Global search failed: {error}"); + Vec::new() + } + }); + cx.notify(); + }) + .ok(); + })); + } + + /// Trigger a global search once the input debounce delay has passed. + fn debounced_global(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, _window, cx| { + this.global_search(cx); + }) + .ok(); + }) + } + + /// Toggle whether searches also cover global (relay) profiles. + fn toggle_global(&mut self, cx: &mut Context) { + self.global_open = !self.global_open; + + if self.global_open { + self.global_search(cx); + } else { + self.global_finding = false; + self.global_results = None; + self.global_task = None; + } + + cx.notify(); + } + /// Discard the search results. fn clear_results(&mut self, cx: &mut Context) { self.results.clear(); + self.global_finding = false; + self.global_results = None; + self.global_task = None; cx.notify(); } @@ -630,6 +738,7 @@ impl Sidebar { Button::new("sidebar-search") .icon(IconName::Search) .tooltip("Search") + .selected(self.search_open) .ghost() .small() .on_click(cx.listener(|this, _event, window, cx| { @@ -707,20 +816,38 @@ impl Sidebar { fn render_find(&self, cx: &mut Context) -> AnyElement { h_flex() + .px_2() .flex_shrink_0() .w_full() - .px_2() .child( - Input::new(&self.find_input).small().text_xs().suffix( - Button::new("sidebar-search-close") - .icon(IconName::Close) - .tooltip("Close search") - .transparent() - .small() - .on_click(cx.listener(|this, _event, window, cx| { - this.toggle_search(window, cx); - })), - ), + h_flex() + .px_1p5() + .gap_1() + .w_full() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child( + Input::new(&self.find_input) + .appearance(false) + .small() + .text_xs(), + ) + .child( + Button::new("sidebar-global") + .icon(IconName::Compass) + .tooltip(if self.global_open { + "Disable global search" + } else { + "Enable global search" + }) + .small() + .ghost() + .selected(self.global_open) + .loading(self.global_finding) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_global(cx); + })), + ), ) .into_any_element() } @@ -789,6 +916,23 @@ impl Sidebar { rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit })); } + // Global profiles only appear once a global search was triggered + if self.global_finding || self.global_results.is_some() { + rows.push(SidebarRow::Heading("Global")); + + match self.global_results.as_ref() { + Some(profiles) if profiles.is_empty() => { + rows.push(SidebarRow::Empty("No profiles found")); + } + Some(profiles) => { + rows.extend(profiles.iter().map(|public_key| SidebarRow::Profile { + public_key: *public_key, + })); + } + None => rows.push(SidebarRow::Empty("Searching...")), + } + } + rows } @@ -1208,6 +1352,27 @@ impl Sidebar { }) .into_any_element() } + SidebarRow::Profile { public_key } => { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let public_key = public_key.to_owned(); + + Nav::new(index) + .label(person.name()) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .on_click(move |_event, window, cx| { + profile::open(public_key, window, cx); + }) + .into_any_element() + } SidebarRow::Group { group } => { let dock = self.dock.clone(); let sidebar = cx.entity().downgrade();