notedeck

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

side_panel.rs (29717B)


      1 use egui::{
      2     vec2, CursorIcon, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator,
      3     Stroke, Widget,
      4 };
      5 use tracing::{error, info};
      6 
      7 use crate::{
      8     app::{get_active_columns_mut, get_decks_mut},
      9     app_style::DECK_ICON_SIZE,
     10     decks::{DecksAction, DecksCache},
     11     nav::SwitchingAction,
     12     route::Route,
     13 };
     14 
     15 use enostr::RelayStatus;
     16 use notedeck::{
     17     tr, Accounts, Localization, MediaJobSender, NotedeckTextStyle, RelayInspectApi, UserAccount,
     18 };
     19 use notedeck_ui::{
     20     anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
     21     app_images, colors, ProfilePic, View,
     22 };
     23 
     24 use super::configure_deck::deck_icon;
     25 
     26 pub static SIDE_PANEL_WIDTH: f32 = 68.0;
     27 static ICON_WIDTH: f32 = 40.0;
     28 
     29 pub struct DesktopSidePanel<'r, 'a> {
     30     selected_account: &'a UserAccount,
     31     decks_cache: &'a DecksCache,
     32     i18n: &'a mut Localization,
     33     ndb: &'a nostrdb::Ndb,
     34     img_cache: &'a mut notedeck::Images,
     35     jobs: &'a MediaJobSender,
     36     current_route: Option<&'a Route>,
     37     relay_inspect: RelayInspectApi<'r, 'a>,
     38 }
     39 
     40 impl View for DesktopSidePanel<'_, '_> {
     41     fn ui(&mut self, ui: &mut egui::Ui) {
     42         self.show(ui);
     43     }
     44 }
     45 
     46 #[derive(Debug, Eq, PartialEq, Clone, Copy)]
     47 pub enum SidePanelAction {
     48     Home,
     49     Columns,
     50     ComposeNote,
     51     Search,
     52     ExpandSidePanel,
     53     NewDeck,
     54     SwitchDeck(usize),
     55     EditDeck(usize),
     56     Wallet,
     57     Profile,
     58     Settings,
     59     Relays,
     60     Accounts,
     61     Support,
     62 }
     63 
     64 pub struct SidePanelResponse {
     65     pub response: egui::Response,
     66     pub action: SidePanelAction,
     67 }
     68 
     69 impl SidePanelResponse {
     70     fn new(action: SidePanelAction, response: egui::Response) -> Self {
     71         SidePanelResponse { action, response }
     72     }
     73 }
     74 
     75 impl<'r, 'a> DesktopSidePanel<'r, 'a> {
     76     #[allow(clippy::too_many_arguments)]
     77     pub fn new(
     78         selected_account: &'a UserAccount,
     79         decks_cache: &'a DecksCache,
     80         i18n: &'a mut Localization,
     81         ndb: &'a nostrdb::Ndb,
     82         img_cache: &'a mut notedeck::Images,
     83         jobs: &'a MediaJobSender,
     84         current_route: Option<&'a Route>,
     85         relay_inspect: RelayInspectApi<'r, 'a>,
     86     ) -> Self {
     87         Self {
     88             selected_account,
     89             decks_cache,
     90             i18n,
     91             ndb,
     92             img_cache,
     93             jobs,
     94             current_route,
     95             relay_inspect,
     96         }
     97     }
     98 
     99     pub fn show(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> {
    100         let frame =
    101             egui::Frame::new().inner_margin(Margin::same(notedeck_ui::constants::FRAME_MARGIN));
    102 
    103         if !ui.visuals().dark_mode {
    104             let rect = ui.available_rect_before_wrap();
    105             ui.painter().rect(
    106                 rect,
    107                 0,
    108                 colors::ALMOST_WHITE,
    109                 egui::Stroke::new(0.0, egui::Color32::TRANSPARENT),
    110                 egui::StrokeKind::Inside,
    111             );
    112         }
    113 
    114         frame.show(ui, |ui| self.show_inner(ui)).inner
    115     }
    116 
    117     fn show_inner(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> {
    118         let avatar_size = 40.0;
    119         let bottom_padding = 8.0;
    120         let connectivity_indicator_height = 48.0;
    121         let is_read_only = self.selected_account.key.secret_key.is_none();
    122         let read_only_label_height = if is_read_only { 16.0 } else { 0.0 };
    123         let avatar_section_height =
    124             avatar_size + bottom_padding + read_only_label_height + connectivity_indicator_height;
    125 
    126         ui.vertical(|ui| {
    127             #[cfg(target_os = "macos")]
    128             ui.add_space(32.0);
    129 
    130             let available_for_scroll = ui.available_height() - avatar_section_height;
    131 
    132             let scroll_out = ScrollArea::vertical()
    133                 .max_height(available_for_scroll)
    134                 .show(ui, |ui| {
    135                     ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    136                         let home_resp = ui.add(home_button());
    137                         let compose_resp = ui
    138                             .add(crate::ui::post::compose_note_button(ui.visuals().dark_mode))
    139                             .on_hover_cursor(egui::CursorIcon::PointingHand);
    140                         let search_resp = ui.add(search_button(self.current_route));
    141                         let settings_resp = ui.add(settings_button(self.current_route));
    142                         let wallet_resp = ui.add(wallet_button(self.current_route));
    143 
    144                         let profile_resp = ui.add(profile_button(
    145                             self.current_route,
    146                             self.selected_account.key.pubkey,
    147                         ));
    148 
    149                         let support_resp = ui.add(support_button(self.current_route));
    150 
    151                         ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0));
    152 
    153                         ui.add_space(8.0);
    154                         ui.add(egui::Label::new(
    155                             RichText::new(tr!(
    156                                 self.i18n,
    157                                 "DECKS",
    158                                 "Label for decks section in side panel"
    159                             ))
    160                             .size(11.0)
    161                             .color(ui.visuals().noninteractive().fg_stroke.color),
    162                         ));
    163                         ui.add_space(8.0);
    164 
    165                         let column_resp = ui.add(add_column_button());
    166                         let add_deck_resp = ui.add(add_deck_button(self.i18n));
    167 
    168                         let decks_inner = show_decks(ui, self.decks_cache, self.selected_account);
    169 
    170                         (
    171                             home_resp,
    172                             compose_resp,
    173                             search_resp,
    174                             column_resp,
    175                             settings_resp,
    176                             profile_resp,
    177                             wallet_resp,
    178                             support_resp,
    179                             add_deck_resp,
    180                             decks_inner,
    181                         )
    182                     })
    183                 });
    184 
    185             let (
    186                 home_resp,
    187                 compose_resp,
    188                 search_resp,
    189                 column_resp,
    190                 settings_resp,
    191                 profile_resp,
    192                 wallet_resp,
    193                 support_resp,
    194                 add_deck_resp,
    195                 decks_inner,
    196             ) = scroll_out.inner.inner;
    197 
    198             let remaining = ui.available_height();
    199             if remaining > avatar_section_height {
    200                 ui.add_space(remaining - avatar_section_height);
    201             }
    202 
    203             // Connectivity indicator
    204             let connectivity_resp = ui
    205                 .with_layout(Layout::top_down(egui::Align::Center), |ui| {
    206                     connectivity_indicator(ui, &self.relay_inspect, self.current_route)
    207                 })
    208                 .inner;
    209 
    210             let pfp_resp = ui
    211                 .with_layout(Layout::top_down(egui::Align::Center), |ui| {
    212                     let is_read_only = self.selected_account.key.secret_key.is_none();
    213 
    214                     if is_read_only {
    215                         ui.add(
    216                             Label::new(
    217                                 RichText::new(tr!(
    218                                     self.i18n,
    219                                     "Read only",
    220                                     "Label for read-only profile mode"
    221                                 ))
    222                                 .size(notedeck::fonts::get_font_size(
    223                                     ui.ctx(),
    224                                     &NotedeckTextStyle::Tiny,
    225                                 ))
    226                                 .color(ui.visuals().warn_fg_color),
    227                             )
    228                             .selectable(false),
    229                         );
    230                         ui.add_space(4.0);
    231                     }
    232 
    233                     let txn = nostrdb::Transaction::new(self.ndb).ok();
    234                     let profile_url = if let Some(ref txn) = txn {
    235                         if let Ok(profile) = self
    236                             .ndb
    237                             .get_profile_by_pubkey(txn, self.selected_account.key.pubkey.bytes())
    238                         {
    239                             notedeck::profile::get_profile_url(Some(&profile))
    240                         } else {
    241                             notedeck::profile::no_pfp_url()
    242                         }
    243                     } else {
    244                         notedeck::profile::no_pfp_url()
    245                     };
    246 
    247                     let resp = ui
    248                         .add(
    249                             &mut ProfilePic::new(self.img_cache, self.jobs, profile_url)
    250                                 .size(avatar_size)
    251                                 .sense(egui::Sense::click()),
    252                         )
    253                         .on_hover_cursor(egui::CursorIcon::PointingHand);
    254 
    255                     // Draw border if Accounts route is active
    256                     let is_accounts_active = self
    257                         .current_route
    258                         .is_some_and(|r| matches!(r, Route::Accounts(_)));
    259                     if is_accounts_active {
    260                         let rect = resp.rect;
    261                         let radius = avatar_size / 2.0;
    262                         ui.painter().circle_stroke(
    263                             rect.center(),
    264                             radius + 2.0,
    265                             Stroke::new(1.5, ui.visuals().text_color()),
    266                         );
    267                     }
    268 
    269                     resp
    270                 })
    271                 .inner;
    272 
    273             if connectivity_resp.clicked() {
    274                 Some(SidePanelResponse::new(
    275                     SidePanelAction::Relays,
    276                     connectivity_resp,
    277                 ))
    278             } else if home_resp.clicked() {
    279                 Some(SidePanelResponse::new(SidePanelAction::Home, home_resp))
    280             } else if pfp_resp.clicked() {
    281                 Some(SidePanelResponse::new(SidePanelAction::Accounts, pfp_resp))
    282             } else if compose_resp.clicked() {
    283                 Some(SidePanelResponse::new(
    284                     SidePanelAction::ComposeNote,
    285                     compose_resp,
    286                 ))
    287             } else if search_resp.clicked() {
    288                 Some(SidePanelResponse::new(SidePanelAction::Search, search_resp))
    289             } else if column_resp.clicked() {
    290                 Some(SidePanelResponse::new(
    291                     SidePanelAction::Columns,
    292                     column_resp,
    293                 ))
    294             } else if settings_resp.clicked() {
    295                 Some(SidePanelResponse::new(
    296                     SidePanelAction::Settings,
    297                     settings_resp,
    298                 ))
    299             } else if profile_resp.clicked() {
    300                 Some(SidePanelResponse::new(
    301                     SidePanelAction::Profile,
    302                     profile_resp,
    303                 ))
    304             } else if wallet_resp.clicked() {
    305                 Some(SidePanelResponse::new(SidePanelAction::Wallet, wallet_resp))
    306             } else if support_resp.clicked() {
    307                 Some(SidePanelResponse::new(
    308                     SidePanelAction::Support,
    309                     support_resp,
    310                 ))
    311             } else if add_deck_resp.clicked() {
    312                 Some(SidePanelResponse::new(
    313                     SidePanelAction::NewDeck,
    314                     add_deck_resp,
    315                 ))
    316             } else if decks_inner.response.secondary_clicked() {
    317                 info!("decks inner secondary click");
    318                 if let Some(clicked_index) = decks_inner.inner {
    319                     Some(SidePanelResponse::new(
    320                         SidePanelAction::EditDeck(clicked_index),
    321                         decks_inner.response,
    322                     ))
    323                 } else {
    324                     None
    325                 }
    326             } else if decks_inner.response.clicked() {
    327                 if let Some(clicked_index) = decks_inner.inner {
    328                     Some(SidePanelResponse::new(
    329                         SidePanelAction::SwitchDeck(clicked_index),
    330                         decks_inner.response,
    331                     ))
    332                 } else {
    333                     None
    334                 }
    335             } else {
    336                 None
    337             }
    338         })
    339         .inner
    340     }
    341 
    342     pub fn perform_action(
    343         decks_cache: &mut DecksCache,
    344         accounts: &Accounts,
    345         action: SidePanelAction,
    346         i18n: &mut Localization,
    347     ) -> Option<SwitchingAction> {
    348         let router = get_active_columns_mut(i18n, accounts, decks_cache).get_selected_router();
    349         let mut switching_response = None;
    350         match action {
    351             SidePanelAction::Home => {
    352                 let pubkey = accounts.get_selected_account().key.pubkey;
    353                 let home_route =
    354                     Route::timeline(crate::timeline::TimelineKind::contact_list(pubkey));
    355 
    356                 if router.top() == &home_route {
    357                     // TODO: implement scroll to top when already on home route
    358                 } else {
    359                     router.route_to(home_route);
    360                 }
    361             }
    362             SidePanelAction::Columns => {
    363                 if router
    364                     .routes()
    365                     .iter()
    366                     .any(|r| matches!(r, Route::AddColumn(_)))
    367                 {
    368                     router.go_back();
    369                 } else {
    370                     get_active_columns_mut(i18n, accounts, decks_cache).new_column_picker();
    371                 }
    372             }
    373             SidePanelAction::ComposeNote => {
    374                 let can_post = accounts.get_selected_account().key.secret_key.is_some();
    375 
    376                 if !can_post {
    377                     router.route_to(Route::accounts());
    378                 } else if router.routes().iter().any(|r| r == &Route::ComposeNote) {
    379                     router.go_back();
    380                 } else {
    381                     router.route_to(Route::ComposeNote);
    382                 }
    383             }
    384             SidePanelAction::Search => {
    385                 if router.top() == &Route::Search {
    386                     router.go_back();
    387                 } else {
    388                     router.route_to(Route::Search);
    389                 }
    390             }
    391             SidePanelAction::ExpandSidePanel => {
    392                 info!("Clicked expand side panel button");
    393             }
    394             SidePanelAction::NewDeck => {
    395                 if router.routes().iter().any(|r| r == &Route::NewDeck) {
    396                     router.go_back();
    397                 } else {
    398                     router.route_to(Route::NewDeck);
    399                 }
    400             }
    401             SidePanelAction::SwitchDeck(index) => {
    402                 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch(
    403                     index,
    404                 )))
    405             }
    406             SidePanelAction::EditDeck(index) => {
    407                 if router.routes().iter().any(|r| r == &Route::EditDeck(index)) {
    408                     router.go_back();
    409                 } else {
    410                     switching_response = Some(crate::nav::SwitchingAction::Decks(
    411                         DecksAction::Switch(index),
    412                     ));
    413                     if let Some(edit_deck) = get_decks_mut(i18n, accounts, decks_cache)
    414                         .decks_mut()
    415                         .get_mut(index)
    416                     {
    417                         edit_deck
    418                             .columns_mut()
    419                             .get_selected_router()
    420                             .route_to(Route::EditDeck(index));
    421                     } else {
    422                         error!("Cannot push EditDeck route to index {}", index);
    423                     }
    424                 }
    425             }
    426             SidePanelAction::Wallet => 's: {
    427                 if router
    428                     .routes()
    429                     .iter()
    430                     .any(|r| matches!(r, Route::Wallet(_)))
    431                 {
    432                     router.go_back();
    433                     break 's;
    434                 }
    435 
    436                 router.route_to(Route::Wallet(notedeck::WalletType::Auto));
    437             }
    438             SidePanelAction::Profile => {
    439                 let pubkey = accounts.get_selected_account().key.pubkey;
    440                 if router.routes().iter().any(|r| r == &Route::profile(pubkey)) {
    441                     router.go_back();
    442                 } else {
    443                     router.route_to(Route::profile(pubkey));
    444                 }
    445             }
    446             SidePanelAction::Settings => {
    447                 if router.routes().iter().any(|r| r == &Route::Settings) {
    448                     router.go_back();
    449                 } else {
    450                     router.route_to(Route::Settings);
    451                 }
    452             }
    453             SidePanelAction::Relays => {
    454                 if router.routes().iter().any(|r| r == &Route::Relays) {
    455                     router.go_back();
    456                 } else {
    457                     router.route_to(Route::relays());
    458                 }
    459             }
    460             SidePanelAction::Accounts => {
    461                 if router
    462                     .routes()
    463                     .iter()
    464                     .any(|r| matches!(r, Route::Accounts(_)))
    465                 {
    466                     router.go_back();
    467                 } else {
    468                     router.route_to(Route::accounts());
    469                 }
    470             }
    471             SidePanelAction::Support => {
    472                 if router.routes().iter().any(|r| r == &Route::Support) {
    473                     router.go_back();
    474                 } else {
    475                     router.route_to(Route::Support);
    476                 }
    477             }
    478         }
    479         switching_response
    480     }
    481 }
    482 
    483 fn add_column_button() -> impl Widget {
    484     move |ui: &mut egui::Ui| {
    485         let img_size = 24.0;
    486         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    487 
    488         let img = if ui.visuals().dark_mode {
    489             app_images::add_column_dark_image()
    490         } else {
    491             app_images::add_column_light_image()
    492         };
    493 
    494         let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size));
    495 
    496         let cur_img_size = helper.scale_1d_pos(img_size);
    497         img.paint_at(
    498             ui,
    499             helper
    500                 .get_animation_rect()
    501                 .shrink((max_size - cur_img_size) / 2.0),
    502         );
    503 
    504         helper
    505             .take_animation_response()
    506             .on_hover_cursor(CursorIcon::PointingHand)
    507             .on_hover_text("Add new column")
    508     }
    509 }
    510 
    511 pub fn search_button_impl(color: egui::Color32, line_width: f32, is_active: bool) -> impl Widget {
    512     notedeck_ui::icons::search_button(color, line_width, is_active)
    513 }
    514 
    515 pub fn search_button(current_route: Option<&Route>) -> impl Widget + '_ {
    516     let is_active = matches!(current_route, Some(Route::Search));
    517     move |ui: &mut egui::Ui| {
    518         let icon_color = notedeck_ui::side_panel_icon_tint(ui);
    519         search_button_impl(icon_color, 1.5, is_active).ui(ui)
    520     }
    521 }
    522 
    523 // TODO: convert to responsive button when expanded side panel impl is finished
    524 
    525 fn add_deck_button<'a>(i18n: &'a mut Localization) -> impl Widget + 'a {
    526     |ui: &mut egui::Ui| -> egui::Response {
    527         let img_size = 40.0;
    528 
    529         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    530         let img = app_images::new_deck_image().max_width(img_size);
    531 
    532         let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size));
    533 
    534         let cur_img_size = helper.scale_1d_pos(img_size);
    535         img.paint_at(
    536             ui,
    537             helper
    538                 .get_animation_rect()
    539                 .shrink((max_size - cur_img_size) / 2.0),
    540         );
    541 
    542         helper
    543             .take_animation_response()
    544             .on_hover_cursor(CursorIcon::PointingHand)
    545             .on_hover_text(tr!(
    546                 i18n,
    547                 "Add new deck",
    548                 "Tooltip text for adding a new deck button"
    549             ))
    550     }
    551 }
    552 
    553 fn show_decks<'a>(
    554     ui: &mut egui::Ui,
    555     decks_cache: &'a DecksCache,
    556     selected_account: &'a UserAccount,
    557 ) -> InnerResponse<Option<usize>> {
    558     let show_decks_id = ui.id().with("show-decks");
    559     let account_id = selected_account.key.pubkey;
    560     let (cur_decks, account_id) = (
    561         decks_cache.decks(&account_id),
    562         show_decks_id.with(account_id),
    563     );
    564     let active_index = cur_decks.active_index();
    565 
    566     let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click());
    567     let mut clicked_index = None;
    568     for (index, deck) in cur_decks.decks().iter().enumerate() {
    569         let highlight = index == active_index;
    570         let deck_icon_resp = ui
    571             .add(deck_icon(
    572                 account_id.with(index),
    573                 Some(deck.icon),
    574                 DECK_ICON_SIZE,
    575                 40.0,
    576                 highlight,
    577             ))
    578             .on_hover_text_at_pointer(&deck.name)
    579             .on_hover_cursor(CursorIcon::PointingHand);
    580         if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() {
    581             clicked_index = Some(index);
    582         }
    583         resp = resp.union(deck_icon_resp);
    584     }
    585     InnerResponse::new(clicked_index, resp)
    586 }
    587 
    588 fn settings_button(current_route: Option<&Route>) -> impl Widget + '_ {
    589     let is_active = matches!(current_route, Some(Route::Settings));
    590     move |ui: &mut egui::Ui| {
    591         let img_size = 24.0;
    592         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
    593         let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size));
    594 
    595         let painter = ui.painter_at(helper.get_animation_rect());
    596         if is_active {
    597             let circle_radius = max_size / 2.0;
    598             painter.circle(
    599                 helper.get_animation_rect().center(),
    600                 circle_radius,
    601                 notedeck_ui::side_panel_active_bg(ui),
    602                 Stroke::NONE,
    603             );
    604         }
    605 
    606         let img = if ui.visuals().dark_mode {
    607             app_images::settings_dark_image()
    608         } else {
    609             app_images::settings_light_image()
    610         };
    611         let cur_img_size = helper.scale_1d_pos(img_size);
    612         img.paint_at(
    613             ui,
    614             helper
    615                 .get_animation_rect()
    616                 .shrink((max_size - cur_img_size) / 2.0),
    617         );
    618         helper
    619             .take_animation_response()
    620             .on_hover_cursor(CursorIcon::PointingHand)
    621             .on_hover_text("Settings")
    622     }
    623 }
    624 
    625 fn profile_button(current_route: Option<&Route>, pubkey: enostr::Pubkey) -> impl Widget + '_ {
    626     let is_active = matches!(
    627         current_route,
    628         Some(Route::Timeline(crate::timeline::TimelineKind::Profile(pk))) if *pk == pubkey
    629     );
    630     move |ui: &mut egui::Ui| {
    631         let img_size = 24.0;
    632         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
    633         let helper = AnimationHelper::new(ui, "profile-button", vec2(max_size, max_size));
    634 
    635         let painter = ui.painter_at(helper.get_animation_rect());
    636         if is_active {
    637             let circle_radius = max_size / 2.0;
    638             painter.circle(
    639                 helper.get_animation_rect().center(),
    640                 circle_radius,
    641                 notedeck_ui::side_panel_active_bg(ui),
    642                 Stroke::NONE,
    643             );
    644         }
    645 
    646         let img = app_images::profile_image().tint(notedeck_ui::side_panel_icon_tint(ui));
    647         let cur_img_size = helper.scale_1d_pos(img_size);
    648         img.paint_at(
    649             ui,
    650             helper
    651                 .get_animation_rect()
    652                 .shrink((max_size - cur_img_size) / 2.0),
    653         );
    654         helper
    655             .take_animation_response()
    656             .on_hover_cursor(CursorIcon::PointingHand)
    657             .on_hover_text("Profile")
    658     }
    659 }
    660 
    661 fn wallet_button(current_route: Option<&Route>) -> impl Widget + '_ {
    662     let is_active = matches!(current_route, Some(Route::Wallet(_)));
    663     move |ui: &mut egui::Ui| {
    664         let img_size = 24.0;
    665         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
    666         let helper = AnimationHelper::new(ui, "wallet-button", vec2(max_size, max_size));
    667 
    668         let painter = ui.painter_at(helper.get_animation_rect());
    669         if is_active {
    670             let circle_radius = max_size / 2.0;
    671             painter.circle(
    672                 helper.get_animation_rect().center(),
    673                 circle_radius,
    674                 notedeck_ui::side_panel_active_bg(ui),
    675                 Stroke::NONE,
    676             );
    677         }
    678 
    679         let img = if ui.visuals().dark_mode {
    680             app_images::wallet_dark_image()
    681         } else {
    682             app_images::wallet_light_image()
    683         };
    684         let cur_img_size = helper.scale_1d_pos(img_size);
    685         img.paint_at(
    686             ui,
    687             helper
    688                 .get_animation_rect()
    689                 .shrink((max_size - cur_img_size) / 2.0),
    690         );
    691         helper
    692             .take_animation_response()
    693             .on_hover_cursor(CursorIcon::PointingHand)
    694             .on_hover_text("Wallet")
    695     }
    696 }
    697 
    698 fn support_button(current_route: Option<&Route>) -> impl Widget + '_ {
    699     let is_active = matches!(current_route, Some(Route::Support));
    700     move |ui: &mut egui::Ui| {
    701         let img_size = 24.0;
    702         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
    703         let helper = AnimationHelper::new(ui, "support-button", vec2(max_size, max_size));
    704 
    705         let painter = ui.painter_at(helper.get_animation_rect());
    706         if is_active {
    707             let circle_radius = max_size / 2.0;
    708             painter.circle(
    709                 helper.get_animation_rect().center(),
    710                 circle_radius,
    711                 notedeck_ui::side_panel_active_bg(ui),
    712                 Stroke::NONE,
    713             );
    714         }
    715 
    716         let img = if ui.visuals().dark_mode {
    717             app_images::help_dark_image()
    718         } else {
    719             app_images::help_light_image()
    720         };
    721         let cur_img_size = helper.scale_1d_pos(img_size);
    722         img.paint_at(
    723             ui,
    724             helper
    725                 .get_animation_rect()
    726                 .shrink((max_size - cur_img_size) / 2.0),
    727         );
    728         helper
    729             .take_animation_response()
    730             .on_hover_cursor(CursorIcon::PointingHand)
    731             .on_hover_text("Support")
    732     }
    733 }
    734 
    735 fn home_button() -> impl Widget {
    736     |ui: &mut egui::Ui| {
    737         let img_size = 32.0;
    738         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
    739         let helper = AnimationHelper::new(ui, "home-button", vec2(max_size, max_size));
    740 
    741         let img = app_images::damus_image();
    742         let cur_img_size = helper.scale_1d_pos(img_size);
    743         img.paint_at(
    744             ui,
    745             helper
    746                 .get_animation_rect()
    747                 .shrink((max_size - cur_img_size) / 2.0),
    748         );
    749         helper
    750             .take_animation_response()
    751             .on_hover_cursor(CursorIcon::PointingHand)
    752             .on_hover_text("Home")
    753     }
    754 }
    755 fn connectivity_indicator(
    756     ui: &mut egui::Ui,
    757     relay_inspect: &RelayInspectApi<'_, '_>,
    758     _current_route: Option<&Route>,
    759 ) -> egui::Response {
    760     let relay_infos = relay_inspect.relay_infos();
    761     let connected_count = relay_infos
    762         .iter()
    763         .filter(|info| matches!(info.status, RelayStatus::Connected))
    764         .count();
    765     let total_count = relay_infos.len();
    766 
    767     // Calculate connectivity ratio (0.0 to 1.0)
    768     let ratio = if total_count > 0 {
    769         connected_count as f32 / total_count as f32
    770     } else {
    771         0.0
    772     };
    773 
    774     // Color based on ratio: red (0%) -> yellow (50%) -> green (100%)
    775     let active_color = if ratio < 0.5 {
    776         // Red to yellow: interpolate from red (255,102,102) to yellow (255,204,102)
    777         let t = ratio * 2.0;
    778         egui::Color32::from_rgb(0xFF, 0x66 + (0x66 as f32 * t) as u8, 0x66)
    779     } else {
    780         // Yellow to green: interpolate from yellow (255,204,102) to green (102,204,102)
    781         let t = (ratio - 0.5) * 2.0;
    782         egui::Color32::from_rgb(0xFF - (0x99 as f32 * t) as u8, 0xCC, 0x66)
    783     };
    784 
    785     let inactive_color = if ui.visuals().dark_mode {
    786         egui::Color32::from_rgb(60, 60, 60)
    787     } else {
    788         egui::Color32::from_rgb(200, 200, 200)
    789     };
    790 
    791     let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
    792     let helper = AnimationHelper::new(ui, "connectivity-indicator", vec2(max_size, max_size));
    793 
    794     let painter = ui.painter_at(helper.get_animation_rect());
    795     let rect = helper.get_animation_rect();
    796     let center = rect.center();
    797 
    798     let bar_width = 3.0;
    799     let bar_spacing = 2.0;
    800     let num_bars = 4;
    801 
    802     let base_y = center.y + 6.0;
    803     let total_width = (num_bars as f32) * bar_width + ((num_bars - 1) as f32) * bar_spacing;
    804     let start_x = center.x - total_width / 2.0;
    805 
    806     // Progressive bar heights (short to tall)
    807     let bar_heights = [4.0, 7.0, 10.0, 13.0];
    808 
    809     // Determine how many bars should be active based on ratio
    810     let active_bars = if connected_count == 0 {
    811         0
    812     } else {
    813         // At least 1 bar if connected, scale up to 4
    814         ((ratio * (num_bars as f32)).ceil() as usize)
    815             .max(1)
    816             .min(num_bars)
    817     };
    818 
    819     for (i, &height) in bar_heights.iter().enumerate() {
    820         let x = start_x + (i as f32) * (bar_width + bar_spacing);
    821         let bar_rect =
    822             egui::Rect::from_min_size(egui::pos2(x, base_y - height), vec2(bar_width, height));
    823 
    824         let color = if i < active_bars {
    825             active_color
    826         } else {
    827             inactive_color
    828         };
    829         painter.rect_filled(bar_rect, 1.0, color);
    830     }
    831 
    832     helper
    833         .take_animation_response()
    834         .on_hover_cursor(CursorIcon::PointingHand)
    835         .on_hover_text(format!(
    836             "{}/{} relays connected",
    837             connected_count, total_count
    838         ))
    839 }