app.rs (18296B)
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]).pubkeys( 82 ["32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".into()].into(), 83 ); 84 85 let subid = "initial"; 86 for relay in &mut pool.relays { 87 if relay.url == relay_url { 88 relay.subscribe(subid.to_string(), vec![filter]); 89 return; 90 } 91 } 92 } 93 94 fn try_process_event(damus: &mut Damus) { 95 let m_ev = damus.pool.try_recv(); 96 if m_ev.is_none() { 97 return; 98 } 99 let ev = m_ev.unwrap(); 100 let relay = ev.relay.to_owned(); 101 102 match ev.event { 103 RelayEvent::Opened => send_initial_filters(&mut damus.pool, &relay), 104 // TODO: handle reconnects 105 RelayEvent::Closed => warn!("{} connection closed", &relay), 106 RelayEvent::Other(msg) => debug!("other event {:?}", &msg), 107 RelayEvent::Message(msg) => process_message(damus, &relay, msg), 108 } 109 //info!("recv {:?}", ev) 110 } 111 112 fn update_damus(damus: &mut Damus, ctx: &egui::Context) { 113 if damus.state == DamusState::Initializing { 114 damus.pool = RelayPool::new(); 115 relay_setup(&mut damus.pool, ctx); 116 damus.state = DamusState::Initialized; 117 } 118 119 try_process_event(damus); 120 } 121 122 fn process_metadata_event(damus: &mut Damus, ev: &Event) { 123 if let Some(prev_id) = damus.contacts.events.get(&ev.pubkey) { 124 if let Some(prev_ev) = damus.all_events.get(prev_id) { 125 // This profile event is older, ignore it 126 if prev_ev.created_at >= ev.created_at { 127 return; 128 } 129 } 130 } 131 132 let profile: core::result::Result<serde_json::Value, serde_json::Error> = 133 serde_json::from_str(&ev.content); 134 135 match profile { 136 Err(e) => { 137 debug!("Invalid profile data '{}': {:?}", &ev.content, &e); 138 } 139 Ok(v) if !v.is_object() => { 140 debug!("Invalid profile data: '{}'", &ev.content); 141 } 142 Ok(profile) => { 143 damus 144 .contacts 145 .events 146 .insert(ev.pubkey.clone(), ev.id.clone()); 147 148 damus 149 .contacts 150 .profiles 151 .insert(ev.pubkey.clone(), Profile::new(profile)); 152 } 153 } 154 } 155 156 fn process_event(damus: &mut Damus, _subid: &str, event: Event) { 157 if damus.all_events.get(&event.id).is_some() { 158 return; 159 } 160 161 if event.kind == 0 { 162 process_metadata_event(damus, &event); 163 } 164 165 let cloned_id = event.id.clone(); 166 damus.all_events.insert(cloned_id.clone(), event); 167 damus.events.push(cloned_id); 168 } 169 170 fn get_unknown_author_ids(damus: &Damus) -> Vec<Pubkey> { 171 let mut authors: HashSet<Pubkey> = HashSet::new(); 172 173 for (_evid, ev) in damus.all_events.iter() { 174 if !damus.contacts.profiles.contains_key(&ev.pubkey) { 175 authors.insert(ev.pubkey.clone()); 176 } 177 } 178 179 authors.into_iter().collect() 180 } 181 182 fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) { 183 if subid == "initial" { 184 let authors = get_unknown_author_ids(damus); 185 let n_authors = authors.len(); 186 let filter = Filter::new().authors(authors).kinds(vec![0]); 187 info!( 188 "Getting {} unknown author profiles from {}", 189 n_authors, relay_url 190 ); 191 let msg = ClientMessage::req("profiles".to_string(), vec![filter]); 192 damus.pool.send_to(&msg, relay_url); 193 } else if subid == "profiles" { 194 info!("Got profiles from {}", relay_url); 195 let msg = ClientMessage::close("profiles".to_string()); 196 damus.pool.send_to(&msg, relay_url); 197 } 198 } 199 200 fn process_message(damus: &mut Damus, relay: &str, msg: RelayMessage) { 201 match msg { 202 RelayMessage::Event(subid, ev) => process_event(damus, &subid, ev), 203 RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg), 204 RelayMessage::OK(cr) => info!("OK {:?}", cr), 205 RelayMessage::Eose(sid) => handle_eose(damus, &sid, relay), 206 } 207 } 208 209 fn render_damus(damus: &mut Damus, ctx: &Context) { 210 if is_mobile(ctx) { 211 render_damus_mobile(ctx, damus); 212 } else { 213 render_damus_desktop(ctx, damus); 214 } 215 } 216 217 impl Damus { 218 pub fn add_test_events(&mut self) { 219 add_test_events(self); 220 } 221 222 /// Called once before the first frame. 223 pub fn new() -> Self { 224 // This is also where you can customized the look at feel of egui using 225 // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. 226 227 // Load previous app state (if any). 228 // Note that you must enable the `persistence` feature for this to work. 229 //if let Some(storage) = cc.storage { 230 //return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); 231 //} 232 233 Default::default() 234 } 235 } 236 237 #[allow(clippy::needless_pass_by_value)] 238 fn parse_response(response: ehttp::Response) -> Result<RetainedImage> { 239 let content_type = response.content_type().unwrap_or_default(); 240 241 if content_type.starts_with("image/svg") { 242 Ok(RetainedImage::from_svg_bytes( 243 &response.url, 244 &response.bytes, 245 )?) 246 } else if content_type.starts_with("image/") { 247 Ok(RetainedImage::from_image_bytes( 248 &response.url, 249 &response.bytes, 250 )?) 251 } else { 252 Err(format!("Expected image, found content-type {:?}", content_type).into()) 253 } 254 } 255 256 fn fetch_img(ctx: &egui::Context, url: &str) -> Promise<Result<RetainedImage>> { 257 // TODO: fetch image from local cache 258 fetch_img_from_net(ctx, url) 259 } 260 261 fn fetch_img_from_net(ctx: &egui::Context, url: &str) -> Promise<Result<RetainedImage>> { 262 let (sender, promise) = Promise::new(); 263 let request = ehttp::Request::get(url); 264 let ctx = ctx.clone(); 265 ehttp::fetch(request, move |response| { 266 let image = response.map_err(Error::Generic).and_then(parse_response); 267 sender.send(image); // send the results back to the UI thread. 268 ctx.request_repaint(); 269 }); 270 promise 271 } 272 273 fn render_pfp(ui: &mut egui::Ui, img_cache: &mut ImageCache, url: &str) { 274 let urlkey = UrlKey::Orig(url).to_u64(); 275 let m_cached_promise = img_cache.get(&urlkey); 276 if m_cached_promise.is_none() { 277 debug!("urlkey: {:?}", &urlkey); 278 img_cache.insert(urlkey, fetch_img(ui.ctx(), url)); 279 } 280 281 let pfp_size = 50.0; 282 283 match img_cache[&urlkey].ready() { 284 None => { 285 ui.spinner(); // still loading 286 } 287 Some(Err(_err)) => { 288 let failed_key = UrlKey::Failed(url).to_u64(); 289 debug!( 290 "has failed promise? {}", 291 img_cache.contains_key(&failed_key) 292 ); 293 let m_failed_promise = img_cache.get_mut(&failed_key); 294 if m_failed_promise.is_none() { 295 warn!("failed key: {:?}", &failed_key); 296 let no_pfp = fetch_img(ui.ctx(), no_pfp_url()); 297 img_cache.insert(failed_key, no_pfp); 298 } 299 300 match img_cache[&failed_key].ready() { 301 None => { 302 ui.spinner(); // still loading 303 } 304 Some(Err(e)) => { 305 error!("Image load error: {:?}", e); 306 ui.label("❌"); 307 } 308 Some(Ok(img)) => { 309 pfp_image(ui, img, pfp_size); 310 } 311 } 312 } 313 Some(Ok(img)) => { 314 pfp_image(ui, img, pfp_size); 315 } 316 } 317 } 318 319 fn pfp_image(ui: &mut egui::Ui, img: &RetainedImage, size: f32) -> egui::Response { 320 img.show_max_size(ui, egui::vec2(size, size)) 321 } 322 323 fn render_username(ui: &mut egui::Ui, contacts: &Contacts, pk: &Pubkey) { 324 ui.horizontal(|ui| { 325 //ui.spacing_mut().item_spacing.x = 0.0; 326 if let Some(prof) = contacts.profiles.get(pk) { 327 if let Some(display_name) = prof.display_name() { 328 ui.label(display_name); 329 } 330 } 331 332 ui.label(&pk.as_ref()[0..8]); 333 ui.label(":"); 334 ui.label(&pk.as_ref()[64 - 8..]); 335 }); 336 } 337 338 fn no_pfp_url() -> &'static str { 339 "https://damus.io/img/no-profile.svg" 340 } 341 342 fn render_events(ui: &mut egui::Ui, damus: &mut Damus) { 343 for evid in &damus.events { 344 if !damus.all_events.contains_key(evid) { 345 return; 346 } 347 348 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 349 let ev = damus.all_events.get(evid).unwrap(); 350 351 match damus 352 .contacts 353 .profiles 354 .get(&ev.pubkey) 355 .and_then(|p| p.picture()) 356 { 357 // these have different lifetimes and types, 358 // so the calls must be separate 359 Some(pic) => render_pfp(ui, &mut damus.img_cache, pic), 360 None => render_pfp(ui, &mut damus.img_cache, no_pfp_url()), 361 } 362 363 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { 364 render_username(ui, &damus.contacts, &ev.pubkey); 365 366 ui.label(&ev.content); 367 }) 368 }); 369 370 ui.separator(); 371 } 372 } 373 374 fn timeline_view(ui: &mut egui::Ui, app: &mut Damus) { 375 ui.heading("Timeline"); 376 377 egui::ScrollArea::vertical() 378 .auto_shrink([false; 2]) 379 .show(ui, |ui| { 380 render_events(ui, app); 381 }); 382 } 383 384 fn render_panel(ctx: &egui::Context, app: &mut Damus) { 385 egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { 386 ui.horizontal_wrapped(|ui| { 387 ui.visuals_mut().button_frame = false; 388 egui::widgets::global_dark_light_mode_switch(ui); 389 390 if ui 391 .add(egui::Button::new("+").frame(false)) 392 .on_hover_text("Add Timeline") 393 .clicked() 394 { 395 app.n_panels += 1; 396 } 397 398 if app.n_panels != 1 399 && ui 400 .add(egui::Button::new("-").frame(false)) 401 .on_hover_text("Remove Timeline") 402 .clicked() 403 { 404 app.n_panels -= 1; 405 } 406 }); 407 }); 408 } 409 410 fn set_app_style(ui: &mut egui::Ui) { 411 if ui.visuals().dark_mode { 412 ui.visuals_mut().override_text_color = Some(egui::Color32::WHITE); 413 ui.visuals_mut().panel_fill = egui::Color32::from_rgb(30, 30, 30); 414 } else { 415 ui.visuals_mut().override_text_color = Some(egui::Color32::BLACK); 416 }; 417 } 418 419 fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { 420 let panel_width = ctx.input().screen_rect.width(); 421 egui::CentralPanel::default().show(ctx, |ui| { 422 set_app_style(ui); 423 timeline_panel(ui, app, panel_width, 0); 424 }); 425 } 426 427 fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { 428 render_panel(ctx, app); 429 430 let screen_size = ctx.input().screen_rect.width(); 431 let calc_panel_width = (screen_size / app.n_panels as f32) - 30.0; 432 let min_width = 300.0; 433 let need_scroll = calc_panel_width < min_width; 434 let panel_width = if need_scroll { 435 min_width 436 } else { 437 calc_panel_width 438 }; 439 440 if app.n_panels == 1 { 441 let panel_width = ctx.input().screen_rect.width(); 442 egui::CentralPanel::default().show(ctx, |ui| { 443 set_app_style(ui); 444 timeline_panel(ui, app, panel_width, 0); 445 }); 446 447 return; 448 } 449 450 egui::CentralPanel::default().show(ctx, |ui| { 451 set_app_style(ui); 452 egui::ScrollArea::horizontal() 453 .auto_shrink([false; 2]) 454 .show(ui, |ui| { 455 for ind in 0..app.n_panels { 456 timeline_panel(ui, app, panel_width, ind); 457 } 458 }); 459 }); 460 } 461 462 fn timeline_panel(ui: &mut egui::Ui, app: &mut Damus, panel_width: f32, ind: u32) { 463 egui::SidePanel::left(format!("l{}", ind)) 464 .resizable(false) 465 .max_width(panel_width) 466 .min_width(panel_width) 467 .show_inside(ui, |ui| { 468 timeline_view(ui, app); 469 }); 470 } 471 472 fn add_test_events(damus: &mut Damus) { 473 // Examples of how to create different panels and windows. 474 // Pick whichever suits you. 475 // Tip: a good default choice is to just keep the `CentralPanel`. 476 // For inspiration and more examples, go to https://emilk.github.io/egui 477 478 let test_event = Event { 479 id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string().into(), 480 pubkey: "f0a6ff7f70b872de6d82c8daec692a433fd23b6a49f25923c6f034df715cdeec".to_string().into(), 481 created_at: 1667781968, 482 kind: 1, 483 tags: vec![], 484 content: LOREM_IPSUM.into(), 485 sig: "af02c971015995f79e07fa98aaf98adeeb6a56d0005e451ee4e78844cff712a6bc0f2109f72a878975f162dcefde4173b65ebd4c3d3ab3b520a9dcac6acf092d".to_string(), 486 }; 487 488 let test_event2 = Event { 489 id: "6938e3cd841f3111dbdbd909f87fd52c3d1f1e4a07fd121d1243196e532811cb".to_string().into(), 490 pubkey: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".to_string().into(), 491 created_at: 1667781968, 492 kind: 1, 493 tags: vec![], 494 content: LOREM_IPSUM_LONG.into(), 495 sig: "af02c971015995f79e07fa98aaf98adeeb6a56d0005e451ee4e78844cff712a6bc0f2109f72a878975f162dcefde4173b65ebd4c3d3ab3b520a9dcac6acf092d".to_string(), 496 }; 497 498 damus 499 .all_events 500 .insert(test_event.id.clone(), test_event.clone()); 501 damus 502 .all_events 503 .insert(test_event2.id.clone(), test_event2.clone()); 504 505 if damus.events.is_empty() { 506 damus.events.push(test_event.id.clone()); 507 damus.events.push(test_event2.id.clone()); 508 damus.events.push(test_event.id.clone()); 509 damus.events.push(test_event2.id.clone()); 510 damus.events.push(test_event.id.clone()); 511 damus.events.push(test_event2.id.clone()); 512 damus.events.push(test_event.id.clone()); 513 damus.events.push(test_event2.id); 514 damus.events.push(test_event.id); 515 } 516 } 517 518 impl eframe::App for Damus { 519 /// Called by the frame work to save state before shutdown. 520 fn save(&mut self, _storage: &mut dyn eframe::Storage) { 521 //eframe::set_value(storage, eframe::APP_KEY, self); 522 } 523 524 /// Called each time the UI needs repainting, which may be many times per second. 525 /// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`. 526 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 527 update_damus(self, ctx); 528 render_damus(self, ctx); 529 } 530 } 531 532 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."; 533 534 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. 535 536 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.";