use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::input::Input; use gpui_kit::component::scroll::Scrollbar; use gpui_kit::component::{ActiveTheme, Selectable, Sizable, h_flex, v_flex}; use std::collections::HashSet; use std::rc::Rc; use chat::{ChatRegistry, Room}; use gpui::{ AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task, Window, div, uniform_list, }; use nip29::{Group, GroupsRegistry}; use nostr_sdk::prelude::*; use state::{FIND_LIMIT, NostrRegistry}; use super::Sidebar; use super::rows::SidebarRow; /// An entry in the sidebar's search results. pub(super) enum SearchResult { Room(Entity), Group(Entity), Message(MessageHit), } /// A message found by searching the indexed chat history. #[derive(Clone)] pub(super) struct MessageHit { pub(super) room: Entity, pub(super) author: PublicKey, pub(super) content: SharedString, pub(super) created_at: Timestamp, } /// 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() } impl Sidebar { /// Show or hide the search bar below the header. pub(super) 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.global_open = false; self.clear_results(cx); } cx.notify(); } /// Search rooms and groups by name, then indexed messages by content. pub(super) fn search(&mut self, 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; } let chat = ChatRegistry::global(cx); let rooms = chat.read(cx).find(&query, cx); let groups = find_groups(&terms, cx); let messages = chat .read(cx) .search_messages(&terms, FIND_LIMIT) .into_iter() .filter_map(|matched| { let room = chat.read(cx).room(&matched.room, cx)?.upgrade()?; Some(MessageHit { room, author: matched.author, content: matched.content.into(), created_at: matched.created_at, }) }) .collect::>(); self.results = rooms .into_iter() .map(SearchResult::Room) .chain(groups.into_iter().map(SearchResult::Group)) .chain(messages.into_iter().map(SearchResult::Message)) .collect(); let authors = self.results.iter().filter_map(|result| match result { SearchResult::Message(hit) => Some(hit.author), _ => None, }); self.directory .update(cx, |directory, cx| directory.track(authors, cx)); cx.notify(); } /// Search profiles on the indexer relays for the current query. pub(super) 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; let keys: Vec = 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() } }; this.directory.update(cx, |directory, cx| { directory.track(keys.iter().copied(), cx) }); this.global_results = Some(keys); cx.notify(); }) .ok(); })); } /// Trigger a global search once the input debounce delay has passed. pub(super) 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. pub(super) 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. pub(super) 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(); } pub(super) fn render_find(&self, cx: &mut Context) -> AnyElement { h_flex() .px_2() .flex_shrink_0() .w_full() .child( h_flex() .px_1p5() .gap_1() .w_full() .rounded(cx.theme().radius) .bg(cx.theme().secondary) .child(Input::new(&self.find_input).appearance(false).small()) .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() } pub(super) 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(); rows.push(SidebarRow::Heading("Groups")); if groups.is_empty() { rows.push(SidebarRow::Empty("No groups found")); } else { rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); } rows.push(SidebarRow::Heading("Rooms")); if rooms.is_empty() { rows.push(SidebarRow::Empty("No rooms found")); } else { rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); } rows.push(SidebarRow::Heading("Messages")); if messages.is_empty() { rows.push(SidebarRow::Empty("No messages found")); } else { 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 } }