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:
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
+ }
+}