notedeck

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

custom_zap.rs (14770B)


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