dominus

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

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.";