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 }