mod.rs (14232B)
1 use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit}; 2 use enostr::{NoteId, Pubkey}; 3 use state::TypingType; 4 5 use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView}; 6 use egui_winit::clipboard::Clipboard; 7 use nostrdb::{Filter, Ndb, Transaction}; 8 use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef}; 9 10 use notedeck_ui::{ 11 context_menu::{input_context, PasteBehavior}, 12 icons::search_icon, 13 padding, NoteOptions, 14 }; 15 use std::time::{Duration, Instant}; 16 use tracing::{error, info, warn}; 17 18 mod state; 19 20 pub use state::{FocusState, SearchQueryState, SearchState}; 21 22 use super::mentions_picker::{MentionPickerResponse, MentionPickerView}; 23 24 pub struct SearchView<'a, 'd> { 25 query: &'a mut SearchQueryState, 26 note_options: NoteOptions, 27 txn: &'a Transaction, 28 note_context: &'a mut NoteContext<'d>, 29 jobs: &'a mut JobsCache, 30 } 31 32 impl<'a, 'd> SearchView<'a, 'd> { 33 pub fn new( 34 txn: &'a Transaction, 35 note_options: NoteOptions, 36 query: &'a mut SearchQueryState, 37 note_context: &'a mut NoteContext<'d>, 38 jobs: &'a mut JobsCache, 39 ) -> Self { 40 Self { 41 txn, 42 query, 43 note_options, 44 note_context, 45 jobs, 46 } 47 } 48 49 pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { 50 padding(8.0, ui, |ui| self.show_impl(ui)).inner 51 } 52 53 pub fn show_impl(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { 54 ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); 55 56 let search_resp = search_box( 57 self.note_context.i18n, 58 &mut self.query.string, 59 self.query.focus_state.clone(), 60 ui, 61 self.note_context.clipboard, 62 ); 63 64 search_resp.process(self.query); 65 66 let mut search_action = None; 67 let mut note_action = None; 68 match &self.query.state { 69 SearchState::New | SearchState::Navigating => {} 70 SearchState::Typing(TypingType::Mention(mention_name)) => 's: { 71 let Ok(results) = self 72 .note_context 73 .ndb 74 .search_profile(self.txn, mention_name, 10) 75 else { 76 break 's; 77 }; 78 79 let search_res = MentionPickerView::new( 80 self.note_context.img_cache, 81 self.note_context.ndb, 82 self.txn, 83 &results, 84 ) 85 .show_in_rect(ui.available_rect_before_wrap(), ui); 86 87 search_action = match search_res { 88 MentionPickerResponse::SelectResult(Some(index)) => { 89 let Some(pk_bytes) = results.get(index) else { 90 break 's; 91 }; 92 93 let username = self 94 .note_context 95 .ndb 96 .get_profile_by_pubkey(self.txn, pk_bytes) 97 .ok() 98 .and_then(|p| p.record().profile().and_then(|p| p.name())) 99 .unwrap_or(&self.query.string); 100 101 Some(SearchAction::NewSearch { 102 search_type: SearchType::Profile(Pubkey::new(**pk_bytes)), 103 new_search_text: format!("@{username}"), 104 }) 105 } 106 MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention), 107 MentionPickerResponse::SelectResult(None) => break 's, 108 }; 109 } 110 SearchState::PerformSearch(search_type) => { 111 execute_search( 112 ui.ctx(), 113 search_type, 114 &self.query.string, 115 self.note_context.ndb, 116 self.txn, 117 &mut self.query.notes, 118 ); 119 search_action = Some(SearchAction::Searched); 120 note_action = self.show_search_results(ui); 121 } 122 SearchState::Searched => { 123 ui.label(tr_plural!( 124 self.note_context.i18n, 125 "Got {count} result for '{query}'", // one 126 "Got {count} results for '{query}'", // other 127 "Search results count", // comment 128 self.query.notes.notes.len(), // count 129 query = &self.query.string 130 )); 131 note_action = self.show_search_results(ui); 132 } 133 SearchState::Typing(TypingType::AutoSearch) => { 134 ui.label(tr!( 135 self.note_context.i18n, 136 "Searching for '{query}'", 137 "Search in progress message", 138 query = &self.query.string 139 )); 140 141 note_action = self.show_search_results(ui); 142 } 143 }; 144 145 if let Some(resp) = search_action { 146 resp.process(self.query); 147 } 148 149 note_action 150 } 151 152 fn show_search_results(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { 153 egui::ScrollArea::vertical() 154 .id_salt(SearchView::scroll_id()) 155 .show(ui, |ui| { 156 let reversed = false; 157 TimelineTabView::new( 158 &self.query.notes, 159 reversed, 160 self.note_options, 161 self.txn, 162 self.note_context, 163 self.jobs, 164 ) 165 .show(ui) 166 }) 167 .inner 168 } 169 170 pub fn scroll_id() -> egui::Id { 171 egui::Id::new("search_results") 172 } 173 } 174 175 fn execute_search( 176 ctx: &egui::Context, 177 search_type: &SearchType, 178 raw_input: &String, 179 ndb: &Ndb, 180 txn: &Transaction, 181 tab: &mut TimelineTab, 182 ) { 183 if raw_input.is_empty() { 184 return; 185 } 186 187 let max_results = 500; 188 189 let Some(note_refs) = search_type.search(raw_input, ndb, txn, max_results) else { 190 return; 191 }; 192 193 tab.notes = note_refs; 194 tab.list.borrow_mut().reset(); 195 ctx.request_repaint(); 196 } 197 198 enum SearchAction { 199 NewSearch { 200 search_type: SearchType, 201 new_search_text: String, 202 }, 203 Searched, 204 CloseMention, 205 } 206 207 impl SearchAction { 208 fn process(self, state: &mut SearchQueryState) { 209 match self { 210 SearchAction::NewSearch { 211 search_type, 212 new_search_text, 213 } => { 214 state.state = SearchState::PerformSearch(search_type); 215 state.string = new_search_text; 216 } 217 SearchAction::CloseMention => state.state = SearchState::New, 218 SearchAction::Searched => state.state = SearchState::Searched, 219 } 220 } 221 } 222 223 struct SearchResponse { 224 requested_focus: bool, 225 input_changed: bool, 226 } 227 228 impl SearchResponse { 229 fn process(self, state: &mut SearchQueryState) { 230 if self.requested_focus { 231 state.focus_state = FocusState::RequestedFocus; 232 } 233 234 if state.string.chars().nth(0) != Some('@') { 235 if self.input_changed { 236 state.state = SearchState::Typing(TypingType::AutoSearch); 237 state.debouncer.bounce(); 238 } 239 240 if state.state == SearchState::Typing(TypingType::AutoSearch) 241 && state.debouncer.should_act() 242 { 243 state.state = SearchState::PerformSearch(SearchType::get_type(&state.string)); 244 } 245 246 return; 247 } 248 249 if self.input_changed { 250 if let Some(mention_text) = state.string.get(1..) { 251 state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned())); 252 } 253 } 254 } 255 } 256 257 fn search_box( 258 i18n: &mut Localization, 259 input: &mut String, 260 focus_state: FocusState, 261 ui: &mut egui::Ui, 262 clipboard: &mut Clipboard, 263 ) -> SearchResponse { 264 ui.horizontal(|ui| { 265 // Container for search input and icon 266 let search_container = egui::Frame { 267 inner_margin: egui::Margin::symmetric(8, 0), 268 outer_margin: egui::Margin::ZERO, 269 corner_radius: CornerRadius::same(18), // More rounded corners 270 shadow: Default::default(), 271 fill: if ui.visuals().dark_mode { 272 Color32::from_rgb(30, 30, 30) 273 } else { 274 Color32::from_rgb(240, 240, 240) 275 }, 276 stroke: if ui.visuals().dark_mode { 277 Stroke::new(1.0, Color32::from_rgb(60, 60, 60)) 278 } else { 279 Stroke::new(1.0, Color32::from_rgb(200, 200, 200)) 280 }, 281 }; 282 283 search_container 284 .show(ui, |ui| { 285 // Use layout to align items vertically centered 286 ui.with_layout(egui::Layout::left_to_right(Align::Center), |ui| { 287 ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); 288 289 let search_height = 34.0; 290 // Magnifying glass icon 291 ui.add(search_icon(16.0, search_height)); 292 293 let before_len = input.len(); 294 295 // Search input field 296 //let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); 297 let response = ui.add_sized( 298 [ui.available_width(), search_height], 299 TextEdit::singleline(input) 300 .hint_text( 301 RichText::new(tr!( 302 i18n, 303 "Search notes...", 304 "Placeholder for search notes input field" 305 )) 306 .weak(), 307 ) 308 //.desired_width(available_width - 32.0) 309 //.font(egui::FontId::new(font_size, egui::FontFamily::Proportional)) 310 .margin(vec2(0.0, 8.0)) 311 .frame(false), 312 ); 313 314 input_context(&response, clipboard, input, PasteBehavior::Append); 315 316 let mut requested_focus = false; 317 if focus_state == FocusState::ShouldRequestFocus { 318 response.request_focus(); 319 requested_focus = true; 320 } 321 322 let after_len = input.len(); 323 324 let input_changed = before_len != after_len; 325 326 SearchResponse { 327 requested_focus, 328 input_changed, 329 } 330 }) 331 .inner 332 }) 333 .inner 334 }) 335 .inner 336 } 337 338 #[derive(Debug, Eq, PartialEq)] 339 pub enum SearchType { 340 String, 341 NoteId(NoteId), 342 Profile(Pubkey), 343 Hashtag(String), 344 } 345 346 impl SearchType { 347 fn get_type(query: &str) -> Self { 348 if query.len() == 63 && query.starts_with("note1") { 349 if let Some(noteid) = NoteId::from_bech(query) { 350 return SearchType::NoteId(noteid); 351 } 352 } else if query.len() == 63 && query.starts_with("npub1") { 353 if let Ok(pk) = Pubkey::try_from_bech32_string(query, false) { 354 return SearchType::Profile(pk); 355 } 356 } else if query.chars().nth(0).is_some_and(|c| c == '#') { 357 if let Some(hashtag) = query.get(1..) { 358 return SearchType::Hashtag(hashtag.to_string()); 359 } 360 } 361 362 SearchType::String 363 } 364 365 fn search( 366 &self, 367 raw_query: &String, 368 ndb: &Ndb, 369 txn: &Transaction, 370 max_results: u64, 371 ) -> Option<Vec<NoteRef>> { 372 match self { 373 SearchType::String => search_string(raw_query, ndb, txn, max_results), 374 SearchType::NoteId(noteid) => search_note(noteid, ndb, txn).map(|n| vec![n]), 375 SearchType::Profile(pk) => search_pk(pk, ndb, txn, max_results), 376 SearchType::Hashtag(hashtag) => search_hashtag(hashtag, ndb, txn, max_results), 377 } 378 } 379 } 380 381 fn search_string( 382 query: &String, 383 ndb: &Ndb, 384 txn: &Transaction, 385 max_results: u64, 386 ) -> Option<Vec<NoteRef>> { 387 let filter = Filter::new() 388 .search(query) 389 .kinds([1]) 390 .limit(max_results) 391 .build(); 392 393 // TODO: execute in thread 394 395 let before = Instant::now(); 396 let qrs = ndb.query(txn, &[filter], max_results as i32); 397 let after = Instant::now(); 398 let duration = after - before; 399 400 if duration > Duration::from_millis(20) { 401 warn!( 402 "query took {:?}... let's update this to use a thread!", 403 after - before 404 ); 405 } 406 407 match qrs { 408 Ok(qrs) => { 409 info!("queried '{}' and got {} results", query, qrs.len()); 410 411 return Some(qrs.into_iter().map(NoteRef::from_query_result).collect()); 412 } 413 414 Err(err) => { 415 error!("fulltext query failed: {err}") 416 } 417 } 418 419 None 420 } 421 422 fn search_note(noteid: &NoteId, ndb: &Ndb, txn: &Transaction) -> Option<NoteRef> { 423 ndb.get_note_by_id(txn, noteid.bytes()) 424 .ok() 425 .map(|n| NoteRef::from_note(&n)) 426 } 427 428 fn search_pk(pk: &Pubkey, ndb: &Ndb, txn: &Transaction, max_results: u64) -> Option<Vec<NoteRef>> { 429 let filter = Filter::new() 430 .authors([pk.bytes()]) 431 .kinds([1]) 432 .limit(max_results) 433 .build(); 434 435 let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?; 436 Some(qrs.into_iter().map(NoteRef::from_query_result).collect()) 437 } 438 439 fn search_hashtag( 440 hashtag_name: &str, 441 ndb: &Ndb, 442 txn: &Transaction, 443 max_results: u64, 444 ) -> Option<Vec<NoteRef>> { 445 let filter = Filter::new() 446 .kinds([1]) 447 .limit(max_results) 448 .tags([hashtag_name], 't') 449 .build(); 450 451 let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?; 452 Some(qrs.into_iter().map(NoteRef::from_query_result).collect()) 453 }