notedeck

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

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(), &notedeck::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                     &notedeck::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 }