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 }