notedeck

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

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