diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs index eb12bc0b7..57543f675 100644 --- a/crates/notedeck/src/app.rs +++ b/crates/notedeck/src/app.rs @@ -28,6 +28,7 @@ use android_activity::AndroidApp; pub enum AppAction { Note(NoteAction), ToggleChrome, + SwitchToDave, } pub trait App { diff --git a/crates/notedeck/src/persist/settings_handler.rs b/crates/notedeck/src/persist/settings_handler.rs index fb82172f8..63f4bff05 100644 --- a/crates/notedeck/src/persist/settings_handler.rs +++ b/crates/notedeck/src/persist/settings_handler.rs @@ -14,6 +14,7 @@ const DEFAULT_LOCALE: &str = "en-US"; const DEFAULT_ZOOM_FACTOR: f32 = 1.0; const DEFAULT_SHOW_SOURCE_CLIENT: &str = "hide"; const DEFAULT_SHOW_REPLIES_NEWEST_FIRST: bool = false; +const DEFAULT_ANIMATE_NAV_TRANSITIONS: bool = true; #[cfg(any(target_os = "android", target_os = "ios"))] pub const DEFAULT_NOTE_BODY_FONT_SIZE: f32 = 13.0; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -36,6 +37,12 @@ pub struct Settings { pub show_source_client: String, pub show_replies_newest_first: bool, pub note_body_font_size: f32, + #[serde(default = "default_animate_nav_transitions")] + pub animate_nav_transitions: bool, +} + +fn default_animate_nav_transitions() -> bool { + DEFAULT_ANIMATE_NAV_TRANSITIONS } impl Default for Settings { @@ -47,6 +54,7 @@ impl Default for Settings { show_source_client: DEFAULT_SHOW_SOURCE_CLIENT.to_string(), show_replies_newest_first: DEFAULT_SHOW_REPLIES_NEWEST_FIRST, note_body_font_size: DEFAULT_NOTE_BODY_FONT_SIZE, + animate_nav_transitions: DEFAULT_ANIMATE_NAV_TRANSITIONS, } } } @@ -191,6 +199,11 @@ impl SettingsHandler { self.try_save_settings(); } + pub fn set_animate_nav_transitions(&mut self, value: bool) { + self.get_settings_mut().animate_nav_transitions = value; + self.try_save_settings(); + } + pub fn update_batch(&mut self, update_fn: F) where F: FnOnce(&mut Settings), diff --git a/crates/notedeck/src/profile/context.rs b/crates/notedeck/src/profile/context.rs index f5fc4f018..637ad9395 100644 --- a/crates/notedeck/src/profile/context.rs +++ b/crates/notedeck/src/profile/context.rs @@ -2,6 +2,7 @@ use enostr::Pubkey; pub enum ProfileContextSelection { CopyLink, + ViewAs, } pub struct ProfileContext { @@ -11,10 +12,17 @@ pub struct ProfileContext { impl ProfileContextSelection { pub fn process(&self, ctx: &egui::Context, pk: &Pubkey) { - let Some(npub) = pk.npub() else { - return; - }; + match self { + ProfileContextSelection::CopyLink => { + let Some(npub) = pk.npub() else { + return; + }; - ctx.copy_text(format!("https://damus.io/{npub}")); + ctx.copy_text(format!("https://damus.io/{npub}")); + } + ProfileContextSelection::ViewAs => { + // handled separately in profile.rs + } + } } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs index 29bf7ee9e..891d4b7af 100644 --- a/crates/notedeck_chrome/src/chrome.rs +++ b/crates/notedeck_chrome/src/chrome.rs @@ -101,7 +101,7 @@ impl ChromePanelAction { } Self::Settings => { - Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Settings); + Self::columns_navigate(ctx, chrome, notedeck_columns::Route::settings()); } Self::Wallet => { @@ -197,6 +197,14 @@ impl Chrome { } } + fn switch_to_dave(&mut self) { + for (i, app) in self.apps.iter().enumerate() { + if let NotedeckApp::Dave(_) = app { + self.active = i as i32; + } + } + } + pub fn set_active(&mut self, app: i32) { self.active = app; } @@ -456,6 +464,10 @@ fn chrome_handle_app_action( chrome.toggle(); } + AppAction::SwitchToDave => { + chrome.switch_to_dave(); + } + AppAction::Note(note_action) => { chrome.switch_to_columns(); let Some(columns) = chrome.get_columns_app() else { diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs index a12805342..a87cbbb34 100644 --- a/crates/notedeck_columns/src/app.rs +++ b/crates/notedeck_columns/src/app.rs @@ -62,9 +62,12 @@ pub struct Damus { /// keep track of follow packs pub onboarding: Onboarding, + + /// Track which column is hovered for mouse back/forward navigation + hovered_column: Option, } -fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) { +fn handle_egui_events(input: &egui::InputState, columns: &mut Columns, hovered_column: Option) { for event in &input.raw.events { match event { egui::Event::Key { key, pressed, .. } if *pressed => match key { @@ -89,6 +92,30 @@ fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) { _ => {} }, + egui::Event::PointerButton { + button: egui::PointerButton::Extra1, + pressed: true, + .. + } => { + if let Some(col_idx) = hovered_column { + columns.column_mut(col_idx).router_mut().go_back(); + } else { + columns.get_selected_router().go_back(); + } + } + + egui::Event::PointerButton { + button: egui::PointerButton::Extra2, + pressed: true, + .. + } => { + if let Some(col_idx) = hovered_column { + columns.column_mut(col_idx).router_mut().go_forward(); + } else { + columns.get_selected_router().go_forward(); + } + } + egui::Event::InsetsChanged => { tracing::debug!("insets have changed!"); } @@ -106,7 +133,7 @@ fn try_process_event( ) -> Result<()> { let current_columns = get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache); - ctx.input(|i| handle_egui_events(i, current_columns)); + ctx.input(|i| handle_egui_events(i, current_columns, damus.hovered_column)); let ctx2 = ctx.clone(); let wakeup = move || { @@ -533,6 +560,7 @@ impl Damus { jobs, threads, onboarding: Onboarding::default(), + hovered_column: None, } } @@ -584,6 +612,7 @@ impl Damus { jobs: JobsCache::default(), threads: Threads::default(), onboarding: Onboarding::default(), + hovered_column: None, } } @@ -686,6 +715,21 @@ fn render_damus_mobile( ProcessNavResult::PfpClicked => { app_action = Some(AppAction::ToggleChrome); } + + ProcessNavResult::SwitchAccount(pubkey) => { + // Add as pubkey-only account if not already present + let kp = enostr::Keypair::only_pubkey(*pubkey); + let _ = app_ctx.accounts.add_account(kp); + + let txn = nostrdb::Transaction::new(app_ctx.ndb).expect("txn"); + app_ctx.accounts.select_account( + pubkey, + app_ctx.ndb, + &txn, + app_ctx.pool, + ui.ctx(), + ); + } } } } @@ -775,7 +819,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { Route::Reply(_) => false, Route::Quote(_) => false, Route::Relays => false, - Route::Settings => false, + Route::Settings(_) => false, Route::ComposeNote => false, Route::AddColumn(_) => false, Route::EditProfile(_) => false, @@ -786,6 +830,8 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { Route::Wallet(_) => false, Route::CustomizeZapAmount(_) => false, Route::RepostDecision(_) => false, + Route::Following(_) => false, + Route::FollowedBy(_) => false, } } @@ -826,6 +872,7 @@ fn timelines_view( ) -> AppResponse { let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns(); let mut side_panel_action: Option = None; + let mut app_action: Option = None; let mut responses = Vec::with_capacity(num_cols); let mut can_take_drag_from = Vec::new(); @@ -837,16 +884,25 @@ fn timelines_view( .horizontal(|mut strip| { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); + let current_route = get_active_columns(ctx.accounts, &app.decks_cache) + .selected() + .map(|col| col.router().top()); let side_panel = DesktopSidePanel::new( ctx.accounts.get_selected_account(), &app.decks_cache, ctx.i18n, + ctx.ndb, + ctx.img_cache, + current_route, + ctx.pool, ) .show(ui); if let Some(side_panel) = side_panel { if side_panel.response.clicked() || side_panel.response.secondary_clicked() { - if let Some(action) = DesktopSidePanel::perform_action( + if side_panel.action == SidePanelAction::Dave { + app_action = Some(AppAction::SwitchToDave); + } else if let Some(action) = DesktopSidePanel::perform_action( &mut app.decks_cache, ctx.accounts, side_panel.action, @@ -876,6 +932,8 @@ fn timelines_view( ); }); + app.hovered_column = None; + for col_index in 0..num_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); @@ -889,6 +947,11 @@ fn timelines_view( can_take_drag_from.extend(resp.can_take_drag_from()); responses.push(resp); + // Track hovered column for mouse back/forward navigation + if ui.rect_contains_pointer(rect) { + app.hovered_column = Some(col_index); + } + // vertical line ui.painter() .vline(rect.right(), rect.y_range(), v_line_stroke); @@ -917,9 +980,8 @@ fn timelines_view( ); } - let mut app_action: Option = None; - - for response in responses { + if app_action.is_none() { + for response in responses { let nav_result = response.process_render_nav_response(app, ctx, ui); if let Some(nr) = &nav_result { @@ -929,8 +991,24 @@ fn timelines_view( ProcessNavResult::PfpClicked => { app_action = Some(AppAction::ToggleChrome); } + + ProcessNavResult::SwitchAccount(pubkey) => { + // Add as pubkey-only account if not already present + let kp = enostr::Keypair::only_pubkey(*pubkey); + let _ = ctx.accounts.add_account(kp); + + let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn"); + ctx.accounts.select_account( + pubkey, + ctx.ndb, + &txn, + ctx.pool, + ui.ctx(), + ); + } } } + } } if app.options.contains(AppOptions::TmpColumns) { diff --git a/crates/notedeck_columns/src/draft.rs b/crates/notedeck_columns/src/draft.rs index 6ee7aed16..f6d5798bf 100644 --- a/crates/notedeck_columns/src/draft.rs +++ b/crates/notedeck_columns/src/draft.rs @@ -1,7 +1,7 @@ use egui::text::LayoutJob; use poll_promise::Promise; -use crate::{media_upload::Nip94Event, post::PostBuffer, ui::note::PostType, Error}; +use crate::{media_upload::Nip94Event, post::PostBuffer, ui::{note::PostType, search::FocusState}, Error}; use std::collections::HashMap; #[derive(Default)] @@ -12,6 +12,7 @@ pub struct Draft { pub uploaded_media: Vec, // media uploads to include pub uploading_media: Vec>>, // promises that aren't ready yet pub upload_errors: Vec, // media upload errors to show the user + pub focus_state: FocusState, } pub struct MentionHint { diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs index 35e5451b2..3195ba304 100644 --- a/crates/notedeck_columns/src/lib.rs +++ b/crates/notedeck_columns/src/lib.rs @@ -41,6 +41,6 @@ pub mod storage; pub use app::Damus; pub use error::Error; -pub use route::Route; +pub use route::{Route, SettingsRoute}; pub type Result = std::result::Result; diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs index 558f664fa..d36aba57b 100644 --- a/crates/notedeck_columns/src/nav.rs +++ b/crates/notedeck_columns/src/nav.rs @@ -49,6 +49,7 @@ use tracing::error; pub enum ProcessNavResult { SwitchOccurred, PfpClicked, + SwitchAccount(enostr::Pubkey), } impl ProcessNavResult { @@ -71,6 +72,8 @@ pub enum RenderNavAction { RelayAction(RelayAction), SettingsAction(SettingsAction), RepostAction(RepostAction), + ShowFollowing(enostr::Pubkey), + ShowFollowers(enostr::Pubkey), } pub enum SwitchingAction { @@ -374,6 +377,7 @@ pub enum RouterAction { route: Route, make_new: bool, }, + SwitchAccount(enostr::Pubkey), } pub enum RouterType { @@ -439,6 +443,9 @@ impl RouterAction { sheet_router.after_action = Some(route); None } + RouterAction::SwitchAccount(pubkey) => { + Some(ProcessNavResult::SwitchAccount(pubkey)) + } } } @@ -533,6 +540,18 @@ fn process_render_nav_action( RenderNavAction::RepostAction(action) => { action.process(ctx.ndb, &ctx.accounts.get_selected_account().key, ctx.pool) } + RenderNavAction::ShowFollowing(pubkey) => { + Some(RouterAction::RouteTo( + crate::route::Route::Following(pubkey), + RouterType::Stack, + )) + } + RenderNavAction::ShowFollowers(pubkey) => { + Some(RouterAction::RouteTo( + crate::route::Route::FollowedBy(pubkey), + RouterType::Stack, + )) + } }; if let Some(action) = router_action { @@ -638,13 +657,13 @@ fn render_nav_body( .ui(ui) .map_output(RenderNavAction::RelayAction), - Route::Settings => SettingsView::new( + Route::Settings(route) => SettingsView::new( ctx.settings.get_settings_mut(), &mut note_context, &mut app.note_options, &mut app.jobs, ) - .ui(ui) + .ui(ui, route) .map_output(RenderNavAction::SettingsAction), Route::Reply(id) => { @@ -733,8 +752,19 @@ fn render_nav_body( let Some(kp) = ctx.accounts.get_selected_account().key.to_full() else { return BodyResponse::none(); }; + let navigating = + get_active_columns_mut(note_context.i18n, ctx.accounts, &mut app.decks_cache) + .column(col) + .router() + .navigating; let draft = app.drafts.compose_mut(); + if navigating { + draft.focus_state = FocusState::Navigating + } else if draft.focus_state == FocusState::Navigating { + draft.focus_state = FocusState::ShouldRequestFocus; + } + let txn = Transaction::new(ctx.ndb).expect("txn"); let post_response = ui::PostView::new( &mut note_context, @@ -875,6 +905,66 @@ fn render_nav_body( } }) } + Route::Following(pubkey) => { + let cache_id = egui::Id::new(("following_contacts_cache", pubkey)); + + let contacts = ui.ctx().data_mut(|d| { + d.get_temp::>(cache_id) + }); + + let (txn, contacts) = if let Some(cached) = contacts { + let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn"); + (txn, cached) + } else { + let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn"); + let filter = nostrdb::Filter::new() + .authors([pubkey.bytes()]) + .kinds([3]) + .limit(1) + .build(); + + let mut contacts = vec![]; + if let Ok(results) = ctx.ndb.query(&txn, &[filter], 1) { + if let Some(result) = results.first() { + for tag in result.note.tags() { + if tag.count() >= 2 { + if let Some("p") = tag.get_str(0) { + if let Some(pk_bytes) = tag.get_id(1) { + contacts.push(enostr::Pubkey::new(*pk_bytes)); + } + } + } + } + } + } + + contacts.sort_by_cached_key(|pk| { + ctx.ndb + .get_profile_by_pubkey(&txn, pk.bytes()) + .ok() + .and_then(|p| { + notedeck::name::get_display_name(Some(&p)) + .display_name + .map(|s| s.to_lowercase()) + }) + .unwrap_or_else(|| "zzz".to_string()) + }); + + ui.ctx().data_mut(|d| d.insert_temp(cache_id, contacts.clone())); + (txn, contacts) + }; + + crate::ui::profile::ContactsListView::new(pubkey, contacts, &mut note_context, &txn) + .ui(ui) + .map_output(|action| match action { + crate::ui::profile::ContactsListAction::OpenProfile(pk) => { + RenderNavAction::NoteAction(NoteAction::Profile(pk)) + } + }) + } + Route::FollowedBy(_pubkey) => { + BodyResponse::none() + } Route::Wallet(wallet_type) => { let state = match wallet_type { notedeck::WalletType::Auto => 's: { diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs index 901c33d92..6a393f584 100644 --- a/crates/notedeck_columns/src/profile.rs +++ b/crates/notedeck_columns/src/profile.rs @@ -80,10 +80,18 @@ impl ProfileAction { None } ProfileAction::Context(profile_context) => { - profile_context - .selection - .process(ctx, &profile_context.profile); - None + use notedeck::ProfileContextSelection; + match &profile_context.selection { + ProfileContextSelection::ViewAs => { + Some(RouterAction::SwitchAccount(profile_context.profile)) + } + _ => { + profile_context + .selection + .process(ctx, &profile_context.profile); + None + } + } } } } diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs index 7ae1e340a..3ef8bd5fa 100644 --- a/crates/notedeck_columns/src/route.rs +++ b/crates/notedeck_columns/src/route.rs @@ -21,7 +21,7 @@ pub enum Route { Quote(NoteId), RepostDecision(NoteId), Relays, - Settings, + Settings(SettingsRoute), ComposeNote, AddColumn(AddColumnRoute), EditProfile(Pubkey), @@ -31,6 +31,17 @@ pub enum Route { EditDeck(usize), Wallet(WalletType), CustomizeZapAmount(NoteZapTargetOwned), + Following(Pubkey), + FollowedBy(Pubkey), +} + +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub enum SettingsRoute { + Menu, + Appearance, + Storage, + Keys, + Others, } impl Route { @@ -51,7 +62,7 @@ impl Route { } pub fn settings() -> Self { - Route::Settings + Route::Settings(SettingsRoute::Menu) } pub fn thread(thread_selection: ThreadSelection) -> Self { @@ -117,8 +128,15 @@ impl Route { Route::Relays => { writer.write_token("relay"); } - Route::Settings => { + Route::Settings(route) => { writer.write_token("settings"); + match route { + SettingsRoute::Menu => {} + SettingsRoute::Appearance => writer.write_token("appearance"), + SettingsRoute::Storage => writer.write_token("storage"), + SettingsRoute::Keys => writer.write_token("keys"), + SettingsRoute::Others => writer.write_token("others"), + } } Route::ComposeNote => { writer.write_token("compose"); @@ -138,6 +156,14 @@ impl Route { writer.write_token("repost_decision"); writer.write_token(¬e_id.hex()); } + Route::Following(pubkey) => { + writer.write_token("following"); + writer.write_token(&pubkey.hex()); + } + Route::FollowedBy(pubkey) => { + writer.write_token("followed_by"); + writer.write_token(&pubkey.hex()); + } } } @@ -186,7 +212,30 @@ impl Route { |p| { p.parse_all(|p| { p.parse_token("settings")?; - Ok(Route::Settings) + let route = if let Ok(token) = p.peek_token() { + match token { + "appearance" => { + p.pull_token()?; + SettingsRoute::Appearance + } + "storage" => { + p.pull_token()?; + SettingsRoute::Storage + } + "keys" => { + p.pull_token()?; + SettingsRoute::Keys + } + "others" => { + p.pull_token()?; + SettingsRoute::Others + } + _ => SettingsRoute::Menu, + } + } else { + SettingsRoute::Menu + }; + Ok(Route::Settings(route)) }) }, |p| { @@ -259,6 +308,22 @@ impl Route { ))) }) }, + |p| { + p.parse_all(|p| { + p.parse_token("following")?; + let pubkey = Pubkey::from_hex(p.pull_token()?) + .map_err(|_| ParseError::HexDecodeFailed)?; + Ok(Route::Following(pubkey)) + }) + }, + |p| { + p.parse_all(|p| { + p.parse_token("followed_by")?; + let pubkey = Pubkey::from_hex(p.pull_token()?) + .map_err(|_| ParseError::HexDecodeFailed)?; + Ok(Route::FollowedBy(pubkey)) + }) + }, ], ) } @@ -278,8 +343,24 @@ impl Route { Route::Relays => { ColumnTitle::formatted(tr!(i18n, "Relays", "Column title for relay management")) } - Route::Settings => { - ColumnTitle::formatted(tr!(i18n, "Settings", "Column title for app settings")) + Route::Settings(route) => match route { + SettingsRoute::Menu => { + ColumnTitle::formatted(tr!(i18n, "Settings", "Column title for app settings")) + } + SettingsRoute::Appearance => ColumnTitle::formatted(tr!( + i18n, + "Appearance", + "Column title for appearance settings" + )), + SettingsRoute::Storage => { + ColumnTitle::formatted(tr!(i18n, "Storage", "Column title for storage settings")) + } + SettingsRoute::Keys => { + ColumnTitle::formatted(tr!(i18n, "Keys", "Column title for keys settings")) + } + SettingsRoute::Others => { + ColumnTitle::formatted(tr!(i18n, "Others", "Column title for other settings")) + } } Route::Accounts(amr) => match amr { AccountsRoute::Accounts => ColumnTitle::formatted(tr!( @@ -377,6 +458,16 @@ impl Route { "Repost", "Column title for deciding the type of repost" )), + Route::Following(_) => ColumnTitle::formatted(tr!( + i18n, + "Following", + "Column title for users being followed" + )), + Route::FollowedBy(_) => ColumnTitle::formatted(tr!( + i18n, + "Followed by", + "Column title for followers" + )), } } } @@ -389,6 +480,7 @@ pub struct Router { pub returning: bool, pub navigating: bool, replacing: bool, + forward_stack: Vec, // An overlay captures a range of routes where only one will persist when going back, the most recent added overlay_ranges: Vec>, @@ -407,12 +499,14 @@ impl Router { returning, navigating, replacing, + forward_stack: Vec::new(), overlay_ranges: Vec::new(), } } pub fn route_to(&mut self, route: R) { self.navigating = true; + self.forward_stack.clear(); self.routes.push(route); } @@ -454,31 +548,48 @@ impl Router { self.prev().cloned() } + pub fn go_forward(&mut self) -> bool { + if let Some(route) = self.forward_stack.pop() { + self.navigating = true; + self.routes.push(route); + true + } else { + false + } + } + /// Pop a route, should only be called on a NavRespose::Returned reseponse pub fn pop(&mut self) -> Option { if self.routes.len() == 1 { return None; } - 's: { + let is_overlay = 's: { let Some(last_range) = self.overlay_ranges.last_mut() else { - break 's; + break 's false; }; if last_range.end != self.routes.len() { - break 's; + break 's false; } if last_range.end - 1 <= last_range.start { self.overlay_ranges.pop(); - break 's; + } else { + last_range.end -= 1; } - last_range.end -= 1; - } + true + }; self.returning = false; - self.routes.pop() + let popped = self.routes.pop(); + if !is_overlay { + if let Some(ref route) = popped { + self.forward_stack.push(route.clone()); + } + } + popped } pub fn remove_previous_routes(&mut self) { diff --git a/crates/notedeck_columns/src/timeline/cache.rs b/crates/notedeck_columns/src/timeline/cache.rs index 36c58161f..7b7ee1e50 100644 --- a/crates/notedeck_columns/src/timeline/cache.rs +++ b/crates/notedeck_columns/src/timeline/cache.rs @@ -221,6 +221,7 @@ impl TimelineCache { debug!("got open with *new* subscription for {:?}", &timeline.kind); timeline.subscription.try_add_local(ndb, filter); timeline.subscription.try_add_remote(pool, filter); + } else { // This should never happen reasoning, self.notes would have // failed above if the filter wasn't ready diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs index 4c95f1c5e..0ebe90197 100644 --- a/crates/notedeck_columns/src/timeline/kind.rs +++ b/crates/notedeck_columns/src/timeline/kind.rs @@ -565,11 +565,14 @@ impl TimelineKind { } } - TimelineKind::Profile(pk) => Some(Timeline::new( - TimelineKind::profile(pk), - FilterState::ready_hybrid(profile_filter(pk.bytes())), - TimelineTab::full_tabs(), - )), + TimelineKind::Profile(pk) => { + let filter = profile_filter(pk.bytes()); + Some(Timeline::new( + TimelineKind::profile(pk), + FilterState::ready_hybrid(filter), + TimelineTab::full_tabs(), + )) + } TimelineKind::Notifications(pk) => { let notifications_filter = notifications_filter(&pk); @@ -751,14 +754,14 @@ fn profile_filter(pk: &[u8; 32]) -> HybridFilter { kind: ValidKind::Six, }, ]; - HybridFilter::split( - local, - vec![Filter::new() - .authors([pk]) - .kinds([1, 6, 0]) - .limit(default_remote_limit()) - .build()], - ) + + let remote = vec![Filter::new() + .authors([pk]) + .kinds([1, 6, 0, 3]) + .limit(default_remote_limit()) + .build()]; + + HybridFilter::split(local, remote) } fn search_filter(s: &SearchQuery) -> Vec { diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs index 386259fe3..79b5d4e9e 100644 --- a/crates/notedeck_columns/src/timeline/route.rs +++ b/crates/notedeck_columns/src/timeline/route.rs @@ -132,5 +132,11 @@ pub fn render_profile_route( ui::profile::ProfileViewAction::Context(profile_context_selection) => Some( RenderNavAction::ProfileAction(ProfileAction::Context(profile_context_selection)), ), + ui::profile::ProfileViewAction::ShowFollowing(pubkey) => { + Some(RenderNavAction::ShowFollowing(pubkey)) + } + ui::profile::ProfileViewAction::ShowFollowers(pubkey) => { + Some(RenderNavAction::ShowFollowers(pubkey)) + } }) } diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs index 7ebed2f94..7c7d4ced4 100644 --- a/crates/notedeck_columns/src/ui/column/header.rs +++ b/crates/notedeck_columns/src/ui/column/header.rs @@ -66,6 +66,11 @@ impl<'a> NavTitle<'a> { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); + let interact_rect = child_ui.interact(rect, child_ui.id().with("drag"), Sense::drag()); + if interact_rect.drag_started_by(egui::PointerButton::Primary) { + child_ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + let r = self.title_bar(&mut child_ui); ui.advance_cursor_after_rect(rect); @@ -465,7 +470,7 @@ impl<'a> NavTitle<'a> { TimelineKind::Search(_sq) => { // TODO: show author pfp if author field set? - Some(ui.add(ui::side_panel::search_button())) + Some(ui.add(ui::side_panel::search_button(None))) } TimelineKind::Universe @@ -481,17 +486,19 @@ impl<'a> NavTitle<'a> { Route::AddColumn(_add_col_route) => None, Route::Support => None, Route::Relays => None, - Route::Settings => None, + Route::Settings(_) => None, Route::NewDeck => None, Route::EditDeck(_) => None, Route::EditProfile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), - Route::Search => Some(ui.add(ui::side_panel::search_button())), + Route::Search => Some(ui.add(ui::side_panel::search_button(None))), Route::Wallet(_) => None, Route::CustomizeZapAmount(_) => None, Route::Thread(thread_selection) => { Some(self.thread_pfp(ui, thread_selection, pfp_size)) } Route::RepostDecision(_) => None, + Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), + Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), } } diff --git a/crates/notedeck_columns/src/ui/mentions_picker.rs b/crates/notedeck_columns/src/ui/mentions_picker.rs index 6c15ae171..093cc974f 100644 --- a/crates/notedeck_columns/src/ui/mentions_picker.rs +++ b/crates/notedeck_columns/src/ui/mentions_picker.rs @@ -1,7 +1,8 @@ use egui::{vec2, FontId, Layout, Pos2, Rect, ScrollArea, UiBuilder, Vec2b}; +use enostr::Pubkey; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - fonts::get_font_size, name::get_display_name, profile::get_profile_url, Images, + fonts::get_font_size, name::get_display_name, profile::get_profile_url, Accounts, Images, NotedeckTextStyle, }; use notedeck_ui::{ @@ -20,6 +21,7 @@ pub struct MentionPickerView<'a> { txn: &'a Transaction, img_cache: &'a mut Images, results: &'a Vec<&'a [u8; 32]>, + accounts: &'a Accounts, } pub enum MentionPickerResponse { @@ -33,11 +35,13 @@ impl<'a> MentionPickerView<'a> { ndb: &'a Ndb, txn: &'a Transaction, results: &'a Vec<&'a [u8; 32]>, + accounts: &'a Accounts, ) -> Self { Self { ndb, txn, img_cache, + accounts, results, } } @@ -54,8 +58,9 @@ impl<'a> MentionPickerView<'a> { } }; + let pubkey = Pubkey::new(**res); if ui - .add(user_result(&profile, self.img_cache, i, width)) + .add(user_result(&profile, &pubkey, self.img_cache, self.accounts, i, width)) .clicked() { selection = Some(i) @@ -128,7 +133,9 @@ impl<'a> MentionPickerView<'a> { fn user_result<'a>( profile: &'a ProfileRecord<'_>, + pubkey: &'a Pubkey, cache: &'a mut Images, + accounts: &'a Accounts, index: usize, width: f32, ) -> impl egui::Widget + 'a { @@ -162,7 +169,8 @@ fn user_result<'a>( let pfp_resp = ui.put( icon_rect, &mut ProfilePic::new(cache, get_profile_url(Some(profile))) - .size(helper.scale_1d_pos(min_img_size)), + .size(helper.scale_1d_pos(min_img_size)) + .with_follow_check(pubkey, accounts), ); let name_font = FontId::new( diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs index 2d6f2ca52..651207843 100644 --- a/crates/notedeck_columns/src/ui/note/post.rs +++ b/crates/notedeck_columns/src/ui/note/post.rs @@ -210,6 +210,13 @@ impl<'a, 'd> PostView<'a, 'd> { let out = textedit.show(ui); + if self.draft.focus_state == crate::ui::search::FocusState::ShouldRequestFocus { + out.response.request_focus(); + self.draft.focus_state = crate::ui::search::FocusState::RequestedFocus; + } else if self.draft.focus_state == crate::ui::search::FocusState::RequestedFocus { + self.draft.focus_state = crate::ui::search::FocusState::Navigating; + } + input_context( ui, &out.response, @@ -300,6 +307,7 @@ impl<'a, 'd> PostView<'a, 'd> { self.note_context.ndb, txn, &res, + self.note_context.accounts, ) .show_in_rect(hint_rect, ui); diff --git a/crates/notedeck_columns/src/ui/post.rs b/crates/notedeck_columns/src/ui/post.rs index a53872b8a..32ba62813 100644 --- a/crates/notedeck_columns/src/ui/post.rs +++ b/crates/notedeck_columns/src/ui/post.rs @@ -1,10 +1,10 @@ -use egui::{vec2, Color32, Stroke, Widget}; +use egui::{vec2, Stroke, Widget}; use notedeck_ui::anim::AnimationHelper; static ICON_WIDTH: f32 = 40.0; static ICON_EXPANSION_MULTIPLE: f32 = 1.2; -pub fn compose_note_button(dark_mode: bool) -> impl Widget { +pub fn compose_note_button(_dark_mode: bool) -> impl Widget { move |ui: &mut egui::Ui| -> egui::Response { let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget @@ -20,10 +20,10 @@ pub fn compose_note_button(dark_mode: bool) -> impl Widget { let use_line_width = helper.scale_1d_pos(min_line_width); let use_edge_circle_radius = helper.scale_radius(min_line_width); - // TODO: theme!? - let fill_color = notedeck_ui::colors::PINK; + let text_color = ui.visuals().text_color(); + let bg_color = ui.visuals().extreme_bg_color; - painter.circle_filled(helper.center(), use_background_radius, fill_color); + painter.circle_filled(helper.center(), use_background_radius, text_color); let min_half_plus_sign_size = min_plus_sign_size / 2.0; let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size); @@ -31,27 +31,21 @@ pub fn compose_note_button(dark_mode: bool) -> impl Widget { let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0); let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0); - let icon_color = if !dark_mode { - Color32::BLACK - } else { - Color32::WHITE - }; - painter.line_segment( [north_edge, south_edge], - Stroke::new(use_line_width, icon_color), + Stroke::new(use_line_width, bg_color), ); painter.line_segment( [west_edge, east_edge], - Stroke::new(use_line_width, icon_color), + Stroke::new(use_line_width, bg_color), ); - painter.circle_filled(north_edge, use_edge_circle_radius, icon_color); - painter.circle_filled(south_edge, use_edge_circle_radius, icon_color); - painter.circle_filled(west_edge, use_edge_circle_radius, icon_color); - painter.circle_filled(east_edge, use_edge_circle_radius, icon_color); + painter.circle_filled(north_edge, use_edge_circle_radius, bg_color); + painter.circle_filled(south_edge, use_edge_circle_radius, bg_color); + painter.circle_filled(west_edge, use_edge_circle_radius, bg_color); + painter.circle_filled(east_edge, use_edge_circle_radius, bg_color); helper .take_animation_response() - .on_hover_text("Compose new note") + .on_hover_text("New note") } } diff --git a/crates/notedeck_columns/src/ui/profile/contacts_list.rs b/crates/notedeck_columns/src/ui/profile/contacts_list.rs new file mode 100644 index 000000000..e3ffa9598 --- /dev/null +++ b/crates/notedeck_columns/src/ui/profile/contacts_list.rs @@ -0,0 +1,59 @@ +use enostr::Pubkey; +use nostrdb::Transaction; +use notedeck::NoteContext; + +use crate::{nav::BodyResponse, ui::widgets::UserRow}; + +pub struct ContactsListView<'a, 'd, 'txn> { + contacts: Vec, + note_context: &'a mut NoteContext<'d>, + txn: &'txn Transaction, +} + +#[derive(Clone)] +pub enum ContactsListAction { + OpenProfile(Pubkey), +} + +impl<'a, 'd, 'txn> ContactsListView<'a, 'd, 'txn> { + pub fn new( + _pubkey: &'a Pubkey, + contacts: Vec, + note_context: &'a mut NoteContext<'d>, + txn: &'txn Transaction, + ) -> Self { + ContactsListView { + contacts, + note_context, + txn, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse { + let mut action = None; + + egui::ScrollArea::vertical().show(ui, |ui| { + ui.add_space(8.0); + + for contact_pubkey in &self.contacts { + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(self.txn, contact_pubkey.bytes()) + .ok(); + + let available_width = ui.available_width() - 16.0; + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.add(UserRow::new(profile.as_ref(), contact_pubkey, self.note_context.img_cache, available_width) + .with_accounts(self.note_context.accounts)).clicked() { + action = Some(ContactsListAction::OpenProfile(*contact_pubkey)); + } + ui.add_space(8.0); + }); + } + }); + + BodyResponse::output(action) + } +} diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs index f412ed459..cbdb3335e 100644 --- a/crates/notedeck_columns/src/ui/profile/mod.rs +++ b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -1,6 +1,8 @@ pub mod edit; +pub mod contacts_list; pub use edit::EditProfileView; +pub use contacts_list::{ContactsListView, ContactsListAction}; use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; @@ -20,7 +22,7 @@ use notedeck::{ }; use notedeck_ui::{ app_images, - profile::{about_section_widget, banner, display_name_widget}, + profile::{about_section_widget_expandable, banner, display_name_widget}, NoteOptions, ProfilePic, }; @@ -39,6 +41,8 @@ pub enum ProfileViewAction { Unfollow(Pubkey), Follow(Pubkey), Context(ProfileContext), + ShowFollowing(Pubkey), + ShowFollowers(Pubkey), } struct ProfileScrollResponse { @@ -91,7 +95,7 @@ impl<'a, 'd> ProfileView<'a, 'd> { .ok(); if let Some(profile_view_action) = - profile_body(ui, self.pubkey, self.note_context, profile.as_ref()) + profile_body(ui, self.pubkey, self.note_context, profile.as_ref(), &txn) { action = Some(profile_view_action); } @@ -146,6 +150,7 @@ fn profile_body( pubkey: &Pubkey, note_context: &mut NoteContext, profile: Option<&ProfileRecord<'_>>, + txn: &Transaction, ) -> Option { let mut action = None; ui.vertical(|ui| { @@ -255,7 +260,13 @@ fn profile_body( ui.add_space(8.0); - ui.add(about_section_widget(profile)); + ui.add(about_section_widget_expandable(profile, Some(200))); + + ui.add_space(8.0); + + if let Some(stats_action) = profile_stats(ui, pubkey, note_context, txn) { + action = Some(stats_action); + } ui.horizontal_wrapped(|ui| { let website_url = profile @@ -295,6 +306,93 @@ enum ProfileType { Followable(IsFollowing), } +fn profile_stats( + ui: &mut egui::Ui, + pubkey: &Pubkey, + note_context: &mut NoteContext, + txn: &Transaction, +) -> Option { + let mut action = None; + + let filter = nostrdb::Filter::new() + .authors([pubkey.bytes()]) + .kinds([3]) + .limit(1) + .build(); + + let mut count = 0; + let following_count = { + if let Ok(results) = note_context.ndb.query(txn, &[filter], 1) { + if let Some(result) = results.first() { + for tag in result.note.tags() { + if tag.count() >= 2 { + if let Some("p") = tag.get_str(0) { + if tag.get_id(1).is_some() { + count += 1; + } + } + } + } + } + } + + count + }; + + ui.horizontal(|ui| { + let resp = ui + .label( + RichText::new(format!("{} ", following_count)) + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Small, + )) + .color(ui.visuals().text_color()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + let resp2 = ui + .label( + RichText::new(tr!( + note_context.i18n, + "following", + "Label for number of accounts being followed" + )) + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Small, + )) + .color(ui.visuals().weak_text_color()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if resp.clicked() || resp2.clicked() { + action = Some(ProfileViewAction::ShowFollowing(*pubkey)); + } + + let selected = note_context.accounts.get_selected_account(); + if &selected.key.pubkey != pubkey { + if selected.is_following(pubkey.bytes()) == notedeck::IsFollowing::Yes { + ui.add_space(8.0); + ui.label( + RichText::new(tr!( + note_context.i18n, + "Follows you", + "Badge indicating user follows you" + )) + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Tiny, + )) + .color(ui.visuals().weak_text_color()), + ); + } + } + }); + + action +} + fn handle_link(ui: &mut egui::Ui, website_url: &str) { let img = if ui.visuals().dark_mode { app_images::link_dark_image() diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs index fd939d70f..19493c660 100644 --- a/crates/notedeck_columns/src/ui/search/mod.rs +++ b/crates/notedeck_columns/src/ui/search/mod.rs @@ -1,27 +1,30 @@ -use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit}; +use egui::{vec2, Align, Color32, CornerRadius, Key, RichText, Stroke, TextEdit}; use enostr::{NoteId, Pubkey}; use state::TypingType; use crate::{ nav::BodyResponse, timeline::{TimelineTab, TimelineUnits}, - ui::timeline::TimelineTabView, + ui::{timeline::TimelineTabView, widgets::UserRow}, }; use egui_winit::clipboard::Clipboard; -use nostrdb::{Filter, Ndb, Transaction}; -use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef}; +use nostrdb::{Filter, Ndb, ProfileRecord, Transaction}; +use notedeck::{ + fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural, + Images, JobsCache, Localization, NoteAction, NoteContext, NoteRef, NotedeckTextStyle, +}; use notedeck_ui::{ context_menu::{input_context, PasteBehavior}, icons::search_icon, - padding, NoteOptions, + padding, NoteOptions, ProfilePic, }; use std::time::{Duration, Instant}; use tracing::{error, info, warn}; mod state; -pub use state::{FocusState, SearchQueryState, SearchState}; +pub use state::{FocusState, RecentSearchItem, SearchQueryState, SearchState}; use super::mentions_picker::{MentionPickerResponse, MentionPickerView}; @@ -51,10 +54,13 @@ impl<'a, 'd> SearchView<'a, 'd> { } pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse { - padding(8.0, ui, |ui| self.show_impl(ui)).inner + padding(8.0, ui, |ui| self.show_impl(ui)).inner.map_output(|action| match action { + SearchViewAction::NoteAction(note_action) => note_action, + SearchViewAction::NavigateToProfile(pubkey) => NoteAction::Profile(pubkey), + }) } - pub fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse { + fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse { ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); let search_resp = search_box( @@ -67,53 +73,30 @@ impl<'a, 'd> SearchView<'a, 'd> { search_resp.process(self.query); + let keyboard_resp = handle_keyboard_navigation(ui, &mut self.query.selected_index, &self.query.user_results); + let mut search_action = None; let mut body_resp = BodyResponse::none(); match &self.query.state { - SearchState::New | SearchState::Navigating => {} - SearchState::Typing(TypingType::Mention(mention_name)) => 's: { - let Ok(results) = self - .note_context - .ndb - .search_profile(self.txn, mention_name, 10) - else { - break 's; - }; - - let search_res = MentionPickerView::new( - self.note_context.img_cache, - self.note_context.ndb, - self.txn, - &results, - ) - .show_in_rect(ui.available_rect_before_wrap(), ui); - - let Some(res) = search_res.output else { - break 's; - }; - - search_action = match res { - MentionPickerResponse::SelectResult(Some(index)) => { - let Some(pk_bytes) = results.get(index) else { - break 's; - }; - - let username = self - .note_context - .ndb - .get_profile_by_pubkey(self.txn, pk_bytes) - .ok() - .and_then(|p| p.record().profile().and_then(|p| p.name())) - .unwrap_or(&self.query.string); - - Some(SearchAction::NewSearch { - search_type: SearchType::Profile(Pubkey::new(**pk_bytes)), - new_search_text: format!("@{username}"), - }) + SearchState::New | SearchState::Navigating | SearchState::Typing(TypingType::Mention(_)) => { + if !self.query.string.is_empty() && !self.query.string.starts_with('@') { + self.query.user_results = self.note_context.ndb.search_profile(self.txn, &self.query.string, 10) + .unwrap_or_default() + .iter() + .map(|&pk| pk.to_vec()) + .collect(); + if let Some(action) = self.show_search_suggestions(ui, keyboard_resp) { + search_action = Some(action); } - MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention), - MentionPickerResponse::SelectResult(None) => break 's, - }; + } else if self.query.string.starts_with('@') { + self.handle_mention_search(ui, &mut search_action); + } else { + self.query.user_results.clear(); + self.query.selected_index = -1; + if let Some(action) = self.show_recent_searches(ui, keyboard_resp) { + search_action = Some(action); + } + } } SearchState::PerformSearch(search_type) => { execute_search( @@ -125,7 +108,7 @@ impl<'a, 'd> SearchView<'a, 'd> { &mut self.query.notes, ); search_action = Some(SearchAction::Searched); - body_resp.insert(self.show_search_results(ui)); + body_resp.insert(self.show_search_results(ui).map_output(SearchViewAction::NoteAction)); } SearchState::Searched => { ui.label(tr_plural!( @@ -136,25 +119,183 @@ impl<'a, 'd> SearchView<'a, 'd> { self.query.notes.units.len(), // count query = &self.query.string )); - body_resp.insert(self.show_search_results(ui)); + body_resp.insert(self.show_search_results(ui).map_output(SearchViewAction::NoteAction)); } - SearchState::Typing(TypingType::AutoSearch) => { - ui.label(tr!( - self.note_context.i18n, - "Searching for '{query}'", - "Search in progress message", - query = &self.query.string - )); + }; - body_resp.insert(self.show_search_results(ui)); + if let Some(action) = search_action { + if let Some(view_action) = action.process(self.query) { + body_resp.output = Some(view_action); } + } + + body_resp + } + + fn handle_mention_search(&mut self, ui: &mut egui::Ui, search_action: &mut Option) { + let mention_name = if let Some(mention_text) = self.query.string.get(1..) { + mention_text + } else { + return; }; - if let Some(resp) = search_action { - resp.process(self.query); + 's: { + let Ok(results) = self + .note_context + .ndb + .search_profile(self.txn, mention_name, 10) + else { + break 's; + }; + + let search_res = MentionPickerView::new( + self.note_context.img_cache, + self.note_context.ndb, + self.txn, + &results, + self.note_context.accounts, + ) + .show_in_rect(ui.available_rect_before_wrap(), ui); + + let Some(res) = search_res.output else { + break 's; + }; + + *search_action = match res { + MentionPickerResponse::SelectResult(Some(index)) => { + let Some(pk_bytes) = results.get(index) else { + break 's; + }; + + let username = self + .note_context + .ndb + .get_profile_by_pubkey(self.txn, pk_bytes) + .ok() + .and_then(|p| p.record().profile().and_then(|p| p.name())) + .unwrap_or(&self.query.string); + + Some(SearchAction::NewSearch { + search_type: SearchType::Profile(Pubkey::new(**pk_bytes)), + new_search_text: format!("@{username}"), + }) + } + MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention), + MentionPickerResponse::SelectResult(None) => break 's, + }; } + } - body_resp + fn show_search_suggestions(&mut self, ui: &mut egui::Ui, keyboard_resp: KeyboardResponse) -> Option { + ui.add_space(8.0); + + let is_selected = self.query.selected_index == 0; + let search_posts_clicked = ui.add(search_posts_button( + &self.query.string, + is_selected, + ui.available_width(), + )).clicked() || (is_selected && keyboard_resp.enter_pressed); + + if search_posts_clicked { + return Some(SearchAction::NewSearch { + search_type: SearchType::get_type(&self.query.string), + new_search_text: self.query.string.clone(), + }); + } + + if !self.query.user_results.is_empty() { + ui.add_space(8.0); + + for (i, pk_bytes) in self.query.user_results.iter().enumerate() { + let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) else { + continue; + }; + let pubkey = Pubkey::new(pk_array); + let profile = self.note_context.ndb.get_profile_by_pubkey(self.txn, &pk_array).ok(); + + let is_selected = self.query.selected_index == (i as i32 + 1); + if ui.add(UserRow::new(profile.as_ref(), &pubkey, self.note_context.img_cache, ui.available_width()) + .with_accounts(self.note_context.accounts) + .with_selection(is_selected)).clicked() { + return Some(SearchAction::NavigateToProfile(pubkey)); + } + + // Handle enter on selected user + if keyboard_resp.enter_pressed && is_selected { + return Some(SearchAction::NavigateToProfile(pubkey)); + } + } + } + + None + } + + fn show_recent_searches(&mut self, ui: &mut egui::Ui, keyboard_resp: KeyboardResponse) -> Option { + if self.query.recent_searches.is_empty() { + return None; + } + + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Recent"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(RichText::new("Clear all").size(14.0)).clicked() { + self.query.clear_recent_searches(); + } + }); + }); + ui.add_space(4.0); + + let recent_searches = self.query.recent_searches.clone(); + for (i, search_item) in recent_searches.iter().enumerate() { + let is_selected = self.query.selected_index == i as i32; + + match search_item { + RecentSearchItem::Query(query) => { + let resp = ui.add(recent_search_item( + query, + is_selected, + ui.available_width(), + false, + )); + + if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) { + return Some(SearchAction::NewSearch { + search_type: SearchType::get_type(query), + new_search_text: query.clone(), + }); + } + + if resp.secondary_clicked() || (is_selected && ui.input(|i| i.key_pressed(Key::Delete))) { + self.query.remove_recent_search(i); + } + } + RecentSearchItem::Profile { pubkey, query } => { + let profile = self.note_context.ndb.get_profile_by_pubkey(self.txn, pubkey.bytes()).ok(); + let resp = ui.add(recent_profile_item( + profile.as_ref(), + pubkey, + query, + is_selected, + ui.available_width(), + self.note_context.img_cache, + self.note_context.accounts, + self.note_context.ndb, + self.txn, + )); + + if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) { + return Some(SearchAction::NavigateToProfile(*pubkey)); + } + + if resp.secondary_clicked() || (is_selected && ui.input(|i| i.key_pressed(Key::Delete))) { + self.query.remove_recent_search(i); + } + } + } + } + + None } fn show_search_results(&mut self, ui: &mut egui::Ui) -> BodyResponse { @@ -202,17 +343,23 @@ fn execute_search( ctx.request_repaint(); } +enum SearchViewAction { + NoteAction(NoteAction), + NavigateToProfile(Pubkey), +} + enum SearchAction { NewSearch { search_type: SearchType, new_search_text: String, }, + NavigateToProfile(Pubkey), Searched, CloseMention, } impl SearchAction { - fn process(self, state: &mut SearchQueryState) { + fn process(self, state: &mut SearchQueryState) -> Option { match self { SearchAction::NewSearch { search_type, @@ -220,9 +367,27 @@ impl SearchAction { } => { state.state = SearchState::PerformSearch(search_type); state.string = new_search_text; + state.selected_index = -1; + None + } + SearchAction::NavigateToProfile(pubkey) => { + state.add_recent_profile(pubkey, state.string.clone()); + state.string.clear(); + state.selected_index = -1; + Some(SearchViewAction::NavigateToProfile(pubkey)) + } + SearchAction::CloseMention => { + state.state = SearchState::New; + state.selected_index = -1; + None + } + SearchAction::Searched => { + state.state = SearchState::Searched; + state.selected_index = -1; + state.user_results.clear(); + state.add_recent_query(state.string.clone()); + None } - SearchAction::CloseMention => state.state = SearchState::New, - SearchAction::Searched => state.state = SearchState::Searched, } } } @@ -236,29 +401,48 @@ impl SearchResponse { fn process(self, state: &mut SearchQueryState) { if self.requested_focus { state.focus_state = FocusState::RequestedFocus; + } else if state.focus_state == FocusState::RequestedFocus && !self.input_changed { + state.focus_state = FocusState::Navigating; } - if state.string.chars().nth(0) != Some('@') { - if self.input_changed { - state.state = SearchState::Typing(TypingType::AutoSearch); - state.debouncer.bounce(); + if self.input_changed { + if state.string.starts_with('@') { + state.selected_index = -1; + if let Some(mention_text) = state.string.get(1..) { + state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned())); + } + } else if state.state == SearchState::Searched { + state.state = SearchState::New; + state.selected_index = 0; + } else if !state.string.is_empty() { + state.selected_index = 0; + } else { + state.selected_index = -1; } + } + } +} - if state.state == SearchState::Typing(TypingType::AutoSearch) - && state.debouncer.should_act() - { - state.state = SearchState::PerformSearch(SearchType::get_type(&state.string)); - } +struct KeyboardResponse { + enter_pressed: bool, +} - return; - } +fn handle_keyboard_navigation(ui: &mut egui::Ui, selected_index: &mut i32, user_results: &[Vec]) -> KeyboardResponse { + let max_index = if user_results.is_empty() { + -1 + } else { + user_results.len() as i32 + }; - if self.input_changed { - if let Some(mention_text) = state.string.get(1..) { - state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned())); - } - } + if ui.input(|i| i.key_pressed(Key::ArrowDown)) { + *selected_index = (*selected_index + 1).min(max_index); + } else if ui.input(|i| i.key_pressed(Key::ArrowUp)) { + *selected_index = (*selected_index - 1).max(-1); } + + let enter_pressed = ui.input(|i| i.key_pressed(Key::Enter)); + + KeyboardResponse { enter_pressed } } fn search_box( @@ -307,8 +491,8 @@ fn search_box( .hint_text( RichText::new(tr!( i18n, - "Search notes...", - "Placeholder for search notes input field" + "Search", + "Placeholder for search input field" )) .weak(), ) @@ -318,6 +502,12 @@ fn search_box( .frame(false), ); + if response.has_focus() { + if ui.input(|i| i.key_pressed(Key::ArrowUp) || i.key_pressed(Key::ArrowDown)) { + response.surrender_focus(); + } + } + input_context(ui, &response, clipboard, input, PasteBehavior::Append); let mut requested_focus = false; @@ -458,3 +648,231 @@ fn search_hashtag( let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?; Some(qrs.into_iter().map(NoteRef::from_query_result).collect()) } + +fn recent_profile_item<'a>( + profile: Option<&'a ProfileRecord<'_>>, + pubkey: &'a Pubkey, + _query: &'a str, + is_selected: bool, + width: f32, + cache: &'a mut Images, + accounts: &'a notedeck::Accounts, + _ndb: &'a nostrdb::Ndb, + _txn: &'a nostrdb::Transaction, +) -> impl egui::Widget + 'a { + move |ui: &mut egui::Ui| -> egui::Response { + let min_img_size = 48.0; + let spacing = 8.0; + let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + let x_button_size = 32.0; + + let (rect, resp) = ui.allocate_exact_size( + vec2(width, min_img_size + 8.0), + egui::Sense::click() + ); + + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + + if is_selected { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().selection.bg_fill, + ); + } + + if resp.hovered() { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().widgets.hovered.bg_fill, + ); + } + + let pfp_rect = egui::Rect::from_min_size( + rect.min + vec2(4.0, 4.0), + vec2(min_img_size, min_img_size) + ); + + ui.put( + pfp_rect, + &mut ProfilePic::new(cache, get_profile_url(profile)) + .size(min_img_size) + .with_follow_check(pubkey, accounts), + ); + + let name = get_display_name(profile).name(); + let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family()); + let painter = ui.painter(); + let text_galley = painter.layout( + name.to_string(), + name_font, + ui.visuals().text_color(), + width - min_img_size - spacing - x_button_size - 8.0, + ); + + let galley_pos = egui::Pos2::new( + pfp_rect.right() + spacing, + rect.center().y - (text_galley.rect.height() / 2.0) + ); + + painter.galley(galley_pos, text_galley, ui.visuals().text_color()); + + let x_rect = egui::Rect::from_min_size( + egui::Pos2::new(rect.right() - x_button_size, rect.top()), + vec2(x_button_size, rect.height()) + ); + + let x_center = x_rect.center(); + let x_size = 12.0; + painter.line_segment( + [ + egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0), + egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0), + ], + egui::Stroke::new(1.5, ui.visuals().text_color()), + ); + painter.line_segment( + [ + egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0), + egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0), + ], + egui::Stroke::new(1.5, ui.visuals().text_color()), + ); + + resp + } +} + +fn recent_search_item(query: &str, is_selected: bool, width: f32, _is_profile: bool) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| -> egui::Response { + let min_img_size = 48.0; + let spacing = 8.0; + let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + let x_button_size = 32.0; + + let (rect, resp) = ui.allocate_exact_size( + vec2(width, min_img_size + 8.0), + egui::Sense::click() + ); + + if is_selected { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().selection.bg_fill, + ); + } + + if resp.hovered() { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().widgets.hovered.bg_fill, + ); + } + + let icon_rect = egui::Rect::from_min_size( + rect.min + vec2(4.0, 4.0), + vec2(min_img_size, min_img_size) + ); + + ui.put(icon_rect, search_icon(min_img_size / 2.0, min_img_size)); + + let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family()); + let painter = ui.painter(); + let text_galley = painter.layout( + query.to_string(), + name_font, + ui.visuals().text_color(), + width - min_img_size - spacing - x_button_size - 8.0, + ); + + let galley_pos = egui::Pos2::new( + icon_rect.right() + spacing, + rect.center().y - (text_galley.rect.height() / 2.0) + ); + + painter.galley(galley_pos, text_galley, ui.visuals().text_color()); + + let x_rect = egui::Rect::from_min_size( + egui::Pos2::new(rect.right() - x_button_size, rect.top()), + vec2(x_button_size, rect.height()) + ); + + let x_center = x_rect.center(); + let x_size = 12.0; + painter.line_segment( + [ + egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0), + egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0), + ], + egui::Stroke::new(1.5, ui.visuals().text_color()), + ); + painter.line_segment( + [ + egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0), + egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0), + ], + egui::Stroke::new(1.5, ui.visuals().text_color()), + ); + + resp + } +} + +fn search_posts_button(query: &str, is_selected: bool, width: f32) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| -> egui::Response { + let min_img_size = 48.0; + let spacing = 8.0; + let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + + let (rect, resp) = ui.allocate_exact_size( + vec2(width, min_img_size + 8.0), + egui::Sense::click() + ); + + if is_selected { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().selection.bg_fill, + ); + } + + if resp.hovered() { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().widgets.hovered.bg_fill, + ); + } + + let icon_rect = egui::Rect::from_min_size( + rect.min + vec2(4.0, 4.0), + vec2(min_img_size, min_img_size) + ); + + ui.put(icon_rect, search_icon(min_img_size / 2.0, min_img_size)); + + let text = format!("Search posts for \"{}\"", query); + let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family()); + let painter = ui.painter(); + let text_galley = painter.layout( + text, + name_font, + ui.visuals().text_color(), + width - min_img_size - spacing - 8.0, + ); + + let galley_pos = egui::Pos2::new( + icon_rect.right() + spacing, + rect.center().y - (text_galley.rect.height() / 2.0) + ); + + painter.galley(galley_pos, text_galley, ui.visuals().text_color()); + + resp + } +} + diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs index 569ba9068..5de39093a 100644 --- a/crates/notedeck_columns/src/ui/search/state.rs +++ b/crates/notedeck_columns/src/ui/search/state.rs @@ -1,6 +1,5 @@ use crate::timeline::TimelineTab; -use notedeck::debouncer::Debouncer; -use std::time::Duration; +use enostr::Pubkey; use super::SearchType; @@ -16,10 +15,15 @@ pub enum SearchState { #[derive(Debug, Eq, PartialEq)] pub enum TypingType { Mention(String), - AutoSearch, } -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Clone)] +pub enum RecentSearchItem { + Query(String), + Profile { pubkey: Pubkey, query: String }, +} + +#[derive(Debug, Eq, PartialEq, Clone, Default)] pub enum FocusState { /// Get ready to focus Navigating, @@ -28,6 +32,7 @@ pub enum FocusState { ShouldRequestFocus, /// We already focused, we don't need to do that again + #[default] RequestedFocus, } @@ -37,20 +42,24 @@ pub struct SearchQueryState { /// This holds our search query while we're updating it pub string: String, - /// When the debouncer timer elapses, we execute the search and mark - /// our state as searchd. This will make sure we don't try to search - /// again next frames + /// Current search state pub state: SearchState, /// A bit of context to know if we're navigating to the view. We /// can use this to know when to request focus on the textedit pub focus_state: FocusState, - /// When was the input updated? We use this to debounce searches - pub debouncer: Debouncer, - /// The search results pub notes: TimelineTab, + + /// Currently selected item index in search results (-1 = none, 0 = "search posts", 1+ = users) + pub selected_index: i32, + + /// Cached user search results for the current query + pub user_results: Vec>, + + /// Recent search history (most recent first, max 10) + pub recent_searches: Vec, } impl Default for SearchQueryState { @@ -66,7 +75,43 @@ impl SearchQueryState { state: SearchState::New, notes: TimelineTab::default(), focus_state: FocusState::Navigating, - debouncer: Debouncer::new(Duration::from_millis(200)), + selected_index: -1, + user_results: Vec::new(), + recent_searches: Vec::new(), } } + + pub fn add_recent_query(&mut self, query: String) { + if query.is_empty() { + return; + } + + let item = RecentSearchItem::Query(query.clone()); + self.recent_searches.retain(|s| !matches!(s, RecentSearchItem::Query(q) if q == &query)); + self.recent_searches.insert(0, item); + self.recent_searches.truncate(10); + } + + pub fn add_recent_profile(&mut self, pubkey: Pubkey, query: String) { + if query.is_empty() { + return; + } + + let item = RecentSearchItem::Profile { pubkey, query: query.clone() }; + self.recent_searches.retain(|s| { + !matches!(s, RecentSearchItem::Profile { pubkey: pk, .. } if pk == &pubkey) + }); + self.recent_searches.insert(0, item); + self.recent_searches.truncate(10); + } + + pub fn remove_recent_search(&mut self, index: usize) { + if index < self.recent_searches.len() { + self.recent_searches.remove(index); + } + } + + pub fn clear_recent_searches(&mut self) { + self.recent_searches.clear(); + } } diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs index e95c318ee..4f92965b7 100644 --- a/crates/notedeck_columns/src/ui/settings.rs +++ b/crates/notedeck_columns/src/ui/settings.rs @@ -1,5 +1,5 @@ use egui::{ - vec2, Button, Color32, ComboBox, CornerRadius, FontId, Frame, Layout, Margin, RichText, + vec2, Color32, ComboBox, CornerRadius, FontId, Frame, Layout, Margin, RichText, ScrollArea, TextEdit, ThemePreference, }; use egui_extras::{Size, StripBuilder}; @@ -7,19 +7,19 @@ use enostr::NoteId; use nostrdb::Transaction; use notedeck::{ tr, - ui::{is_narrow, richtext_small}, + ui::richtext_small, Images, JobsCache, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings, SettingsHandler, DEFAULT_NOTE_BODY_FONT_SIZE, }; use notedeck_ui::{ - app_images::{copy_to_clipboard_dark_image, copy_to_clipboard_image}, - AnimationHelper, NoteOptions, NoteView, + app_images::{connected_image, copy_to_clipboard_dark_image, copy_to_clipboard_image, key_image, settings_dark_image, settings_light_image}, + rounded_button, segmented_button, AnimationHelper, NoteOptions, NoteView, }; use crate::{ nav::{BodyResponse, RouterAction}, ui::account_login_view::eye_button, - Damus, Route, + Damus, Route, SettingsRoute, }; const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw"; @@ -29,15 +29,22 @@ const MAX_ZOOM: f32 = 3.0; const ZOOM_STEP: f32 = 0.1; const RESET_ZOOM: f32 = 1.0; +enum SettingsIcon<'a> { + Image(egui::Image<'a>), + Emoji(&'a str), +} + pub enum SettingsAction { SetZoomFactor(f32), SetTheme(ThemePreference), SetLocale(LanguageIdentifier), SetRepliestNewestFirst(bool), SetNoteBodyFontSize(f32), + SetAnimateNavTransitions(bool), OpenRelays, OpenCacheFolder, ClearCacheFolder, + RouteToSettings(SettingsRoute), } impl SettingsAction { @@ -52,6 +59,9 @@ impl SettingsAction { let mut route_action: Option = None; match self { + Self::RouteToSettings(settings_route) => { + route_action = Some(RouterAction::route_to(Route::Settings(settings_route))); + } Self::OpenRelays => { route_action = Some(RouterAction::route_to(Route::Relays)); } @@ -89,6 +99,9 @@ impl SettingsAction { settings.set_note_body_font_size(size); } + Self::SetAnimateNavTransitions(value) => { + settings.set_animate_nav_transitions(value); + } } route_action } @@ -105,18 +118,21 @@ fn settings_group(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egu where S: Into, { + ui.label( + RichText::new(title) + .text_style(NotedeckTextStyle::Small.text_style()) + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(8.0); + Frame::group(ui.style()) .fill(ui.style().visuals.widgets.open.bg_fill) - .inner_margin(10.0) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::same(0)) .show(ui, |ui| { - ui.label(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())); - ui.separator(); - - ui.vertical(|ui| { - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - - contents(ui) - }); + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + contents(ui) }); } @@ -156,183 +172,208 @@ impl<'a> SettingsView<'a> { "Label for appearance settings section", ); settings_group(ui, title, |ui| { - ui.horizontal_wrapped(|ui| { - ui.label(richtext_small(tr!( + // Font size row + ui.horizontal(|ui| { + ui.set_height(44.0); + ui.add_space(16.0); + ui.label(RichText::new(tr!( self.note_context.i18n, - "Font size:", + "Font size", "Label for font size, Appearance settings section", - ))); - - if ui - .add( - egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0) - .text(""), - ) - .changed() - { - action = Some(SettingsAction::SetNoteBodyFontSize( - self.settings.note_body_font_size, - )); - }; - - if ui - .button(richtext_small(tr!( + )).text_style(NotedeckTextStyle::Body.text_style())); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + + if ui.add(rounded_button(tr!( self.note_context.i18n, "Reset", "Label for reset note body font size, Appearance settings section", - ))) - .clicked() - { - action = Some(SettingsAction::SetNoteBodyFontSize( - DEFAULT_NOTE_BODY_FONT_SIZE, - )); - } + ))).clicked() { + action = Some(SettingsAction::SetNoteBodyFontSize(DEFAULT_NOTE_BODY_FONT_SIZE)); + } + + ui.add_space(8.0); + ui.label(format!("{:.0}", self.settings.note_body_font_size)); + ui.add_space(8.0); + + if ui.add(egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0) + .text("") + .show_value(false)).changed() { + action = Some(SettingsAction::SetNoteBodyFontSize(self.settings.note_body_font_size)); + } + }); }); + // Preview note let txn = Transaction::new(self.note_context.ndb).unwrap(); - if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) { - if let Ok(preview_note) = - self.note_context.ndb.get_note_by_id(&txn, note_id.bytes()) - { + if let Ok(preview_note) = self.note_context.ndb.get_note_by_id(&txn, note_id.bytes()) { + ui.add_space(8.0); notedeck_ui::padding(8.0, ui, |ui| { - if is_narrow(ui.ctx()) { - ui.set_max_width(ui.available_width()); - - NoteView::new( - self.note_context, - &preview_note, - *self.note_options, - self.jobs, - ) - .actionbar(false) - .options_button(false) - .show(ui); - } + ui.set_max_width(ui.available_width()); + + NoteView::new( + self.note_context, + &preview_note, + *self.note_options, + self.jobs, + ) + .actionbar(false) + .options_button(false) + .show(ui); }); - ui.separator(); + ui.add_space(8.0); } } - let current_zoom = ui.ctx().zoom_factor(); + ui.painter().line_segment( + [egui::pos2(ui.min_rect().left() + 16.0, ui.min_rect().bottom()), egui::pos2(ui.min_rect().right(), ui.min_rect().bottom())], + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); - ui.horizontal_wrapped(|ui| { - ui.label(richtext_small(tr!( + // Zoom level row + let current_zoom = ui.ctx().zoom_factor(); + ui.horizontal(|ui| { + ui.set_height(44.0); + ui.add_space(16.0); + ui.label(RichText::new(tr!( self.note_context.i18n, - "Zoom Level:", + "Zoom level", "Label for zoom level, Appearance settings section", - ))); - - let min_reached = current_zoom <= MIN_ZOOM; - let max_reached = current_zoom >= MAX_ZOOM; - - if ui - .add_enabled( - !min_reached, - Button::new( - RichText::new("-").text_style(NotedeckTextStyle::Small.text_style()), - ), - ) - .clicked() - { - let new_zoom = (current_zoom - ZOOM_STEP).max(MIN_ZOOM); - action = Some(SettingsAction::SetZoomFactor(new_zoom)); - }; + )).text_style(NotedeckTextStyle::Body.text_style())); - ui.label( - RichText::new(format!("{:.0}%", current_zoom * 100.0)) - .text_style(NotedeckTextStyle::Small.text_style()), - ); + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); - if ui - .add_enabled( - !max_reached, - Button::new( - RichText::new("+").text_style(NotedeckTextStyle::Small.text_style()), - ), - ) - .clicked() - { - let new_zoom = (current_zoom + ZOOM_STEP).min(MAX_ZOOM); - action = Some(SettingsAction::SetZoomFactor(new_zoom)); - }; - - if ui - .button(richtext_small(tr!( + if ui.add(rounded_button(tr!( self.note_context.i18n, "Reset", "Label for reset zoom level, Appearance settings section", - ))) - .clicked() - { - action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM)); - } + ))).clicked() { + action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM)); + } + + ui.add_space(8.0); + + let max_reached = current_zoom >= MAX_ZOOM; + if ui.add_enabled(!max_reached, rounded_button("+")).clicked() { + action = Some(SettingsAction::SetZoomFactor((current_zoom + ZOOM_STEP).min(MAX_ZOOM))); + } + + ui.add_space(4.0); + ui.label(format!("{:.0}%", current_zoom * 100.0)); + ui.add_space(4.0); + + let min_reached = current_zoom <= MIN_ZOOM; + if ui.add_enabled(!min_reached, rounded_button("-")).clicked() { + action = Some(SettingsAction::SetZoomFactor((current_zoom - ZOOM_STEP).max(MIN_ZOOM))); + } + }); }); - ui.horizontal_wrapped(|ui| { - ui.label(richtext_small(tr!( + ui.painter().line_segment( + [egui::pos2(ui.min_rect().left() + 16.0, ui.min_rect().bottom()), egui::pos2(ui.min_rect().right(), ui.min_rect().bottom())], + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); + + // Language row + ui.horizontal(|ui| { + ui.set_height(44.0); + ui.add_space(16.0); + ui.label(RichText::new(tr!( self.note_context.i18n, - "Language:", + "Language", "Label for language, Appearance settings section", - ))); - - // - ComboBox::from_label("") - .selected_text(self.get_selected_language_name()) - .show_ui(ui, |ui| { - for lang in self.note_context.i18n.get_available_locales() { - let name = self - .note_context - .i18n - .get_locale_native_name(lang) - .map(|s| s.to_owned()) - .unwrap_or_else(|| lang.to_string()); - if ui - .selectable_value(&mut self.settings.locale, lang.to_string(), name) - .clicked() - { - action = Some(SettingsAction::SetLocale(lang.to_owned())) + )).text_style(NotedeckTextStyle::Body.text_style())); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + + let combo_style = ui.style_mut(); + combo_style.spacing.combo_height = 32.0; + combo_style.spacing.button_padding = vec2(12.0, 6.0); + + ComboBox::new("language_combo", "") + .selected_text(self.get_selected_language_name()) + .show_ui(ui, |ui| { + for lang in self.note_context.i18n.get_available_locales() { + let name = self.note_context.i18n.get_locale_native_name(lang) + .map(|s| s.to_owned()) + .unwrap_or_else(|| lang.to_string()); + if ui.selectable_value(&mut self.settings.locale, lang.to_string(), name).clicked() { + action = Some(SettingsAction::SetLocale(lang.to_owned())); + } } - } - }); + }); + }); }); - ui.horizontal_wrapped(|ui| { - ui.label(richtext_small(tr!( + ui.painter().line_segment( + [egui::pos2(ui.min_rect().left() + 16.0, ui.min_rect().bottom()), egui::pos2(ui.min_rect().right(), ui.min_rect().bottom())], + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); + + // Theme row + ui.horizontal(|ui| { + ui.set_height(44.0); + ui.add_space(16.0); + ui.label(RichText::new(tr!( self.note_context.i18n, - "Theme:", + "Theme", "Label for theme, Appearance settings section", - ))); + )).text_style(NotedeckTextStyle::Body.text_style())); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + + let is_dark = self.settings.theme == ThemePreference::Dark; + if ui.add(segmented_button( + tr!(self.note_context.i18n, "Dark", "Label for Theme Dark, Appearance settings section"), + is_dark, + ui + )).clicked() { + action = Some(SettingsAction::SetTheme(ThemePreference::Dark)); + } - if ui - .selectable_value( - &mut self.settings.theme, - ThemePreference::Light, - richtext_small(tr!( - self.note_context.i18n, - "Light", - "Label for Theme Light, Appearance settings section", - )), - ) - .clicked() - { - action = Some(SettingsAction::SetTheme(ThemePreference::Light)); - } + ui.add_space(4.0); - if ui - .selectable_value( - &mut self.settings.theme, - ThemePreference::Dark, - richtext_small(tr!( - self.note_context.i18n, - "Dark", - "Label for Theme Dark, Appearance settings section", - )), - ) - .clicked() - { - action = Some(SettingsAction::SetTheme(ThemePreference::Dark)); - } + let is_light = self.settings.theme == ThemePreference::Light; + if ui.add(segmented_button( + tr!(self.note_context.i18n, "Light", "Label for Theme Light, Appearance settings section"), + is_light, + ui + )).clicked() { + action = Some(SettingsAction::SetTheme(ThemePreference::Light)); + } + }); + }); + + ui.painter().line_segment( + [egui::pos2(ui.min_rect().left() + 16.0, ui.min_rect().bottom()), egui::pos2(ui.min_rect().right(), ui.min_rect().bottom())], + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); + + // Animate transitions row (last row, no separator) + ui.horizontal(|ui| { + ui.set_height(44.0); + ui.add_space(16.0); + ui.label(RichText::new("Animate view transitions").text_style(NotedeckTextStyle::Body.text_style())); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + + let btn_text = if self.settings.animate_nav_transitions { "On" } else { "Off" }; + if ui.add(rounded_button(btn_text) + .fill(if self.settings.animate_nav_transitions { + ui.visuals().selection.bg_fill + } else { + ui.visuals().widgets.inactive.bg_fill + })).clicked() { + self.settings.animate_nav_transitions = !self.settings.animate_nav_transitions; + action = Some(SettingsAction::SetAnimateNavTransitions(self.settings.animate_nav_transitions)); + } + }); }); }); @@ -348,58 +389,81 @@ impl<'a> SettingsView<'a> { "Label for storage settings section" ); settings_group(ui, title, |ui| { - ui.horizontal_wrapped(|ui| { - let static_imgs_size = self - .note_context - .img_cache - .static_imgs - .cache_size - .lock() - .unwrap(); + // Image cache size row + let static_imgs_size = self.note_context.img_cache.static_imgs.cache_size.lock().unwrap(); + let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap(); + let total_size = [static_imgs_size, gifs_size].iter().fold(0_u64, |acc, cur| acc + cur.unwrap_or_default()); + + ui.horizontal(|ui| { + ui.set_height(44.0); + ui.add_space(16.0); + ui.label(RichText::new(tr!( + self.note_context.i18n, + "Image cache size", + "Label for Image cache size, Storage settings section" + )).text_style(NotedeckTextStyle::Body.text_style())); - let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap(); + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + ui.label(format_size(total_size)); + }); + }); - ui.label( - RichText::new(format!( - "{} {}", - tr!( - self.note_context.i18n, - "Image cache size:", - "Label for Image cache size, Storage settings section" - ), - format_size( - [static_imgs_size, gifs_size] - .iter() - .fold(0_u64, |acc, cur| acc + cur.unwrap_or_default()) - ) - )) - .text_style(NotedeckTextStyle::Small.text_style()), + // View folder row + if !notedeck::ui::is_compiled_as_mobile() { + ui.painter().line_segment( + [egui::pos2(ui.min_rect().left() + 16.0, ui.min_rect().bottom()), egui::pos2(ui.min_rect().right(), ui.min_rect().bottom())], + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color), ); - ui.end_row(); + ui.horizontal(|ui| { + ui.set_height(44.0); - if !notedeck::ui::is_compiled_as_mobile() - && ui - .button(richtext_small(tr!( - self.note_context.i18n, - "View folder", - "Label for view folder button, Storage settings section", - ))) - .clicked() - { - action = Some(SettingsAction::OpenCacheFolder); - } + let response = ui.interact(ui.max_rect(), ui.id().with("view_folder"), egui::Sense::click()); - let clearcache_resp = ui.button( - richtext_small(tr!( + ui.add_space(16.0); + ui.label(RichText::new(tr!( self.note_context.i18n, - "Clear cache", - "Label for clear cache button, Storage settings section", - )) - .color(Color32::LIGHT_RED), - ); + "View folder", + "Label for view folder button, Storage settings section", + )).text_style(NotedeckTextStyle::Body.text_style())); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + ui.label(RichText::new("›").color(ui.visuals().weak_text_color())); + }); + + if response.clicked() { + action = Some(SettingsAction::OpenCacheFolder); + } + }); + } + + // Clear cache row + let id_clearcache = id.with("clear_cache"); + + ui.painter().line_segment( + [egui::pos2(ui.min_rect().left() + 16.0, ui.min_rect().bottom()), egui::pos2(ui.min_rect().right(), ui.min_rect().bottom())], + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); + + ui.horizontal(|ui| { + ui.set_height(44.0); + + let clearcache_resp = ui.interact(ui.max_rect(), ui.id().with("clear_cache_btn"), egui::Sense::click()); + + ui.add_space(16.0); + ui.label(RichText::new(tr!( + self.note_context.i18n, + "Clear cache", + "Label for clear cache button, Storage settings section", + )).text_style(NotedeckTextStyle::Body.text_style()).color(Color32::from_rgb(255, 69, 58))); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + ui.label(RichText::new("›").color(ui.visuals().weak_text_color())); + }); - let id_clearcache = id.with("clear_cache"); if clearcache_resp.clicked() { ui.data_mut(|d| d.insert_temp(id_clearcache, true)); } @@ -407,23 +471,22 @@ impl<'a> SettingsView<'a> { if ui.data_mut(|d| *d.get_temp_mut_or_default(id_clearcache)) { let mut confirm_pressed = false; clearcache_resp.show_tooltip_ui(|ui| { - let confirm_resp = ui.button(tr!( + let confirm_resp = ui.add(rounded_button(tr!( self.note_context.i18n, "Confirm", "Label for confirm clear cache, Storage settings section" - )); + ))); + if confirm_resp.clicked() { confirm_pressed = true; } if confirm_resp.clicked() - || ui - .button(tr!( - self.note_context.i18n, - "Cancel", - "Label for cancel clear cache, Storage settings section" - )) - .clicked() + || ui.add(rounded_button(tr!( + self.note_context.i18n, + "Cancel", + "Label for cancel clear cache, Storage settings section" + ))).clicked() { ui.data_mut(|d| d.insert_temp(id_clearcache, false)); } @@ -434,7 +497,7 @@ impl<'a> SettingsView<'a> { } else if !confirm_pressed && clearcache_resp.clicked_elsewhere() { ui.data_mut(|d| d.insert_temp(id_clearcache, false)); } - }; + } }); }); @@ -446,33 +509,35 @@ impl<'a> SettingsView<'a> { let title = tr!( self.note_context.i18n, - "Others", - "Label for others settings section" + "Content", + "Label for content settings section" ); settings_group(ui, title, |ui| { - ui.horizontal_wrapped(|ui| { - ui.label(richtext_small(tr!( + // Sort replies row + ui.horizontal(|ui| { + ui.set_height(44.0); + ui.add_space(16.0); + ui.label(RichText::new(tr!( self.note_context.i18n, - "Sort replies newest first:", - "Label for Sort replies newest first, others settings section", - ))); - - if ui - .toggle_value( - &mut self.settings.show_replies_newest_first, - RichText::new(tr!( - self.note_context.i18n, - "On", - "Setting to turn on sorting replies so that the newest are shown first" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ) - .changed() - { - action = Some(SettingsAction::SetRepliestNewestFirst( - self.settings.show_replies_newest_first, - )); - } + "Sort replies newest first", + "Label for Sort replies newest first, content settings section", + )).text_style(NotedeckTextStyle::Body.text_style())); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + + if ui.add(segmented_button("On", self.settings.show_replies_newest_first, ui)).clicked() { + self.settings.show_replies_newest_first = true; + action = Some(SettingsAction::SetRepliestNewestFirst(true)); + } + + ui.add_space(4.0); + + if ui.add(segmented_button("Off", !self.settings.show_replies_newest_first, ui)).clicked() { + self.settings.show_replies_newest_first = false; + action = Some(SettingsAction::SetRepliestNewestFirst(false)); + } + }); }); }); @@ -622,63 +687,263 @@ impl<'a> SettingsView<'a> { }); } - fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option { + fn settings_menu(&mut self, ui: &mut egui::Ui) -> Option { let mut action = None; - if ui - .add_sized( - [ui.available_width(), 30.0], - Button::new(richtext_small(tr!( - self.note_context.i18n, - "Configure relays", - "Label for configure relays, settings section", - ))), - ) - .clicked() - { - action = Some(SettingsAction::OpenRelays); - } + Frame::default() + .inner_margin(Margin::symmetric(10, 10)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + + let dark_mode = ui.visuals().dark_mode; + self.settings_section_with_relay(ui, "", &mut action, &[ + ("Appearance", SettingsRoute::Appearance, Some(SettingsIcon::Image(if dark_mode { settings_dark_image() } else { settings_light_image() }))), + ("Content", SettingsRoute::Others, Some(SettingsIcon::Emoji("📄"))), + ("Storage", SettingsRoute::Storage, Some(SettingsIcon::Emoji("💾"))), + ("Keys", SettingsRoute::Keys, Some(SettingsIcon::Image(key_image()))), + ]); + }); action } - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse { - let scroll_out = Frame::default() - .inner_margin(Margin::symmetric(10, 10)) + fn settings_section_with_relay<'b>( + &mut self, + ui: &mut egui::Ui, + title: &str, + action: &mut Option, + items: &[(&str, SettingsRoute, Option>)], + ) { + self.settings_section(ui, title, action, items, true); + } + + fn settings_section<'b>( + &mut self, + ui: &mut egui::Ui, + title: &str, + action: &mut Option, + items: &[(&str, SettingsRoute, Option>)], + include_relay: bool, + ) { + if !title.is_empty() { + ui.label( + RichText::new(title) + .text_style(NotedeckTextStyle::Small.text_style()) + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(8.0); + } + + Frame::group(ui.style()) + .fill(ui.style().visuals.widgets.open.bg_fill) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::same(0)) .show(ui, |ui| { - ScrollArea::vertical().show(ui, |ui| { - let mut action = None; - if let Some(new_action) = self.appearance_section(ui) { - action = Some(new_action); + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + + for (idx, (label, route, icon)) in items.iter().enumerate() { + let label = *label; + let route = *route; + let is_last = idx == items.len() - 1 && !include_relay; + + let response = ui.allocate_response( + vec2(ui.available_width(), 44.0), + egui::Sense::click(), + ); + + if response.clicked() { + *action = Some(SettingsAction::RouteToSettings(route)); } - ui.add_space(5.0); + let rect = response.rect; + let visuals = ui.style().interact(&response); - if let Some(new_action) = self.storage_section(ui) { - action = Some(new_action); + if response.hovered() { + ui.painter().rect_filled( + rect, + CornerRadius::same(0), + ui.visuals().widgets.hovered.bg_fill, + ); } - ui.add_space(5.0); + let mut text_x = rect.left() + 16.0; + + // Draw icon if present + if let Some(icon_data) = icon { + let icon_size = 20.0; + match icon_data { + SettingsIcon::Image(img) => { + let icon_rect = egui::Rect::from_center_size( + egui::pos2(text_x + icon_size / 2.0, rect.center().y), + vec2(icon_size, icon_size), + ); + img.clone().paint_at(ui, icon_rect); + text_x += icon_size + 12.0; + } + SettingsIcon::Emoji(emoji) => { + let emoji_galley = ui.painter().layout_no_wrap( + emoji.to_string(), + NotedeckTextStyle::Body.text_style().resolve(ui.style()), + visuals.text_color(), + ); + ui.painter().galley( + egui::pos2(text_x, rect.center().y - emoji_galley.size().y / 2.0), + emoji_galley, + visuals.text_color(), + ); + text_x += icon_size + 12.0; + } + } + } - self.keys_section(ui); + let galley = ui.painter().layout_no_wrap( + label.to_string(), + NotedeckTextStyle::Body.text_style().resolve(ui.style()), + visuals.text_color(), + ); + + ui.painter().galley( + egui::pos2(text_x, rect.center().y - galley.size().y / 2.0), + galley, + visuals.text_color(), + ); + + // Draw chevron + let chevron_galley = ui.painter().layout_no_wrap( + "›".to_string(), + NotedeckTextStyle::Body.text_style().resolve(ui.style()), + ui.visuals().weak_text_color(), + ); + + ui.painter().galley( + rect.right_center() + vec2(-16.0 - chevron_galley.size().x, -chevron_galley.size().y / 2.0), + chevron_galley, + ui.visuals().weak_text_color(), + ); + + // Draw separator line + if !is_last { + let line_y = rect.bottom(); + ui.painter().line_segment( + [ + egui::pos2(rect.left() + 16.0, line_y), + egui::pos2(rect.right(), line_y), + ], + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); + } + } - ui.add_space(5.0); + // Add relay configuration item if requested + if include_relay { + let response = ui.allocate_response( + vec2(ui.available_width(), 44.0), + egui::Sense::click(), + ); - if let Some(new_action) = self.other_options_section(ui) { - action = Some(new_action); + if response.clicked() { + *action = Some(SettingsAction::OpenRelays); } - ui.add_space(10.0); + let rect = response.rect; + let visuals = ui.style().interact(&response); - if let Some(new_action) = self.manage_relays_section(ui) { - action = Some(new_action); + if response.hovered() { + ui.painter().rect_filled( + rect, + CornerRadius::same(0), + ui.visuals().widgets.hovered.bg_fill, + ); } - action - }) - }) - .inner; - BodyResponse::scroll(scroll_out) + let mut text_x = rect.left() + 16.0; + + // Draw relay icon + let icon_size = 20.0; + let icon_rect = egui::Rect::from_center_size( + egui::pos2(text_x + icon_size / 2.0, rect.center().y), + vec2(icon_size, icon_size), + ); + connected_image().paint_at(ui, icon_rect); + text_x += icon_size + 12.0; + + // Draw label + let label = tr!( + self.note_context.i18n, + "Configure relays", + "Label for configure relays, settings section", + ); + let galley = ui.painter().layout_no_wrap( + label, + NotedeckTextStyle::Body.text_style().resolve(ui.style()), + visuals.text_color(), + ); + + ui.painter().galley( + egui::pos2(text_x, rect.center().y - galley.size().y / 2.0), + galley, + visuals.text_color(), + ); + + // Draw chevron + let chevron_galley = ui.painter().layout_no_wrap( + "›".to_string(), + NotedeckTextStyle::Body.text_style().resolve(ui.style()), + ui.visuals().weak_text_color(), + ); + + ui.painter().galley( + rect.right_center() + vec2(-16.0 - chevron_galley.size().x, -chevron_galley.size().y / 2.0), + chevron_galley, + ui.visuals().weak_text_color(), + ); + } + }); + } + + pub fn ui(&mut self, ui: &mut egui::Ui, route: &SettingsRoute) -> BodyResponse { + match route { + SettingsRoute::Menu => { + BodyResponse::output(self.settings_menu(ui)) + } + _ => { + let scroll_out = Frame::default() + .inner_margin(Margin::symmetric(10, 10)) + .show(ui, |ui| { + ScrollArea::vertical().show(ui, |ui| { + let mut action = None; + + match route { + SettingsRoute::Appearance => { + if let Some(new_action) = self.appearance_section(ui) { + action = Some(new_action); + } + } + SettingsRoute::Storage => { + if let Some(new_action) = self.storage_section(ui) { + action = Some(new_action); + } + } + SettingsRoute::Keys => { + self.keys_section(ui); + } + SettingsRoute::Others => { + if let Some(new_action) = self.other_options_section(ui) { + action = Some(new_action); + } + } + SettingsRoute::Menu => {} + } + + action + }) + }) + .inner; + + BodyResponse::scroll(scroll_out) + } + } } } diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs index af7f2f04e..6ce5b0aac 100644 --- a/crates/notedeck_columns/src/ui/side_panel.rs +++ b/crates/notedeck_columns/src/ui/side_panel.rs @@ -1,6 +1,6 @@ use egui::{ - vec2, CursorIcon, InnerResponse, Layout, Margin, RichText, ScrollArea, Separator, Stroke, - Widget, + vec2, CursorIcon, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator, + Stroke, Widget, }; use tracing::{error, info}; @@ -12,10 +12,11 @@ use crate::{ route::Route, }; -use notedeck::{tr, Accounts, Localization, UserAccount}; +use enostr::{RelayPool, RelayStatus}; +use notedeck::{tr, Accounts, Localization, NotedeckTextStyle, UserAccount}; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, - app_images, colors, View, + app_images, colors, ProfilePic, View, }; use super::configure_deck::deck_icon; @@ -27,6 +28,10 @@ pub struct DesktopSidePanel<'a> { selected_account: &'a UserAccount, decks_cache: &'a DecksCache, i18n: &'a mut Localization, + ndb: &'a nostrdb::Ndb, + img_cache: &'a mut notedeck::Images, + current_route: Option<&'a Route>, + pool: &'a RelayPool, } impl View for DesktopSidePanel<'_> { @@ -37,6 +42,7 @@ impl View for DesktopSidePanel<'_> { #[derive(Debug, Eq, PartialEq, Clone, Copy)] pub enum SidePanelAction { + Home, Columns, ComposeNote, Search, @@ -45,6 +51,12 @@ pub enum SidePanelAction { SwitchDeck(usize), EditDeck(usize), Wallet, + Profile, + Settings, + Relays, + Accounts, + Support, + Dave, } pub struct SidePanelResponse { @@ -63,11 +75,19 @@ impl<'a> DesktopSidePanel<'a> { selected_account: &'a UserAccount, decks_cache: &'a DecksCache, i18n: &'a mut Localization, + ndb: &'a nostrdb::Ndb, + img_cache: &'a mut notedeck::Images, + current_route: Option<&'a Route>, + pool: &'a RelayPool, ) -> Self { Self { selected_account, decks_cache, i18n, + ndb, + img_cache, + current_route, + pool, } } @@ -90,95 +110,175 @@ impl<'a> DesktopSidePanel<'a> { } fn show_inner(&mut self, ui: &mut egui::Ui) -> Option { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - let inner = ui - .vertical(|ui| { - ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { - // macos needs a bit of space to make room for window - // minimize/close buttons - //if cfg!(target_os = "macos") { - // ui.add_space(24.0); - //} - - let compose_resp = ui - .add(crate::ui::post::compose_note_button(dark_mode)) - .on_hover_cursor(egui::CursorIcon::PointingHand); - let search_resp = ui.add(search_button()); - let column_resp = ui.add(add_column_button()); - - ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); - - ui.add_space(8.0); - ui.add(egui::Label::new( - RichText::new(tr!( - self.i18n, - "DECKS", - "Label for decks section in side panel" - )) - .size(11.0) - .color(ui.visuals().noninteractive().fg_stroke.color), - )); - ui.add_space(8.0); - let add_deck_resp = ui.add(add_deck_button(self.i18n)); - - let decks_inner = ScrollArea::vertical() - .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) - .show(ui, |ui| { - show_decks(ui, self.decks_cache, self.selected_account) - }) - .inner; - - /* - if expand_resp.clicked() { - Some(InnerResponse::new( - SidePanelAction::ExpandSidePanel, - expand_resp, - )) - */ - if compose_resp.clicked() { - Some(InnerResponse::new( - SidePanelAction::ComposeNote, - compose_resp, - )) - } else if search_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::Search, search_resp)) - } else if column_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) - } else if add_deck_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) - } else if decks_inner.response.secondary_clicked() { - info!("decks inner secondary click"); - if let Some(clicked_index) = decks_inner.inner { - Some(InnerResponse::new( - SidePanelAction::EditDeck(clicked_index), - decks_inner.response, + let avatar_size = 40.0; + let bottom_padding = 8.0; + let connectivity_indicator_height = 48.0; // Height for the connectivity indicator + let is_read_only = self.selected_account.key.secret_key.is_none(); + let read_only_label_height = if is_read_only { 16.0 } else { 0.0 }; + let avatar_section_height = avatar_size + bottom_padding + read_only_label_height + connectivity_indicator_height; + + ui.vertical(|ui| { + #[cfg(target_os = "macos")] + ui.add_space(32.0); + + let available_for_scroll = ui.available_height() - avatar_section_height; + + let scroll_out = ScrollArea::vertical() + .max_height(available_for_scroll) + .show(ui, |ui| { + ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + let home_resp = ui.add(home_button(self.current_route)); + let search_resp = ui.add(search_button(self.current_route)); + let settings_resp = ui.add(settings_button(self.current_route)); + let wallet_resp = ui.add(wallet_button(self.current_route)); + + let dave_resp = ui.add(dave_button()); + + let profile_resp = ui.add(profile_button(self.current_route, self.selected_account.key.pubkey)); + + let support_resp = ui.add(support_button(self.current_route)); + + let compose_resp = ui + .add(crate::ui::post::compose_note_button(ui.visuals().dark_mode)) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); + + ui.add_space(8.0); + ui.add(egui::Label::new( + RichText::new(tr!( + self.i18n, + "DECKS", + "Label for decks section in side panel" )) - } else { - None - } - } else if decks_inner.response.clicked() { - if let Some(clicked_index) = decks_inner.inner { - Some(InnerResponse::new( - SidePanelAction::SwitchDeck(clicked_index), - decks_inner.response, + .size(11.0) + .color(ui.visuals().noninteractive().fg_stroke.color), + )); + ui.add_space(8.0); + + let column_resp = ui.add(add_column_button()); + let add_deck_resp = ui.add(add_deck_button(self.i18n)); + + let decks_inner = show_decks(ui, self.decks_cache, self.selected_account); + + (home_resp, dave_resp, compose_resp, search_resp, column_resp, settings_resp, profile_resp, wallet_resp, support_resp, add_deck_resp, decks_inner) + }) + }); + + let (home_resp, dave_resp, compose_resp, search_resp, column_resp, settings_resp, profile_resp, wallet_resp, support_resp, add_deck_resp, decks_inner) = scroll_out.inner.inner; + + let remaining = ui.available_height(); + if remaining > avatar_section_height { + ui.add_space(remaining - avatar_section_height); + } + + // Connectivity indicator + let connectivity_resp = ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + connectivity_indicator(ui, self.pool, self.current_route) + }).inner; + + let pfp_resp = ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + let is_read_only = self.selected_account.key.secret_key.is_none(); + + if is_read_only { + ui.add( + Label::new( + RichText::new(tr!( + self.i18n, + "Read only", + "Label for read-only profile mode" + )) + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Tiny, )) - } else { - None - } + .color(ui.visuals().warn_fg_color), + ) + .selectable(false), + ); + ui.add_space(4.0); + } + + let txn = nostrdb::Transaction::new(self.ndb).ok(); + let profile_url = if let Some(ref txn) = txn { + if let Ok(profile) = self.ndb.get_profile_by_pubkey(txn, self.selected_account.key.pubkey.bytes()) { + notedeck::profile::get_profile_url(Some(&profile)) } else { - None + notedeck::profile::no_pfp_url() } - }) - .inner + } else { + notedeck::profile::no_pfp_url() + }; + + let resp = ui.add(&mut ProfilePic::new(self.img_cache, profile_url) + .size(avatar_size) + .sense(egui::Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + // Draw border if Accounts route is active + let is_accounts_active = self.current_route.map_or(false, |r| matches!(r, Route::Accounts(_))); + if is_accounts_active { + let rect = resp.rect; + let radius = avatar_size / 2.0; + ui.painter().circle_stroke( + rect.center(), + radius + 2.0, + Stroke::new(1.5, ui.visuals().text_color()), + ); + } + + resp }) .inner; - if let Some(inner) = inner { - Some(SidePanelResponse::new(inner.inner, inner.response)) - } else { - None - } + if connectivity_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Relays, connectivity_resp)) + } else if home_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Home, home_resp)) + } else if dave_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Dave, dave_resp)) + } else if pfp_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Accounts, pfp_resp)) + } else if compose_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::ComposeNote, compose_resp)) + } else if search_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Search, search_resp)) + } else if column_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Columns, column_resp)) + } else if settings_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Settings, settings_resp)) + } else if profile_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Profile, profile_resp)) + } else if wallet_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Wallet, wallet_resp)) + } else if support_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::Support, support_resp)) + } else if add_deck_resp.clicked() { + Some(SidePanelResponse::new(SidePanelAction::NewDeck, add_deck_resp)) + } else if decks_inner.response.secondary_clicked() { + info!("decks inner secondary click"); + if let Some(clicked_index) = decks_inner.inner { + Some(SidePanelResponse::new( + SidePanelAction::EditDeck(clicked_index), + decks_inner.response, + )) + } else { + None + } + } else if decks_inner.response.clicked() { + if let Some(clicked_index) = decks_inner.inner { + Some(SidePanelResponse::new( + SidePanelAction::SwitchDeck(clicked_index), + decks_inner.response, + )) + } else { + None + } + } else { + None + } + }) + .inner } pub fn perform_action( @@ -190,37 +290,16 @@ impl<'a> DesktopSidePanel<'a> { let router = get_active_columns_mut(i18n, accounts, decks_cache).get_selected_router(); let mut switching_response = None; match action { - /* - SidePanelAction::Panel => {} // TODO - SidePanelAction::Account => { - if router - .routes() - .iter() - .any(|r| r == &Route::Accounts(AccountsRoute::Accounts)) - { - // return if we are already routing to accounts - router.go_back(); - } else { - router.route_to(Route::accounts()); - } - } - SidePanelAction::Settings => { - if router.routes().iter().any(|r| r == &Route::Relays) { - // return if we are already routing to accounts - router.go_back(); - } else { - router.route_to(Route::relays()); - } - } - SidePanelAction::Support => { - if router.routes().iter().any(|r| r == &Route::Support) { - router.go_back(); + SidePanelAction::Home => { + let pubkey = accounts.get_selected_account().key.pubkey; + let home_route = Route::timeline(crate::timeline::TimelineKind::contact_list(pubkey)); + + if router.top() == &home_route { + // TODO: implement scroll to top when already on home route } else { - support.refresh(); - router.route_to(Route::Support); + router.route_to(home_route); } } - */ SidePanelAction::Columns => { if router .routes() @@ -299,6 +378,44 @@ impl<'a> DesktopSidePanel<'a> { router.route_to(Route::Wallet(notedeck::WalletType::Auto)); } + SidePanelAction::Profile => { + let pubkey = accounts.get_selected_account().key.pubkey; + if router.routes().iter().any(|r| r == &Route::profile(pubkey)) { + router.go_back(); + } else { + router.route_to(Route::profile(pubkey)); + } + } + SidePanelAction::Settings => { + if router.routes().iter().any(|r| matches!(r, Route::Settings(_))) { + router.go_back(); + } else { + router.route_to(Route::settings()); + } + } + SidePanelAction::Relays => { + if router.routes().iter().any(|r| r == &Route::Relays) { + router.go_back(); + } else { + router.route_to(Route::relays()); + } + } + SidePanelAction::Accounts => { + if router.routes().iter().any(|r| matches!(r, Route::Accounts(_))) { + router.go_back(); + } else { + router.route_to(Route::accounts()); + } + } + SidePanelAction::Support => { + if router.routes().iter().any(|r| r == &Route::Support) { + router.go_back(); + } else { + router.route_to(Route::Support); + } + } + SidePanelAction::Dave => { + } } switching_response } @@ -307,7 +424,7 @@ impl<'a> DesktopSidePanel<'a> { fn add_column_button() -> impl Widget { move |ui: &mut egui::Ui| { let img_size = 24.0; - let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; let img = if ui.visuals().dark_mode { app_images::add_column_dark_image() @@ -332,15 +449,25 @@ fn add_column_button() -> impl Widget { } } -pub fn search_button_impl(color: egui::Color32, line_width: f32) -> impl Widget { +pub fn search_button_impl(color: egui::Color32, line_width: f32, is_active: bool) -> impl Widget { move |ui: &mut egui::Ui| -> egui::Response { - let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget - let min_line_width_circle = line_width; // width of the magnifying glass + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let min_line_width_circle = line_width; let min_line_width_handle = line_width; let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size)); let painter = ui.painter_at(helper.get_animation_rect()); + if is_active { + let circle_radius = max_size / 2.0; + painter.circle( + helper.get_animation_rect().center(), + circle_radius, + notedeck_ui::side_panel_active_bg(ui), + Stroke::NONE, + ); + } + let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle); let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle); let min_outer_circle_radius = helper.scale_radius(15.0); @@ -359,8 +486,9 @@ pub fn search_button_impl(color: egui::Color32, line_width: f32) -> impl Widget let handle_pos_2 = circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length)); - let circle_stroke = Stroke::new(cur_line_width_circle, color); - let handle_stroke = Stroke::new(cur_line_width_handle, color); + let icon_color = if is_active { ui.visuals().strong_text_color() } else { color }; + let circle_stroke = Stroke::new(cur_line_width_circle, icon_color); + let handle_stroke = Stroke::new(cur_line_width_handle, icon_color); painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke); painter.circle( @@ -377,8 +505,12 @@ pub fn search_button_impl(color: egui::Color32, line_width: f32) -> impl Widget } } -pub fn search_button() -> impl Widget { - search_button_impl(colors::MID_GRAY, 1.5) +pub fn search_button(current_route: Option<&Route>) -> impl Widget + '_ { + let is_active = matches!(current_route, Some(Route::Search)); + move |ui: &mut egui::Ui| { + let icon_color = notedeck_ui::side_panel_icon_tint(ui); + search_button_impl(icon_color, 1.5, is_active).ui(ui) + } } // TODO: convert to responsive button when expanded side panel impl is finished @@ -445,3 +577,224 @@ fn show_decks<'a>( } InnerResponse::new(clicked_index, resp) } + +fn settings_button(current_route: Option<&Route>) -> impl Widget + '_ { + let is_active = matches!(current_route, Some(Route::Settings(_))); + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + if is_active { + let circle_radius = max_size / 2.0; + painter.circle( + helper.get_animation_rect().center(), + circle_radius, + notedeck_ui::side_panel_active_bg(ui), + Stroke::NONE, + ); + } + + let img = if ui.visuals().dark_mode { + app_images::settings_dark_image() + } else { + app_images::settings_light_image() + }; + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at(ui, helper.get_animation_rect().shrink((max_size - cur_img_size) / 2.0)); + helper.take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("Settings") + } +} + +fn profile_button(current_route: Option<&Route>, pubkey: enostr::Pubkey) -> impl Widget + '_ { + let is_active = matches!(current_route, Some(Route::Timeline(crate::timeline::TimelineKind::Profile(pk))) if *pk == pubkey); + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let helper = AnimationHelper::new(ui, "profile-button", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + if is_active { + let circle_radius = max_size / 2.0; + painter.circle( + helper.get_animation_rect().center(), + circle_radius, + notedeck_ui::side_panel_active_bg(ui), + Stroke::NONE, + ); + } + + let img = app_images::profile_image().tint(notedeck_ui::side_panel_icon_tint(ui)); + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at(ui, helper.get_animation_rect().shrink((max_size - cur_img_size) / 2.0)); + helper.take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("Profile") + } +} + +fn wallet_button(current_route: Option<&Route>) -> impl Widget + '_ { + let is_active = matches!(current_route, Some(Route::Wallet(_))); + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let helper = AnimationHelper::new(ui, "wallet-button", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + if is_active { + let circle_radius = max_size / 2.0; + painter.circle( + helper.get_animation_rect().center(), + circle_radius, + notedeck_ui::side_panel_active_bg(ui), + Stroke::NONE, + ); + } + + let img = if ui.visuals().dark_mode { + app_images::wallet_dark_image() + } else { + app_images::wallet_light_image() + }; + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at(ui, helper.get_animation_rect().shrink((max_size - cur_img_size) / 2.0)); + helper.take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("Wallet") + } +} + +fn support_button(current_route: Option<&Route>) -> impl Widget + '_ { + let is_active = matches!(current_route, Some(Route::Support)); + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let helper = AnimationHelper::new(ui, "support-button", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + if is_active { + let circle_radius = max_size / 2.0; + painter.circle( + helper.get_animation_rect().center(), + circle_radius, + notedeck_ui::side_panel_active_bg(ui), + Stroke::NONE, + ); + } + + let img = if ui.visuals().dark_mode { + app_images::help_dark_image() + } else { + app_images::help_light_image() + }; + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at(ui, helper.get_animation_rect().shrink((max_size - cur_img_size) / 2.0)); + helper.take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("Support") + } +} + +fn home_button(current_route: Option<&Route>) -> impl Widget + '_ { + let is_active = matches!(current_route, Some(Route::Timeline(crate::timeline::TimelineKind::List(crate::timeline::kind::ListKind::Contact(_))))); + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let helper = AnimationHelper::new(ui, "home-button", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + if is_active { + let circle_radius = max_size / 2.0; + painter.circle( + helper.get_animation_rect().center(), + circle_radius, + notedeck_ui::side_panel_active_bg(ui), + Stroke::NONE, + ); + } + + let img = if ui.visuals().dark_mode { + app_images::home_dark_image() + } else { + app_images::home_light_image() + }; + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at(ui, helper.get_animation_rect().shrink((max_size - cur_img_size) / 2.0)); + helper.take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("Home") + } +} + +fn dave_button() -> impl Widget { + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let img = app_images::sparkle_image().tint(notedeck_ui::side_panel_icon_tint(ui)); + let helper = AnimationHelper::new(ui, "dave-button", vec2(max_size, max_size)); + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at(ui, helper.get_animation_rect().shrink((max_size - cur_img_size) / 2.0)); + helper.take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("Dave AI") + } +} + +fn connectivity_indicator(ui: &mut egui::Ui, pool: &RelayPool, _current_route: Option<&Route>) -> egui::Response { + let connected_count = pool.relays.iter().filter(|r| matches!(r.status(), RelayStatus::Connected)).count(); + let total_count = pool.relays.len(); + + let indicator_color = if total_count > 1 { + if connected_count == 0 { + egui::Color32::from_rgb(0xFF, 0x66, 0x66) + } else if connected_count == 1 { + egui::Color32::from_rgb(0xFF, 0xCC, 0x66) + } else { + notedeck_ui::side_panel_icon_tint(ui) + } + } else { + notedeck_ui::side_panel_icon_tint(ui) + }; + + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let helper = AnimationHelper::new(ui, "connectivity-indicator", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + let rect = helper.get_animation_rect(); + let center = rect.center(); + + let bar_width = 2.0; + let bar_spacing = 3.0; + + let base_y = center.y + 4.0; + let start_x = center.x - (bar_width + bar_spacing); + + let bar_heights = [4.0, 7.0, 10.0]; + for (i, &height) in bar_heights.iter().enumerate() { + let x = start_x + (i as f32) * (bar_width + bar_spacing); + let bar_rect = egui::Rect::from_min_size( + egui::pos2(x, base_y - height), + vec2(bar_width, height) + ); + painter.rect_filled(bar_rect, 0.0, indicator_color); + } + + let count_text = format!("{}", connected_count); + let font_id = egui::FontId::proportional(10.0); + + painter.text( + egui::pos2(center.x, center.y - 8.0), + egui::Align2::CENTER_CENTER, + count_text, + font_id, + indicator_color, + ); + + helper.take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text(format!("{}/{} relays connected", connected_count, total_count)) +} + diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs index 9aeaf64e2..abfbb99e2 100644 --- a/crates/notedeck_columns/src/ui/timeline.rs +++ b/crates/notedeck_columns/src/ui/timeline.rs @@ -794,6 +794,7 @@ fn render_composite_entry( &composite_type, note_context.img_cache, note_options.contains(NoteOptions::Notification), + note_context.accounts, ) }, ) @@ -887,6 +888,7 @@ fn render_profiles( composite_type: &CompositeType, img_cache: &mut notedeck::Images, notification: bool, + accounts: ¬edeck::Accounts, ) -> PfpsResponse { let mut action = None; if notification { @@ -934,7 +936,8 @@ fn render_profiles( let mut widget = ProfilePic::from_profile_or_default(img_cache, entry.record.as_ref()) .size(24.0) - .sense(Sense::click()); + .sense(Sense::click()) + .with_follow_check(entry.pk, accounts); let mut resp = ui.put(rect, &mut widget); rendered = true; diff --git a/crates/notedeck_columns/src/ui/toolbar.rs b/crates/notedeck_columns/src/ui/toolbar.rs index 79ae5d495..e781264e3 100644 --- a/crates/notedeck_columns/src/ui/toolbar.rs +++ b/crates/notedeck_columns/src/ui/toolbar.rs @@ -43,7 +43,7 @@ pub fn toolbar(ui: &mut egui::Ui, unseen_notification: bool) -> Option impl Widget + '_ { styled_button_toggleable(text, fill_color, true) } + +pub struct UserRow<'a> { + profile: Option<&'a ProfileRecord<'a>>, + pubkey: &'a Pubkey, + cache: &'a mut Images, + accounts: Option<&'a Accounts>, + width: f32, + is_selected: bool, +} + +impl<'a> UserRow<'a> { + pub fn new( + profile: Option<&'a ProfileRecord<'a>>, + pubkey: &'a Pubkey, + cache: &'a mut Images, + width: f32, + ) -> Self { + Self { + profile, + pubkey, + cache, + accounts: None, + width, + is_selected: false, + } + } + + pub fn with_accounts(mut self, accounts: &'a Accounts) -> Self { + self.accounts = Some(accounts); + self + } + + pub fn with_selection(mut self, is_selected: bool) -> Self { + self.is_selected = is_selected; + self + } +} + +impl<'a> Widget for UserRow<'a> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let pic_size = 48.0; + let spacing = 8.0; + let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + + let (rect, resp) = ui.allocate_exact_size( + vec2(self.width, pic_size + 8.0), + egui::Sense::click(), + ); + + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + + if self.is_selected { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().selection.bg_fill, + ); + } + + if resp.hovered() { + ui.painter().rect_filled( + rect, + 4.0, + ui.visuals().widgets.hovered.bg_fill, + ); + } + + let pfp_rect = egui::Rect::from_min_size( + rect.min + vec2(4.0, 4.0), + vec2(pic_size, pic_size), + ); + + let mut profile_pic = ProfilePic::new(self.cache, get_profile_url(self.profile)) + .size(pic_size); + + if let Some(accounts) = self.accounts { + profile_pic = profile_pic.with_follow_check(self.pubkey, accounts); + } + + ui.put(pfp_rect, &mut profile_pic); + + let name = get_display_name(self.profile).name(); + let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family()); + let painter = ui.painter(); + let name_galley = painter.layout( + name.to_owned(), + name_font, + ui.visuals().text_color(), + self.width - pic_size - spacing - 8.0, + ); + + let galley_pos = egui::Pos2::new( + pfp_rect.right() + spacing, + rect.center().y - (name_galley.rect.height() / 2.0), + ); + + painter.galley(galley_pos, name_galley, ui.visuals().text_color()); + + resp + } +} diff --git a/crates/notedeck_ui/src/app_images.rs b/crates/notedeck_ui/src/app_images.rs index f4615d4c1..49a6ebb69 100644 --- a/crates/notedeck_ui/src/app_images.rs +++ b/crates/notedeck_ui/src/app_images.rs @@ -272,3 +272,7 @@ pub fn copy_to_clipboard_image() -> Image<'static> { pub fn copy_to_clipboard_dark_image() -> Image<'static> { copy_to_clipboard_image().tint(Color32::BLACK) } + +pub fn sparkle_image() -> Image<'static> { + Image::new(include_image!("../../../assets/icons/sparkle.svg")) +} diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs index 41cef5efa..6bb99284c 100644 --- a/crates/notedeck_ui/src/lib.rs +++ b/crates/notedeck_ui/src/lib.rs @@ -21,6 +21,7 @@ pub use mention::Mention; pub use note::{NoteContents, NoteOptions, NoteView}; pub use profile::{ProfilePic, ProfilePreview}; pub use username::Username; +pub use widgets::{rounded_button, segmented_button, side_panel_active_bg, side_panel_icon_tint, small_rounded_button}; use egui::{Label, Margin, Pos2, RichText}; diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs index 6288d5178..66435960d 100644 --- a/crates/notedeck_ui/src/note/mod.rs +++ b/crates/notedeck_ui/src/note/mod.rs @@ -295,6 +295,8 @@ impl<'a, 'd> NoteView<'a, 'd> { pfp_size, note_key, profile, + self.note.pubkey(), + self.note_context.accounts, ), None => show_fallback_pfp(ui, self.note_context.img_cache, pfp_size), @@ -345,12 +347,18 @@ impl<'a, 'd> NoteView<'a, 'd> { note: &Note, profile: &Result, nostrdb::Error>, flags: NoteOptions, - ) { + ) -> Option { + let mut note_action = None; let horiz_resp = ui .horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 }; let response = ui .add(Username::new(i18n, profile.as_ref().ok(), note.pubkey()).abbreviated(20)); + + if response.clicked() { + note_action = Some(NoteAction::Profile(enostr::Pubkey::new(*note.pubkey()))); + } + if !flags.contains(NoteOptions::FullCreatedDate) { return render_notetime(ui, i18n, note.created_at(), true); } @@ -369,6 +377,8 @@ impl<'a, 'd> NoteView<'a, 'd> { ui.painter() .circle_filled(circle_center, radius, crate::colors::PINK); } + + note_action } fn wide_ui( @@ -397,13 +407,13 @@ impl<'a, 'd> NoteView<'a, 'd> { [size.x, self.options().pfp_size() as f32], |ui: &mut egui::Ui| { ui.horizontal_centered(|ui| { - NoteView::note_header( + note_action = NoteView::note_header( ui, self.note_context.i18n, self.note, profile, self.flags, - ); + ).or(note_action.take()); }) .response }, @@ -513,13 +523,13 @@ impl<'a, 'd> NoteView<'a, 'd> { ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { if !self.flags.contains(NoteOptions::NotificationPreview) { - NoteView::note_header( + note_action = NoteView::note_header( ui, self.note_context.i18n, self.note, profile, self.flags, - ); + ).or(note_action.take()); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 1.0; @@ -717,6 +727,8 @@ fn show_actual_pfp( pfp_size: i8, note_key: NoteKey, profile: &Result, nostrdb::Error>, + note_pubkey: &[u8; 32], + accounts: &Accounts, ) -> PfpResponse { let anim_speed = 0.05; let profile_key = profile.as_ref().unwrap().record().note_key(); @@ -732,7 +744,10 @@ fn show_actual_pfp( let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - let mut pfp = ProfilePic::new(images, pic).size(size); + let pubkey = Pubkey::new(*note_pubkey); + let mut pfp = ProfilePic::new(images, pic) + .size(size) + .with_follow_check(&pubkey, accounts); let pfp_resp = ui.put(rect, &mut pfp); let action = pfp.action; diff --git a/crates/notedeck_ui/src/profile/context.rs b/crates/notedeck_ui/src/profile/context.rs index 04f5776c6..45df112e5 100644 --- a/crates/notedeck_ui/src/profile/context.rs +++ b/crates/notedeck_ui/src/profile/context.rs @@ -34,6 +34,18 @@ impl ProfileContextWidget { stationary_arbitrary_menu_button(ui, button_response, |ui| { ui.set_max_width(100.0); + if ui + .button(tr!( + i18n, + "View as", + "Switch active user to this profile" + )) + .clicked() + { + context_selection = Some(ProfileContextSelection::ViewAs); + ui.close_menu(); + } + if ui .button(tr!( i18n, diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs index ee8cd55f2..a495ce808 100644 --- a/crates/notedeck_ui/src/profile/mod.rs +++ b/crates/notedeck_ui/src/profile/mod.rs @@ -78,16 +78,66 @@ pub fn display_name_widget<'a>( } pub fn about_section_widget<'a>(profile: Option<&'a ProfileRecord<'a>>) -> impl egui::Widget + 'a { + about_section_widget_impl(profile, None, false) +} + +pub fn about_section_widget_with_truncate<'a>( + profile: Option<&'a ProfileRecord<'a>>, + max_chars: Option, +) -> impl egui::Widget + 'a { + about_section_widget_impl(profile, max_chars, false) +} + +pub fn about_section_widget_expandable<'a>( + profile: Option<&'a ProfileRecord<'a>>, + max_chars: Option, +) -> impl egui::Widget + 'a { + about_section_widget_impl(profile, max_chars, true) +} + +fn about_section_widget_impl<'a>( + profile: Option<&'a ProfileRecord<'a>>, + max_chars: Option, + expandable: bool, +) -> impl egui::Widget + 'a { move |ui: &mut egui::Ui| { if let Some(about) = profile .map(|p| p.record().profile()) .and_then(|p| p.and_then(|p| p.about())) { - let resp = ui.label(about); + let resp = if let Some(max) = max_chars { + if about.len() > max { + if expandable { + let id = ui.id().with("about_expanded"); + let mut expanded = ui.ctx().data_mut(|d| d.get_temp(id).unwrap_or(false)); + + let resp = if expanded { + ui.label(about) + } else { + let truncated: String = about.chars().take(max).collect(); + ui.label(truncated + "...") + }; + + let link_text = if expanded { "show less" } else { "show more" }; + if ui.add(egui::Label::new(egui::RichText::new(link_text).color(ui.visuals().hyperlink_color)).sense(egui::Sense::click())).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + expanded = !expanded; + ui.ctx().data_mut(|d| d.insert_temp(id, expanded)); + } + + resp + } else { + let truncated: String = about.chars().take(max).collect(); + ui.label(truncated + "...") + } + } else { + ui.label(about) + } + } else { + ui.label(about) + }; ui.add_space(8.0); resp } else { - // need any Response so we dont need an Option ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) } } diff --git a/crates/notedeck_ui/src/profile/picture.rs b/crates/notedeck_ui/src/profile/picture.rs index d0ce09526..29d9a967c 100644 --- a/crates/notedeck_ui/src/profile/picture.rs +++ b/crates/notedeck_ui/src/profile/picture.rs @@ -1,11 +1,12 @@ use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle}; +use enostr::Pubkey; use notedeck::get_render_state; use notedeck::media::gif::ensure_latest_texture; use notedeck::media::images::{fetch_no_pfp_promise, ImageType}; use notedeck::media::AnimationMode; use notedeck::MediaAction; -use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images}; +use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Accounts, Images, IsFollowing}; pub struct ProfilePic<'cache, 'url> { cache: &'cache mut Images, @@ -15,6 +16,8 @@ pub struct ProfilePic<'cache, 'url> { border: Option, animation_mode: AnimationMode, pub action: Option, + pubkey: Option<&'url Pubkey>, + accounts: Option<&'url Accounts>, } impl egui::Widget for &mut ProfilePic<'_, '_> { @@ -32,6 +35,34 @@ impl egui::Widget for &mut ProfilePic<'_, '_> { self.action = inner.inner; + let should_show_badge = if let (Some(pubkey), Some(accounts)) = (self.pubkey, self.accounts) { + let selected = accounts.get_selected_account(); + selected.key.pubkey == *pubkey || selected.is_following(pubkey.bytes()) == IsFollowing::Yes + } else { + false + }; + + if should_show_badge { + let rect = inner.response.rect; + let badge_size = (self.size * 0.4).max(12.0); + let offset = badge_size * 0.25; + let badge_pos = rect.right_top() + egui::vec2(-offset, offset); + + ui.painter().circle_filled( + badge_pos, + badge_size / 2.0, + egui::Color32::from_rgb(139, 92, 246), + ); + + ui.painter().text( + badge_pos, + egui::Align2::CENTER_CENTER, + "✓", + egui::FontId::proportional(badge_size * 0.6), + egui::Color32::WHITE, + ); + } + inner.response } } @@ -49,9 +80,17 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { animation_mode: AnimationMode::Reactive, border: None, action: None, + pubkey: None, + accounts: None, } } + pub fn with_follow_check(mut self, pubkey: &'url Pubkey, accounts: &'url Accounts) -> Self { + self.pubkey = Some(pubkey); + self.accounts = Some(accounts); + self + } + pub fn sense(mut self, sense: Sense) -> Self { self.sense = sense; self diff --git a/crates/notedeck_ui/src/profile/preview.rs b/crates/notedeck_ui/src/profile/preview.rs index c20809b48..bc9c4233e 100644 --- a/crates/notedeck_ui/src/profile/preview.rs +++ b/crates/notedeck_ui/src/profile/preview.rs @@ -7,7 +7,7 @@ use notedeck::{ name::get_display_name, profile::get_profile_url, tr, Images, Localization, NotedeckTextStyle, }; -use super::{about_section_widget, banner, display_name_widget}; +use super::{about_section_widget_with_truncate, banner, display_name_widget}; pub struct ProfilePreview<'a, 'cache> { profile: &'a ProfileRecord<'a>, @@ -48,7 +48,10 @@ impl<'a, 'cache> ProfilePreview<'a, 'cache> { &get_display_name(Some(self.profile)), false, )); - ui.add(about_section_widget(Some(self.profile))); + ui.add(about_section_widget_with_truncate( + Some(self.profile), + Some(150), + )); }); } } diff --git a/crates/notedeck_ui/src/username.rs b/crates/notedeck_ui/src/username.rs index cb3155e50..c27fc6791 100644 --- a/crates/notedeck_ui/src/username.rs +++ b/crates/notedeck_ui/src/username.rs @@ -40,14 +40,14 @@ impl<'a> Username<'a> { impl Widget for Username<'_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; + let color = if self.pk_colored { + Some(pk_color(self.pk)) + } else { + None + }; - let color = if self.pk_colored { - Some(pk_color(self.pk)) - } else { - None - }; + let text_resp = ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; if let Some(profile) = self.profile { if let Some(prof) = profile.record().profile() { @@ -69,8 +69,12 @@ impl Widget for Username<'_> { } ui.label(txt); } - }) - .response + }); + + let rect = text_resp.response.rect; + let id = ui.auto_id_with("username_clickable"); + + ui.interact(rect, id, egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand) } } @@ -100,6 +104,26 @@ fn ui_abbreviate_name(ui: &mut egui::Ui, name: &str, len: usize, color: Option) -> bool { + let should_abbrev = name.len() > len; + let name = if should_abbrev { + let closest = notedeck::abbrev::floor_char_boundary(name, len); + &name[..closest] + } else { + name + }; + + let resp1 = ui.add(egui::Label::new(colored_name(name, color)).sense(egui::Sense::click())); + + let resp2 = if should_abbrev { + ui.add(egui::Label::new(colored_name("..", color)).sense(egui::Sense::click())) + } else { + resp1.clone() + }; + + resp1.clicked() || resp2.clicked() +} + fn pk_color(pk: &[u8; 32]) -> Color32 { Color32::from_rgb(pk[8], pk[10], pk[12]) } diff --git a/crates/notedeck_ui/src/widgets.rs b/crates/notedeck_ui/src/widgets.rs index 3d9d7f82c..2908618be 100644 --- a/crates/notedeck_ui/src/widgets.rs +++ b/crates/notedeck_ui/src/widgets.rs @@ -77,3 +77,57 @@ pub fn styled_button_toggleable( resp } } + +/// Standard rounded button with proper padding +pub fn rounded_button<'a>(text: impl Into) -> egui::Button<'a> { + egui::Button::new(text) + .corner_radius(egui::CornerRadius::same(12)) + .min_size(egui::vec2(60.0, 32.0)) +} + +/// Small rounded button for compact controls +pub fn small_rounded_button<'a>(text: impl Into) -> egui::Button<'a> { + egui::Button::new(text) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(60.0, 32.0)) +} + +/// Segmented button for binary choices (Light/Dark, On/Off, etc) +pub fn segmented_button<'a>(text: impl Into, selected: bool, ui: &egui::Ui) -> egui::Button<'a> { + let fill = if selected { + if ui.visuals().dark_mode { + egui::Color32::from_rgb(70, 70, 70) + } else { + egui::Color32::WHITE + } + } else { + if ui.visuals().dark_mode { + egui::Color32::from_rgb(40, 40, 40) + } else { + egui::Color32::from_rgb(220, 220, 220) + } + }; + + egui::Button::new(text) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(60.0, 32.0)) + .fill(fill) +} + +/// Get appropriate background color for active side panel icon button +pub fn side_panel_active_bg(ui: &egui::Ui) -> egui::Color32 { + if ui.visuals().dark_mode { + egui::Color32::from_rgb(70, 70, 70) + } else { + egui::Color32::from_rgb(220, 220, 220) + } +} + +/// Get appropriate tint color for side panel icons to ensure visibility +pub fn side_panel_icon_tint(ui: &egui::Ui) -> egui::Color32 { + if ui.visuals().dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + } +}