notedeck

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

contacts_list.rs (8241B)


      1 use std::collections::HashSet;
      2 
      3 use crate::ProfilePic;
      4 use egui::{RichText, Sense, Stroke};
      5 use enostr::Pubkey;
      6 use nostrdb::{Ndb, ProfileRecord, Transaction};
      7 use notedeck::{
      8     name::get_display_name, profile::get_profile_url, tr, ContactState, DragResponse, Images,
      9     Localization, MediaJobSender,
     10 };
     11 
     12 /// Configuration options for profile row rendering.
     13 #[derive(Default)]
     14 pub struct ProfileRowOptions {
     15     /// Show "Contact" badge next to the name
     16     pub show_contact_badge: bool,
     17     /// Show X button on the right (visual only - deletion handled by caller)
     18     pub show_x_button: bool,
     19     /// Highlight as selected (keyboard navigation)
     20     pub is_selected: bool,
     21 }
     22 
     23 impl ProfileRowOptions {
     24     pub fn new() -> Self {
     25         Self::default()
     26     }
     27 
     28     pub fn contact_badge(mut self, show: bool) -> Self {
     29         self.show_contact_badge = show;
     30         self
     31     }
     32 
     33     pub fn x_button(mut self, show: bool) -> Self {
     34         self.show_x_button = show;
     35         self
     36     }
     37 
     38     pub fn selected(mut self, selected: bool) -> Self {
     39         self.is_selected = selected;
     40         self
     41     }
     42 }
     43 
     44 /// Render a profile row with picture and name, with configurable options.
     45 /// Returns the response for handling clicks.
     46 pub fn profile_row_widget<'a>(
     47     profile: Option<&'a ProfileRecord<'a>>,
     48     img_cache: &'a mut Images,
     49     jobs: &'a MediaJobSender,
     50     i18n: &'a mut Localization,
     51     options: ProfileRowOptions,
     52 ) -> impl egui::Widget + 'a {
     53     move |ui: &mut egui::Ui| -> egui::Response {
     54         let (rect, resp) =
     55             ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click());
     56 
     57         if !ui.clip_rect().intersects(rect) {
     58             return resp;
     59         }
     60 
     61         let name_str = get_display_name(profile).name();
     62         let profile_url = get_profile_url(profile);
     63 
     64         let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
     65 
     66         // Selection highlighting
     67         if options.is_selected {
     68             ui.painter()
     69                 .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill);
     70         }
     71 
     72         // Hover highlighting
     73         if resp.hovered() {
     74             ui.painter()
     75                 .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill);
     76         }
     77 
     78         let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect));
     79         child_ui.horizontal(|ui| {
     80             ui.add_space(4.0);
     81             ui.add(&mut ProfilePic::new(img_cache, jobs, profile_url).size(48.0));
     82             ui.add_space(8.0);
     83             ui.add(
     84                 egui::Label::new(
     85                     RichText::new(name_str)
     86                         .size(16.0)
     87                         .color(ui.visuals().text_color()),
     88                 )
     89                 .selectable(false),
     90             );
     91             if options.show_contact_badge {
     92                 ui.add_space(8.0);
     93                 let badge_text = tr!(
     94                     i18n,
     95                     "Contact",
     96                     "Badge indicating this profile is in contacts"
     97                 );
     98                 ui.add(
     99                     egui::Label::new(
    100                         RichText::new(badge_text)
    101                             .size(12.0)
    102                             .color(ui.visuals().weak_text_color()),
    103                     )
    104                     .selectable(false),
    105                 );
    106             }
    107         });
    108 
    109         // Draw X button on the right
    110         if options.show_x_button {
    111             let x_button_size = 32.0;
    112             let x_size = 12.0;
    113             let x_rect = egui::Rect::from_min_size(
    114                 egui::Pos2::new(rect.right() - x_button_size, rect.top()),
    115                 egui::vec2(x_button_size, rect.height()),
    116             );
    117             let x_center = x_rect.center();
    118             let painter = ui.painter();
    119             painter.line_segment(
    120                 [
    121                     egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0),
    122                     egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0),
    123                 ],
    124                 Stroke::new(1.5, ui.visuals().text_color()),
    125             );
    126             painter.line_segment(
    127                 [
    128                     egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0),
    129                     egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0),
    130                 ],
    131                 Stroke::new(1.5, ui.visuals().text_color()),
    132             );
    133         }
    134 
    135         resp
    136     }
    137 }
    138 
    139 /// Render a profile row with picture and name, optionally showing a contact badge. Returns true if clicked.
    140 pub fn profile_row(
    141     ui: &mut egui::Ui,
    142     profile: Option<&ProfileRecord<'_>>,
    143     is_contact: bool,
    144     img_cache: &mut Images,
    145     jobs: &MediaJobSender,
    146     i18n: &mut Localization,
    147 ) -> bool {
    148     let options = ProfileRowOptions::new().contact_badge(is_contact);
    149     ui.add(profile_row_widget(profile, img_cache, jobs, i18n, options))
    150         .clicked()
    151 }
    152 
    153 pub struct ContactsListView<'a, 'txn, I> {
    154     contacts: I,
    155     jobs: &'a MediaJobSender,
    156     ndb: &'a Ndb,
    157     img_cache: &'a mut Images,
    158     txn: &'txn Transaction,
    159     i18n: &'a mut Localization,
    160 }
    161 
    162 #[derive(Clone)]
    163 pub enum ContactsListAction {
    164     Select(Pubkey),
    165 }
    166 
    167 impl<'a, 'txn, I> ContactsListView<'a, 'txn, I>
    168 where
    169     I: IntoIterator<Item = &'a Pubkey>,
    170 {
    171     pub fn new(
    172         contacts: I,
    173         jobs: &'a MediaJobSender,
    174         ndb: &'a Ndb,
    175         img_cache: &'a mut Images,
    176         txn: &'txn Transaction,
    177         i18n: &'a mut Localization,
    178     ) -> Self {
    179         ContactsListView {
    180             contacts,
    181             ndb,
    182             img_cache,
    183             txn,
    184             jobs,
    185             i18n,
    186         }
    187     }
    188 
    189     pub fn ui(self, ui: &mut egui::Ui) -> DragResponse<ContactsListAction> {
    190         let mut action = None;
    191 
    192         egui::ScrollArea::vertical().show(ui, |ui| {
    193             for contact_pubkey in self.contacts {
    194                 let profile = self
    195                     .ndb
    196                     .get_profile_by_pubkey(self.txn, contact_pubkey.bytes())
    197                     .ok();
    198 
    199                 if profile_row(
    200                     ui,
    201                     profile.as_ref(),
    202                     false,
    203                     self.img_cache,
    204                     self.jobs,
    205                     self.i18n,
    206                 ) {
    207                     action = Some(ContactsListAction::Select(*contact_pubkey));
    208                 }
    209             }
    210         });
    211 
    212         DragResponse::output(action)
    213     }
    214 }
    215 
    216 /// A profile search result.
    217 pub struct ProfileSearchResult {
    218     /// The public key bytes of the matched profile.
    219     pub pk: [u8; 32],
    220     /// Whether this profile is in the user's contacts.
    221     pub is_contact: bool,
    222 }
    223 
    224 /// Searches for profiles matching `query`, prioritizing contacts first and deduplicating.
    225 /// Contacts that match appear first, followed by non-contact results.
    226 /// Returns up to `max_results` matches.
    227 pub fn search_profiles(
    228     ndb: &Ndb,
    229     txn: &Transaction,
    230     query: &str,
    231     contacts_state: &ContactState,
    232     max_results: usize,
    233 ) -> Vec<ProfileSearchResult> {
    234     let contacts_set = match contacts_state {
    235         ContactState::Received { contacts, .. } => Some(contacts),
    236         _ => None,
    237     };
    238 
    239     // Get ndb search results and partition into contacts and non-contacts
    240     let mut contact_results: Vec<ProfileSearchResult> = Vec::new();
    241     let mut other_results: Vec<ProfileSearchResult> = Vec::new();
    242     let mut seen: HashSet<&[u8; 32]> = HashSet::new();
    243 
    244     if let Ok(pks) = ndb.search_profile(txn, query, max_results as u32) {
    245         for pk_bytes in pks {
    246             // Skip duplicates
    247             if seen.contains(pk_bytes) {
    248                 continue;
    249             }
    250             seen.insert(pk_bytes);
    251 
    252             let is_contact = contacts_set.is_some_and(|c| c.contains(pk_bytes));
    253             let result = ProfileSearchResult {
    254                 pk: *pk_bytes,
    255                 is_contact,
    256             };
    257             if is_contact {
    258                 contact_results.push(result);
    259             } else {
    260                 other_results.push(result);
    261             }
    262         }
    263     }
    264 
    265     // Combine: contacts first, then others
    266     contact_results.extend(other_results);
    267     contact_results.truncate(max_results);
    268     contact_results
    269 }