notedeck

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

commit 13d6873eb182474931bef36de3d355e171dbb4f7
parent 2208e68726f723944d01cb17b2e1fa5608728ad7
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Sep 2024 12:33:43 -0700

Merge note context menu #328

This merges kernel's note context menu with a bunch of refactorings on
top, closing #328 and #318

William Casarin (7):
      refactor: remove processs_note_selection
      refactor: make options_button a NoteOptions
      note: switch to muted menu_options_button color
      context: move note context button to its own file
      context: fix hitbox, float on far right
      context: set cursor icon on hover

kernelkind (3):
      Add 'more options' to each note
      can left click note more options button
      process 'more options' for previews

Diffstat:
Menostr/src/pubkey.rs | 4++++
Msrc/notecache.rs | 8+-------
Msrc/ui/note/contents.rs | 7++++++-
Asrc/ui/note/context.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/note/mod.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/ui/note/options.rs | 43+++++++++++++++++++++----------------------
Msrc/ui/note/reply.rs | 1+
Msrc/ui/thread.rs | 12++++++++----
Msrc/ui/timeline.rs | 5+++++
9 files changed, 302 insertions(+), 50 deletions(-)

diff --git 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<String> { + nostr::bech32::encode::<nostr::bech32::Bech32>(HRP_NPUB, &self.0).ok() + } } impl fmt::Display for Pubkey { diff --git a/src/notecache.rs b/src/notecache.rs @@ -35,7 +35,6 @@ impl NoteCache { pub struct CachedNote { reltime: TimeCached<String>, 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 @@ -103,12 +103,17 @@ pub fn render_note_preview( ui.visuals().noninteractive().bg_stroke.color, )) .show(ui, |ui| { - ui::NoteView::new(ndb, note_cache, img_cache, &note) + let resp = ui::NoteView::new(ndb, note_cache, img_cache, &note) .actionbar(false) .small_pfp(true) .wide(true) .note_previews(false) + .options_button(true) .show(ui); + + if let Some(context) = resp.context_selection { + context.process(ui, &note); + } }) .response } diff --git a/src/ui/note/context.rs b/src/ui/note/context.rs @@ -0,0 +1,177 @@ +use crate::colors; +use egui::{Rect, Vec2}; +use enostr::{NoteId, Pubkey}; +use nostrdb::{Note, NoteKey}; + +#[derive(Clone)] +#[allow(clippy::enum_variant_names)] +pub enum NoteContextSelection { + CopyText, + CopyPubkey, + CopyNoteId, +} + +impl NoteContextSelection { + pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>) { + match self { + NoteContextSelection::CopyText => { + ui.output_mut(|w| { + w.copied_text = note.content().to_string(); + }); + } + NoteContextSelection::CopyPubkey => { + ui.output_mut(|w| { + if let Some(bech) = Pubkey::new(*note.pubkey()).to_bech() { + w.copied_text = bech; + } + }); + } + NoteContextSelection::CopyNoteId => { + ui.output_mut(|w| { + if let Some(bech) = NoteId::new(*note.id()).to_bech() { + w.copied_text = bech; + } + }); + } + } + } +} + +pub struct NoteContextButton { + put_at: Option<Rect>, + note_key: NoteKey, +} + +impl egui::Widget for NoteContextButton { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let r = if let Some(r) = self.put_at { + r + } else { + let mut place = ui.available_rect_before_wrap(); + let size = Self::max_width(); + place.set_width(size); + place.set_height(size); + place + }; + + Self::show(ui, self.note_key, r) + } +} + +impl NoteContextButton { + pub fn new(note_key: NoteKey) -> Self { + let put_at: Option<Rect> = None; + NoteContextButton { note_key, put_at } + } + + pub fn place_at(mut self, rect: Rect) -> Self { + self.put_at = Some(rect); + self + } + + pub fn max_width() -> f32 { + Self::max_radius() * 3.0 + Self::max_distance_between_circles() * 2.0 + } + + pub fn size() -> Vec2 { + let width = Self::max_width(); + egui::vec2(width, width) + } + + fn max_radius() -> f32 { + 8.0 + } + + fn min_radius() -> f32 { + Self::max_radius() / Self::expansion_multiple() + } + + fn max_distance_between_circles() -> f32 { + 2.0 + } + + fn expansion_multiple() -> f32 { + 2.0 + } + + fn min_distance_between_circles() -> f32 { + Self::max_distance_between_circles() / Self::expansion_multiple() + } + + pub fn show(ui: &mut egui::Ui, note_key: NoteKey, put_at: Rect) -> egui::Response { + let id = ui.id().with(("more_options_anim", note_key)); + + let min_radius = Self::min_radius(); + let anim_speed = 0.05; + let response = ui.interact(put_at, id, egui::Sense::click()); + + let hovered = response.hovered(); + let animation_progress = ui.ctx().animate_bool_with_time(id, hovered, anim_speed); + + if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + let min_distance = Self::min_distance_between_circles(); + let cur_distance = min_distance + + (Self::max_distance_between_circles() - min_distance) * animation_progress; + + let cur_radius = min_radius + (Self::max_radius() - min_radius) * animation_progress; + + let center = put_at.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; + + // This works in both themes + let color = colors::GRAY_SECONDARY; + + // Draw circles + let painter = ui.painter_at(put_at); + 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 + } + + pub fn menu( + ui: &mut egui::Ui, + button_response: egui::Response, + ) -> Option<NoteContextSelection> { + let mut context_selection: Option<NoteContextSelection> = None; + + stationary_arbitrary_menu_button(ui, button_response, |ui| { + ui.set_max_width(200.0); + if ui.button("Copy text").clicked() { + context_selection = Some(NoteContextSelection::CopyText); + ui.close_menu(); + } + if ui.button("Copy user public key").clicked() { + context_selection = Some(NoteContextSelection::CopyPubkey); + ui.close_menu(); + } + if ui.button("Copy note id").clicked() { + context_selection = Some(NoteContextSelection::CopyNoteId); + ui.close_menu(); + } + }); + + context_selection + } +} + +fn stationary_arbitrary_menu_button<R>( + ui: &mut egui::Ui, + button_response: egui::Response, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse<Option<R>> { + let bar_id = ui.id(); + let mut bar_state = egui::menu::BarState::load(ui.ctx(), bar_id); + + let inner = bar_state.bar_menu(&button_response, add_contents); + + bar_state.store(ui.ctx(), bar_id); + egui::InnerResponse::new(inner.map(|r| r.inner), button_response) +} diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs @@ -1,10 +1,12 @@ pub mod contents; +pub mod context; pub mod options; pub mod post; pub mod quote_repost; pub mod reply; pub use contents::NoteContents; +pub use context::{NoteContextButton, NoteContextSelection}; pub use options::NoteOptions; pub use post::{PostAction, PostResponse, PostView}; pub use quote_repost::QuoteRepostView; @@ -18,7 +20,7 @@ use crate::{ notecache::{CachedNote, NoteCache}, ui::{self, View}, }; -use egui::{Id, Label, Response, RichText, Sense}; +use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; use enostr::NoteId; use nostrdb::{Ndb, Note, NoteKey, NoteReply, Transaction}; @@ -35,6 +37,28 @@ pub struct NoteView<'a> { pub struct NoteResponse { pub response: egui::Response, pub action: Option<BarAction>, + pub context_selection: Option<NoteContextSelection>, +} + +impl NoteResponse { + pub fn new(response: egui::Response) -> Self { + Self { + response, + action: None, + context_selection: None, + } + } + + pub fn with_action(self, action: Option<BarAction>) -> Self { + Self { action, ..self } + } + + pub fn select_option(self, context_selection: Option<NoteContextSelection>) -> Self { + Self { + context_selection, + ..self + } + } } impl<'a> View for NoteView<'a> { @@ -215,6 +239,11 @@ impl<'a> NoteView<'a> { self } + pub fn options_button(mut self, enable: bool) -> Self { + self.options_mut().set_options_button(enable); + self + } + pub fn options(&self) -> NoteOptions { self.flags } @@ -324,10 +353,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 +395,33 @@ impl<'a> NoteView<'a> { note_cache: &mut NoteCache, note: &Note, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, - ) -> egui::Response { + options: NoteOptions, + container_right: Pos2, + ) -> 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 options.has_options_button() { + let context_pos = { + let size = NoteContextButton::max_width(); + let min = Pos2::new(container_right.x - size, container_right.y); + Rect::from_min_size(min, egui::vec2(size, size)) + }; + + let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); + NoteContextButton::menu(ui, resp.clone()) + } else { + None + } + }); + + NoteResponse::new(inner_response.response).select_option(inner_response.inner) } fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { @@ -388,8 +430,15 @@ 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<BarAction> = None; + let mut selected_option: Option<NoteContextSelection> = None; let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); let maybe_hitbox = maybe_note_hitbox(ui, note_key); + let container_right = { + let r = ui.available_rect_before_wrap(); + let x = r.max.x; + let y = r.min.y; + Pos2::new(x, y) + }; // wide design let response = if self.options().has_wide() { @@ -400,7 +449,15 @@ 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.options(), + container_right, + ) + .context_selection; }) .response }); @@ -440,8 +497,15 @@ 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.options(), + container_right, + ) + .context_selection; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; @@ -483,10 +547,9 @@ impl<'a> NoteView<'a> { note_action, ); - NoteResponse { - response, - action: note_action, - } + NoteResponse::new(response) + .with_action(note_action) + .select_option(selected_option) } } diff --git a/src/ui/note/options.rs b/src/ui/note/options.rs @@ -5,14 +5,15 @@ bitflags! { // Attributes can be applied to flags types #[repr(transparent)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub struct NoteOptions: u32 { - const actionbar = 0b00000001; - const note_previews = 0b00000010; - const small_pfp = 0b00000100; - const medium_pfp = 0b00001000; - const wide = 0b00010000; - const selectable_text = 0b00100000; - const textmode = 0b01000000; + pub struct NoteOptions: u64 { + const actionbar = 0b0000000000000001; + const note_previews = 0b0000000000000010; + const small_pfp = 0b0000000000000100; + const medium_pfp = 0b0000000000001000; + const wide = 0b0000000000010000; + const selectable_text = 0b0000000000100000; + const textmode = 0b0000000001000000; + const options_button = 0b0000000010000000; } } @@ -36,6 +37,8 @@ impl NoteOptions { create_setter!(set_selectable_text, selectable_text); create_setter!(set_textmode, textmode); create_setter!(set_actionbar, actionbar); + create_setter!(set_wide, wide); + create_setter!(set_options_button, options_button); #[inline] pub fn has_actionbar(self) -> bool { @@ -67,27 +70,23 @@ impl NoteOptions { (self & NoteOptions::medium_pfp) == NoteOptions::medium_pfp } - pub fn pfp_size(&self) -> f32 { - if self.has_small_pfp() { - ProfilePic::small_size() - } else if self.has_medium_pfp() { - ProfilePic::medium_size() - } else { - ProfilePic::default_size() - } - } - #[inline] pub fn has_wide(self) -> bool { (self & NoteOptions::wide) == NoteOptions::wide } #[inline] - pub fn set_wide(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::wide; + pub fn has_options_button(self) -> bool { + (self & NoteOptions::options_button) == NoteOptions::options_button + } + + pub fn pfp_size(&self) -> f32 { + if self.has_small_pfp() { + ProfilePic::small_size() + } else if self.has_medium_pfp() { + ProfilePic::medium_size() } else { - *self &= !NoteOptions::wide; + ProfilePic::default_size() } } } diff --git 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) + .options_button(true) .show(ui); }); diff --git a/src/ui/thread.rs b/src/ui/thread.rs @@ -115,15 +115,19 @@ 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, &note) .note_previews(!self.textmode) .textmode(self.textmode) - .show(ui) - .action - { + .options_button(!self.textmode) + .show(ui); + if let Some(bar_action) = note_response.action { action = Some(bar_action); } + + if let Some(selection) = note_response.context_selection { + selection.process(ui, &note); + } }); ui::hline(ui); diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs @@ -149,6 +149,7 @@ fn timeline_ui( let resp = ui::NoteView::new(ndb, note_cache, img_cache, &note) .note_previews(!textmode) .selectable_text(false) + .options_button(true) .show(ui); if let Some(ba) = resp.action { @@ -156,6 +157,10 @@ fn timeline_ui( } else if resp.response.clicked() { debug!("clicked note"); } + + if let Some(context) = resp.context_selection { + context.process(ui, &note); + } }); ui::hline(ui);