notedeck

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

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 }