notedeck

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

commit 34c51536188005eca3c8d55408e07a007b517f9d
parent e20861f8d677f48eac16afeb725f15521f6be66f
Author: William Casarin <jb55@jb55.com>
Date:   Mon,  6 Oct 2025 08:41:11 -0700

Merge quote reposts, share links by kernel

kernelkind (17):
      add `ProfileContext`
      add `RenderNavAction::RepostAction` & render `RepostDecisionView`
      add `after_action` to `SingletonRouter`
      add repost fns
      feat: copy damus.io link to clipboard
      fix: custom zap wrapping
      fix: profile wrapping
      process popup after action
      refactor: move profile.rs -> url.rs to new module
      refactor: move shared context stuff up in scope
      refactor: rename `NoteAction::Quote` -> `Repost`
      render profile context button
      route: add CloseSheetThenRoute `RouterAction`
      route: add `RepostDecision`
      split popup correctly for repost & update egui-nav
      ui: add `ProfileContextWidget`
      ui: add `RepostDecisionView`

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mcrates/notedeck/src/lib.rs | 2+-
Mcrates/notedeck/src/note/action.rs | 4++--
Mcrates/notedeck/src/note/context.rs | 28+++++++++++++++++++++++++++-
Acrates/notedeck/src/profile/context.rs | 20++++++++++++++++++++
Acrates/notedeck/src/profile/mod.rs | 5+++++
Rcrates/notedeck/src/profile.rs -> crates/notedeck/src/profile/url.rs | 0
Mcrates/notedeck_columns/src/actionbar.rs | 24++++++++++++++++++------
Mcrates/notedeck_columns/src/app.rs | 1+
Mcrates/notedeck_columns/src/lib.rs | 1+
Mcrates/notedeck_columns/src/nav.rs | 36++++++++++++++++++++++++++++--------
Mcrates/notedeck_columns/src/profile.rs | 10+++++++++-
Acrates/notedeck_columns/src/repost.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/route.rs | 29++++++++++++++++++++++++++---
Mcrates/notedeck_columns/src/timeline/route.rs | 3+++
Mcrates/notedeck_columns/src/ui/column/header.rs | 1+
Mcrates/notedeck_columns/src/ui/mod.rs | 1+
Mcrates/notedeck_columns/src/ui/note/custom_zap.rs | 4+++-
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 29++++++++++++++++++++++++-----
Acrates/notedeck_columns/src/ui/repost.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/context_menu.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/note/context.rs | 74+++++++++++++++-----------------------------------------------------------
Mcrates/notedeck_ui/src/note/mod.rs | 2+-
Acrates/notedeck_ui/src/profile/context.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/profile/mod.rs | 3++-
26 files changed, 582 insertions(+), 91 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1555,7 +1555,7 @@ dependencies = [ [[package]] name = "egui_nav" version = "0.2.0" -source = "git+https://github.com/kernelkind/egui-nav?rev=41e389c77dff7df548299028684cd2ffc32d7987#41e389c77dff7df548299028684cd2ffc32d7987" +source = "git+https://github.com/damus-io/egui-nav?rev=8767df4bc8d12a90fbcee7493d9c9604fe30f1a2#8767df4bc8d12a90fbcee7493d9c9604fe30f1a2" dependencies = [ "bitflags 2.9.1", "egui", diff --git a/Cargo.toml b/Cargo.toml @@ -28,7 +28,7 @@ egui = { version = "0.31.1", features = ["serde"] } egui-wgpu = "0.31.1" egui_extras = { version = "0.31.1", features = ["all_loaders"] } egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] } -egui_nav = { git = "https://github.com/kernelkind/egui-nav", rev = "41e389c77dff7df548299028684cd2ffc32d7987" } +egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "8767df4bc8d12a90fbcee7493d9c9604fe30f1a2" } egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b374a8a6658c09c9a4181299734d0" } #egui_virtual_list = "0.6.0" egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" } diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -77,7 +77,7 @@ pub use note::{ pub use notecache::{CachedNote, NoteCache}; pub use options::NotedeckOptions; pub use persist::*; -pub use profile::get_profile_url; +pub use profile::*; pub use relay_debug::RelayDebugView; pub use relayspec::RelaySpec; pub use result::Result; diff --git a/crates/notedeck/src/note/action.rs b/crates/notedeck/src/note/action.rs @@ -14,8 +14,8 @@ pub enum NoteAction { /// User has clicked the quote reply action Reply(NoteId), - /// User has clicked the quote repost action - Quote(NoteId), + /// User has clicked the repost button + Repost(NoteId), /// User has clicked a hashtag Hashtag(String), diff --git a/crates/notedeck/src/note/context.rs b/crates/notedeck/src/note/context.rs @@ -18,6 +18,7 @@ pub enum NoteContextSelection { CopyNoteId, CopyNoteJSON, Broadcast(BroadcastContext), + CopyLink, } #[derive(Debug, Eq, PartialEq, Clone)] @@ -27,7 +28,13 @@ pub struct ContextSelection { } impl NoteContextSelection { - pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>, pool: &mut RelayPool) { + pub fn process( + &self, + ui: &mut egui::Ui, + note: &Note<'_>, + pool: &mut RelayPool, + note_author_is_selected_acc: bool, + ) { match self { NoteContextSelection::Broadcast(context) => { tracing::info!("Broadcasting note {}", hex::encode(note.id())); @@ -58,6 +65,25 @@ impl NoteContextSelection { Ok(json) => ui.ctx().copy_text(json), Err(err) => error!("error copying note json: {err}"), }, + NoteContextSelection::CopyLink => { + let damus_url = |s| format!("https://damus.io/{s}"); + if note_author_is_selected_acc { + let nip19event = nostr::nips::nip19::Nip19Event::new( + nostr::event::EventId::from_byte_array(*note.id()), + pool.urls(), + ); + let Ok(bech) = nostr::nips::nip19::ToBech32::to_bech32(&nip19event) else { + return; + }; + ui.ctx().copy_text(damus_url(bech)); + } else { + let Some(bech) = NoteId::new(*note.id()).to_bech() else { + return; + }; + + ui.ctx().copy_text(damus_url(bech)); + } + } } } } diff --git a/crates/notedeck/src/profile/context.rs b/crates/notedeck/src/profile/context.rs @@ -0,0 +1,20 @@ +use enostr::Pubkey; + +pub enum ProfileContextSelection { + CopyLink, +} + +pub struct ProfileContext { + pub profile: Pubkey, + pub selection: ProfileContextSelection, +} + +impl ProfileContextSelection { + pub fn process(&self, ctx: &egui::Context, pk: &Pubkey) { + let Some(npub) = pk.npub() else { + return; + }; + + ctx.copy_text(format!("https://damus.io/{npub}")); + } +} diff --git a/crates/notedeck/src/profile/mod.rs b/crates/notedeck/src/profile/mod.rs @@ -0,0 +1,5 @@ +mod context; +mod url; + +pub use context::{ProfileContext, ProfileContextSelection}; +pub use url::{get_profile_url, no_pfp_url, unwrap_profile_url}; diff --git a/crates/notedeck/src/profile.rs b/crates/notedeck/src/profile/url.rs diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -11,6 +11,7 @@ use crate::{ view_state::ViewState, }; +use egui_nav::Percent; use enostr::{NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, NoteKey, Transaction}; use notedeck::{ @@ -119,9 +120,12 @@ fn execute_note_action( .open(ndb, note_cache, txn, pool, &kind) .map(NotesOpenResult::Timeline); } - NoteAction::Quote(note_id) => { + NoteAction::Repost(note_id) => { if can_post { - router_action = Some(RouterAction::route_to(Route::quote(note_id))); + router_action = Some(RouterAction::route_to_sheet( + Route::RepostDecision(note_id), + egui_nav::Split::AbsoluteFromBottom(224.0), + )); } else { router_action = Some(RouterAction::route_to(Route::accounts())); } @@ -143,7 +147,7 @@ fn execute_note_action( break 'a; }; - if let RouterType::Sheet = router_type { + if let RouterType::Sheet(_) = router_type { router_action = Some(RouterAction::GoBack); } @@ -158,14 +162,22 @@ fn execute_note_action( ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target), ZapAction::CustomizeAmount(target) => { let route = Route::CustomizeZapAmount(target.to_owned()); - router_action = Some(RouterAction::route_to_sheet(route)); + router_action = Some(RouterAction::route_to_sheet( + route, + egui_nav::Split::PercentFromTop(Percent::new(35).expect("35 <= 100")), + )); } } } NoteAction::Context(context) => match ndb.get_note_by_key(txn, context.note_key) { Err(err) => tracing::error!("{err}"), Ok(note) => { - context.action.process(ui, &note, pool); + context.action.process( + ui, + &note, + pool, + *accounts.selected_account_pubkey().bytes() == *note.pubkey(), + ); } }, NoteAction::Media(media_action) => { @@ -212,7 +224,7 @@ pub fn execute_and_process_note_action( let sheet_router = &mut columns.column_mut(col).sheet_router; if sheet_router.route().is_some() { - RouterType::Sheet + RouterType::Sheet(sheet_router.split) } else { RouterType::Stack } diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -789,6 +789,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { Route::EditDeck(_) => false, Route::Wallet(_) => false, Route::CustomizeZapAmount(_) => false, + Route::RepostDecision(_) => false, } } diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -21,6 +21,7 @@ mod onboarding; pub mod options; mod post; mod profile; +mod repost; mod route; mod search; mod subscriptions; diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -6,6 +6,7 @@ use crate::{ decks::{Deck, DecksAction, DecksCache}, options::AppOptions, profile::{ProfileAction, SaveProfileChanges}, + repost::RepostAction, route::{Route, Router, SingletonRouter}, subscriptions::Subscriptions, timeline::{ @@ -21,6 +22,7 @@ use crate::{ edit_deck::{EditDeckResponse, EditDeckView}, note::{custom_zap::CustomZapView, NewPostAction, PostAction, PostType}, profile::EditProfileView, + repost::RepostDecisionView, search::{FocusState, SearchView}, settings::SettingsAction, support::SupportView, @@ -32,7 +34,7 @@ use crate::{ use egui::scroll_area::ScrollAreaOutput; use egui_nav::{ - Nav, NavAction, NavResponse, NavUiType, Percent, PopupResponse, PopupSheet, RouteResponse, + Nav, NavAction, NavResponse, NavUiType, PopupResponse, PopupSheet, RouteResponse, Split, }; use enostr::ProfileState; use nostrdb::{Filter, Ndb, Transaction}; @@ -68,6 +70,7 @@ pub enum RenderNavAction { WalletAction(WalletAction), RelayAction(RelayAction), SettingsAction(SettingsAction), + RepostAction(RepostAction), } pub enum SwitchingAction { @@ -240,6 +243,9 @@ fn process_popup_resp( if let Some(NavAction::Returned(_)) = action.action { let column = app.columns_mut(ctx.i18n, ctx.accounts).column_mut(col); + if let Some(after_action) = column.sheet_router.after_action.clone() { + column.router_mut().route_to(after_action); + } column.sheet_router.clear(); } else if let Some(NavAction::Navigating) = action.action { let column = app.columns_mut(ctx.i18n, ctx.accounts).column_mut(col); @@ -363,6 +369,7 @@ pub enum RouterAction { /// chrome atm PfpClicked, RouteTo(Route, RouterType), + CloseSheetThenRoute(Route), Overlay { route: Route, make_new: bool, @@ -370,7 +377,7 @@ pub enum RouterAction { } pub enum RouterType { - Sheet, + Sheet(Split), Stack, } @@ -410,8 +417,8 @@ impl RouterAction { } RouterAction::RouteTo(route, router_type) => match router_type { - RouterType::Sheet => { - sheet_router.route_to(route); + RouterType::Sheet(percent) => { + sheet_router.route_to(route, percent); None } RouterType::Stack => { @@ -427,6 +434,11 @@ impl RouterAction { } None } + RouterAction::CloseSheetThenRoute(route) => { + sheet_router.go_back(); + sheet_router.after_action = Some(route); + None + } } } @@ -434,8 +446,8 @@ impl RouterAction { RouterAction::RouteTo(route, RouterType::Stack) } - pub fn route_to_sheet(route: Route) -> Self { - RouterAction::RouteTo(route, RouterType::Sheet) + pub fn route_to_sheet(route: Route, split: Split) -> Self { + RouterAction::RouteTo(route, RouterType::Sheet(split)) } } @@ -505,7 +517,7 @@ fn process_render_nav_action( } } RenderNavAction::ProfileAction(profile_action) => { - profile_action.process_profile_action(ctx.ndb, ctx.pool, ctx.accounts) + profile_action.process_profile_action(ui.ctx(), ctx.ndb, ctx.pool, ctx.accounts) } RenderNavAction::WalletAction(wallet_action) => { wallet_action.process(ctx.accounts, ctx.global_wallet) @@ -518,6 +530,9 @@ fn process_render_nav_action( RenderNavAction::SettingsAction(action) => { action.process_settings_action(app, ctx.settings, ctx.i18n, ctx.img_cache, ui.ctx()) } + RenderNavAction::RepostAction(action) => { + action.process(ctx.ndb, &ctx.accounts.get_selected_account().key, ctx.pool) + } }; if let Some(action) = router_action { @@ -935,6 +950,10 @@ fn render_nav_body( ))) }) } + Route::RepostDecision(note_id) => { + BodyResponse::output(RepostDecisionView::new(note_id).show(ui)) + .map_output(RenderNavAction::RepostAction) + } } } @@ -1043,6 +1062,7 @@ pub fn render_nav( .sheet_router .navigating; let returning = app.columns(ctx.accounts).column(col).sheet_router.returning; + let split = app.columns(ctx.accounts).column(col).sheet_router.split; let bg_route = app .columns(ctx.accounts) .column(col) @@ -1055,7 +1075,7 @@ pub fn render_nav( .id_source(egui::Id::new(("nav", col))) .navigating(navigating) .returning(returning) - .with_split_percent_from_top(Percent::new(35).expect("35 <= 100")) + .with_split(split) .show_mut(ui, |ui, typ, route| match typ { NavUiType::Title => NavTitle::new( ctx.ndb, diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -1,7 +1,7 @@ use enostr::{FilledKeypair, FullKeypair, ProfileState, Pubkey, RelayPool}; use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder, Transaction}; -use notedeck::{Accounts, ContactState}; +use notedeck::{Accounts, ContactState, ProfileContext}; use tracing::info; use crate::{nav::RouterAction, route::Route}; @@ -38,11 +38,13 @@ pub enum ProfileAction { SaveChanges(SaveProfileChanges), Follow(Pubkey), Unfollow(Pubkey), + Context(ProfileContext), } impl ProfileAction { pub fn process_profile_action( &self, + ctx: &egui::Context, ndb: &Ndb, pool: &mut RelayPool, accounts: &Accounts, @@ -77,6 +79,12 @@ impl ProfileAction { Self::send_unfollow_user_event(ndb, pool, accounts, target_key); None } + ProfileAction::Context(profile_context) => { + profile_context + .selection + .process(ctx, &profile_context.profile); + None + } } } diff --git a/crates/notedeck_columns/src/repost.rs b/crates/notedeck_columns/src/repost.rs @@ -0,0 +1,102 @@ +use enostr::{Keypair, NoteId, RelayPool}; +use nostrdb::{Ndb, Note, NoteBuilder, Transaction}; + +use crate::{nav::RouterAction, Route}; + +pub fn generate_repost_event<'a>( + ndb: &'a Ndb, + noteid_to_repost: &NoteId, + signer_nsec: &[u8; 32], + pool: &RelayPool, +) -> Result<Note<'a>, String> { + let txn = Transaction::new(ndb).expect("txn"); + let note_to_repost = ndb + .get_note_by_id(&txn, noteid_to_repost.bytes()) + .map_err(|e| format!("could not find note to repost {noteid_to_repost:?}: {e}"))?; + + if note_to_repost.kind() != 1 { + return Err(format!( + "trying to generate a kind 6 repost but the kind is not 1 (it's {})", + note_to_repost.kind() + )); + } + + let urls = pool.urls(); + let Some(relay) = urls.first() else { + return Err( + "relay pool does not have any relays. This makes meeting the repost spec impossible" + .to_owned(), + ); + }; + + let note_to_repost_content = note_to_repost + .json() + .map_err(|e| format!("could not convert note {note_to_repost:?} to json: {e}"))?; + + NoteBuilder::new() + .content(&note_to_repost_content) + .kind(6) + .start_tag() + .tag_str("e") + .tag_id(note_to_repost.id()) + .tag_str(relay) + .start_tag() + .tag_str("p") + .tag_id(note_to_repost.pubkey()) + .sign(signer_nsec) + .build() + .ok_or("Failure in NoteBuilder::build".to_owned()) +} + +pub enum RepostAction { + Kind06Repost(NoteId), + Quote(NoteId), + Cancel, +} + +impl RepostAction { + pub fn process( + self, + ndb: &nostrdb::Ndb, + current_user: &Keypair, + pool: &mut RelayPool, + ) -> Option<RouterAction> { + match self { + RepostAction::Quote(note_id) => { + Some(RouterAction::CloseSheetThenRoute(Route::quote(note_id))) + } + RepostAction::Kind06Repost(note_id) => { + let Some(full_user) = current_user.to_full() else { + tracing::error!("Attempting to make a kind 6 repost, but we don't have nsec"); + return None; + }; + + let repost_ev = generate_repost_event( + ndb, + &note_id, + &full_user.secret_key.secret_bytes(), + pool, + ) + .inspect_err(|e| tracing::error!("failure to generate repost event: {e}")) + .ok()?; + + let Ok(event) = &enostr::ClientMessage::event(&repost_ev) else { + tracing::error!("send_note_builder: failed to build json"); + return None; + }; + + let Ok(json) = event.to_json() else { + tracing::error!("send_note_builder: failed to build json"); + return None; + }; + + let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true)); + + pool.send(event); + + Some(RouterAction::GoBack) + } + RepostAction::Cancel => Some(RouterAction::GoBack), + } + } +} diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -1,3 +1,4 @@ +use egui_nav::Percent; use enostr::{NoteId, Pubkey}; use notedeck::{tr, Localization, NoteZapTargetOwned, RootNoteIdBuf, WalletType}; use std::ops::Range; @@ -18,6 +19,7 @@ pub enum Route { Accounts(AccountsRoute), Reply(NoteId), Quote(NoteId), + RepostDecision(NoteId), Relays, Settings, ComposeNote, @@ -132,6 +134,10 @@ impl Route { writer.write_token("wallet"); } Route::CustomizeZapAmount(_) => writer.write_token("customize zap amount"), + Route::RepostDecision(note_id) => { + writer.write_token("repost_decision"); + writer.write_token(&note_id.hex()); + } } } @@ -185,6 +191,14 @@ impl Route { }, |p| { p.parse_all(|p| { + p.parse_token("repost_decision")?; + let note_id = NoteId::from_hex(p.pull_token()?) + .map_err(|_| ParseError::HexDecodeFailed)?; + Ok(Route::RepostDecision(note_id)) + }) + }, + |p| { + p.parse_all(|p| { p.parse_token("quote")?; Ok(Route::Quote(NoteId::new(tokenator::parse_hex_id(p)?))) }) @@ -358,6 +372,11 @@ impl Route { "Customize Zap Amount", "Column title for zap amount customization" )), + Route::RepostDecision(_) => ColumnTitle::formatted(tr!( + i18n, + "Repost", + "Column title for deciding the type of repost" + )), } } } @@ -627,12 +646,15 @@ pub struct SingletonRouter<R: Clone> { route: Option<R>, pub returning: bool, pub navigating: bool, + pub after_action: Option<R>, + pub split: egui_nav::Split, } impl<R: Clone> SingletonRouter<R> { - pub fn route_to(&mut self, route: R) { + pub fn route_to(&mut self, route: R, split: egui_nav::Split) { self.navigating = true; self.route = Some(route); + self.split = split; } pub fn go_back(&mut self) { @@ -640,8 +662,7 @@ impl<R: Clone> SingletonRouter<R> { } pub fn clear(&mut self) { - self.route = None; - self.returning = false; + *self = Self::default(); } pub fn route(&self) -> &Option<R> { @@ -655,6 +676,8 @@ impl<R: Clone> Default for SingletonRouter<R> { route: None, returning: false, navigating: false, + after_action: None, + split: egui_nav::Split::PercentFromTop(Percent::new(35).expect("35 <= 100")), } } } diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -129,5 +129,8 @@ pub fn render_profile_route( ui::profile::ProfileViewAction::Unfollow(target_key) => Some( RenderNavAction::ProfileAction(ProfileAction::Unfollow(target_key)), ), + ui::profile::ProfileViewAction::Context(profile_context_selection) => Some( + RenderNavAction::ProfileAction(ProfileAction::Context(profile_context_selection)), + ), }) } diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -491,6 +491,7 @@ impl<'a> NavTitle<'a> { Route::Thread(thread_selection) => { Some(self.thread_pfp(ui, thread_selection, pfp_size)) } + Route::RepostDecision(_) => None, } } diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -12,6 +12,7 @@ pub mod post; pub mod preview; pub mod profile; pub mod relay; +pub mod repost; pub mod search; pub mod settings; pub mod side_panel; diff --git a/crates/notedeck_columns/src/ui/note/custom_zap.rs b/crates/notedeck_columns/src/ui/note/custom_zap.rs @@ -174,7 +174,9 @@ fn show_profile(ui: &mut egui::Ui, images: &mut Images, profile: Option<&Profile 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)); + ui.vertical(|ui| { + ui.add(display_name_widget(&get_display_name(profile), false)); + }); }, ); } diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -4,8 +4,8 @@ pub use edit::EditProfileView; use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; -use notedeck::{tr, Localization}; -use notedeck_ui::profile::follow_button; +use notedeck::{tr, Localization, ProfileContext}; +use notedeck_ui::profile::{context::ProfileContextWidget, follow_button}; use robius_open::Uri; use tracing::error; @@ -38,6 +38,7 @@ pub enum ProfileViewAction { Note(NoteAction), Unfollow(Pubkey), Follow(Pubkey), + Context(ProfileContext), } struct ProfileScrollResponse { @@ -148,7 +149,7 @@ fn profile_body( ) -> Option<ProfileViewAction> { let mut action = None; ui.vertical(|ui| { - banner( + let banner_resp = banner( ui, profile .map(|p| p.record().profile()) @@ -156,6 +157,24 @@ fn profile_body( 120.0, ); + let place_context = { + let mut rect = banner_resp.rect; + let size = 24.0; + rect.set_bottom(rect.top() + size); + rect.set_left(rect.right() - size); + rect.translate(vec2(-16.0, 16.0)) + }; + + let context_resp = ProfileContextWidget::new(place_context).context_button(ui, pubkey); + if let Some(selection) = + ProfileContextWidget::context_menu(ui, note_context.i18n, context_resp) + { + action = Some(ProfileViewAction::Context(ProfileContext { + profile: *pubkey, + selection, + })); + } + let padding = 12.0; notedeck_ui::padding(padding, ui, |ui| { let mut pfp_rect = ui.available_rect_before_wrap(); @@ -250,7 +269,7 @@ fn profile_body( .and_then(|p| p.and_then(|p| p.lud16()).filter(|s| !s.is_empty())); if let Some(website_url) = website_url { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { handle_link(ui, website_url); }); } @@ -259,7 +278,7 @@ fn profile_body( if website_url.is_some() { ui.end_row(); } - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { handle_lud16(ui, lud16); }); } diff --git a/crates/notedeck_columns/src/ui/repost.rs b/crates/notedeck_columns/src/ui/repost.rs @@ -0,0 +1,186 @@ +use std::f32::consts::PI; + +use egui::{ + epaint::PathShape, pos2, vec2, CornerRadius, Layout, Margin, Pos2, RichText, Sense, Shape, + Stroke, +}; +use egui_extras::StripBuilder; +use enostr::NoteId; +use notedeck::{fonts::get_font_size, NotedeckTextStyle}; +use notedeck_ui::app_images; + +use crate::repost::RepostAction; + +pub struct RepostDecisionView<'a> { + noteid: &'a NoteId, +} + +impl<'a> RepostDecisionView<'a> { + pub fn new(noteid: &'a NoteId) -> Self { + Self { noteid } + } + + pub fn show(&self, ui: &mut egui::Ui) -> Option<RepostAction> { + let mut action = None; + egui::Frame::new() + .inner_margin(Margin::symmetric(48, 24)) + .show(ui, |ui| { + StripBuilder::new(ui) + .sizes(egui_extras::Size::exact(48.0), 2) + .size(egui_extras::Size::exact(80.0)) + .vertical(|mut strip| { + strip.cell(|ui| { + if ui + .with_layout(Layout::left_to_right(egui::Align::Center), |ui| { + let r = ui.add( + app_images::repost_image(ui.visuals().dark_mode) + .max_height(24.0) + .sense(Sense::click()), + ); + r.union(ui.add(repost_item_text("Repost"))) + }) + .inner + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + action = Some(RepostAction::Kind06Repost(*self.noteid)) + } + }); + + strip.cell(|ui| { + if ui + .with_layout(Layout::left_to_right(egui::Align::Center), |ui| { + let r = ui + .add(quote_icon()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + r.union(ui.add(repost_item_text("Quote"))) + }) + .inner + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + action = Some(RepostAction::Quote(*self.noteid)) + } + }); + + strip.cell(|ui| { + ui.add_space(16.0); + let resp = ui.allocate_response( + vec2(ui.available_width(), 48.0), + Sense::click(), + ); + + let color = if resp.hovered() { + ui.visuals().widgets.hovered.bg_fill + } else { + ui.visuals().text_color() + }; + + let painter = ui.painter_at(resp.rect); + ui.painter().rect_stroke( + resp.rect, + CornerRadius::same(32), + egui::Stroke::new(1.5, color), + egui::StrokeKind::Inside, + ); + + let galley = painter.layout_no_wrap( + "Cancel".to_owned(), + NotedeckTextStyle::Heading3.get_font_id(ui.ctx()), + ui.visuals().text_color(), + ); + + painter.galley( + galley_top_left_from_center(&galley, resp.rect.center()), + galley, + ui.visuals().text_color(), + ); + + if resp.clicked() { + action = Some(RepostAction::Cancel); + } + }); + }); + }); + + action + } +} + +fn galley_top_left_from_center(galley: &std::sync::Arc<egui::Galley>, center: Pos2) -> Pos2 { + let mut top_left = center; + top_left.x -= galley.rect.width() / 2.0; + top_left.y -= galley.rect.height() / 2.0; + + top_left +} + +fn repost_item_text(text: &str) -> impl egui::Widget + use<'_> { + move |ui: &mut egui::Ui| -> egui::Response { + ui.add(egui::Label::new( + RichText::new(text).size(get_font_size(ui.ctx(), &NotedeckTextStyle::Heading3)), + )) + .on_hover_cursor(egui::CursorIcon::PointingHand) + } +} + +pub fn quote_icon() -> impl egui::Widget { + move |ui: &mut egui::Ui| -> egui::Response { + let h = 32.0; // scaling constant + let color = ui.visuals().strong_text_color(); + let r = h * 0.12; // dot radius + let arc_r = r * 2.0; // larger so it protrudes above + let sw = h * 0.06; // stroke width + let stroke = Stroke::new(sw, color); + + // Same horizontal layout as before (in "local" coords using height scale) + let cx1_raw = h * 0.34; + let cx2_raw = h * 0.72; + let gap = cx2_raw - cx1_raw; + + // Bounds including stroke (only the left side needs +sw/2 for arcs) + let left_bound = (cx1_raw - r) - sw * 0.5; + let right_bound = (cx2_raw + r).max(cx2_raw + (arc_r - r)); // safe if arc_r > 2r + let width = right_bound - left_bound; + + // Vertical bounds: arc top needs sw/2; total content height = arc_r + r + sw/2 + let content_h = arc_r + r + sw * 0.5; + let v_margin = (h - content_h) * 0.5; + + let resp = ui.allocate_response(vec2(width, h), egui::Sense::click()); + let rect = resp.rect; + let origin = rect.min; + + // Place centers with bounds aligned to rect + let dx = origin.x - left_bound; + let cy = origin.y + v_margin + arc_r + sw * 0.5; + + let c1 = pos2(dx + cx1_raw, cy); + let c2 = pos2(dx + cx1_raw + gap, cy); + + // arc centers (unchanged) + let a1 = pos2(c1.x + (arc_r - r) + 0.8, c1.y); + let a2 = pos2(c2.x + (arc_r - r) + 0.8, c2.y); + + // Draw arcs FIRST (upper-left quadrant [π, 3π/2]) + let arc = |ac: egui::Pos2| { + let steps = 16; + (0..=steps) + .map(|i| { + let t = i as f32 / steps as f32; + let ang = PI + t * (PI * 0.5); + pos2(ac.x + arc_r * ang.cos(), ac.y + arc_r * ang.sin()) + }) + .collect::<Vec<_>>() + }; + let painter = ui.painter_at(rect); + painter.add(Shape::Path(PathShape::line(arc(a1), stroke))); + painter.add(Shape::Path(PathShape::line(arc(a2), stroke))); + + // Dots LAST to hide the junction + painter.circle_filled(c1, r, color); + painter.circle_filled(c2, r, color); + + resp + } +} diff --git a/crates/notedeck_ui/src/context_menu.rs b/crates/notedeck_ui/src/context_menu.rs @@ -51,3 +51,55 @@ pub fn input_context( // for keyboard visibility crate::include_input(ui, response) } + +pub 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) +} + +pub fn context_button(ui: &mut egui::Ui, id: egui::Id, put_at: egui::Rect) -> egui::Response { + let min_radius = 2.0; + 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 max_distance = 2.0; + let expansion_mult = 2.0; + let min_distance = max_distance / expansion_mult; + let cur_distance = min_distance + (max_distance - min_distance) * animation_progress; + + let max_radius = 4.0; + let cur_radius = min_radius + (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 = ui.style().visuals.noninteractive().fg_stroke.color; + + // 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 +} diff --git a/crates/notedeck_ui/src/note/context.rs b/crates/notedeck_ui/src/note/context.rs @@ -2,6 +2,8 @@ use egui::{Rect, Vec2}; use nostrdb::NoteKey; use notedeck::{tr, BroadcastContext, Localization, NoteContextSelection}; +use crate::context_menu::{context_button, stationary_arbitrary_menu_button}; + pub struct NoteContextButton { put_at: Option<Rect>, note_key: NoteKey, @@ -47,59 +49,15 @@ impl NoteContextButton { 4.0 } - fn min_radius() -> f32 { - 2.0 - } - 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() - } - #[profiling::function] 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 = ui.style().visuals.noninteractive().fg_stroke.color; - - // 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 + context_button(ui, id, put_at) } #[profiling::function] @@ -113,6 +71,18 @@ impl NoteContextButton { stationary_arbitrary_menu_button(ui, button_response, |ui| { ui.set_max_width(200.0); + if ui + .button(tr!( + i18n, + "Copy Link", + "Copy the damus.io link to this note to clipboard" + )) + .clicked() + { + context_selection = Some(NoteContextSelection::CopyLink); + ui.close_menu(); + } + // Debug: Check what the tr! macro returns let copy_text = tr!( i18n, @@ -189,17 +159,3 @@ impl NoteContextButton { 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/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -867,7 +867,7 @@ fn render_note_actionbar( } if quote_resp.clicked() { - action = Some(NoteAction::Quote(NoteId::new(*note_id))); + action = Some(NoteAction::Repost(NoteId::new(*note_id))); } action = zap_actionbar_button(ui, note_id, note_pubkey, zapper, i18n).or(action); diff --git a/crates/notedeck_ui/src/profile/context.rs b/crates/notedeck_ui/src/profile/context.rs @@ -0,0 +1,52 @@ +use enostr::Pubkey; +use notedeck::{tr, Localization, ProfileContextSelection}; + +use crate::context_menu::{context_button, stationary_arbitrary_menu_button}; + +pub struct ProfileContextWidget { + place_at: egui::Rect, +} + +impl ProfileContextWidget { + pub fn new(place_at: egui::Rect) -> Self { + Self { place_at } + } + + pub fn context_button(&self, ui: &mut egui::Ui, pubkey: &Pubkey) -> egui::Response { + let painter = ui.painter_at(self.place_at); + + painter.circle_filled( + self.place_at.center(), + self.place_at.width() / 2.0, + ui.visuals().window_fill, + ); + + context_button(ui, ui.id().with(pubkey), self.place_at.shrink(4.0)) + } + + pub fn context_menu( + ui: &mut egui::Ui, + i18n: &mut Localization, + button_response: egui::Response, + ) -> Option<ProfileContextSelection> { + let mut context_selection: Option<ProfileContextSelection> = None; + + stationary_arbitrary_menu_button(ui, button_response, |ui| { + ui.set_max_width(100.0); + + if ui + .button(tr!( + i18n, + "Copy Link", + "Copy a damus.io link to the author's profile to keyboard" + )) + .clicked() + { + context_selection = Some(ProfileContextSelection::CopyLink); + ui.close_menu(); + } + }); + + context_selection + } +} diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs @@ -1,5 +1,6 @@ use nostrdb::ProfileRecord; +pub mod context; pub mod name; pub mod picture; pub mod preview; @@ -44,7 +45,7 @@ pub fn display_name_widget<'a>( } let nip05_resp = name.nip05.map(|nip05| { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 2.0; ui.add(app_images::verified_image());