nip51_set.rs (13968B)
1 use bitflags::bitflags; 2 use egui::{vec2, Checkbox, CornerRadius, Margin, RichText, Sense, UiBuilder}; 3 use enostr::Pubkey; 4 use hashbrown::{hash_map::RawEntryMut, HashMap}; 5 use nostrdb::{Ndb, ProfileRecord, Transaction}; 6 use notedeck::{ 7 fonts::get_font_size, get_profile_url, name::get_display_name, tr, tr_plural, Images, 8 Localization, MediaJobSender, Nip51Set, Nip51SetCache, NotedeckTextStyle, 9 }; 10 11 use crate::{ 12 note::media::{render_media, ScaledTextureFlags}, 13 ProfilePic, 14 }; 15 16 pub struct Nip51SetWidget<'a> { 17 state: &'a Nip51SetCache, 18 ui_state: &'a mut Nip51SetUiCache, 19 ndb: &'a Ndb, 20 images: &'a mut Images, 21 loc: &'a mut Localization, 22 jobs: &'a MediaJobSender, 23 flags: Nip51SetWidgetFlags, 24 } 25 26 bitflags! { 27 #[repr(transparent)] 28 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 29 pub struct Nip51SetWidgetFlags: u8 { 30 const REQUIRES_TITLE = 1u8; 31 const REQUIRES_IMAGE = 2u8; 32 const REQUIRES_DESCRIPTION = 3u8; 33 const NON_EMPTY_PKS = 4u8; 34 const TRUST_IMAGES = 5u8; 35 } 36 } 37 38 impl Default for Nip51SetWidgetFlags { 39 fn default() -> Self { 40 Self::empty() 41 } 42 } 43 44 pub enum Nip51SetWidgetAction { 45 ViewProfile(Pubkey), 46 } 47 48 impl<'a> Nip51SetWidget<'a> { 49 pub fn new( 50 state: &'a Nip51SetCache, 51 ui_state: &'a mut Nip51SetUiCache, 52 ndb: &'a Ndb, 53 loc: &'a mut Localization, 54 images: &'a mut Images, 55 jobs: &'a MediaJobSender, 56 ) -> Self { 57 Self { 58 state, 59 ui_state, 60 ndb, 61 loc, 62 images, 63 jobs, 64 flags: Nip51SetWidgetFlags::default(), 65 } 66 } 67 68 pub fn with_flags(mut self, flags: Nip51SetWidgetFlags) -> Self { 69 self.flags = flags; 70 self 71 } 72 73 fn render_set(&mut self, ui: &mut egui::Ui, set: &Nip51Set) -> Nip51SetWidgetResponse { 74 if should_skip(set, &self.flags) { 75 return Nip51SetWidgetResponse { 76 action: None, 77 rendered: false, 78 visibility_changed: false, 79 }; 80 } 81 82 let pack_resp = egui::Frame::new() 83 .corner_radius(CornerRadius::same(8)) 84 //.fill(ui.visuals().extreme_bg_color) 85 .inner_margin(Margin::same(8)) 86 .show(ui, |ui| { 87 render_pack( 88 ui, 89 set, 90 self.ui_state, 91 self.ndb, 92 self.images, 93 self.jobs, 94 self.loc, 95 self.flags.contains(Nip51SetWidgetFlags::TRUST_IMAGES), 96 ) 97 }) 98 .inner; 99 100 Nip51SetWidgetResponse { 101 action: pack_resp.action, 102 rendered: true, 103 visibility_changed: pack_resp.visibility_changed, 104 } 105 } 106 107 pub fn render_at_index(&mut self, ui: &mut egui::Ui, index: usize) -> Nip51SetWidgetResponse { 108 let Some(set) = self.state.at_index(index) else { 109 return Nip51SetWidgetResponse { 110 action: None, 111 rendered: false, 112 visibility_changed: false, 113 }; 114 }; 115 116 self.render_set(ui, set) 117 } 118 119 pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<Nip51SetWidgetAction> { 120 let mut resp = None; 121 for pack in self.state.iter() { 122 let res = self.render_set(ui, pack); 123 124 if let Some(action) = res.action { 125 resp = Some(action); 126 } 127 128 if !res.rendered { 129 continue; 130 } 131 132 ui.add_space(8.0); 133 } 134 135 resp 136 } 137 } 138 139 pub struct Nip51SetWidgetResponse { 140 pub action: Option<Nip51SetWidgetAction>, 141 pub rendered: bool, 142 pub visibility_changed: bool, 143 } 144 145 fn should_skip(set: &Nip51Set, required: &Nip51SetWidgetFlags) -> bool { 146 (required.contains(Nip51SetWidgetFlags::REQUIRES_TITLE) && set.title.is_none()) 147 || (required.contains(Nip51SetWidgetFlags::REQUIRES_IMAGE) && set.image.is_none()) 148 || (required.contains(Nip51SetWidgetFlags::REQUIRES_DESCRIPTION) 149 && set.description.is_none()) 150 || (required.contains(Nip51SetWidgetFlags::NON_EMPTY_PKS) && set.pks.is_empty()) 151 } 152 153 /// Internal response type from rendering a follow pack. Tracks user actions and whether the 154 /// pack's visibility state changed. 155 struct RenderPackResponse { 156 action: Option<Nip51SetWidgetAction>, 157 visibility_changed: bool, 158 } 159 160 /// Renders a "Select All" checkbox for a follow pack. 161 /// When toggled, applies the selection state to all profiles in the pack. 162 fn select_all_ui( 163 pack: &Nip51Set, 164 ui_state: &mut Nip51SetUiCache, 165 loc: &mut Localization, 166 ui: &mut egui::Ui, 167 ) { 168 let select_all_resp = ui.checkbox( 169 ui_state.get_select_all_state(&pack.identifier), 170 format!( 171 "{} ({})", 172 tr!( 173 loc, 174 "Select All", 175 "Button to select all profiles in follow pack" 176 ), 177 pack.pks.len() 178 ), 179 ); 180 181 let new_select_all_state = if select_all_resp.clicked() { 182 Some(*ui_state.get_select_all_state(&pack.identifier)) 183 } else { 184 None 185 }; 186 187 if let Some(use_state) = new_select_all_state { 188 ui_state.apply_select_all_to_pack(&pack.identifier, &pack.pks, use_state); 189 } 190 } 191 192 #[allow(clippy::too_many_arguments)] 193 fn render_pack( 194 ui: &mut egui::Ui, 195 pack: &Nip51Set, 196 ui_state: &mut Nip51SetUiCache, 197 ndb: &Ndb, 198 images: &mut Images, 199 jobs: &MediaJobSender, 200 loc: &mut Localization, 201 image_trusted: bool, 202 ) -> RenderPackResponse { 203 let max_img_size = vec2(ui.available_width(), 200.0); 204 205 ui.allocate_new_ui(UiBuilder::new(), |ui| 's: { 206 let Some(url) = &pack.image else { 207 break 's; 208 }; 209 let Some(media) = images.get_renderable_media(url) else { 210 break 's; 211 }; 212 213 let media_rect = render_media( 214 ui, 215 images, 216 jobs, 217 &media, 218 image_trusted, 219 loc, 220 max_img_size, 221 None, 222 ScaledTextureFlags::RESPECT_MAX_DIMS, 223 ) 224 .response 225 .rect; 226 227 ui.advance_cursor_after_rect(media_rect); 228 }); 229 230 ui.add_space(4.0); 231 232 let mut action = None; 233 234 if let Some(title) = &pack.title { 235 ui.add(egui::Label::new(egui::RichText::new(title).size( 236 get_font_size(ui.ctx(), ¬edeck::NotedeckTextStyle::Heading), 237 ))); 238 } 239 240 if let Some(desc) = &pack.description { 241 ui.add(egui::Label::new( 242 egui::RichText::new(desc) 243 .size(get_font_size( 244 ui.ctx(), 245 ¬edeck::NotedeckTextStyle::Heading3, 246 )) 247 .color(ui.visuals().weak_text_color()), 248 )); 249 } 250 251 let pack_len = pack.pks.len(); 252 let default_open = pack_len < 6; 253 254 let r = egui::CollapsingHeader::new(tr_plural!( 255 loc, 256 "{pack_len} person", 257 "{pack_len} people", 258 "Label showing count of people in a follow pack", 259 pack_len, 260 )) 261 .default_open(default_open) 262 .show(ui, |ui| { 263 select_all_ui(pack, ui_state, loc, ui); 264 265 let txn = Transaction::new(ndb).expect("txn"); 266 267 for pk in &pack.pks { 268 let m_profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); 269 270 let cur_state = ui_state.get_pk_selected_state(&pack.identifier, pk); 271 272 crate::hline(ui); 273 274 if render_profile_item(ui, images, jobs, m_profile.as_ref(), cur_state).clicked() { 275 action = Some(Nip51SetWidgetAction::ViewProfile(*pk)); 276 } 277 } 278 }); 279 280 let visibility_changed = r.header_response.clicked(); 281 RenderPackResponse { 282 action, 283 visibility_changed, 284 } 285 } 286 287 const PFP_SIZE: f32 = 32.0; 288 289 fn render_profile_item( 290 ui: &mut egui::Ui, 291 images: &mut Images, 292 jobs: &MediaJobSender, 293 profile: Option<&ProfileRecord>, 294 checked: &mut bool, 295 ) -> egui::Response { 296 let (card_rect, card_resp) = 297 ui.allocate_exact_size(vec2(ui.available_width(), PFP_SIZE), egui::Sense::click()); 298 299 let mut clicked_response = card_resp; 300 301 let checkbox_size = { 302 let mut size = egui::Vec2::splat(ui.spacing().interact_size.y); 303 size.y = size.y.max(ui.spacing().icon_width); 304 size 305 }; 306 307 let (checkbox_section_rect, remaining_rect) = 308 card_rect.split_left_right_at_x(card_rect.left() + checkbox_size.x + 8.0); 309 310 let checkbox_rect = egui::Rect::from_center_size(checkbox_section_rect.center(), checkbox_size); 311 312 let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(checkbox_rect), |ui| { 313 ui.add(Checkbox::without_text(checked)); 314 }); 315 ui.advance_cursor_after_rect(checkbox_rect); 316 317 clicked_response = clicked_response.union(resp.response); 318 319 let (pfp_rect, body_rect) = 320 remaining_rect.split_left_right_at_x(remaining_rect.left() + PFP_SIZE); 321 322 let pfp_response = ui 323 .allocate_new_ui(UiBuilder::new().max_rect(pfp_rect), |ui| { 324 ui.add( 325 &mut ProfilePic::new(images, jobs, get_profile_url(profile)) 326 .sense(Sense::click()) 327 .size(PFP_SIZE), 328 ) 329 }) 330 .inner; 331 332 ui.advance_cursor_after_rect(pfp_rect); 333 334 let (_, body_rect) = body_rect.split_left_right_at_x(body_rect.left() + 8.0); 335 336 let (name_rect, description_rect) = body_rect.split_top_bottom_at_fraction(0.5); 337 338 let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(name_rect), |ui| { 339 let name = get_display_name(profile); 340 341 let painter = ui.painter_at(name_rect); 342 343 let mut left_x_pos = name_rect.left(); 344 345 if let Some(disp) = name.display_name { 346 let galley = painter.layout_no_wrap( 347 disp.to_owned(), 348 NotedeckTextStyle::Body.get_font_id(ui.ctx()), 349 ui.visuals().text_color(), 350 ); 351 352 left_x_pos += galley.rect.width() + 4.0; 353 354 painter.galley(name_rect.min, galley, ui.visuals().text_color()); 355 } 356 357 if let Some(username) = name.username { 358 let galley = painter.layout_no_wrap( 359 format!("@{username}"), 360 NotedeckTextStyle::Small.get_font_id(ui.ctx()), 361 crate::colors::MID_GRAY, 362 ); 363 364 let pos = { 365 let mut pos = name_rect.min; 366 pos.x = left_x_pos; 367 368 let padding = name_rect.height() - galley.rect.height(); 369 370 pos.y += padding * 2.5; 371 372 pos 373 }; 374 painter.galley(pos, galley, ui.visuals().text_color()); 375 } 376 }); 377 ui.advance_cursor_after_rect(name_rect); 378 379 clicked_response = clicked_response.union(resp.response); 380 381 let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(description_rect), |ui| 's: { 382 let Some(record) = profile else { 383 break 's; 384 }; 385 386 let Some(ndb_profile) = record.record().profile() else { 387 break 's; 388 }; 389 390 let Some(about) = ndb_profile.about() else { 391 break 's; 392 }; 393 394 ui.add( 395 egui::Label::new( 396 RichText::new(about) 397 .color(ui.visuals().weak_text_color()) 398 .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)), 399 ) 400 .selectable(false) 401 .truncate(), 402 ); 403 }); 404 405 ui.advance_cursor_after_rect(description_rect); 406 407 clicked_response = clicked_response.union(resp.response); 408 409 if clicked_response.clicked() { 410 *checked = !*checked; 411 } 412 413 pfp_response 414 } 415 416 #[derive(Default)] 417 pub struct Nip51SetUiCache { 418 state: HashMap<String, Nip51SetUiState>, 419 } 420 421 #[derive(Default)] 422 struct Nip51SetUiState { 423 select_all: bool, 424 select_pk: HashMap<Pubkey, bool>, 425 } 426 427 impl Nip51SetUiCache { 428 /// Gets or creates a mutable reference to the UI state for a given pack identifier. If the 429 /// pack state doesn't exist, it will be initialized with default values. 430 fn entry_for_pack(&mut self, identifier: &str) -> &mut Nip51SetUiState { 431 match self.state.raw_entry_mut().from_key(identifier) { 432 RawEntryMut::Occupied(entry) => entry.into_mut(), 433 RawEntryMut::Vacant(entry) => { 434 let (_, pack_state) = 435 entry.insert(identifier.to_owned(), Nip51SetUiState::default()); 436 437 pack_state 438 } 439 } 440 } 441 442 pub fn get_pk_selected_state(&mut self, identifier: &str, pk: &Pubkey) -> &mut bool { 443 let pack_state = self.entry_for_pack(identifier); 444 445 match pack_state.select_pk.raw_entry_mut().from_key(pk) { 446 RawEntryMut::Occupied(entry) => entry.into_mut(), 447 RawEntryMut::Vacant(entry) => { 448 let (_, state) = entry.insert(*pk, false); 449 state 450 } 451 } 452 } 453 454 pub fn get_select_all_state(&mut self, identifier: &str) -> &mut bool { 455 &mut self.entry_for_pack(identifier).select_all 456 } 457 458 /// Applies a selection state to all profiles in a pack. Updates both the pack's select_all 459 /// flag and individual profile selection states. 460 pub fn apply_select_all_to_pack(&mut self, identifier: &str, pks: &[Pubkey], value: bool) { 461 let pack_state = self.entry_for_pack(identifier); 462 pack_state.select_all = value; 463 464 for pk in pks { 465 pack_state.select_pk.insert(*pk, value); 466 } 467 } 468 469 pub fn get_all_selected(&self) -> Vec<Pubkey> { 470 let mut pks = Vec::new(); 471 472 for pack in self.state.values() { 473 for (pk, select_state) in &pack.select_pk { 474 if !*select_state { 475 continue; 476 } 477 478 pks.push(*pk); 479 } 480 } 481 482 pks 483 } 484 }