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;