notedeck

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

commit a9a819f742d3facdeab801b47e9e3e35aec76701
parent 68b5c32e7f1d21949228cc70368ec4fedeea4b50
Author: kernelkind <kernelkind@gmail.com>
Date:   Mon, 12 May 2025 12:31:14 -0400

add `CustomZapView`

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Aassets/icons/filled_zap_icon.svg | 3+++
Acrates/notedeck_columns/src/ui/note/custom_zap.rs | 417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/ui/note/mod.rs | 1+
3 files changed, 421 insertions(+), 0 deletions(-)

diff --git a/assets/icons/filled_zap_icon.svg b/assets/icons/filled_zap_icon.svg @@ -0,0 +1,3 @@ +<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.32844 1.4159C8.36511 1.12223 8.20384 0.839518 7.93237 0.721671C7.66091 0.603828 7.34424 0.679064 7.15478 0.906418L1.20178 8.04995C1.0989 8.17335 0.994695 8.29835 0.918828 8.40822C0.847082 8.51208 0.716075 8.71635 0.712028 8.98475C0.707388 9.29208 0.844335 9.58449 1.08338 9.77762C1.2922 9.94635 1.53297 9.97649 1.65871 9.98788C1.79166 9.99995 1.95438 9.99988 2.11504 9.99988H6.24504L5.67204 14.5838C5.63533 14.8775 5.79664 15.1602 6.06811 15.2781C6.33958 15.3959 6.65624 15.3207 6.84571 15.0933L12.7987 7.94975C12.9016 7.82635 13.0058 7.70135 13.0816 7.59149C13.1534 7.48762 13.2844 7.28335 13.2884 7.01495C13.293 6.70762 13.1561 6.41525 12.9171 6.22207C12.7082 6.05333 12.4675 6.02321 12.3418 6.01183C12.2088 5.99979 12.046 5.99982 11.8854 5.99985L7.75544 5.99986L8.32844 1.41588V1.4159Z" fill="#FFB757"/> +</svg> diff --git a/crates/notedeck_columns/src/ui/note/custom_zap.rs 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<u64> { + 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<u64> { + 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::<u64>().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<u64>) -> 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::<u64>().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<u64>) -> Option<u64> { + 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<u64>, + button: ZapSelectionButton, +) -> Option<u64> { + 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 @@ -1,3 +1,4 @@ +pub mod custom_zap; pub mod post; pub mod quote_repost; pub mod reply;