diff --git a/enostr/src/pubkey.rs b/enostr/src/pubkey.rs index a46c75f..ad8c849 100644 --- a/enostr/src/pubkey.rs +++ b/enostr/src/pubkey.rs @@ -68,6 +68,10 @@ impl Pubkey { Ok(Pubkey(data.1.try_into().unwrap())) } } + + pub fn to_bech(&self) -> Option { + nostr::bech32::encode::(HRP_NPUB, &self.0).ok() + } } impl fmt::Display for Pubkey { diff --git a/src/lib.rs b/src/lib.rs index 22610e4..676d87e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub mod login_manager; mod macos_key_storage; mod nav; mod note; +mod note_options; mod notecache; mod post; mod post_action_executor; diff --git a/src/note_options.rs b/src/note_options.rs new file mode 100644 index 0000000..580253c --- /dev/null +++ b/src/note_options.rs @@ -0,0 +1,40 @@ +use enostr::{NoteId, Pubkey}; +use nostrdb::Note; + +#[derive(Clone)] +#[allow(clippy::enum_variant_names)] +pub enum NoteOptionSelection { + CopyText, + CopyPubkey, + CopyNoteId, +} + +pub fn process_note_selection( + ui: &mut egui::Ui, + selection: Option, + note: &Note<'_>, +) { + if let Some(option) = selection { + match option { + NoteOptionSelection::CopyText => { + ui.output_mut(|w| { + w.copied_text = note.content().to_string(); + }); + } + NoteOptionSelection::CopyPubkey => { + ui.output_mut(|w| { + if let Some(bech) = Pubkey::new(*note.pubkey()).to_bech() { + w.copied_text = bech; + } + }); + } + NoteOptionSelection::CopyNoteId => { + ui.output_mut(|w| { + if let Some(bech) = NoteId::new(*note.id()).to_bech() { + w.copied_text = bech; + } + }); + } + } + } +} diff --git a/src/notecache.rs b/src/notecache.rs index 82c1798..51bdc75 100644 --- a/src/notecache.rs +++ b/src/notecache.rs @@ -35,7 +35,6 @@ impl NoteCache { pub struct CachedNote { reltime: TimeCached, pub reply: NoteReplyBuf, - pub bar_open: bool, } impl CachedNote { @@ -46,12 +45,7 @@ impl CachedNote { Box::new(move || time_ago_since(created_at)), ); let reply = NoteReply::new(note.tags()).to_owned(); - let bar_open = false; - CachedNote { - reltime, - reply, - bar_open, - } + CachedNote { reltime, reply } } pub fn reltime_str_mut(&mut self) -> &str { diff --git a/src/ui/note/contents.rs b/src/ui/note/contents.rs index 21eb115..327b9bd 100644 --- a/src/ui/note/contents.rs +++ b/src/ui/note/contents.rs @@ -1,5 +1,6 @@ use crate::images::ImageType; use crate::imgcache::ImageCache; +use crate::note_options::process_note_selection; use crate::notecache::NoteCache; use crate::ui::note::NoteOptions; use crate::ui::ProfilePic; @@ -103,12 +104,15 @@ pub fn render_note_preview( ui.visuals().noninteractive().bg_stroke.color, )) .show(ui, |ui| { - ui::NoteView::new(ndb, note_cache, img_cache, ¬e) + let resp = ui::NoteView::new(ndb, note_cache, img_cache, ¬e) .actionbar(false) .small_pfp(true) .wide(true) .note_previews(false) + .use_more_options_button(true) .show(ui); + + process_note_selection(ui, resp.option_selection, ¬e); }) .response } diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs index 491905b..cbe5155 100644 --- a/src/ui/note/mod.rs +++ b/src/ui/note/mod.rs @@ -15,10 +15,11 @@ use crate::{ app_style::NotedeckTextStyle, colors, imgcache::ImageCache, + note_options::NoteOptionSelection, notecache::{CachedNote, NoteCache}, ui::{self, View}, }; -use egui::{Id, Label, Response, RichText, Sense}; +use egui::{menu::BarState, Align, Id, InnerResponse, Label, Layout, Response, RichText, Sense}; use enostr::NoteId; use nostrdb::{Ndb, Note, NoteKey, NoteReply, Transaction}; @@ -30,11 +31,34 @@ pub struct NoteView<'a> { img_cache: &'a mut ImageCache, note: &'a nostrdb::Note<'a>, flags: NoteOptions, + use_options: bool, } pub struct NoteResponse { pub response: egui::Response, pub action: Option, + pub option_selection: Option, +} + +impl NoteResponse { + pub fn new(response: egui::Response) -> Self { + Self { + response, + action: None, + option_selection: None, + } + } + + pub fn with_action(self, action: Option) -> Self { + Self { action, ..self } + } + + pub fn select_option(self, option_selection: Option) -> Self { + Self { + option_selection, + ..self + } + } } impl<'a> View for NoteView<'a> { @@ -177,6 +201,7 @@ impl<'a> NoteView<'a> { img_cache, note, flags, + use_options: false, } } @@ -215,6 +240,13 @@ impl<'a> NoteView<'a> { self } + pub fn use_more_options_button(self, enable: bool) -> Self { + Self { + use_options: enable, + ..self + } + } + pub fn options(&self) -> NoteOptions { self.flags } @@ -324,10 +356,7 @@ impl<'a> NoteView<'a> { pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { if self.options().has_textmode() { - NoteResponse { - response: self.textmode_ui(ui), - action: None, - } + NoteResponse::new(self.textmode_ui(ui)) } else { let txn = self.note.txn().expect("txn"); if let Some(note_to_repost) = get_reposted_note(self.ndb, txn, self.note) { @@ -369,17 +398,29 @@ impl<'a> NoteView<'a> { note_cache: &mut NoteCache, note: &Note, profile: &Result, nostrdb::Error>, - ) -> egui::Response { + use_options_button: bool, + ) -> NoteResponse { let note_key = note.key().unwrap(); - ui.horizontal(|ui| { + let inner_response = ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); render_reltime(ui, cached_note, true); - }) - .response + + if use_options_button { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let more_options_resp = more_options_button(ui, note_key, 8.0); + options_context_menu(ui, more_options_resp) + }) + .inner + } else { + None + } + }); + + NoteResponse::new(inner_response.response).select_option(inner_response.inner) } fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { @@ -388,6 +429,7 @@ impl<'a> NoteView<'a> { let note_key = self.note.key().expect("todo: support non-db notes"); let txn = self.note.txn().expect("todo: support non-db notes"); let mut note_action: Option = None; + let mut selected_option: Option = None; let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); let maybe_hitbox = maybe_note_hitbox(ui, note_key); @@ -400,7 +442,14 @@ impl<'a> NoteView<'a> { ui.vertical(|ui| { ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| { ui.horizontal_centered(|ui| { - NoteView::note_header(ui, self.note_cache, self.note, &profile); + selected_option = NoteView::note_header( + ui, + self.note_cache, + self.note, + &profile, + self.use_options, + ) + .option_selection; }) .response }); @@ -440,8 +489,14 @@ impl<'a> NoteView<'a> { self.pfp(note_key, &profile, ui); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - NoteView::note_header(ui, self.note_cache, self.note, &profile); - + selected_option = NoteView::note_header( + ui, + self.note_cache, + self.note, + &profile, + self.use_options, + ) + .option_selection; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; @@ -483,10 +538,9 @@ impl<'a> NoteView<'a> { note_action, ); - NoteResponse { - response, - action: note_action, - } + NoteResponse::new(response) + .with_action(note_action) + .select_option(selected_option) } } @@ -631,3 +685,84 @@ fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { resp.union(put_resp) } + +fn more_options_button(ui: &mut egui::Ui, note_key: NoteKey, max_height: f32) -> egui::Response { + let id = ui.id().with(("more_options_anim", note_key)); + + let expansion_multiple = 2.0; + let max_radius = max_height; + let min_radius = max_radius / expansion_multiple; + let max_distance_between_circles = 2.0; + let min_distance_between_circles = max_distance_between_circles / expansion_multiple; + let max_width = max_radius * 3.0 + max_distance_between_circles * 2.0; + + let anim_speed = 0.05; + let expanded_size = egui::vec2(max_width, max_height); + let (rect, response) = ui.allocate_exact_size(expanded_size, egui::Sense::click()); + + let animation_progress = ui + .ctx() + .animate_bool_with_time(id, response.hovered(), anim_speed); + let cur_distance = min_distance_between_circles + + (max_distance_between_circles - min_distance_between_circles) * animation_progress; + let cur_radius = min_radius + (max_radius - min_radius) * animation_progress; + + let center = rect.center(); + let left_circle_center = center - egui::vec2(cur_distance + cur_radius, 0.0); + let right_circle_center = center + egui::vec2(cur_distance + cur_radius, 0.0); + + let translated_radius = (cur_radius - 1.0) / 2.0; + + let color = if ui.style().visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + + // Draw circles + let painter = ui.painter_at(rect); + painter.circle_filled(left_circle_center, translated_radius, color); + painter.circle_filled(center, translated_radius, color); + painter.circle_filled(right_circle_center, translated_radius, color); + + response +} + +fn options_context_menu( + ui: &mut egui::Ui, + more_options_button_resp: egui::Response, +) -> Option { + let mut selected_option: Option = None; + + stationary_arbitrary_menu_button(ui, more_options_button_resp, |ui| { + ui.set_max_width(200.0); + if ui.button("Copy text").clicked() { + selected_option = Some(NoteOptionSelection::CopyText); + ui.close_menu(); + } + if ui.button("Copy user public key").clicked() { + selected_option = Some(NoteOptionSelection::CopyPubkey); + ui.close_menu(); + } + if ui.button("Copy note id").clicked() { + selected_option = Some(NoteOptionSelection::CopyNoteId); + ui.close_menu(); + } + }); + + selected_option +} + +fn stationary_arbitrary_menu_button( + ui: &mut egui::Ui, + button_response: egui::Response, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> InnerResponse> { + let bar_id = ui.id(); + let mut bar_state = BarState::load(ui.ctx(), bar_id); + + let inner = bar_state.bar_menu(&button_response, add_contents); + + bar_state.store(ui.ctx(), bar_id); + InnerResponse::new(inner.map(|r| r.inner), button_response) +} diff --git a/src/ui/note/reply.rs b/src/ui/note/reply.rs index a46ac5e..c2ed606 100644 --- a/src/ui/note/reply.rs +++ b/src/ui/note/reply.rs @@ -67,6 +67,7 @@ impl<'a> PostReplyView<'a> { ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, self.note) .actionbar(false) .medium_pfp(true) + .use_more_options_button(true) .show(ui); }); diff --git a/src/ui/thread.rs b/src/ui/thread.rs index 0abf67f..3f67f04 100644 --- a/src/ui/thread.rs +++ b/src/ui/thread.rs @@ -1,5 +1,6 @@ use crate::{ - actionbar::BarAction, imgcache::ImageCache, notecache::NoteCache, thread::Threads, ui, + actionbar::BarAction, imgcache::ImageCache, note_options::process_note_selection, + notecache::NoteCache, thread::Threads, ui, }; use nostrdb::{Ndb, NoteKey, Transaction}; use tracing::{error, warn}; @@ -115,15 +116,17 @@ impl<'a> ThreadView<'a> { }; ui::padding(8.0, ui, |ui| { - if let Some(bar_action) = + let note_response = ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, ¬e) .note_previews(!self.textmode) .textmode(self.textmode) - .show(ui) - .action - { + .use_more_options_button(!self.textmode) + .show(ui); + if let Some(bar_action) = note_response.action { action = Some(bar_action); } + + process_note_selection(ui, note_response.option_selection, ¬e); }); ui::hline(ui); diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index bc1e02b..a9e112e 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -1,4 +1,5 @@ use crate::draft::Draft; +use crate::note_options::process_note_selection; use crate::{ actionbar::BarAction, column::Columns, imgcache::ImageCache, notecache::NoteCache, timeline::TimelineId, ui, @@ -149,6 +150,7 @@ fn timeline_ui( let resp = ui::NoteView::new(ndb, note_cache, img_cache, ¬e) .note_previews(!textmode) .selectable_text(false) + .use_more_options_button(true) .show(ui); if let Some(ba) = resp.action { @@ -156,6 +158,8 @@ fn timeline_ui( } else if resp.response.clicked() { debug!("clicked note"); } + + process_note_selection(ui, resp.option_selection, ¬e); }); ui::hline(ui);