notedeck

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

relay.rs (14088B)


      1 use std::collections::{HashMap, HashSet};
      2 
      3 use egui::{Align, Button, CornerRadius, Frame, Id, Layout, Margin, Rgba, RichText, Ui, Vec2};
      4 use enostr::{NormRelayUrl, RelayStatus};
      5 use notedeck::{
      6     tr, DragResponse, Localization, NotedeckTextStyle, RelayAction, RelayInspectApi, RelaySpec,
      7 };
      8 use notedeck_ui::app_images;
      9 use notedeck_ui::{colors::PINK, padding};
     10 use tracing::debug;
     11 
     12 use super::widgets::styled_button;
     13 
     14 pub struct RelayView<'r, 'a> {
     15     relay_inspect: RelayInspectApi<'r, 'a>,
     16     advertised_relays: &'a std::collections::BTreeSet<RelaySpec>,
     17     id_string_map: &'a mut HashMap<Id, String>,
     18     i18n: &'a mut Localization,
     19 }
     20 
     21 struct RelayRow {
     22     relay_url: String,
     23     status: RelayStatus,
     24 }
     25 
     26 impl RelayView<'_, '_> {
     27     pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<RelayAction> {
     28         let scroll_out = Frame::new()
     29             .inner_margin(Margin::symmetric(10, 0))
     30             .show(ui, |ui| {
     31                 ui.add_space(24.0);
     32 
     33                 ui.horizontal(|ui| {
     34                     ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
     35                         ui.label(
     36                             RichText::new(tr!(self.i18n, "Relays", "Label for relay list section"))
     37                                 .text_style(NotedeckTextStyle::Heading2.text_style()),
     38                         );
     39                     });
     40                 });
     41 
     42                 ui.add_space(8.0);
     43 
     44                 egui::ScrollArea::vertical()
     45                     .id_salt(RelayView::scroll_id())
     46                     .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
     47                     .auto_shrink([false; 2])
     48                     .show(ui, |ui| {
     49                         let mut action = None;
     50                         if let Some(relay_to_remove) = self.show_relays(ui) {
     51                             action = Some(RelayAction::Remove(relay_to_remove));
     52                         }
     53                         ui.add_space(8.0);
     54                         if let Some(relay_to_add) = self.show_add_relay_ui(ui) {
     55                             action = Some(RelayAction::Add(relay_to_add));
     56                         }
     57                         action
     58                     })
     59             })
     60             .inner;
     61 
     62         DragResponse::scroll(scroll_out)
     63     }
     64 
     65     pub fn scroll_id() -> egui::Id {
     66         egui::Id::new("relay_scroll")
     67     }
     68 }
     69 
     70 impl<'r, 'a> RelayView<'r, 'a> {
     71     pub fn new(
     72         relay_inspect: RelayInspectApi<'r, 'a>,
     73         advertised_relays: &'a std::collections::BTreeSet<RelaySpec>,
     74         id_string_map: &'a mut HashMap<Id, String>,
     75         i18n: &'a mut Localization,
     76     ) -> Self {
     77         RelayView {
     78             relay_inspect,
     79             advertised_relays,
     80             id_string_map,
     81             i18n,
     82         }
     83     }
     84 
     85     pub fn panel(&mut self, ui: &mut egui::Ui) {
     86         egui::CentralPanel::default().show(ui.ctx(), |ui| self.ui(ui));
     87     }
     88 
     89     /// Show the selected account's advertised relays and
     90     /// any other currently-connected outbox relays.
     91     fn show_relays(&mut self, ui: &mut Ui) -> Option<String> {
     92         let relay_infos = self.relay_inspect.relay_infos();
     93         let status_by_url: HashMap<String, RelayStatus> = relay_infos
     94             .iter()
     95             .map(|relay_info| (relay_info.relay_url.to_string(), relay_info.status))
     96             .collect();
     97 
     98         let advertised_urls: HashSet<String> = self
     99             .advertised_relays
    100             .iter()
    101             .map(|relay| relay.url.to_string())
    102             .collect();
    103 
    104         let mut advertised = Vec::new();
    105 
    106         for relay in self.advertised_relays {
    107             let url = relay.url.to_string();
    108             let status = status_by_url
    109                 .get(&url)
    110                 .copied()
    111                 .unwrap_or(RelayStatus::Disconnected);
    112 
    113             advertised.push(RelayRow {
    114                 relay_url: url,
    115                 status,
    116             });
    117         }
    118 
    119         let mut outbox_other = Vec::new();
    120         for relay_info in relay_infos {
    121             let url = relay_info.relay_url.to_string();
    122             if advertised_urls.contains(&url) {
    123                 continue;
    124             }
    125             outbox_other.push(RelayRow {
    126                 relay_url: url,
    127                 status: relay_info.status,
    128             });
    129         }
    130 
    131         let mut relay_to_remove = None;
    132         let advertised_label = tr!(
    133             self.i18n,
    134             "Advertised",
    135             "Section header for advertised relays"
    136         );
    137         let outbox_other_label = tr!(
    138             self.i18n,
    139             "Other",
    140             "Section header for non-advertised connected relays"
    141         );
    142 
    143         relay_to_remove = relay_to_remove.or_else(|| {
    144             self.show_relay_section(ui, &advertised_label, &advertised, true, "relay-advertised")
    145         });
    146         relay_to_remove = relay_to_remove.or_else(|| {
    147             self.show_relay_section(
    148                 ui,
    149                 &outbox_other_label,
    150                 &outbox_other,
    151                 false,
    152                 "relay-outbox-other",
    153             )
    154         });
    155 
    156         relay_to_remove
    157     }
    158 
    159     fn show_relay_section(
    160         &mut self,
    161         ui: &mut Ui,
    162         title: &str,
    163         rows: &[RelayRow],
    164         allow_delete: bool,
    165         id_prefix: &'static str,
    166     ) -> Option<String> {
    167         let mut relay_to_remove = None;
    168 
    169         ui.add_space(8.0);
    170         ui.label(
    171             RichText::new(title)
    172                 .text_style(NotedeckTextStyle::Body.text_style())
    173                 .strong(),
    174         );
    175         ui.add_space(4.0);
    176 
    177         if rows.is_empty() {
    178             ui.label(
    179                 RichText::new(tr!(self.i18n, "None", "Empty relay section placeholder"))
    180                     .text_style(NotedeckTextStyle::Body.text_style())
    181                     .weak(),
    182             );
    183             return None;
    184         }
    185 
    186         for (index, relay_row) in rows.iter().enumerate() {
    187             relay_to_remove = relay_to_remove
    188                 .or_else(|| self.show_relay_row(ui, relay_row, allow_delete, (id_prefix, index)));
    189         }
    190 
    191         relay_to_remove
    192     }
    193 
    194     fn show_relay_row(
    195         &mut self,
    196         ui: &mut Ui,
    197         relay_row: &RelayRow,
    198         allow_delete: bool,
    199         id_salt: impl std::hash::Hash,
    200     ) -> Option<String> {
    201         let mut relay_to_remove = None;
    202 
    203         ui.add_space(8.0);
    204         ui.vertical_centered_justified(|ui| {
    205             relay_frame(ui).show(ui, |ui| {
    206                 ui.horizontal(|ui| {
    207                     ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
    208                         Frame::new()
    209                             // This frame is needed to add margin because the label will be added to the outer frame first and centered vertically before the connection status is added so the vertical centering isn't accurate.
    210                             // TODO: remove this hack and actually center the url & status at the same time
    211                             .inner_margin(Margin::symmetric(0, 4))
    212                             .show(ui, |ui| {
    213                                 egui::ScrollArea::horizontal()
    214                                     .id_salt(id_salt)
    215                                     .max_width(
    216                                         ui.max_rect().width()
    217                                             - get_right_side_width(relay_row.status),
    218                                     ) // TODO: refactor to dynamically check the size of the 'right to left' portion and set the max width to be the screen width minus padding minus 'right to left' width
    219                                     .show(ui, |ui| {
    220                                         ui.label(
    221                                             RichText::new(&relay_row.relay_url)
    222                                                 .text_style(
    223                                                     NotedeckTextStyle::Monospace.text_style(),
    224                                                 )
    225                                                 .color(
    226                                                     ui.style()
    227                                                         .visuals
    228                                                         .noninteractive()
    229                                                         .fg_stroke
    230                                                         .color,
    231                                                 ),
    232                                         );
    233                                     });
    234                             });
    235                     });
    236 
    237                     ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
    238                         if allow_delete && ui.add(delete_button(ui.visuals().dark_mode)).clicked() {
    239                             relay_to_remove = Some(relay_row.relay_url.clone());
    240                         }
    241 
    242                         show_connection_status(ui, self.i18n, relay_row.status);
    243                     });
    244                 });
    245             });
    246         });
    247 
    248         relay_to_remove
    249     }
    250 
    251     const RELAY_PREFILL: &'static str = "wss://";
    252 
    253     fn show_add_relay_ui(&mut self, ui: &mut Ui) -> Option<String> {
    254         let id = ui.id().with("add-relay)");
    255         match self.id_string_map.get(&id) {
    256             None => {
    257                 ui.with_layout(Layout::top_down(Align::Min), |ui| {
    258                     let relay_button = add_relay_button(self.i18n);
    259                     if ui.add(relay_button).clicked() {
    260                         debug!("add relay clicked");
    261                         self.id_string_map
    262                             .insert(id, Self::RELAY_PREFILL.to_string());
    263                     };
    264                 });
    265                 None
    266             }
    267             Some(_) => {
    268                 ui.with_layout(Layout::top_down(Align::Min), |ui| {
    269                     self.add_relay_entry(ui, id)
    270                 })
    271                 .inner
    272             }
    273         }
    274     }
    275 
    276     pub fn add_relay_entry(&mut self, ui: &mut Ui, id: Id) -> Option<String> {
    277         padding(16.0, ui, |ui| {
    278             let text_buffer = self
    279                 .id_string_map
    280                 .entry(id)
    281                 .or_insert_with(|| Self::RELAY_PREFILL.to_string());
    282             let is_enabled = NormRelayUrl::new(text_buffer).is_ok();
    283             let text_edit = egui::TextEdit::singleline(text_buffer)
    284                 .hint_text(
    285                     RichText::new(tr!(
    286                         self.i18n,
    287                         "Enter the relay here",
    288                         "Placeholder for relay input field"
    289                     ))
    290                     .text_style(NotedeckTextStyle::Body.text_style()),
    291                 )
    292                 .vertical_align(Align::Center)
    293                 .desired_width(f32::INFINITY)
    294                 .min_size(Vec2::new(0.0, 40.0))
    295                 .margin(Margin::same(12));
    296             ui.add(text_edit);
    297             ui.add_space(8.0);
    298             if ui
    299                 .add_sized(
    300                     egui::vec2(50.0, 40.0),
    301                     add_relay_button2(self.i18n, is_enabled),
    302                 )
    303                 .clicked()
    304             {
    305                 self.id_string_map.remove(&id) // remove and return the value
    306             } else {
    307                 None
    308             }
    309         })
    310         .inner
    311     }
    312 }
    313 
    314 fn add_relay_button(i18n: &mut Localization) -> Button<'static> {
    315     Button::image_and_text(
    316         app_images::add_relay_image().fit_to_exact_size(Vec2::new(48.0, 48.0)),
    317         RichText::new(tr!(i18n, "Add relay", "Button label to add a relay"))
    318             .size(16.0)
    319             // TODO: this color should not be hard coded. Find some way to add it to the visuals
    320             .color(PINK),
    321     )
    322     .frame(false)
    323 }
    324 
    325 fn add_relay_button2<'a>(i18n: &'a mut Localization, is_enabled: bool) -> impl egui::Widget + 'a {
    326     move |ui: &mut egui::Ui| -> egui::Response {
    327         let add_text = tr!(i18n, "Add", "Button label to add a relay");
    328         let button_widget = styled_button(add_text.as_str(), notedeck_ui::colors::PINK);
    329         ui.add_enabled(is_enabled, button_widget)
    330     }
    331 }
    332 
    333 fn get_right_side_width(status: RelayStatus) -> f32 {
    334     match status {
    335         RelayStatus::Connected => 150.0,
    336         RelayStatus::Connecting => 160.0,
    337         RelayStatus::Disconnected => 175.0,
    338     }
    339 }
    340 
    341 fn delete_button(dark_mode: bool) -> egui::Button<'static> {
    342     let img = if dark_mode {
    343         app_images::delete_dark_image()
    344     } else {
    345         app_images::delete_light_image()
    346     };
    347 
    348     egui::Button::image(img.max_width(10.0)).frame(false)
    349 }
    350 
    351 fn relay_frame(ui: &mut Ui) -> Frame {
    352     Frame::new()
    353         .inner_margin(Margin::same(8))
    354         .corner_radius(ui.style().noninteractive().corner_radius)
    355         .stroke(ui.style().visuals.noninteractive().bg_stroke)
    356 }
    357 
    358 fn show_connection_status(ui: &mut Ui, i18n: &mut Localization, status: RelayStatus) {
    359     let fg_color = match status {
    360         RelayStatus::Connected => ui.visuals().selection.bg_fill,
    361         RelayStatus::Connecting => ui.visuals().warn_fg_color,
    362         RelayStatus::Disconnected => ui.visuals().error_fg_color,
    363     };
    364     let bg_color = egui::lerp(Rgba::from(fg_color)..=Rgba::BLACK, 0.8).into();
    365 
    366     let label_text = match status {
    367         RelayStatus::Connected => tr!(i18n, "Connected", "Status label for connected relay"),
    368         RelayStatus::Connecting => tr!(i18n, "Connecting...", "Status label for connecting relay"),
    369         RelayStatus::Disconnected => {
    370             tr!(i18n, "Not Connected", "Status label for disconnected relay")
    371         }
    372     };
    373 
    374     let frame = Frame::new()
    375         .corner_radius(CornerRadius::same(100))
    376         .fill(bg_color)
    377         .inner_margin(Margin::symmetric(12, 4));
    378 
    379     frame.show(ui, |ui| {
    380         ui.label(RichText::new(label_text).color(fg_color));
    381         ui.add(get_connection_icon(status));
    382     });
    383 }
    384 
    385 fn get_connection_icon(status: RelayStatus) -> egui::Image<'static> {
    386     match status {
    387         RelayStatus::Connected => app_images::connected_image(),
    388         RelayStatus::Connecting => app_images::connecting_image(),
    389         RelayStatus::Disconnected => app_images::disconnected_image(),
    390     }
    391 }