diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 84c4b962..c932a8c8 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -5,7 +5,7 @@ use std::rc::Rc; 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, @@ -13,18 +13,20 @@ 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::{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}; use ui::dialog::DialogButtonProps; use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::indicator::Indicator; +use ui::input::{Input, InputEvent, InputState}; use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; use ui::nav::Nav; use ui::nav_item::NavItem; @@ -41,6 +43,7 @@ use crate::dialogs::{import, profile, screening}; mod utils; pub(crate) use utils::pick_banner; +use utils::{MessageMatch, search_messages}; /// A collapsible section of the sidebar's main list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -62,12 +65,83 @@ impl ListSection { pub enum SidebarRow { Section(ListSection), - Room { room: Entity }, - Group { group: Entity }, - Contact { public_key: PublicKey }, + Room { + room: Entity, + }, + Group { + group: Entity, + }, + Contact { + public_key: PublicKey, + }, + Message { + hit: MessageHit, + }, + /// A non-interactive label, such as a search results section. + Heading(&'static str), Empty(&'static str), } +/// An entry in the sidebar's search results. +enum SearchResult { + Room(Entity), + Group(Entity), + Message(MessageHit), +} + +/// A message found by searching the content of the local database. +#[derive(Clone)] +pub(crate) struct MessageHit { + room: Entity, + author: PublicKey, + content: SharedString, + created_at: Timestamp, +} + +/// Open a room in the dock, screening new requests when enabled. +fn open_room(room: &Entity, dock: &WeakEntity, window: &mut Window, cx: &mut App) { + ui::dock::add_panel_to( + dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + + if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { + let peer = room.read(cx).display_member(cx).public_key(); + let view = screening::init(peer, window, cx); + + window.open_dialog(cx, move |this, _window, _cx| { + this.confirm() + .child(view.clone()) + .button_props( + DialogButtonProps::default() + .cancel_text("Ignore") + .ok_text("Response"), + ) + .on_cancel(move |_event, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + true + }) + }); + } +} + +/// Find groups whose display name contains all of the terms. +fn find_groups(terms: &[String], cx: &App) -> Vec> { + GroupsRegistry::global(cx) + .read(cx) + .groups() + .iter() + .filter(|group| { + let name = group.read(cx).display_name().to_lowercase(); + terms.iter().all(|term| name.contains(term.as_str())) + }) + .cloned() + .collect() +} + /// A collapsible section of the group view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GroupSection { @@ -132,6 +206,23 @@ pub struct Sidebar { new_requests: bool, requests_scroll: UniformListScrollHandle, + /// Whether the search bar is shown below the header. + search_open: bool, + /// Search input state. + find_input: Entity, + /// Debounced delay for the search input. + find_debouncer: DebouncedDelay, + /// Whether a search is in progress. + finding: bool, + /// The query the current search results belong to. + searched: Option, + /// Search results: rooms and groups by name, then messages by content. + results: Vec, + /// The async message content search. + search_task: Option>, + /// Scroll state of the search results list. + search_scroll: UniformListScrollHandle, + tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, } @@ -142,6 +233,12 @@ impl Sidebar { let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let find_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Search chats, groups and messages") + .clean_on_escape() + }); + let mut subscriptions = smallvec![]; subscriptions.push( @@ -169,15 +266,42 @@ impl Sidebar { ); subscriptions.push( - cx.subscribe_in(&nostr, window, |this, _, event, _window, cx| { + cx.subscribe_in(&nostr, window, |this, _, event, window, cx| { // Re-pick the background each time the signed-out screen is shown. if let StateEvent::NoSigner = event { this.banner = pick_banner(); + if this.search_open { + this.toggle_search(window, cx); + } cx.notify(); } }), ); + subscriptions.push( + // 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::Change => { + if state.read(cx).value().is_empty() { + this.clear_results(window, cx); + } else { + this.find_debouncer.fire_new( + Duration::from_millis(FIND_DELAY), + window, + cx, + |this, window, cx| this.debounced_search(window, cx), + ); + } + } + _ => {} + }, + ), + ); + Self { focus_handle: cx.focus_handle(), list_scroll: UniformListScrollHandle::new(), @@ -198,6 +322,14 @@ impl Sidebar { requests_open: false, requests_scroll: UniformListScrollHandle::new(), new_requests: false, + search_open: false, + find_input, + find_debouncer: DebouncedDelay::new(), + finding: false, + searched: None, + results: Vec::new(), + search_task: None, + search_scroll: UniformListScrollHandle::new(), tasks: smallvec![], _subscriptions: subscriptions, } @@ -388,6 +520,124 @@ impl Sidebar { })); } + /// Show or hide the search bar below the header. + fn toggle_search(&mut self, window: &mut Window, cx: &mut Context) { + self.search_open = !self.search_open; + + if self.search_open { + self.find_input + .update(cx, |input, cx| input.focus(window, cx)); + } else { + self.find_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + self.clear_results(window, cx); + } + + cx.notify(); + } + + /// Trigger a search once the input debounce delay has passed. + fn debounced_search(&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.search(window, cx); + }) + .ok(); + }) + } + + /// Search rooms and groups by name, then messages by content. + fn search(&mut self, window: &mut Window, cx: &mut Context) { + let query = self.find_input.read(cx).value().to_string(); + let terms: Vec = query + .to_lowercase() + .split_whitespace() + .map(str::to_owned) + .collect(); + + if terms.is_empty() { + return; + } + + // Name matches are local so show them right away while messages are searched + let rooms = ChatRegistry::global(cx).read(cx).find(&query, cx); + let groups = find_groups(&terms, cx); + + self.searched = Some(query.clone()); + self.results = rooms + .into_iter() + .map(SearchResult::Room) + .chain(groups.into_iter().map(SearchResult::Group)) + .collect(); + cx.notify(); + + // Block the input until the search completes + self.set_finding(true, window, cx); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let find_messages = cx.background_spawn(search_messages(client, terms, FIND_LIMIT)); + + self.search_task = Some(cx.spawn_in(window, async move |this, cx| { + let matches = find_messages.await; + + this.update_in(cx, move |this, window, cx| { + match matches { + Ok(matches) => this.push_messages(query, matches, cx), + Err(error) => { + window.push_notification(Notification::error(error.to_string()), cx); + } + } + this.set_finding(false, window, cx); + }) + .ok(); + })); + } + + /// Append message content matches, keeping results for the latest query only. + fn push_messages(&mut self, query: String, matches: Vec, cx: &mut Context) { + if self.searched.as_deref() != Some(query.as_str()) { + return; + } + + let chat = ChatRegistry::global(cx); + let hits = matches.into_iter().filter_map(|matched| { + let room = chat.read(cx).room(&matched.room, cx)?.upgrade()?; + + Some(MessageHit { + room, + author: matched.author, + content: matched.content, + created_at: matched.created_at, + }) + }); + + self.results.extend(hits.map(SearchResult::Message)); + cx.notify(); + } + + /// Set the search status and block the input while searching. + fn set_finding(&mut self, finding: bool, window: &mut Window, cx: &mut Context) { + self.finding = finding; + self.find_input.update(cx, |input, cx| { + input.set_loading(finding, window, cx); + }); + cx.notify(); + } + + /// Discard the search results and cancel any ongoing search. + fn clear_results(&mut self, window: &mut Window, cx: &mut Context) { + if self.finding { + self.set_finding(false, window, cx); + } + + self.searched = None; + self.search_task = None; + self.results.clear(); + cx.notify(); + } + fn render_user(&self, current_user: &PublicKey, cx: &mut Context) -> Stateful
{ let updater = AutoUpdater::try_global(cx); let persons = PersonRegistry::global(cx); @@ -442,6 +692,16 @@ impl Sidebar { .when_some(updater, |this, updater| { this.child(self.render_updater(updater, cx)) }) + .child( + Button::new("sidebar-search") + .icon(IconName::Search) + .tooltip("Search") + .ghost() + .small() + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_search(window, cx); + })), + ) .child( Button::new("sidebar-new") .icon(IconName::PlusChat) @@ -486,17 +746,17 @@ impl Sidebar { .gap_2() .when(!up_to_date, |this| { this.child( - Button::new("update-status") + Button::new("update") .icon(IconName::ArrowDownCircle) .tooltip(status) .small() - .warning() + .ghost() .disabled(true), ) }) .when(staged, |this| { this.child( - Button::new("restart-to-update") + Button::new("restart") .icon(IconName::ArrowDownCircle) .tooltip("Quit and relaunch into the installed update") .small() @@ -511,6 +771,95 @@ impl Sidebar { .into_any_element() } + fn render_find(&self, cx: &mut Context) -> AnyElement { + h_flex() + .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); + })), + ), + ) + .into_any_element() + } + + fn render_results(&self, cx: &mut Context) -> AnyElement { + let rows = Rc::new(self.results_for()); + + v_flex() + .flex_1() + .min_h_0() + .relative() + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + "sidebar-search", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), + ) + .track_scroll(&self.search_scroll) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.search_scroll)), + ) + .into_any_element() + } + + fn results_for(&self) -> Vec { + let mut rooms = Vec::new(); + let mut groups = Vec::new(); + let mut messages = Vec::new(); + + for result in &self.results { + match result { + SearchResult::Room(room) => rooms.push(room.clone()), + SearchResult::Group(group) => groups.push(group.clone()), + SearchResult::Message(hit) => messages.push(hit.clone()), + } + } + + let mut rows = Vec::new(); + + if !rooms.is_empty() { + rows.push(SidebarRow::Heading("Chats")); + rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); + } + + if !groups.is_empty() { + rows.push(SidebarRow::Heading("Groups")); + rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); + } + + if !messages.is_empty() { + rows.push(SidebarRow::Heading("Messages")); + rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit })); + } + + if rows.is_empty() { + rows.push(SidebarRow::Empty(if self.finding { + "Searching..." + } else { + "No results" + })); + } + + rows + } + fn render_list(&mut self, cx: &mut Context) -> AnyElement { let requests_open = self.requests_open; let contacts_open = self.contacts_open && !requests_open; @@ -809,6 +1158,15 @@ impl Sidebar { })) .into_any_element() } + SidebarRow::Heading(label) => div() + .px_1p5() + .pt_2() + .pb_1() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .child(*label) + .into_any_element(), SidebarRow::Empty(text) => h_flex() .h_10() .w_full() @@ -832,8 +1190,6 @@ impl Sidebar { let picture = room.read(cx).display_image(cx); let seed = room.read(cx).display_image_seed(cx); let created_at = room.read(cx).created_at.to_ago(); - let kind = room.read(cx).kind; - let peer = room.read(cx).display_member(cx).public_key(); let id = room.read(cx).id; let member = room.read(cx).members().first().copied(); let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); @@ -860,30 +1216,7 @@ impl Sidebar { .child(created_at), ) .on_click(move |_event, window, cx| { - ui::dock::add_panel_to( - &dock, - PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { - let screening = screening::init(peer, window, cx); - - window.open_dialog(cx, move |this, _window, _cx| { - this.confirm() - .child(screening.clone()) - .button_props( - DialogButtonProps::default() - .cancel_text("Ignore") - .ok_text("Response"), - ) - .on_cancel(move |_event, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - true - }) - }); - } + open_room(&room, &dock, window, cx); }); ContextMenu::new( @@ -1027,6 +1360,38 @@ impl Sidebar { ) .into_any_element() } + SidebarRow::Message { hit } => { + let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx); + let name = hit.room.read(cx).display_name(cx); + let detail = format!("{} ยท {}", name, hit.created_at.to_ago()); + let dock = self.dock.clone(); + let room = hit.room.clone(); + let content = hit.content.clone(); + + Nav::new(index) + .label(content) + .text_sm() + .font_medium() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(detail), + ) + .on_click(move |_event, window, cx| { + open_room(&room, &dock, window, cx); + }) + .into_any_element() + } } }) .collect() @@ -1309,6 +1674,7 @@ impl Render for Sidebar { let loading = chat.read(cx).loading(); let group = self.group.clone().and_then(|group| group.upgrade()); + let searching = self.search_open && !self.find_input.read(cx).value().is_empty(); v_flex() .image_cache(retain_all("sidebar")) @@ -1325,6 +1691,7 @@ impl Render for Sidebar { cx, )) }) + .when(self.search_open, |this| this.child(self.render_find(cx))) .when(!logged_in, |this| { this.relative() .child(title_bar_drag_handlers( @@ -1371,7 +1738,10 @@ impl Render for Sidebar { ) }) .map(|this| match group { - Some(group) if self.group_open => this.child(self.render_group(group, cx)), + Some(group) if self.group_open && !searching => { + this.child(self.render_group(group, cx)) + } + _ if searching => this.child(self.render_results(cx)), _ => this.child(self.render_list(cx)), }) .when(loading && logged_in, |this| { diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs index 9781ba3f..3361ae0b 100644 --- a/crates/workspace/src/sidebar/utils.rs +++ b/crates/workspace/src/sidebar/utils.rs @@ -1,6 +1,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use anyhow::Error; +use chat::Message; +use common::EventExt; use gpui::SharedString; +use nostr_sdk::prelude::*; /// Brand backgrounds shown behind the signed-out screen; one is picked at random. const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"]; @@ -14,3 +18,59 @@ pub(crate) fn pick_banner() -> SharedString { let index = nanos as usize % SIGNED_OUT_BANNERS.len(); SIGNED_OUT_BANNERS[index].into() } + +/// A chat message in the local database matching a search query. +pub(crate) struct MessageMatch { + /// ID of the room the message belongs to + pub room: u64, + /// Author of the message + pub author: PublicKey, + /// The content of the message + pub content: SharedString, + /// When the message was created + pub created_at: Timestamp, +} + +/// Search the local database for chat messages containing all of the terms. +pub(crate) async fn search_messages( + client: Client, + terms: Vec, + limit: usize, +) -> Result, Error> { + // Message rumors are cached as application specific data with the original + // event kind stored in the `k` tag, so only text messages are queried + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag( + SingleLetterTag::LOWERCASE_K, + Kind::PrivateDirectMessage.to_string(), + ); + + let events = client.database().query(filter).await?; + + let mut matches = Vec::new(); + + for event in events { + let Ok(rumor) = UnsignedEvent::from_json(&event.content) else { + continue; + }; + + let message = Message::from(&rumor); + let content = message.content.to_lowercase(); + + if terms.iter().all(|term| content.contains(term)) { + matches.push(MessageMatch { + room: rumor.uniq_id(), + author: message.author, + content: message.content.into(), + created_at: message.created_at, + }); + + if matches.len() >= limit { + break; + } + } + } + + Ok(matches) +}