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 }