nip51_set.rs (13875B)
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, Images, Localization, 8 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(format!("{} people", pack_len)) 255 .default_open(default_open) 256 .show(ui, |ui| { 257 select_all_ui(pack, ui_state, loc, ui); 258 259 let txn = Transaction::new(ndb).expect("txn"); 260 261 for pk in &pack.pks { 262 let m_profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); 263 264 let cur_state = ui_state.get_pk_selected_state(&pack.identifier, pk); 265 266 crate::hline(ui); 267 268 if render_profile_item(ui, images, jobs, m_profile.as_ref(), cur_state).clicked() { 269 action = Some(Nip51SetWidgetAction::ViewProfile(*pk)); 270 } 271 } 272 }); 273 274 let visibility_changed = r.header_response.clicked(); 275 RenderPackResponse { 276 action, 277 visibility_changed, 278 } 279 } 280 281 const PFP_SIZE: f32 = 32.0; 282 283 fn render_profile_item( 284 ui: &mut egui::Ui, 285 images: &mut Images, 286 jobs: &MediaJobSender, 287 profile: Option<&ProfileRecord>, 288 checked: &mut bool, 289 ) -> egui::Response { 290 let (card_rect, card_resp) = 291 ui.allocate_exact_size(vec2(ui.available_width(), PFP_SIZE), egui::Sense::click()); 292 293 let mut clicked_response = card_resp; 294 295 let checkbox_size = { 296 let mut size = egui::Vec2::splat(ui.spacing().interact_size.y); 297 size.y = size.y.max(ui.spacing().icon_width); 298 size 299 }; 300 301 let (checkbox_section_rect, remaining_rect) = 302 card_rect.split_left_right_at_x(card_rect.left() + checkbox_size.x + 8.0); 303 304 let checkbox_rect = egui::Rect::from_center_size(checkbox_section_rect.center(), checkbox_size); 305 306 let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(checkbox_rect), |ui| { 307 ui.add(Checkbox::without_text(checked)); 308 }); 309 ui.advance_cursor_after_rect(checkbox_rect); 310 311 clicked_response = clicked_response.union(resp.response); 312 313 let (pfp_rect, body_rect) = 314 remaining_rect.split_left_right_at_x(remaining_rect.left() + PFP_SIZE); 315 316 let pfp_response = ui 317 .allocate_new_ui(UiBuilder::new().max_rect(pfp_rect), |ui| { 318 ui.add( 319 &mut ProfilePic::new(images, jobs, get_profile_url(profile)) 320 .sense(Sense::click()) 321 .size(PFP_SIZE), 322 ) 323 }) 324 .inner; 325 326 ui.advance_cursor_after_rect(pfp_rect); 327 328 let (_, body_rect) = body_rect.split_left_right_at_x(body_rect.left() + 8.0); 329 330 let (name_rect, description_rect) = body_rect.split_top_bottom_at_fraction(0.5); 331 332 let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(name_rect), |ui| { 333 let name = get_display_name(profile); 334 335 let painter = ui.painter_at(name_rect); 336 337 let mut left_x_pos = name_rect.left(); 338 339 if let Some(disp) = name.display_name { 340 let galley = painter.layout_no_wrap( 341 disp.to_owned(), 342 NotedeckTextStyle::Body.get_font_id(ui.ctx()), 343 ui.visuals().text_color(), 344 ); 345 346 left_x_pos += galley.rect.width() + 4.0; 347 348 painter.galley(name_rect.min, galley, ui.visuals().text_color()); 349 } 350 351 if let Some(username) = name.username { 352 let galley = painter.layout_no_wrap( 353 format!("@{username}"), 354 NotedeckTextStyle::Small.get_font_id(ui.ctx()), 355 crate::colors::MID_GRAY, 356 ); 357 358 let pos = { 359 let mut pos = name_rect.min; 360 pos.x = left_x_pos; 361 362 let padding = name_rect.height() - galley.rect.height(); 363 364 pos.y += padding * 2.5; 365 366 pos 367 }; 368 painter.galley(pos, galley, ui.visuals().text_color()); 369 } 370 }); 371 ui.advance_cursor_after_rect(name_rect); 372 373 clicked_response = clicked_response.union(resp.response); 374 375 let resp = ui.allocate_new_ui(UiBuilder::new().max_rect(description_rect), |ui| 's: { 376 let Some(record) = profile else { 377 break 's; 378 }; 379 380 let Some(ndb_profile) = record.record().profile() else { 381 break 's; 382 }; 383 384 let Some(about) = ndb_profile.about() else { 385 break 's; 386 }; 387 388 ui.add( 389 egui::Label::new( 390 RichText::new(about) 391 .color(ui.visuals().weak_text_color()) 392 .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)), 393 ) 394 .selectable(false) 395 .truncate(), 396 ); 397 }); 398 399 ui.advance_cursor_after_rect(description_rect); 400 401 clicked_response = clicked_response.union(resp.response); 402 403 if clicked_response.clicked() { 404 *checked = !*checked; 405 } 406 407 pfp_response 408 } 409 410 #[derive(Default)] 411 pub struct Nip51SetUiCache { 412 state: HashMap<String, Nip51SetUiState>, 413 } 414 415 #[derive(Default)] 416 struct Nip51SetUiState { 417 select_all: bool, 418 select_pk: HashMap<Pubkey, bool>, 419 } 420 421 impl Nip51SetUiCache { 422 /// Gets or creates a mutable reference to the UI state for a given pack identifier. If the 423 /// pack state doesn't exist, it will be initialized with default values. 424 fn entry_for_pack(&mut self, identifier: &str) -> &mut Nip51SetUiState { 425 match self.state.raw_entry_mut().from_key(identifier) { 426 RawEntryMut::Occupied(entry) => entry.into_mut(), 427 RawEntryMut::Vacant(entry) => { 428 let (_, pack_state) = 429 entry.insert(identifier.to_owned(), Nip51SetUiState::default()); 430 431 pack_state 432 } 433 } 434 } 435 436 pub fn get_pk_selected_state(&mut self, identifier: &str, pk: &Pubkey) -> &mut bool { 437 let pack_state = self.entry_for_pack(identifier); 438 439 match pack_state.select_pk.raw_entry_mut().from_key(pk) { 440 RawEntryMut::Occupied(entry) => entry.into_mut(), 441 RawEntryMut::Vacant(entry) => { 442 let (_, state) = entry.insert(*pk, false); 443 state 444 } 445 } 446 } 447 448 pub fn get_select_all_state(&mut self, identifier: &str) -> &mut bool { 449 &mut self.entry_for_pack(identifier).select_all 450 } 451 452 /// Applies a selection state to all profiles in a pack. Updates both the pack's select_all 453 /// flag and individual profile selection states. 454 pub fn apply_select_all_to_pack(&mut self, identifier: &str, pks: &[Pubkey], value: bool) { 455 let pack_state = self.entry_for_pack(identifier); 456 pack_state.select_all = value; 457 458 for pk in pks { 459 pack_state.select_pk.insert(*pk, value); 460 } 461 } 462 463 pub fn get_all_selected(&self) -> Vec<Pubkey> { 464 let mut pks = Vec::new(); 465 466 for pack in self.state.values() { 467 for (pk, select_state) in &pack.select_pk { 468 if !*select_state { 469 continue; 470 } 471 472 pks.push(*pk); 473 } 474 } 475 476 pks 477 } 478 }