app.rs (18188B)
1 use egui_extras::RetainedImage; 2 3 use crate::contacts::Contacts; 4 use crate::{Error, Result}; 5 use egui::Context; 6 use enostr::{ClientMessage, EventId, Filter, Profile, Pubkey, RelayEvent, RelayMessage}; 7 use poll_promise::Promise; 8 use std::collections::{HashMap, HashSet}; 9 use std::hash::{Hash, Hasher}; 10 use tracing::{debug, error, info, warn}; 11 12 use enostr::{Event, RelayPool}; 13 14 #[derive(Hash, Eq, PartialEq, Clone, Debug)] 15 enum UrlKey<'a> { 16 Orig(&'a str), 17 Failed(&'a str), 18 } 19 20 impl UrlKey<'_> { 21 fn to_u64(&self) -> u64 { 22 let mut hasher = std::collections::hash_map::DefaultHasher::new(); 23 self.hash(&mut hasher); 24 hasher.finish() 25 } 26 } 27 28 type ImageCache = HashMap<u64, Promise<Result<RetainedImage>>>; 29 30 #[derive(Eq, PartialEq, Clone)] 31 pub enum DamusState { 32 Initializing, 33 Initialized, 34 } 35 36 /// We derive Deserialize/Serialize so we can persist app state on shutdown. 37 pub struct Damus { 38 state: DamusState, 39 contacts: Contacts, 40 n_panels: u32, 41 42 pool: RelayPool, 43 44 all_events: HashMap<EventId, Event>, 45 events: Vec<EventId>, 46 47 img_cache: ImageCache, 48 } 49 50 impl Default for Damus { 51 fn default() -> Self { 52 Self { 53 state: DamusState::Initializing, 54 contacts: Contacts::new(), 55 all_events: HashMap::new(), 56 pool: RelayPool::default(), 57 events: vec![], 58 img_cache: HashMap::new(), 59 n_panels: 1, 60 } 61 } 62 } 63 64 pub fn is_mobile(ctx: &egui::Context) -> bool { 65 let screen_size = ctx.input().screen_rect().size(); 66 screen_size.x < 550.0 67 } 68 69 fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) { 70 let ctx = ctx.clone(); 71 let wakeup = move || { 72 debug!("Woke up"); 73 ctx.request_repaint(); 74 }; 75 if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup) { 76 error!("{:?}", e) 77 } 78 } 79 80 fn send_initial_filters(pool: &mut RelayPool, relay_url: &str) { 81 let filter = Filter::new().limit(100).kinds(vec![1, 42]); 82 let subid = "initial"; 83 for relay in &mut pool.relays { 84 if relay.url == relay_url { 85 relay.subscribe(subid.to_string(), vec![filter]); 86 return; 87 } 88 } 89 } 90 91 fn try_process_event(damus: &mut Damus) { 92 let m_ev = damus.pool.try_recv(); 93 if m_ev.is_none() { 94 return; 95 } 96 let ev = m_ev.unwrap(); 97 let relay = ev.relay.to_owned(); 98 99 match ev.event { 100 RelayEvent::Opened => send_initial_filters(&mut damus.pool, &relay), 101 // TODO: handle reconnects 102 RelayEvent::Closed => warn!("{} connection closed", &relay), 103 RelayEvent::Other(msg) => debug!("other event {:?}", &msg), 104 RelayEvent::Message(msg) => process_message(damus, &relay, msg), 105 } 106 //info!("recv {:?}", ev) 107 } 108 109 fn update_damus(damus: &mut Damus, ctx: &egui::Context) { 110 if damus.state == DamusState::Initializing { 111 damus.pool = RelayPool::new(); 112 relay_setup(&mut damus.pool, ctx); 113 damus.state = DamusState::Initialized; 114 } 115 116 try_process_event(damus); 117 } 118 119 fn process_metadata_event(damus: &mut Damus, ev: &Event) { 120 if let Some(prev_id) = damus.contacts.events.get(&ev.pubkey) { 121 if let Some(prev_ev) = damus.all_events.get(prev_id) { 122 // This profile event is older, ignore it 123 if prev_ev.created_at >= ev.created_at { 124 return; 125 } 126 } 127 } 128 129 let profile: core::result::Result<serde_json::Value, serde_json::Error> = 130 serde_json::from_str(&ev.content); 131 132 match profile { 133 Err(e) => { 134 debug!("Invalid profile data '{}': {:?}", &ev.content, &e); 135 } 136 Ok(v) if !v.is_object() => { 137 debug!("Invalid profile data: '{}'", &ev.content); 138 } 139 Ok(profile) => { 140 damus 141 .contacts 142 .events 143 .insert(ev.pubkey.clone(), ev.id.clone()); 144 145 damus 146 .contacts 147 .profiles 148 .insert(ev.pubkey.clone(), Profile::new(profile)); 149 } 150 } 151 } 152 153 fn process_event(damus: &mut Damus, _subid: &str, event: Event) { 154 if damus.all_events.get(&event.id).is_some() { 155 return; 156 } 157 158 if event.kind == 0 { 159 process_metadata_event(damus, &event); 160 } 161 162 let cloned_id = event.id.clone(); 163 damus.all_events.insert(cloned_id.clone(), event); 164 damus.events.push(cloned_id); 165 } 166 167 fn get_unknown_author_ids(damus: &Damus) -> Vec<Pubkey> { 168 let mut authors: HashSet<Pubkey> = HashSet::new(); 169 170 for (_evid, ev) in damus.all_events.iter() { 171 if !damus.contacts.profiles.contains_key(&ev.pubkey) { 172 authors.insert(ev.pubkey.clone()); 173 } 174 } 175 176 authors.into_iter().collect() 177 } 178 179 fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) { 180 if subid == "initial" { 181 let authors = get_unknown_author_ids(damus); 182 let n_authors = authors.len(); 183 let filter = Filter::new().authors(authors).kinds(vec![0]); 184 info!( 185 "Getting {} unknown author profiles from {}", 186 n_authors, relay_url 187 ); 188 let msg = ClientMessage::req("profiles".to_string(), vec![filter]); 189 damus.pool.send_to(&msg, relay_url); 190 } else if subid == "profiles" { 191 info!("Got profiles from {}", relay_url); 192 let msg = ClientMessage::close("profiles".to_string()); 193 damus.pool.send_to(&msg, relay_url); 194 } 195 } 196 197 fn process_message(damus: &mut Damus, relay: &str, msg: RelayMessage) { 198 match msg { 199 RelayMessage::Event(subid, ev) => process_event(damus, &subid, ev), 200 RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg), 201 RelayMessage::OK(cr) => info!("OK {:?}", cr), 202 RelayMessage::Eose(sid) => handle_eose(damus, &sid, relay), 203 } 204 } 205 206 fn render_damus(damus: &mut Damus, ctx: &Context) { 207 if is_mobile(ctx) { 208 render_damus_mobile(ctx, damus); 209 } else { 210 render_damus_desktop(ctx, damus); 211 } 212 } 213 214 impl Damus { 215 pub fn add_test_events(&mut self) { 216 add_test_events(self); 217 } 218 219 /// Called once before the first frame. 220 pub fn new() -> Self { 221 // This is also where you can customized the look at feel of egui using 222 // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. 223 224 // Load previous app state (if any). 225 // Note that you must enable the `persistence` feature for this to work. 226 //if let Some(storage) = cc.storage { 227 //return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); 228 //} 229 230 Default::default() 231 } 232 } 233 234 #[allow(clippy::needless_pass_by_value)] 235 fn parse_response(response: ehttp::Response) -> Result<RetainedImage> { 236 let content_type = response.content_type().unwrap_or_default(); 237 238 if content_type.starts_with("image/svg") { 239 Ok(RetainedImage::from_svg_bytes( 240 &response.url, 241 &response.bytes, 242 )?) 243 } else if content_type.starts_with("image/") { 244 Ok(RetainedImage::from_image_bytes( 245 &response.url, 246 &response.bytes, 247 )?) 248 } else { 249 Err(format!("Expected image, found content-type {:?}", content_type).into()) 250 } 251 } 252 253 fn fetch_img(ctx: &egui::Context, url: &str) -> Promise<Result<RetainedImage>> { 254 // TODO: fetch image from local cache 255 fetch_img_from_net(ctx, url) 256 } 257 258 fn fetch_img_from_net(ctx: &egui::Context, url: &str) -> Promise<Result<RetainedImage>> { 259 let (sender, promise) = Promise::new(); 260 let request = ehttp::Request::get(url); 261 let ctx = ctx.clone(); 262 ehttp::fetch(request, move |response| { 263 let image = response.map_err(Error::Generic).and_then(parse_response); 264 sender.send(image); // send the results back to the UI thread. 265 ctx.request_repaint(); 266 }); 267 promise 268 } 269 270 fn render_pfp(ui: &mut egui::Ui, img_cache: &mut ImageCache, url: &str) { 271 let urlkey = UrlKey::Orig(url).to_u64(); 272 let m_cached_promise = img_cache.get(&urlkey); 273 if m_cached_promise.is_none() { 274 debug!("urlkey: {:?}", &urlkey); 275 img_cache.insert(urlkey, fetch_img(ui.ctx(), url)); 276 } 277 278 let pfp_size = 50.0; 279 280 match img_cache[&urlkey].ready() { 281 None => { 282 ui.spinner(); // still loading 283 } 284 Some(Err(_err)) => { 285 let failed_key = UrlKey::Failed(url).to_u64(); 286 debug!( 287 "has failed promise? {}", 288 img_cache.contains_key(&failed_key) 289 ); 290 let m_failed_promise = img_cache.get_mut(&failed_key); 291 if m_failed_promise.is_none() { 292 warn!("failed key: {:?}", &failed_key); 293 let no_pfp = fetch_img(ui.ctx(), no_pfp_url()); 294 img_cache.insert(failed_key, no_pfp); 295 } 296 297 match img_cache[&failed_key].ready() { 298 None => { 299 ui.spinner(); // still loading 300 } 301 Some(Err(e)) => { 302 error!("Image load error: {:?}", e); 303 ui.label("❌"); 304 } 305 Some(Ok(img)) => { 306 pfp_image(ui, img, pfp_size); 307 } 308 } 309 } 310 Some(Ok(img)) => { 311 pfp_image(ui, img, pfp_size); 312 } 313 } 314 } 315 316 fn pfp_image(ui: &mut egui::Ui, img: &RetainedImage, size: f32) -> egui::Response { 317 img.show_max_size(ui, egui::vec2(size, size)) 318 } 319 320 fn render_username(ui: &mut egui::Ui, contacts: &Contacts, pk: &Pubkey) { 321 ui.horizontal(|ui| { 322 //ui.spacing_mut().item_spacing.x = 0.0; 323 if let Some(prof) = contacts.profiles.get(pk) { 324 if let Some(display_name) = prof.display_name() { 325 ui.label(display_name); 326 } 327 } 328 329 ui.label(&pk.as_ref()[0..8]); 330 ui.label(":"); 331 ui.label(&pk.as_ref()[64 - 8..]); 332 }); 333 } 334 335 fn no_pfp_url() -> &'static str { 336 "https://damus.io/img/no-profile.svg" 337 } 338 339 fn render_events(ui: &mut egui::Ui, damus: &mut Damus) { 340 for evid in &damus.events { 341 if !damus.all_events.contains_key(evid) { 342 return; 343 } 344 345 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 346 let ev = damus.all_events.get(evid).unwrap(); 347 348 match damus 349 .contacts 350 .profiles 351 .get(&ev.pubkey) 352 .and_then(|p| p.picture()) 353 { 354 // these have different lifetimes and types, 355 // so the calls must be separate 356 Some(pic) => render_pfp(ui, &mut damus.img_cache, pic), 357 None => render_pfp(ui, &mut damus.img_cache, no_pfp_url()), 358 } 359 360 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { 361 render_username(ui, &damus.contacts, &ev.pubkey); 362 363 ui.label(&ev.content); 364 }) 365 }); 366 367 ui.separator(); 368 } 369 } 370 371 fn timeline_view(ui: &mut egui::Ui, app: &mut Damus) { 372 ui.heading("Timeline"); 373 374 egui::ScrollArea::vertical() 375 .auto_shrink([false; 2]) 376 .show(ui, |ui| { 377 render_events(ui, app); 378 }); 379 } 380 381 fn render_panel(ctx: &egui::Context, app: &mut Damus) { 382 egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { 383 ui.horizontal_wrapped(|ui| { 384 ui.visuals_mut().button_frame = false; 385 egui::widgets::global_dark_light_mode_switch(ui); 386 387 if ui 388 .add(egui::Button::new("+").frame(false)) 389 .on_hover_text("Add Timeline") 390 .clicked() 391 { 392 app.n_panels += 1; 393 } 394 395 if app.n_panels != 1 396 && ui 397 .add(egui::Button::new("-").frame(false)) 398 .on_hover_text("Remove Timeline") 399 .clicked() 400 { 401 app.n_panels -= 1; 402 } 403 }); 404 }); 405 } 406 407 fn set_app_style(ui: &mut egui::Ui) { 408 if ui.visuals().dark_mode { 409 ui.visuals_mut().override_text_color = Some(egui::Color32::WHITE); 410 ui.visuals_mut().panel_fill = egui::Color32::from_rgb(30, 30, 30); 411 } else { 412 ui.visuals_mut().override_text_color = Some(egui::Color32::BLACK); 413 }; 414 } 415 416 fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { 417 let panel_width = ctx.input().screen_rect.width(); 418 egui::CentralPanel::default().show(ctx, |ui| { 419 set_app_style(ui); 420 timeline_panel(ui, app, panel_width, 0); 421 }); 422 } 423 424 fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { 425 render_panel(ctx, app); 426 427 let screen_size = ctx.input().screen_rect.width(); 428 let calc_panel_width = (screen_size / app.n_panels as f32) - 30.0; 429 let min_width = 300.0; 430 let need_scroll = calc_panel_width < min_width; 431 let panel_width = if need_scroll { 432 min_width 433 } else { 434 calc_panel_width 435 }; 436 437 if app.n_panels == 1 { 438 let panel_width = ctx.input().screen_rect.width(); 439 egui::CentralPanel::default().show(ctx, |ui| { 440 set_app_style(ui); 441 timeline_panel(ui, app, panel_width, 0); 442 }); 443 444 return; 445 } 446 447 egui::CentralPanel::default().show(ctx, |ui| { 448 set_app_style(ui); 449 egui::ScrollArea::horizontal() 450 .auto_shrink([false; 2]) 451 .show(ui, |ui| { 452 for ind in 0..app.n_panels { 453 timeline_panel(ui, app, panel_width, ind); 454 } 455 }); 456 }); 457 } 458 459 fn timeline_panel(ui: &mut egui::Ui, app: &mut Damus, panel_width: f32, ind: u32) { 460 egui::SidePanel::left(format!("l{}", ind)) 461 .resizable(false) 462 .max_width(panel_width) 463 .min_width(panel_width) 464 .show_inside(ui, |ui| { 465 timeline_view(ui, app); 466 }); 467 } 468 469 fn add_test_events(damus: &mut Damus) { 470 // Examples of how to create different panels and windows. 471 // Pick whichever suits you. 472 // Tip: a good default choice is to just keep the `CentralPanel`. 473 // For inspiration and more examples, go to https://emilk.github.io/egui 474 475 let test_event = Event { 476 id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string().into(), 477 pubkey: "f0a6ff7f70b872de6d82c8daec692a433fd23b6a49f25923c6f034df715cdeec".to_string().into(), 478 created_at: 1667781968, 479 kind: 1, 480 tags: vec![], 481 content: LOREM_IPSUM.into(), 482 sig: "af02c971015995f79e07fa98aaf98adeeb6a56d0005e451ee4e78844cff712a6bc0f2109f72a878975f162dcefde4173b65ebd4c3d3ab3b520a9dcac6acf092d".to_string(), 483 }; 484 485 let test_event2 = Event { 486 id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string().into(), 487 pubkey: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".to_string().into(), 488 created_at: 1667781968, 489 kind: 1, 490 tags: vec![], 491 content: LOREM_IPSUM_LONG.into(), 492 sig: "af02c971015995f79e07fa98aaf98adeeb6a56d0005e451ee4e78844cff712a6bc0f2109f72a878975f162dcefde4173b65ebd4c3d3ab3b520a9dcac6acf092d".to_string(), 493 }; 494 495 damus 496 .all_events 497 .insert(test_event.id.clone(), test_event.clone()); 498 damus 499 .all_events 500 .insert(test_event2.id.clone(), test_event2.clone()); 501 502 if damus.events.is_empty() { 503 damus.events.push(test_event.id.clone()); 504 damus.events.push(test_event2.id.clone()); 505 damus.events.push(test_event.id.clone()); 506 damus.events.push(test_event2.id.clone()); 507 damus.events.push(test_event.id.clone()); 508 damus.events.push(test_event2.id.clone()); 509 damus.events.push(test_event.id.clone()); 510 damus.events.push(test_event2.id); 511 damus.events.push(test_event.id); 512 } 513 } 514 515 impl eframe::App for Damus { 516 /// Called by the frame work to save state before shutdown. 517 fn save(&mut self, _storage: &mut dyn eframe::Storage) { 518 //eframe::set_value(storage, eframe::APP_KEY, self); 519 } 520 521 /// Called each time the UI needs repainting, which may be many times per second. 522 /// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`. 523 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 524 update_damus(self, ctx); 525 render_damus(self, ctx); 526 } 527 } 528 529 pub const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; 530 531 pub const LOREM_IPSUM_LONG: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 532 533 Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec lobortis risus a elit. Etiam tempor. Ut ullamcorper, ligula eu tempor congue, eros est euismod turpis, id tincidunt sapien risus a quam. Maecenas fermentum consequat mi. Donec fermentum. Pellentesque malesuada nulla a mi. Duis sapien sem, aliquet nec, commodo eget, consequat quis, neque. Aliquam faucibus, elit ut dictum aliquet, felis nisl adipiscing sapien, sed malesuada diam lacus eget erat. Cras mollis scelerisque nunc. Nullam arcu. Aliquam consequat. Curabitur augue lorem, dapibus quis, laoreet et, pretium ac, nisi. Aenean magna nisl, mollis quis, molestie eu, feugiat in, orci. In hac habitasse platea dictumst.";