domus

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

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