notedeck

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

commit 59de3bbba65ae334aa66229356d12d980e5d0936
parent d7551645d7f46e0e4d507b2cb6cae0fc19ee3022
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 Feb 2026 20:07:17 -0800

side_panel: add menu items, relay indicator, and active route highlighting

Port side panel improvements from feature/ui-improvements branch:
- Add home, settings, profile, wallet, support, and dave buttons
- Add connected relays indicator showing connection status
- Add read-only indicator above avatar for pubkey-only accounts
- Add active route highlighting for all navigation buttons
- Show border on avatar when accounts route is active

Co-Authored-By: Martti Malmi <sirius@iki.fi>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
A.beads/issues.jsonl | 24++++++++++++++++++++++++
Mcrates/notedeck_columns/src/app.rs | 6++++++
Mcrates/notedeck_columns/src/ui/column/header.rs | 4++--
Mcrates/notedeck_columns/src/ui/side_panel.rs | 645++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/notedeck_columns/src/ui/toolbar.rs | 2+-
Mcrates/notedeck_dashboard/src/ui.rs | 4+++-
Mcrates/notedeck_ui/src/app_images.rs | 4++++
Mcrates/notedeck_ui/src/lib.rs | 1+
Mcrates/notedeck_ui/src/widgets.rs | 18++++++++++++++++++
9 files changed, 567 insertions(+), 141 deletions(-)

diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl @@ -0,0 +1,24 @@ +{"id":"notedeck-0mh","title":"Remote NIP-50 search","description":"GitHub #1110: Implement remote search using NIP-50 protocol. See https://github.com/damus-io/notedeck/issues/1110","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:41.013086749-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:41.013086749-08:00","labels":["columns"]} +{"id":"notedeck-27x","title":"Auto-steal focus should return to original session","description":"In crates/notedeck_dave, when an agent steals focus from another agent to ask a question, I want it to focus back to where it was after the interaction completes.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:38:44.526107651-08:00","created_by":"William Casarin","updated_at":"2026-01-30T22:35:00.825936422-08:00","closed_at":"2026-01-30T22:35:00.825936422-08:00","close_reason":"Auto-steal focus now returns to original session after interaction completes","labels":["dave"]} +{"id":"notedeck-2yj","title":"Profile search","description":"GitHub #1111: Extend search functionality to include profile lookups. See https://github.com/damus-io/notedeck/issues/1111","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:36.780164431-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:36.780164431-08:00","labels":["columns"]} +{"id":"notedeck-3ns","title":"Approve/deny view text not wrapping - goes off screen","description":"The approve/deny view for tool calls doesn't wrap text properly. Long descriptions go all the way off the screen, making them unreadable.","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:52:24.878602874-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:52:24.878602874-08:00","labels":["dave"]} +{"id":"notedeck-4no","title":"Follow packs show blank profiles","description":"GitHub #1107: Some profiles in follow packs display blank. See https://github.com/damus-io/notedeck/issues/1107","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:46:44.374295002-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:46:44.374295002-08:00","labels":["columns"]} +{"id":"notedeck-84e","title":"Link previews (OpenGraph)","description":"GitHub #992: Display link previews using OpenGraph metadata. See https://github.com/damus-io/notedeck/issues/992","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:46.117752018-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:46.117752018-08:00","labels":["columns"]} +{"id":"notedeck-ajn","title":"Contact lists aren't updated periodically","description":"GitHub #575: Contact lists don't refresh periodically as they should. See https://github.com/damus-io/notedeck/issues/575","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:46.741346469-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:46.741346469-08:00","labels":["columns"]} +{"id":"notedeck-azp","title":"Column requires opening app twice to show new notes","description":"GitHub #780: Columns require opening notedeck twice to display new notes on initial launch. See https://github.com/damus-io/notedeck/issues/780","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:39.093911034-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:39.093911034-08:00","labels":["columns"]} +{"id":"notedeck-bce","title":"Handle ExitPlanMode tool call","description":"Handle ExitPlanMode which simply exits plan mode. Claude-code sends this when it's done its planning phase.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:17.311242243-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:19.839039601-08:00","closed_at":"2026-01-30T12:38:19.839039601-08:00","close_reason":"Implemented ExitPlanMode UI with Approve/Reject buttons. When approved, exits plan mode and allows the tool call. UI shows PLAN badge with 'Plan ready for approval' message.","labels":["dave"]} +{"id":"notedeck-c3p","title":"Implement multiline message composer (Signal-style)","description":"Replaced singleline TextEdit with multiline, using Signal-style keybindings: Enter to send, Shift+Enter for newline. Based on existing dave.rs implementation.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T12:32:45.563930191-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:33:07.061369966-08:00","closed_at":"2026-01-30T12:33:07.061369966-08:00","close_reason":"Implemented: Changed TextEdit from singleline to multiline with Signal-style keybindings (Enter=send, Shift+Enter=newline) in convo.rs"} +{"id":"notedeck-cf0","title":"Preserve edit view after approval/denial","description":"When approving or denying an edit, keep the diff visible instead of making it disappear. Allows reviewing what was changed even after responding.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:41:04.789975491-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:41:04.789975491-08:00","labels":["dave"]} +{"id":"notedeck-dx2","title":"Multi column image reply bug","description":"GitHub #1104: Images in multi-column replies appear in wrong column. See https://github.com/damus-io/notedeck/issues/1104","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:15.806023697-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:15.806023697-08:00","labels":["columns"]} +{"id":"notedeck-fs8","title":"Ctrl+P not dropping from NeedsInput to Done","description":"Commit c6a96d8dbfef is supposed to enable dropping from NeedsInput to Done with Ctrl+P, but it's not working. The commit message says Ctrl+P should navigate backward within a priority group and drop to the next lower priority level when at the first item (NeedsInput → Error → Done).","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:42:27.63658035-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:57:51.25992885-08:00","closed_at":"2026-01-30T11:57:51.25992885-08:00","close_reason":"Fixed swapped keybindings in keybindings.rs:82-90 - Ctrl+N now returns FocusQueueNext (higher priority) and Ctrl+P returns FocusQueuePrev (lower priority)","labels":["dave"]} +{"id":"notedeck-gj0","title":"Timeline carousel does not work","description":"GitHub #1006: Image carousel swiping functionality needs implementation or repair for navigating images. See https://github.com/damus-io/notedeck/issues/1006","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:49.42410683-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:49.42410683-08:00","labels":["columns"]} +{"id":"notedeck-h9s","title":"Note not ingesting when sending locally (offline)","description":"GitHub #1050: Offline notes fail to sync when device reconnects; messages aren't appearing even after network restored. See https://github.com/damus-io/notedeck/issues/1050","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:34.188084601-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:34.188084601-08:00","labels":["columns"]} +{"id":"notedeck-hav","title":"Add auto-accept mode for agent tool calls","description":"Add a toggle that automatically approves agent tool calls without requiring manual confirmation. Useful for trusted tasks or batch mode. Could be global or per-agent.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:00.952701242-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:40:00.952701242-08:00","labels":["dave"]} +{"id":"notedeck-i40","title":"Quoted note target changes depending on wide or selected mode","description":"GitHub #1117: Quoted note references inconsistent based on view mode. See https://github.com/damus-io/notedeck/issues/1117","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:45:42.264025111-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:45:42.264025111-08:00","labels":["columns"]} +{"id":"notedeck-j8c","title":"Chat sidebar should show last message from user or AI","description":"Chat sidebar text should show the user's or AI's last message, not our last message.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:39:11.482262998-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:39:11.482262998-08:00","labels":["dave"]} +{"id":"notedeck-kpa","title":"Zap notification","description":"GitHub #1037: Notify users when zapped, showing zap amount, sender, and zapped content details. See https://github.com/damus-io/notedeck/issues/1037","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:29.181749931-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:29.181749931-08:00","labels":["columns"]} +{"id":"notedeck-p1n","title":"NIP-05 validation not working as intended","description":"GitHub #1274: NIP-05 validation feature is malfunctioning. See https://github.com/damus-io/notedeck/issues/1274","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:38:30.276030933-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:30.276030933-08:00","labels":["columns"]} +{"id":"notedeck-pj7","title":"Like button not visible in light theme","description":"GitHub #1246: Like button rendering visibility issue when switching to light theme mode. See https://github.com/damus-io/notedeck/issues/1246","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:16.601075159-08:00","created_by":"William Casarin","updated_at":"2026-01-30T13:38:50.529278612-08:00","closed_at":"2026-01-30T13:38:50.529278612-08:00","close_reason":"Fixed by applying text color tint unconditionally in like_button()","labels":["columns"]} +{"id":"notedeck-war","title":"MacOS crash on PFP → Side menu Accounts","description":"GitHub #1270: Application crashes when navigating to Accounts via profile picture menu due to 'layer_id change panic'. See https://github.com/damus-io/notedeck/issues/1270","status":"open","priority":1,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:09.082890703-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:41:09.082890703-08:00","labels":["columns"]} +{"id":"notedeck-xer","title":"Persist conversation across app restarts","description":"Save and restore conversation state so it survives app restarts.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:11.196068397-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:40:11.196068397-08:00","labels":["dave"]} +{"id":"notedeck-zg7","title":"Quote notification","description":"GitHub #1041: Implement notifications when users' posts are quoted by others. See https://github.com/damus-io/notedeck/issues/1041","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:58.370192754-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:58.370192754-08:00","labels":["columns"]} diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -883,6 +883,10 @@ fn timelines_view( .horizontal(|mut strip| { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); + // Clone the route to avoid holding a borrow on app.decks_cache + let current_route = get_active_columns(ctx.accounts, &app.decks_cache) + .selected() + .map(|col| col.router().top().clone()); let side_panel = DesktopSidePanel::new( ctx.accounts.get_selected_account(), &app.decks_cache, @@ -890,6 +894,8 @@ fn timelines_view( ctx.ndb, ctx.img_cache, ctx.media_jobs.sender(), + current_route.as_ref(), + ctx.pool, ) .show(ui); diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -519,7 +519,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(Some(top)))) } TimelineKind::Universe @@ -539,7 +539,7 @@ impl<'a> NavTitle<'a> { 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(Some(top)))), Route::Wallet(_) => None, Route::CustomizeZapAmount(_) => None, Route::Thread(thread_selection) => { diff --git 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,7 +12,8 @@ use crate::{ route::Route, }; -use notedeck::{tr, Accounts, Localization, MediaJobSender, UserAccount}; +use enostr::{RelayPool, RelayStatus}; +use notedeck::{tr, Accounts, Localization, MediaJobSender, NotedeckTextStyle, UserAccount}; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, app_images, colors, ProfilePic, View, @@ -30,6 +31,8 @@ pub struct DesktopSidePanel<'a> { ndb: &'a nostrdb::Ndb, img_cache: &'a mut notedeck::Images, jobs: &'a MediaJobSender, + current_route: Option<&'a Route>, + pool: &'a RelayPool, } impl View for DesktopSidePanel<'_> { @@ -40,6 +43,7 @@ impl View for DesktopSidePanel<'_> { #[derive(Debug, Eq, PartialEq, Clone, Copy)] pub enum SidePanelAction { + Home, Columns, ComposeNote, Search, @@ -48,7 +52,11 @@ pub enum SidePanelAction { SwitchDeck(usize), EditDeck(usize), Wallet, - ProfileAvatar, + Profile, + Settings, + Relays, + Accounts, + Support, } pub struct SidePanelResponse { @@ -70,6 +78,8 @@ impl<'a> DesktopSidePanel<'a> { ndb: &'a nostrdb::Ndb, img_cache: &'a mut notedeck::Images, jobs: &'a MediaJobSender, + current_route: Option<&'a Route>, + pool: &'a RelayPool, ) -> Self { Self { selected_account, @@ -78,6 +88,8 @@ impl<'a> DesktopSidePanel<'a> { ndb, img_cache, jobs, + current_route, + pool, } } @@ -100,54 +112,119 @@ impl<'a> DesktopSidePanel<'a> { } fn show_inner(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> { - 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 avatar_size = 40.0; + let bottom_padding = 8.0; + let connectivity_indicator_height = 48.0; + 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()); + let compose_resp = ui + .add(crate::ui::post::compose_note_button(ui.visuals().dark_mode)) + .on_hover_cursor(egui::CursorIcon::PointingHand); + 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 profile_resp = ui.add(profile_button( + self.current_route, + self.selected_account.key.pubkey, + )); + + let support_resp = ui.add(support_button(self.current_route)); + + 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 avatar_size = 40.0; - let bottom_padding = 8.0; - let avatar_section_height = avatar_size + bottom_padding; + let column_resp = ui.add(add_column_button()); + let add_deck_resp = ui.add(add_deck_button(self.i18n)); - let available_for_decks = ui.available_height() - avatar_section_height; + let decks_inner = show_decks(ui, self.decks_cache, self.selected_account); - let decks_inner = ScrollArea::vertical() - .max_height(available_for_decks) - .show(ui, |ui| { - show_decks(ui, self.decks_cache, self.selected_account) - }) - .inner; + ( + home_resp, + compose_resp, + search_resp, + column_resp, + settings_resp, + profile_resp, + wallet_resp, + support_resp, + add_deck_resp, + decks_inner, + ) + }) + }); + + let ( + home_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); + } - 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, + )) + .color(ui.visuals().warn_fg_color), + ) + .selectable(false), + ); + ui.add_space(4.0); } let txn = nostrdb::Transaction::new(self.ndb).ok(); @@ -164,7 +241,7 @@ impl<'a> DesktopSidePanel<'a> { notedeck::profile::no_pfp_url() }; - let pfp_resp = ui + let resp = ui .add( &mut ProfilePic::new(self.img_cache, self.jobs, profile_url) .size(avatar_size) @@ -172,58 +249,91 @@ impl<'a> DesktopSidePanel<'a> { ) .on_hover_cursor(egui::CursorIcon::PointingHand); - /* - if expand_resp.clicked() { - Some(InnerResponse::new( - SidePanelAction::ExpandSidePanel, - expand_resp, - )) - */ - if pfp_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::ProfileAvatar, pfp_resp)) - } else 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, - )) - } 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, - )) - } else { - None - } - } else { - None + // 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()), + ); } - }) - .inner - }) - .inner; - if let Some(inner) = inner { - Some(SidePanelResponse::new(inner.inner, inner.response)) - } else { - None - } + resp + }) + .inner; + + 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 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( @@ -235,37 +345,17 @@ 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() @@ -289,7 +379,6 @@ impl<'a> DesktopSidePanel<'a> { } } SidePanelAction::Search => { - // TODO if router.top() == &Route::Search { router.go_back(); } else { @@ -297,7 +386,6 @@ impl<'a> DesktopSidePanel<'a> { } } SidePanelAction::ExpandSidePanel => { - // TODO info!("Clicked expand side panel button"); } SidePanelAction::NewDeck => { @@ -344,7 +432,7 @@ impl<'a> DesktopSidePanel<'a> { router.route_to(Route::Wallet(notedeck::WalletType::Auto)); } - SidePanelAction::ProfileAvatar => { + SidePanelAction::Profile => { let pubkey = accounts.get_selected_account().key.pubkey; if router.routes().iter().any(|r| r == &Route::profile(pubkey)) { router.go_back(); @@ -352,6 +440,38 @@ impl<'a> DesktopSidePanel<'a> { router.route_to(Route::profile(pubkey)); } } + SidePanelAction::Settings => { + if router.routes().iter().any(|r| 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); + } + } } switching_response } @@ -385,15 +505,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); @@ -412,8 +542,13 @@ 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( @@ -430,8 +565,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 @@ -498,3 +637,235 @@ 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() -> impl Widget { + |ui: &mut egui::Ui| { + let img_size = 32.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; + let helper = AnimationHelper::new(ui, "home-button", vec2(max_size, max_size)); + + let img = app_images::damus_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 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/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<ToolbarAc } } else if index == 1 && ui - .add(search_button_impl(ui.visuals().text_color(), 2.0)) + .add(search_button_impl(ui.visuals().text_color(), 2.0, false)) .clicked() { action = Some(ToolbarAction::Search) diff --git a/crates/notedeck_dashboard/src/ui.rs b/crates/notedeck_dashboard/src/ui.rs @@ -5,7 +5,9 @@ use std::time::Duration; use std::time::Instant; use nostrdb::Transaction; -use notedeck::{abbrev::floor_char_boundary, name::get_display_name, profile::get_profile_url, AppContext}; +use notedeck::{ + AppContext, abbrev::floor_char_boundary, name::get_display_name, profile::get_profile_url, +}; use notedeck_ui::ProfilePic; use crate::Dashboard; diff --git 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 @@ -24,6 +24,7 @@ pub use mention::Mention; pub use note::{NoteContents, NoteOptions, NoteView}; pub use profile::{ProfilePic, ProfilePreview}; pub use username::Username; +pub use widgets::{side_panel_active_bg, side_panel_icon_tint}; use egui::{Label, Margin, Pos2, RichText}; diff --git a/crates/notedeck_ui/src/widgets.rs b/crates/notedeck_ui/src/widgets.rs @@ -77,3 +77,21 @@ pub fn styled_button_toggleable( resp } } + +/// 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 + } +}