diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 98c14cdd..c76c65dd 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -19,11 +19,15 @@ use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; mod message; mod room; +mod search; pub use message::*; pub use room::*; +pub use search::SearchMessage; pub use state::FileAttachment; +use crate::search::MessageSearchIndex; + /// A static keypair used only for signing locally-cached rumor events. static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); @@ -82,6 +86,8 @@ pub struct ChatRegistry { seen: Arc>>>, /// Mapping of unwrapped event ids to their gift wrap event ids event_map: Arc>>, + /// In-memory message content index for search + search: Arc>, /// True while the initial event backlog is still loading tracking: Arc, /// Channel for sending signals to the UI. @@ -170,6 +176,7 @@ impl ChatRegistry { trash: cx.new(|_| BTreeSet::default()), seen: Arc::new(RwLock::new(HashMap::default())), event_map: Arc::new(RwLock::new(HashMap::default())), + search: Arc::default(), tracking: Arc::new(AtomicBool::new(true)), matcher: CachedMatcher(SkimMatcherV2::default()), signal_rx: rx, @@ -646,10 +653,16 @@ impl ChatRegistry { } } + /// Search the content of all indexed messages, newest first. + pub fn search_messages(&self, terms: &[String], limit: usize) -> Vec { + self.search.read().unwrap().search(terms, limit) + } + /// Reset the registry. pub fn reset(&mut self, cx: &mut Context) { self.rooms.clear(); self.room_index.clear(); + self.search.write().unwrap().clear(); self.trash.update(cx, |this, cx| { this.clear(); cx.notify(); @@ -715,6 +728,7 @@ impl ChatRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); + let search = self.search.clone(); cx.background_spawn(async move { let public_key = signer.get_public_key_async().await?; @@ -746,6 +760,7 @@ impl ChatRegistry { if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) && rumor.tags.public_keys().next().is_some() { + search.write().unwrap().insert(&rumor); if rumor.pubkey != public_key && !rumor.tags.public_keys().any(|k| k == public_key) { @@ -782,6 +797,8 @@ impl ChatRegistry { /// - If the room doesn't exist, it will be created. /// - Updates room ordering based on the most recent messages. pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { + self.search.write().unwrap().insert(&message.rumor); + let nostr = NostrRegistry::global(cx); let Some(public_key) = nostr.read(cx).current_user() else { diff --git a/crates/chat/src/search.rs b/crates/chat/src/search.rs new file mode 100644 index 00000000..320ceec8 --- /dev/null +++ b/crates/chat/src/search.rs @@ -0,0 +1,133 @@ +use std::cmp::Reverse; +use std::collections::BTreeMap; +use std::sync::Arc; + +use common::EventExt; +use nostr_sdk::prelude::*; + +/// A chat message kept in memory for content search. +#[derive(Debug, Clone)] +pub struct SearchMessage { + /// 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: Arc, + /// When the message was created + pub created_at: Timestamp, +} + +#[derive(Debug)] +struct IndexedMessage { + message: SearchMessage, + /// Lowercase content so repeated searches don't re-allocate per message + normalized: Arc, +} + +/// In-memory index of chat message rumors, keyed by room and rumor id. +#[derive(Debug, Default)] +pub struct MessageSearchIndex(BTreeMap>>); + +impl MessageSearchIndex { + /// Index a rumor for search, ignoring reactions and empty content. + pub fn insert(&mut self, rumor: &UnsignedEvent) { + if !is_chat(rumor.kind) || rumor.content.trim().is_empty() { + return; + } + + let Some(id) = rumor.id else { return }; + + let indexed = Arc::new(IndexedMessage { + message: SearchMessage { + room: rumor.uniq_id(), + author: rumor.pubkey, + content: rumor.content.as_str().into(), + created_at: rumor.created_at, + }, + normalized: rumor.content.to_lowercase().into(), + }); + + self.0 + .entry(rumor.uniq_id()) + .or_default() + .entry(id) + .or_insert(indexed); + } + + /// Find messages whose content contains all of the terms, newest first. + pub fn search(&self, terms: &[String], limit: usize) -> Vec { + let mut matches: Vec<&Arc> = self + .0 + .values() + .flat_map(|messages| messages.values()) + .filter(|indexed| terms.iter().all(|term| indexed.normalized.contains(term))) + .collect(); + + matches.sort_by_key(|indexed| Reverse(indexed.message.created_at)); + matches.truncate(limit); + + matches + .into_iter() + .map(|indexed| indexed.message.clone()) + .collect() + } + + /// Drop all indexed messages. + pub fn clear(&mut self) { + self.0.clear(); + } +} + +fn is_chat(kind: Kind) -> bool { + kind == Kind::PrivateDirectMessage || kind == Kind::Custom(15) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rumor(kind: Kind, content: &str, created_at: u64) -> UnsignedEvent { + let sender = Keys::generate(); + let peer = Keys::generate().public_key(); + + let mut rumor = EventBuilder::new(kind, content) + .tag(Tag::public_key(peer)) + .finalize_unsigned(sender.public_key()); + rumor.ensure_id(); + rumor.created_at = Timestamp::from(created_at); + rumor + } + + #[test] + fn search_matches_all_terms_newest_first_and_deduplicates() { + let mut index = MessageSearchIndex::default(); + let first = rumor(Kind::PrivateDirectMessage, "Hello ALICE", 10); + let second = rumor(Kind::PrivateDirectMessage, "hello again", 20); + let reaction = rumor(Kind::Reaction, "hello world", 30); + + index.insert(&first); + index.insert(&first); + index.insert(&second); + index.insert(&reaction); + + let hits = index.search(&["hello".to_owned()], 10); + assert_eq!(hits.len(), 2); + assert_eq!(&*hits[0].content, "hello again"); + assert_eq!(hits[0].created_at, Timestamp::from(20)); + + let hits = index.search(&["hello".to_owned(), "again".to_owned()], 10); + assert_eq!(hits.len(), 1); + + let hits = index.search(&["hello".to_owned()], 1); + assert_eq!(hits.len(), 1); + } + + #[test] + fn empty_content_is_not_indexed() { + let mut index = MessageSearchIndex::default(); + index.insert(&rumor(Kind::PrivateDirectMessage, " ", 10)); + + assert!(index.search(&["hello".to_owned()], 10).is_empty()); + } +} diff --git a/crates/ui/src/nav.rs b/crates/ui/src/nav.rs index 2193eb5d..e3c47774 100644 --- a/crates/ui/src/nav.rs +++ b/crates/ui/src/nav.rs @@ -105,7 +105,7 @@ impl RenderOnce for Nav { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let clickable = self.on_click.is_some() || self.clickable; - v_flex().w_full().h_10().child( + v_flex().h_10().w_full().items_center().child( h_flex() .id(self.id) .h_9() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 05beebc6..f5a05c89 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,12 +1,12 @@ -use std::cmp::Reverse; use std::collections::HashSet; use std::ops::Range; use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::{DebouncedDelay, TimestampExt}; +use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, @@ -14,13 +14,12 @@ 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_DELAY, FIND_LIMIT, NostrRegistry, StateEvent}; +use state::{FIND_LIMIT, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; @@ -41,11 +40,6 @@ use ui::{ use crate::Command; 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)] pub enum ListSection { @@ -90,7 +84,7 @@ enum SearchResult { Message(MessageHit), } -/// A message found by searching the content of the local database. +/// A message found by searching the indexed chat history. #[derive(Clone)] pub(crate) struct MessageHit { room: Entity, @@ -143,6 +137,19 @@ fn find_groups(terms: &[String], cx: &App) -> Vec> { .collect() } +/// 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"]; + +/// Pick one of the signed-out backgrounds at random. +fn pick_banner() -> SharedString { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.subsec_nanos()) + .unwrap_or_default(); + let index = nanos as usize % SIGNED_OUT_BANNERS.len(); + SIGNED_OUT_BANNERS[index].into() +} + /// A collapsible section of the group view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GroupSection { @@ -211,16 +218,8 @@ pub struct Sidebar { 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, @@ -268,7 +267,6 @@ impl Sidebar { subscriptions.push( 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 { @@ -279,29 +277,21 @@ impl Sidebar { }), ); - 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), - ); - } + subscriptions.push(cx.subscribe_in( + &find_input, + window, + |this, state, event, _window, cx| match event { + InputEvent::PressEnter { .. } => this.search(cx), + InputEvent::Change => { + if state.read(cx).value().is_empty() { + this.clear_results(cx); + } else { + this.search(cx); } - _ => {} - }, - ), - ); + } + _ => {} + }, + )); Self { focus_handle: cx.focus_handle(), @@ -325,11 +315,7 @@ impl Sidebar { 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, @@ -532,24 +518,14 @@ impl Sidebar { self.find_input.update(cx, |input, cx| { input.set_value("", window, cx); }); - self.clear_results(window, cx); + self.clear_results(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) { + /// Search rooms and groups by name, then indexed messages by content. + fn search(&mut self, cx: &mut Context) { let query = self.find_input.read(cx).value().to_string(); let terms: Vec = query .to_lowercase() @@ -561,49 +537,13 @@ impl Sidebar { 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 chat = ChatRegistry::global(cx); + let rooms = chat.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 mut hits = matches + 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()?; @@ -611,37 +551,23 @@ impl Sidebar { Some(MessageHit { room, author: matched.author, - content: matched.content, + content: matched.content.into(), created_at: matched.created_at, }) }) .collect::>(); - // Newest matching message first - hits.sort_by_key(|hit| Reverse(hit.created_at)); - - self.results - .extend(hits.into_iter().map(SearchResult::Message)); + self.results = rooms + .into_iter() + .map(SearchResult::Room) + .chain(groups.into_iter().map(SearchResult::Group)) + .chain(messages.into_iter().map(SearchResult::Message)) + .collect(); 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; + /// Discard the search results. + fn clear_results(&mut self, cx: &mut Context) { self.results.clear(); cx.notify(); } @@ -858,11 +784,7 @@ impl Sidebar { rows.push(SidebarRow::Heading("Messages")); if messages.is_empty() { - rows.push(SidebarRow::Empty(if self.finding { - "Searching..." - } else { - "No messages found" - })); + rows.push(SidebarRow::Empty("No messages found")); } else { rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit })); } @@ -1136,17 +1058,19 @@ impl Sidebar { match row { SidebarRow::Section(section) => { let section = *section; + let label = section.label(); + let open = match section { ListSection::Pins => self.pins_open, ListSection::Groups => self.is_groups_open(cx), ListSection::Chats => self.is_chats_open(cx), }; + let icon = if open { IconName::CaretDown } else { IconName::CaretRight }; - let label = section.label(); Nav::new(label) .label(label) @@ -1178,10 +1102,11 @@ impl Sidebar { .text_color(cx.theme().text_placeholder) .into_any_element() } - SidebarRow::Empty(text) => h_flex() + SidebarRow::Empty(text) => v_flex() .h_10() .w_full() .px_1p5() + .items_center() .child( h_flex() .h_9() @@ -1210,7 +1135,6 @@ impl Sidebar { let nav = Nav::new(index) .label(name) .text_sm() - .font_medium() .when(!hide_avatar, |this| { this.prefix( Avatar::from_source(picture) @@ -1266,7 +1190,6 @@ impl Sidebar { Nav::new(index) .label(person.name()) .text_sm() - .font_medium() .when(!hide_avatar, |this| { this.prefix( Avatar::from_source(person.avatar()) @@ -1307,7 +1230,6 @@ impl Sidebar { let nav = Nav::new(index) .label(name) .text_sm() - .font_medium() .when(!hide_avatar, |this| { this.prefix( Avatar::from_source(picture) diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs deleted file mode 100644 index 3361ae0b..00000000 --- a/crates/workspace/src/sidebar/utils.rs +++ /dev/null @@ -1,76 +0,0 @@ -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"]; - -/// Pick one of the signed-out backgrounds at random. -pub(crate) fn pick_banner() -> SharedString { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.subsec_nanos()) - .unwrap_or_default(); - 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) -}