diff --git a/assets/icons/filled_zap_icon.svg b/assets/icons/filled_zap_icon.svg new file mode 100644 index 00000000..0b33fcdb --- /dev/null +++ b/assets/icons/filled_zap_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/notedeck_columns/src/ui/note/custom_zap.rs b/crates/notedeck_columns/src/ui/note/custom_zap.rs new file mode 100644 index 00000000..f9052e1a --- /dev/null +++ b/crates/notedeck_columns/src/ui/note/custom_zap.rs @@ -0,0 +1,417 @@ +use std::fmt::Display; + +use egui::{ + emath::GuiRounding, pos2, vec2, Color32, CornerRadius, FontId, Frame, Label, Layout, Slider, + Stroke, +}; +use enostr::Pubkey; +use nostrdb::{Ndb, ProfileRecord, Transaction}; +use notedeck::{ + fonts::get_font_size, get_profile_url, name::get_display_name, Images, NotedeckTextStyle, +}; +use notedeck_ui::{colors, profile::display_name_widget, AnimationHelper, ProfilePic}; + +use crate::ui::widgets::styled_button_toggleable; + +pub struct CustomZapView<'a> { + images: &'a mut Images, + ndb: &'a Ndb, + txn: &'a Transaction, + target_pubkey: &'a Pubkey, + default_msats: u64, +} + +#[allow(clippy::new_without_default)] +impl<'a> CustomZapView<'a> { + pub fn new( + images: &'a mut Images, + ndb: &'a Ndb, + txn: &'a Transaction, + target_pubkey: &'a Pubkey, + default_msats: u64, + ) -> Self { + Self { + target_pubkey, + images, + ndb, + txn, + default_msats, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { + egui::Frame::NONE + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| self.ui_internal(ui)) + .inner + } + + fn ui_internal(&mut self, ui: &mut egui::Ui) -> Option { + show_title(ui); + + ui.add_space(16.0); + + let profile = self + .ndb + .get_profile_by_pubkey(self.txn, self.target_pubkey.bytes()) + .ok(); + let profile = profile.as_ref(); + show_profile(ui, self.images, profile); + + ui.add_space(8.0); + + let slider_width = { + let desired_slider_width = ui.available_width() * 0.6; + if desired_slider_width < 224.0 { + 224.0 + } else { + desired_slider_width + } + }; + + let id = ui.id().with(("CustomZap", self.target_pubkey)); + + let default_sats = self.default_msats / 1000; + ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 16.0); + ui.spacing_mut().slider_width = slider_width; + + let mut cur_amount = if let Some(input) = ui.data(|d| d.get_temp(id)) { + input + } else { + (self.default_msats / 1000).to_string() + }; + show_amount(ui, id, &mut cur_amount, slider_width); + let mut maybe_sats = cur_amount.parse::().ok(); + + let prev_slider_sats = maybe_sats.unwrap_or(default_sats).clamp(1, 100000); + let mut slider_sats = prev_slider_sats; + ui.allocate_new_ui(egui::UiBuilder::new(), |ui| { + ui.set_width(slider_width); + ui.add( + Slider::new(&mut slider_sats, 1..=100000) + .logarithmic(true) + .trailing_fill(true) + .show_value(false), + ); + }); + + if slider_sats != prev_slider_sats { + cur_amount = slider_sats.to_string(); + maybe_sats = Some(slider_sats); + } + + if let Some(selection) = show_selection_buttons(ui, maybe_sats) { + cur_amount = selection.to_string(); + maybe_sats = Some(selection); + } + + ui.data_mut(|d| d.insert_temp(id, cur_amount)); + + let resp = ui.add(styled_button_toggleable( + "Send", + colors::PINK, + is_valid_zap(maybe_sats), + )); + + if resp.clicked() { + maybe_sats.map(|i| i * 1000) + } else { + None + } + }) + .inner + } +} + +fn is_valid_zap(amount: Option) -> bool { + amount.map_or(false, |sats| sats > 0) +} + +fn show_title(ui: &mut egui::Ui) { + let max_size = 32.0; + ui.allocate_ui_with_layout( + vec2(ui.available_width(), max_size), + Layout::left_to_right(egui::Align::Center), + |ui| { + let (rect, _) = ui.allocate_exact_size(vec2(max_size, max_size), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let circle_color = lerp_color( + egui::Color32::from_rgb(0xFF, 0xB7, 0x57), + ui.visuals().noninteractive().bg_fill, + 0.5, + ); + painter.circle_filled(rect.center(), max_size / 2.0, circle_color); + + let img_data = egui::include_image!("../../../../../assets/icons/filled_zap_icon.svg"); + let zap_max_width = 25.16; + let zap_max_height = 29.34; + let img = egui::Image::new(img_data) + .max_width(zap_max_width) + .max_height(zap_max_height); + + let img_rect = rect + .shrink2(vec2(max_size - zap_max_width, max_size - zap_max_height)) + .round_to_pixel_center(ui.pixels_per_point()); + img.paint_at(ui, img_rect); + + ui.add_space(8.0); + + ui.add(egui::Label::new( + egui::RichText::new("Zap").text_style(NotedeckTextStyle::Heading2.text_style()), + )); + }, + ); +} + +fn show_profile(ui: &mut egui::Ui, images: &mut Images, profile: Option<&ProfileRecord>) { + let max_size = 24.0; + ui.allocate_ui_with_layout( + vec2(ui.available_width(), max_size), + Layout::left_to_right(egui::Align::Center).with_main_wrap(true), + |ui| { + ui.add(&mut ProfilePic::new(images, get_profile_url(profile)).size(max_size)); + ui.add(display_name_widget(&get_display_name(profile), false)); + }, + ); +} + +fn show_amount(ui: &mut egui::Ui, id: egui::Id, user_input: &mut String, width: f32) { + let user_input_font = NotedeckTextStyle::Heading.get_bolded_font(ui.ctx()); + + let user_input_id = id.with("sats_amount"); + + let user_input_galley = ui.painter().layout_no_wrap( + user_input.to_owned(), + user_input_font.clone(), + ui.visuals().text_color(), + ); + + let painter = ui.painter(); + + let sats_galley = painter.layout_no_wrap( + "SATS".to_owned(), + NotedeckTextStyle::Heading4.get_font_id(ui.ctx()), + ui.visuals().noninteractive().text_color(), + ); + + let user_input_rect = { + let mut rect = user_input_galley.rect; + rect.extend_with_x(user_input_galley.rect.left() - 8.0); + rect + }; + let sats_width = sats_galley.rect.width() + 8.0; + + Frame::NONE + .fill(ui.visuals().noninteractive().weak_bg_fill) + .corner_radius(8) + .show(ui, |ui| { + ui.set_width(width); + ui.add_space(8.0); + ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + let textedit = egui::TextEdit::singleline(user_input) + .frame(false) + .id(user_input_id) + .font(user_input_font); + + let amount_resp = ui.add(Label::new( + egui::RichText::new("Amount") + .text_style(NotedeckTextStyle::Heading3.text_style()) + .color(ui.visuals().noninteractive().text_color()), + )); + + let user_input_padding = { + let available_width = ui.available_width(); + if user_input_rect.width() + sats_width > available_width { + 0.0 + } else if (user_input_rect.width() / 2.0) + sats_width > (available_width / 2.0) + { + available_width - sats_width - user_input_rect.width() + } else { + (available_width / 2.0) - (user_input_rect.width() / 2.0) + } + }; + + let user_input_rect = { + let max_input_width = ui.available_width() - sats_width; + + let user_input_size = if user_input_rect.width() > max_input_width { + vec2(max_input_width, user_input_rect.height()) + } else { + user_input_rect.size() + }; + + let user_input_pos = pos2( + ui.available_rect_before_wrap().left() + user_input_padding, + amount_resp.rect.bottom(), + ); + egui::Rect::from_min_size(user_input_pos, user_input_size) + .intersect(ui.available_rect_before_wrap()) + }; + + let textout = ui + .allocate_new_ui( + egui::UiBuilder::new() + .max_rect(user_input_rect) + .layout(Layout::centered_and_justified(egui::Direction::TopDown)), + |ui| textedit.show(ui), + ) + .inner; + + let out_rect = textout.text_clip_rect; + + ui.advance_cursor_after_rect(out_rect); + + let sats_pos = pos2( + out_rect.right() + 8.0, + out_rect.center().y - (sats_galley.rect.height() / 2.0), + ); + + let sats_rect = egui::Rect::from_min_size(sats_pos, sats_galley.size()); + ui.painter() + .galley(sats_pos, sats_galley, ui.visuals().text_color()); + + ui.advance_cursor_after_rect(sats_rect); + + if !is_valid_zap(user_input.parse::().ok()) { + ui.colored_label(ui.visuals().warn_fg_color, "Please enter valid amount."); + } + ui.add_space(8.0); + }); + }); + + // let user_changed = cur_input != Some(user_input.clone()); + ui.memory_mut(|m| m.request_focus(user_input_id)); + // ui.data_mut(|d| d.insert_temp(id, user_input)); +} + +const SELECTION_BUTTONS: [ZapSelectionButton; 8] = [ + ZapSelectionButton::First, + ZapSelectionButton::Second, + ZapSelectionButton::Third, + ZapSelectionButton::Fourth, + ZapSelectionButton::Fifth, + ZapSelectionButton::Sixth, + ZapSelectionButton::Seventh, + ZapSelectionButton::Eighth, +]; + +fn show_selection_buttons(ui: &mut egui::Ui, sats_selection: Option) -> Option { + let mut our_selection = None; + ui.allocate_ui_with_layout( + vec2(224.0, 116.0), + Layout::left_to_right(egui::Align::Min).with_main_wrap(true), + |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + + for button in SELECTION_BUTTONS { + our_selection = our_selection.or(show_selection_button(ui, sats_selection, button)); + } + }, + ); + + our_selection +} + +fn show_selection_button( + ui: &mut egui::Ui, + sats_selection: Option, + button: ZapSelectionButton, +) -> Option { + let (rect, _) = ui.allocate_exact_size(vec2(50.0, 50.0), egui::Sense::click()); + let helper = AnimationHelper::new_from_rect(ui, ("zap_selection_button", &button), rect); + let painter = ui.painter(); + + let corner = CornerRadius::same(8); + painter.rect_filled(rect, corner, ui.visuals().noninteractive().weak_bg_fill); + + let amount = button.sats(); + let current_selected = if let Some(selection) = sats_selection { + selection == amount + } else { + false + }; + + if current_selected { + painter.rect_stroke( + rect, + corner, + Stroke { + width: 1.0, + color: colors::PINK, + }, + egui::StrokeKind::Inside, + ); + } + + let fontid = FontId::new( + helper.scale_1d_pos(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)), + NotedeckTextStyle::Body.font_family(), + ); + + let galley = painter.layout_no_wrap(button.to_string(), fontid, ui.visuals().text_color()); + let text_rect = { + let mut galley_rect = galley.rect; + galley_rect.set_center(rect.center()); + galley_rect + }; + + painter.galley(text_rect.min, galley, ui.visuals().text_color()); + + if helper.take_animation_response().clicked() { + return Some(amount); + } + + None +} + +#[derive(Hash)] +enum ZapSelectionButton { + First, + Second, + Third, + Fourth, + Fifth, + Sixth, + Seventh, + Eighth, +} + +impl ZapSelectionButton { + pub fn sats(&self) -> u64 { + match self { + ZapSelectionButton::First => 69, + ZapSelectionButton::Second => 100, + ZapSelectionButton::Third => 420, + ZapSelectionButton::Fourth => 5_000, + ZapSelectionButton::Fifth => 10_000, + ZapSelectionButton::Sixth => 20_000, + ZapSelectionButton::Seventh => 50_000, + ZapSelectionButton::Eighth => 100_000, + } + } +} + +impl Display for ZapSelectionButton { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ZapSelectionButton::First => write!(f, "69"), + ZapSelectionButton::Second => write!(f, "100"), + ZapSelectionButton::Third => write!(f, "420"), + ZapSelectionButton::Fourth => write!(f, "5K"), + ZapSelectionButton::Fifth => write!(f, "10K"), + ZapSelectionButton::Sixth => write!(f, "20K"), + ZapSelectionButton::Seventh => write!(f, "50K"), + ZapSelectionButton::Eighth => write!(f, "100K"), + } + } +} + +fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { + Color32::from_rgba_premultiplied( + egui::lerp(a.r() as f32..=b.r() as f32, t) as u8, + egui::lerp(a.g() as f32..=b.g() as f32, t) as u8, + egui::lerp(a.b() as f32..=b.b() as f32, t) as u8, + egui::lerp(a.a() as f32..=b.a() as f32, t) as u8, + ) +} diff --git a/crates/notedeck_columns/src/ui/note/mod.rs b/crates/notedeck_columns/src/ui/note/mod.rs index adf04391..8eb9f2e5 100644 --- a/crates/notedeck_columns/src/ui/note/mod.rs +++ b/crates/notedeck_columns/src/ui/note/mod.rs @@ -1,3 +1,4 @@ +pub mod custom_zap; pub mod post; pub mod quote_repost; pub mod reply;