notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 2d55c8fb0676f0656790f8185d110671018e53f8
parent 9387fe4973d26c43f7358064c04a3a3ab4d8c7db
Author: kernelkind <kernelkind@gmail.com>
Date:   Mon, 26 May 2025 16:29:42 -0400

add search improvements

- '@' symbol brings up mention picker
- search for npub1, note1, and hashtags work

closes: https://github.com/damus-io/notedeck/issues/83
closes: https://github.com/damus-io/notedeck/issues/85

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck_columns/src/ui/search/mod.rs | 288++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_columns/src/ui/search/state.rs | 29++++++++++-------------------
2 files changed, 206 insertions(+), 111 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -1,7 +1,8 @@ use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit}; use enostr::{KeypairUnowned, NoteId, Pubkey}; +use state::TypingType; -use crate::ui::timeline::TimelineTabView; +use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView}; use egui_winit::clipboard::Clipboard; use nostrdb::{Filter, Ndb, Transaction}; use notedeck::{MuteFun, NoteAction, NoteContext, NoteRef}; @@ -13,6 +14,8 @@ mod state; pub use state::{FocusState, SearchQueryState, SearchState}; +use super::search_results::{SearchResultsResponse, SearchResultsView}; + pub struct SearchView<'a, 'd> { query: &'a mut SearchQueryState, note_options: NoteOptions, @@ -55,95 +58,201 @@ impl<'a, 'd> SearchView<'a, 'd> { ) -> Option<NoteAction> { ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); - if search_box(self.query, ui, clipboard) { - self.execute_search(ui.ctx()); - } + let search_resp = search_box( + &mut self.query.string, + self.query.focus_state.clone(), + ui, + clipboard, + ); - match self.query.state { - SearchState::New | SearchState::Navigating => None, - - SearchState::Searched | SearchState::Typing => { - if self.query.state == SearchState::Typing { - ui.label(format!("Searching for '{}'", &self.query.string)); - } else { - ui.label(format!( - "Got {} results for '{}'", - self.query.notes.notes.len(), - &self.query.string - )); - } - - egui::ScrollArea::vertical() - .show(ui, |ui| { - let reversed = false; - TimelineTabView::new( - &self.query.notes, - reversed, - self.note_options, - self.txn, - self.is_muted, - self.note_context, - self.cur_acc, - self.jobs, - ) - .show(ui) - }) - .inner + search_resp.process(self.query); + + let mut search_action = None; + let mut note_action = None; + match &self.query.state { + SearchState::New | SearchState::Navigating => {} + SearchState::Typing(TypingType::Mention(mention_name)) => 's: { + let Ok(results) = self + .note_context + .ndb + .search_profile(self.txn, mention_name, 10) + else { + break 's; + }; + + let search_res = SearchResultsView::new( + self.note_context.img_cache, + self.note_context.ndb, + self.txn, + &results, + ) + .show_in_rect(ui.available_rect_before_wrap(), ui); + + search_action = match search_res { + SearchResultsResponse::SelectResult(Some(index)) => { + let Some(pk_bytes) = results.get(index) else { + break 's; + }; + + let username = self + .note_context + .ndb + .get_profile_by_pubkey(self.txn, pk_bytes) + .ok() + .and_then(|p| p.record().profile().and_then(|p| p.name())) + .unwrap_or(&self.query.string); + + Some(SearchAction::NewSearch { + search_type: SearchType::Profile(Pubkey::new(**pk_bytes)), + new_search_text: format!("@{username}"), + }) + } + SearchResultsResponse::DeleteMention => Some(SearchAction::CloseMention), + SearchResultsResponse::SelectResult(None) => break 's, + }; + } + SearchState::PerformSearch(search_type) => { + execute_search( + ui.ctx(), + search_type, + &self.query.string, + self.note_context.ndb, + self.txn, + &mut self.query.notes, + ); + search_action = Some(SearchAction::Searched); + note_action = self.show_search_results(ui); } + SearchState::Searched => { + ui.label(format!( + "Got {} results for '{}'", + self.query.notes.notes.len(), + &self.query.string + )); + note_action = self.show_search_results(ui); + } + SearchState::Typing(TypingType::AutoSearch) => { + ui.label(format!("Searching for '{}'", &self.query.string)); + + note_action = self.show_search_results(ui); + } + }; + + if let Some(resp) = search_action { + resp.process(self.query); } + + note_action } - fn execute_search(&mut self, ctx: &egui::Context) { - if self.query.string.is_empty() { - return; + fn show_search_results(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { + egui::ScrollArea::vertical() + .show(ui, |ui| { + let reversed = false; + TimelineTabView::new( + &self.query.notes, + reversed, + self.note_options, + self.txn, + self.is_muted, + self.note_context, + self.cur_acc, + self.jobs, + ) + .show(ui) + }) + .inner + } +} + +fn execute_search( + ctx: &egui::Context, + search_type: &SearchType, + raw_input: &String, + ndb: &Ndb, + txn: &Transaction, + tab: &mut TimelineTab, +) { + if raw_input.is_empty() { + return; + } + + let max_results = 500; + + let Some(note_refs) = search_type.search(raw_input, ndb, txn, max_results) else { + return; + }; + + tab.notes = note_refs; + tab.list.borrow_mut().reset(); + ctx.request_repaint(); +} + +enum SearchAction { + NewSearch { + search_type: SearchType, + new_search_text: String, + }, + Searched, + CloseMention, +} + +impl SearchAction { + fn process(self, state: &mut SearchQueryState) { + match self { + SearchAction::NewSearch { + search_type, + new_search_text, + } => { + state.state = SearchState::PerformSearch(search_type); + state.string = new_search_text; + } + SearchAction::CloseMention => state.state = SearchState::New, + SearchAction::Searched => state.state = SearchState::Searched, } + } +} - let max_results = 500; - let filter = Filter::new() - .search(&self.query.string) - .kinds([1]) - .limit(max_results) - .build(); - - // TODO: execute in thread - - let before = Instant::now(); - let qrs = self - .note_context - .ndb - .query(self.txn, &[filter], max_results as i32); - let after = Instant::now(); - let duration = after - before; - - if duration > Duration::from_millis(20) { - warn!( - "query took {:?}... let's update this to use a thread!", - after - before - ); +struct SearchResponse { + requested_focus: bool, + input_changed: bool, +} + +impl SearchResponse { + fn process(self, state: &mut SearchQueryState) { + if self.requested_focus { + state.focus_state = FocusState::RequestedFocus; } - match qrs { - Ok(qrs) => { - info!( - "queried '{}' and got {} results", - self.query.string, - qrs.len() - ); + if state.string.chars().nth(0) != Some('@') { + if self.input_changed { + state.state = SearchState::Typing(TypingType::AutoSearch); + state.debouncer.bounce(); + } - let note_refs = qrs.into_iter().map(NoteRef::from_query_result).collect(); - self.query.notes.notes = note_refs; - self.query.notes.list.borrow_mut().reset(); - ctx.request_repaint(); + if state.state == SearchState::Typing(TypingType::AutoSearch) + && state.debouncer.should_act() + { + state.state = SearchState::PerformSearch(SearchType::get_type(&state.string)); } - Err(err) => { - error!("fulltext query failed: {err}") + return; + } + + if self.input_changed { + if let Some(mention_text) = state.string.get(1..) { + state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned())); } } } } -fn search_box(query: &mut SearchQueryState, ui: &mut egui::Ui, clipboard: &mut Clipboard) -> bool { +fn search_box( + input: &mut String, + focus_state: FocusState, + ui: &mut egui::Ui, + clipboard: &mut Clipboard, +) -> SearchResponse { ui.horizontal(|ui| { // Container for search input and icon let search_container = egui::Frame { @@ -165,13 +274,13 @@ fn search_box(query: &mut SearchQueryState, ui: &mut egui::Ui, clipboard: &mut C // Magnifying glass icon ui.add(search_icon(16.0, search_height)); - let before_len = query.string.len(); + let before_len = input.len(); // Search input field //let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); let response = ui.add_sized( [ui.available_width(), search_height], - TextEdit::singleline(&mut query.string) + TextEdit::singleline(input) .hint_text(RichText::new("Search notes...").weak()) //.desired_width(available_width - 32.0) //.font(egui::FontId::new(font_size, egui::FontFamily::Proportional)) @@ -182,37 +291,32 @@ fn search_box(query: &mut SearchQueryState, ui: &mut egui::Ui, clipboard: &mut C response.context_menu(|ui| { if ui.button("paste").clicked() { if let Some(text) = clipboard.get() { - query.string.clear(); - query.string.push_str(&text); + input.clear(); + input.push_str(&text); } } }); if response.middle_clicked() { if let Some(text) = clipboard.get() { - query.string.clear(); - query.string.push_str(&text); + input.clear(); + input.push_str(&text); } } - if query.focus_state == FocusState::ShouldRequestFocus { + let mut requested_focus = false; + if focus_state == FocusState::ShouldRequestFocus { response.request_focus(); - query.focus_state = FocusState::RequestedFocus; + requested_focus = true; } - let after_len = query.string.len(); + let after_len = input.len(); - let changed = before_len != after_len; - if changed { - query.mark_updated(); - } + let input_changed = before_len != after_len; - // Execute search after debouncing - if query.should_search() { - query.mark_searched(SearchState::Searched); - true - } else { - false + SearchResponse { + requested_focus, + input_changed, } }) .inner diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs @@ -2,15 +2,24 @@ use crate::timeline::TimelineTab; use notedeck::debouncer::Debouncer; use std::time::Duration; +use super::SearchType; + #[derive(Debug, Eq, PartialEq)] pub enum SearchState { - Typing, + Typing(TypingType), + PerformSearch(SearchType), Searched, Navigating, New, } #[derive(Debug, Eq, PartialEq)] +pub enum TypingType { + Mention(String), + AutoSearch, +} + +#[derive(Debug, Eq, PartialEq, Clone)] pub enum FocusState { /// Get ready to focus Navigating, @@ -60,22 +69,4 @@ impl SearchQueryState { debouncer: Debouncer::new(Duration::from_millis(200)), } } - - pub fn should_search(&self) -> bool { - self.state == SearchState::Typing && self.debouncer.should_act() - } - - /// Mark the search as updated. This will update our debouncer and clear - /// the searched flag, enabling us to search again. This should be - /// called when the search box changes - pub fn mark_updated(&mut self) { - self.state = SearchState::Typing; - self.debouncer.bounce(); - } - - /// Call this when you are about to do a search so that we don't try - /// to search again next frame - pub fn mark_searched(&mut self, state: SearchState) { - self.state = state; - } }