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 }