notedeck

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

custom_zap.rs (14802B)


      1 use egui::{
      2     emath::GuiRounding, pos2, vec2, Color32, CornerRadius, FontId, Frame, Label, Layout, Slider,
      3     Stroke,
      4 };
      5 use enostr::Pubkey;
      6 use nostrdb::{Ndb, ProfileRecord, Transaction};
      7 use notedeck::{
      8     fonts::get_font_size, get_profile_url, name::get_display_name, tr, Images, Localization,
      9     MediaJobSender, NotedeckTextStyle,
     10 };
     11 use notedeck_ui::{
     12     app_images, colors, profile::display_name_widget, widgets::styled_button_toggleable,
     13     AnimationHelper, ProfilePic,
     14 };
     15 
     16 pub struct CustomZapView<'a> {
     17     images: &'a mut Images,
     18     ndb: &'a Ndb,
     19     txn: &'a Transaction,
     20     target_pubkey: &'a Pubkey,
     21     default_msats: u64,
     22     i18n: &'a mut Localization,
     23     jobs: &'a MediaJobSender,
     24 }
     25 
     26 #[allow(clippy::new_without_default)]
     27 impl<'a> CustomZapView<'a> {
     28     pub fn new(
     29         i18n: &'a mut Localization,
     30         images: &'a mut Images,
     31         ndb: &'a Ndb,
     32         txn: &'a Transaction,
     33         target_pubkey: &'a Pubkey,
     34         default_msats: u64,
     35         jobs: &'a MediaJobSender,
     36     ) -> Self {
     37         Self {
     38             target_pubkey,
     39             images,
     40             ndb,
     41             txn,
     42             default_msats,
     43             i18n,
     44             jobs,
     45         }
     46     }
     47 
     48     pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<u64> {
     49         egui::Frame::NONE
     50             .inner_margin(egui::Margin::same(8))
     51             .show(ui, |ui| self.ui_internal(ui))
     52             .inner
     53     }
     54 
     55     fn ui_internal(&mut self, ui: &mut egui::Ui) -> Option<u64> {
     56         show_title(ui, self.i18n);
     57 
     58         ui.add_space(16.0);
     59 
     60         let profile = self
     61             .ndb
     62             .get_profile_by_pubkey(self.txn, self.target_pubkey.bytes())
     63             .ok();
     64         let profile = profile.as_ref();
     65         show_profile(ui, self.images, self.jobs, profile);
     66 
     67         ui.add_space(8.0);
     68 
     69         let slider_width = {
     70             let desired_slider_width = ui.available_width() * 0.6;
     71             if desired_slider_width < 224.0 {
     72                 224.0
     73             } else {
     74                 desired_slider_width
     75             }
     76         };
     77 
     78         let id = ui.id().with(("CustomZap", self.target_pubkey));
     79 
     80         let default_sats = self.default_msats / 1000;
     81         ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
     82             ui.spacing_mut().item_spacing = vec2(0.0, 16.0);
     83             ui.spacing_mut().slider_width = slider_width;
     84 
     85             let mut cur_amount = if let Some(input) = ui.data(|d| d.get_temp(id)) {
     86                 input
     87             } else {
     88                 (self.default_msats / 1000).to_string()
     89             };
     90             show_amount(ui, self.i18n, id, &mut cur_amount, slider_width);
     91             let mut maybe_sats = cur_amount.parse::<u64>().ok();
     92 
     93             let prev_slider_sats = maybe_sats.unwrap_or(default_sats).clamp(1, 100000);
     94             let mut slider_sats = prev_slider_sats;
     95             ui.allocate_new_ui(egui::UiBuilder::new(), |ui| {
     96                 ui.set_width(slider_width);
     97                 ui.add(
     98                     Slider::new(&mut slider_sats, 1..=100000)
     99                         .logarithmic(true)
    100                         .trailing_fill(true)
    101                         .show_value(false),
    102                 );
    103             });
    104 
    105             if slider_sats != prev_slider_sats {
    106                 cur_amount = slider_sats.to_string();
    107                 maybe_sats = Some(slider_sats);
    108             }
    109 
    110             if let Some(selection) = show_selection_buttons(ui, maybe_sats, self.i18n) {
    111                 cur_amount = selection.to_string();
    112                 maybe_sats = Some(selection);
    113             }
    114 
    115             ui.data_mut(|d| d.insert_temp(id, cur_amount));
    116 
    117             let resp = ui.add(styled_button_toggleable(
    118                 &tr!(self.i18n, "Send", "Button label to send a zap"),
    119                 colors::PINK,
    120                 is_valid_zap(maybe_sats),
    121             ));
    122 
    123             if resp.clicked() {
    124                 maybe_sats.map(|i| i * 1000)
    125             } else {
    126                 None
    127             }
    128         })
    129         .inner
    130     }
    131 }
    132 
    133 fn is_valid_zap(amount: Option<u64>) -> bool {
    134     amount.is_some_and(|sats| sats > 0)
    135 }
    136 
    137 fn show_title(ui: &mut egui::Ui, i18n: &mut Localization) {
    138     let max_size = 32.0;
    139     ui.allocate_ui_with_layout(
    140         vec2(ui.available_width(), max_size),
    141         Layout::left_to_right(egui::Align::Center),
    142         |ui| {
    143             let (rect, _) = ui.allocate_exact_size(vec2(max_size, max_size), egui::Sense::hover());
    144             let painter = ui.painter_at(rect);
    145             let circle_color = lerp_color(
    146                 egui::Color32::from_rgb(0xFF, 0xB7, 0x57),
    147                 ui.visuals().noninteractive().bg_fill,
    148                 0.5,
    149             );
    150             painter.circle_filled(rect.center(), max_size / 2.0, circle_color);
    151 
    152             let zap_max_width = 25.16;
    153             let zap_max_height = 29.34;
    154             let img = app_images::filled_zap_image()
    155                 .max_width(zap_max_width)
    156                 .max_height(zap_max_height);
    157 
    158             let img_rect = rect
    159                 .shrink2(vec2(max_size - zap_max_width, max_size - zap_max_height))
    160                 .round_to_pixel_center(ui.pixels_per_point());
    161             img.paint_at(ui, img_rect);
    162 
    163             ui.add_space(8.0);
    164 
    165             ui.add(egui::Label::new(
    166                 egui::RichText::new(tr!(i18n, "Zap", "Heading for zap (tip) action"))
    167                     .text_style(NotedeckTextStyle::Heading2.text_style()),
    168             ));
    169         },
    170     );
    171 }
    172 
    173 fn show_profile(
    174     ui: &mut egui::Ui,
    175     images: &mut Images,
    176     jobs: &MediaJobSender,
    177     profile: Option<&ProfileRecord>,
    178 ) {
    179     let max_size = 24.0;
    180     ui.allocate_ui_with_layout(
    181         vec2(ui.available_width(), max_size),
    182         Layout::left_to_right(egui::Align::Center).with_main_wrap(true),
    183         |ui| {
    184             ui.add(&mut ProfilePic::new(images, jobs, get_profile_url(profile)).size(max_size));
    185             ui.vertical(|ui| {
    186                 ui.add(display_name_widget(&get_display_name(profile), false));
    187             });
    188         },
    189     );
    190 }
    191 
    192 fn show_amount(
    193     ui: &mut egui::Ui,
    194     i18n: &mut Localization,
    195     id: egui::Id,
    196     user_input: &mut String,
    197     width: f32,
    198 ) {
    199     let user_input_font = NotedeckTextStyle::Heading.get_bolded_font(ui.ctx());
    200 
    201     let user_input_id = id.with("sats_amount");
    202 
    203     let user_input_galley = ui.painter().layout_no_wrap(
    204         user_input.to_owned(),
    205         user_input_font.clone(),
    206         ui.visuals().text_color(),
    207     );
    208 
    209     let painter = ui.painter();
    210 
    211     let sats_galley = painter.layout_no_wrap(
    212         tr!(
    213             i18n,
    214             "SATS",
    215             "Label for satoshis (Bitcoin unit) for custom zap amount input field"
    216         ),
    217         NotedeckTextStyle::Heading4.get_font_id(ui.ctx()),
    218         ui.visuals().noninteractive().text_color(),
    219     );
    220 
    221     let user_input_rect = {
    222         let mut rect = user_input_galley.rect;
    223         rect.extend_with_x(user_input_galley.rect.left() - 8.0);
    224         rect
    225     };
    226     let sats_width = sats_galley.rect.width() + 8.0;
    227 
    228     Frame::NONE
    229         .fill(ui.visuals().noninteractive().weak_bg_fill)
    230         .corner_radius(8)
    231         .show(ui, |ui| {
    232             ui.set_width(width);
    233             ui.add_space(8.0);
    234             ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    235                 let textedit = egui::TextEdit::singleline(user_input)
    236                     .frame(false)
    237                     .id(user_input_id)
    238                     .font(user_input_font);
    239 
    240                 let amount_resp = ui.add(Label::new(
    241                     egui::RichText::new(tr!(i18n, "Amount", "Label for zap amount input field"))
    242                         .text_style(NotedeckTextStyle::Heading3.text_style())
    243                         .color(ui.visuals().noninteractive().text_color()),
    244                 ));
    245 
    246                 let user_input_padding = {
    247                     let available_width = ui.available_width();
    248                     if user_input_rect.width() + sats_width > available_width {
    249                         0.0
    250                     } else if (user_input_rect.width() / 2.0) + sats_width > (available_width / 2.0)
    251                     {
    252                         available_width - sats_width - user_input_rect.width()
    253                     } else {
    254                         (available_width / 2.0) - (user_input_rect.width() / 2.0)
    255                     }
    256                 };
    257 
    258                 let user_input_rect = {
    259                     let max_input_width = ui.available_width() - sats_width;
    260 
    261                     let user_input_size = if user_input_rect.width() > max_input_width {
    262                         vec2(max_input_width, user_input_rect.height())
    263                     } else {
    264                         user_input_rect.size()
    265                     };
    266 
    267                     let user_input_pos = pos2(
    268                         ui.available_rect_before_wrap().left() + user_input_padding,
    269                         amount_resp.rect.bottom(),
    270                     );
    271                     egui::Rect::from_min_size(user_input_pos, user_input_size)
    272                         .intersect(ui.available_rect_before_wrap())
    273                 };
    274 
    275                 let textout = ui
    276                     .allocate_new_ui(
    277                         egui::UiBuilder::new()
    278                             .max_rect(user_input_rect)
    279                             .layout(Layout::centered_and_justified(egui::Direction::TopDown)),
    280                         |ui| textedit.show(ui),
    281                     )
    282                     .inner;
    283 
    284                 let out_rect = textout.text_clip_rect;
    285 
    286                 ui.advance_cursor_after_rect(out_rect);
    287 
    288                 let sats_pos = pos2(
    289                     out_rect.right() + 8.0,
    290                     out_rect.center().y - (sats_galley.rect.height() / 2.0),
    291                 );
    292 
    293                 let sats_rect = egui::Rect::from_min_size(sats_pos, sats_galley.size());
    294                 ui.painter()
    295                     .galley(sats_pos, sats_galley, ui.visuals().text_color());
    296 
    297                 ui.advance_cursor_after_rect(sats_rect);
    298 
    299                 if !is_valid_zap(user_input.parse::<u64>().ok()) {
    300                     ui.colored_label(ui.visuals().warn_fg_color, "Please enter valid amount.");
    301                 }
    302                 ui.add_space(8.0);
    303             });
    304         });
    305 }
    306 
    307 const SELECTION_BUTTONS: [ZapSelectionButton; 8] = [
    308     ZapSelectionButton::First,
    309     ZapSelectionButton::Second,
    310     ZapSelectionButton::Third,
    311     ZapSelectionButton::Fourth,
    312     ZapSelectionButton::Fifth,
    313     ZapSelectionButton::Sixth,
    314     ZapSelectionButton::Seventh,
    315     ZapSelectionButton::Eighth,
    316 ];
    317 
    318 fn show_selection_buttons(
    319     ui: &mut egui::Ui,
    320     sats_selection: Option<u64>,
    321     i18n: &mut Localization,
    322 ) -> Option<u64> {
    323     let mut our_selection = None;
    324     ui.allocate_ui_with_layout(
    325         vec2(224.0, 116.0),
    326         Layout::left_to_right(egui::Align::Min).with_main_wrap(true),
    327         |ui| {
    328             ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
    329 
    330             for button in SELECTION_BUTTONS {
    331                 our_selection =
    332                     our_selection.or(show_selection_button(ui, sats_selection, button, i18n));
    333             }
    334         },
    335     );
    336 
    337     our_selection
    338 }
    339 
    340 fn show_selection_button(
    341     ui: &mut egui::Ui,
    342     sats_selection: Option<u64>,
    343     button: ZapSelectionButton,
    344     i18n: &mut Localization,
    345 ) -> Option<u64> {
    346     let (rect, _) = ui.allocate_exact_size(vec2(50.0, 50.0), egui::Sense::click());
    347     let helper = AnimationHelper::new_from_rect(ui, ("zap_selection_button", &button), rect);
    348     let painter = ui.painter();
    349 
    350     let corner = CornerRadius::same(8);
    351     painter.rect_filled(rect, corner, ui.visuals().noninteractive().weak_bg_fill);
    352 
    353     let amount = button.sats();
    354     let current_selected = if let Some(selection) = sats_selection {
    355         selection == amount
    356     } else {
    357         false
    358     };
    359 
    360     if current_selected {
    361         painter.rect_stroke(
    362             rect,
    363             corner,
    364             Stroke {
    365                 width: 1.0,
    366                 color: colors::PINK,
    367             },
    368             egui::StrokeKind::Inside,
    369         );
    370     }
    371 
    372     let fontid = FontId::new(
    373         helper.scale_1d_pos(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)),
    374         NotedeckTextStyle::Body.font_family(),
    375     );
    376 
    377     let galley = painter.layout_no_wrap(
    378         button.to_desc_string(i18n),
    379         fontid,
    380         ui.visuals().text_color(),
    381     );
    382     let text_rect = {
    383         let mut galley_rect = galley.rect;
    384         galley_rect.set_center(rect.center());
    385         galley_rect
    386     };
    387 
    388     painter.galley(text_rect.min, galley, ui.visuals().text_color());
    389 
    390     if helper.take_animation_response().clicked() {
    391         return Some(amount);
    392     }
    393 
    394     None
    395 }
    396 
    397 #[derive(Hash)]
    398 enum ZapSelectionButton {
    399     First,
    400     Second,
    401     Third,
    402     Fourth,
    403     Fifth,
    404     Sixth,
    405     Seventh,
    406     Eighth,
    407 }
    408 
    409 impl ZapSelectionButton {
    410     pub fn sats(&self) -> u64 {
    411         match self {
    412             ZapSelectionButton::First => 69,
    413             ZapSelectionButton::Second => 100,
    414             ZapSelectionButton::Third => 420,
    415             ZapSelectionButton::Fourth => 5_000,
    416             ZapSelectionButton::Fifth => 10_000,
    417             ZapSelectionButton::Sixth => 20_000,
    418             ZapSelectionButton::Seventh => 50_000,
    419             ZapSelectionButton::Eighth => 100_000,
    420         }
    421     }
    422 
    423     pub fn to_desc_string(&self, i18n: &mut Localization) -> String {
    424         match self {
    425             ZapSelectionButton::First => "69".to_string(),
    426             ZapSelectionButton::Second => "100".to_string(),
    427             ZapSelectionButton::Third => "420".to_string(),
    428             ZapSelectionButton::Fourth => tr!(i18n, "5K", "Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount."),
    429             ZapSelectionButton::Fifth => tr!(i18n, "10K", "Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount."),
    430             ZapSelectionButton::Sixth => tr!(i18n, "20K", "Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount."),
    431             ZapSelectionButton::Seventh => tr!(i18n, "50K", "Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount."),
    432             ZapSelectionButton::Eighth => tr!(i18n, "100K", "Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount."),
    433         }
    434     }
    435 }
    436 
    437 fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
    438     Color32::from_rgba_premultiplied(
    439         egui::lerp(a.r() as f32..=b.r() as f32, t) as u8,
    440         egui::lerp(a.g() as f32..=b.g() as f32, t) as u8,
    441         egui::lerp(a.b() as f32..=b.b() as f32, t) as u8,
    442         egui::lerp(a.a() as f32..=b.a() as f32, t) as u8,
    443     )
    444 }