diff --git a/src/main.rs b/src/main.rs index caec20bb..be0a00c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,9 @@ use std::ops::DerefMut; use std::{env, mem, thread}; use tracing_subscriber::filter::EnvFilter; +pub const AVATAR_SIZE: u32 = 48; +pub const AVATAR_SIZE_F32: f32 = 48.0; + fn main() -> Result<(), Error> { if env::var("RUST_LOG").is_err() { env::set_var("RUST_LOG", "info"); diff --git a/src/people.rs b/src/people.rs index 2002484d..c57f2588 100644 --- a/src/people.rs +++ b/src/people.rs @@ -1,19 +1,31 @@ use crate::db::DbPerson; use crate::error::Error; use crate::globals::GLOBALS; -use nostr_types::{Metadata, PublicKeyHex, Unixtime}; +use image::RgbaImage; +use nostr_types::{Metadata, PublicKeyHex, Unixtime, Url}; use std::cmp::Ordering; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tokio::task; pub struct People { people: HashMap, + + // We fetch (with Fetcher), process, and temporarily hold avatars + // until the UI next asks for them, at which point we remove them + // and hand them over. This way we can do the work that takes + // longer and the UI can do as little work as possible. + avatars_temp: HashMap, + avatars_pending_processing: HashSet, + avatars_failed: HashSet, } impl People { pub fn new() -> People { People { people: HashMap::new(), + avatars_temp: HashMap::new(), + avatars_pending_processing: HashSet::new(), + avatars_failed: HashSet::new(), } } @@ -196,6 +208,89 @@ impl People { v } + // If returns Err, means you're never going to get it so stop trying. + pub fn get_avatar(&mut self, pubkeyhex: &PublicKeyHex) -> Result, ()> { + // If we have it, hand it over (we won't need a copy anymore) + if let Some(th) = self.avatars_temp.remove(pubkeyhex) { + return Ok(Some(th)); + } + + // If it failed before, error out now + if self.avatars_failed.contains(pubkeyhex) { + return Err(()); + } + + // If it is pending processing, respond now + if self.avatars_pending_processing.contains(pubkeyhex) { + return Ok(None); + } + + // Get the person this is about + let person = match self.people.get(pubkeyhex) { + Some(person) => person, + None => { + return Err(()); + } + }; + + // Fail if they don't have a picture url + // FIXME: we could get metadata that sets this while we are running, so just failing for + // the duration of the client isn't quite right. But for now, retrying is taxing. + if person.picture.is_none() { + return Err(()); + } + + // FIXME: we could get metadata that sets this while we are running, so just failing for + // the duration of the client isn't quite right. But for now, retrying is taxing. + let url = Url::new(person.picture.as_ref().unwrap()); + if !url.is_valid() { + return Err(()); + } + + match GLOBALS.fetcher.try_get(url) { + Ok(None) => Ok(None), + Ok(Some(bytes)) => { + // Finish this later + let apubkeyhex = pubkeyhex.to_owned(); + tokio::spawn(async move { + let image = match image::load_from_memory(&bytes) { + // DynamicImage + Ok(di) => di, + Err(_) => { + let _ = GLOBALS + .people + .write() + .await + .avatars_failed + .insert(apubkeyhex.clone()); + return; + } + }; + let image = image.resize( + crate::AVATAR_SIZE, + crate::AVATAR_SIZE, + image::imageops::FilterType::Nearest, + ); // DynamicImage + let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) + + GLOBALS + .people + .write() + .await + .avatars_temp + .insert(apubkeyhex, image_buffer); + }); + self.avatars_pending_processing.insert(pubkeyhex.to_owned()); + Ok(None) + } + Err(e) => { + tracing::error!("{}", e); + self.avatars_failed.insert(pubkeyhex.to_owned()); + Err(()) + } + } + } + /// This is a 'just in case' the main code isn't keeping them in sync. pub async fn populate_new_people() -> Result<(), Error> { let sql = "INSERT or IGNORE INTO person (pubkey) SELECT DISTINCT pubkey FROM EVENT"; diff --git a/src/ui/feed.rs b/src/ui/feed.rs index cc884fc5..d1a6c1ae 100644 --- a/src/ui/feed.rs +++ b/src/ui/feed.rs @@ -244,10 +244,21 @@ fn render_post( } // Avatar first + let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &event.pubkey.into()) { + avatar + } else { + app.placeholder_avatar.clone() + }; if ui .add( - Image::new(&app.placeholder_avatar, Vec2 { x: 36.0, y: 36.0 }) - .sense(Sense::click()), + Image::new( + &avatar, + Vec2 { + x: crate::AVATAR_SIZE_F32, + y: crate::AVATAR_SIZE_F32, + }, + ) + .sense(Sense::click()), ) .clicked() { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ce4e9bb5..a105c627 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,6 +16,7 @@ use crate::settings::Settings; use eframe::{egui, IconData, Theme}; use egui::{ColorImage, Context, ImageData, RichText, TextureHandle, TextureOptions, Ui}; use nostr_types::{Id, PublicKey, PublicKeyHex}; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; use zeroize::Zeroize; @@ -82,6 +83,8 @@ struct GossipUi { person_view_pubkey: Option, person_view_person: Option, person_view_name: Option, + avatars: HashMap, + failed_avatars: HashSet, } impl Drop for GossipUi { @@ -152,6 +155,8 @@ impl GossipUi { person_view_pubkey: None, person_view_person: None, person_view_name: None, + avatars: HashMap::new(), + failed_avatars: HashSet::new(), } } } @@ -249,4 +254,39 @@ impl GossipUi { } }); } + + pub fn try_get_avatar( + &mut self, + ctx: &Context, + pubkeyhex: &PublicKeyHex, + ) -> Option { + // Do not keep retrying if failed + if self.failed_avatars.contains(pubkeyhex) { + return None; + } + + if let Some(th) = self.avatars.get(pubkeyhex) { + return Some(th.to_owned()); + } + + match GLOBALS.people.blocking_write().get_avatar(pubkeyhex) { + Err(_) => { + self.failed_avatars.insert(pubkeyhex.to_owned()); + None + } + Ok(Some(rgbaimage)) => { + let size = [rgbaimage.width() as _, rgbaimage.height() as _]; + let pixels = rgbaimage.as_flat_samples(); + let texture_handle = ctx.load_texture( + pubkeyhex.0.clone(), + ImageData::Color(ColorImage::from_rgba_unmultiplied(size, pixels.as_slice())), + TextureOptions::default(), + ); + self.avatars + .insert(pubkeyhex.to_owned(), texture_handle.clone()); + Some(texture_handle) + } + Ok(None) => None, + } + } } diff --git a/src/ui/people.rs b/src/ui/people.rs index 61023368..77e288e6 100644 --- a/src/ui/people.rs +++ b/src/ui/people.rs @@ -129,10 +129,21 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Fra ui.horizontal(|ui| { // Avatar first + let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &person.pubkey) { + avatar + } else { + app.placeholder_avatar.clone() + }; if ui .add( - Image::new(&app.placeholder_avatar, Vec2 { x: 36.0, y: 36.0 }) - .sense(Sense::click()), + Image::new( + &avatar, + Vec2 { + x: crate::AVATAR_SIZE_F32, + y: crate::AVATAR_SIZE_F32, + }, + ) + .sense(Sense::click()), ) .clicked() { @@ -157,9 +168,9 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Fra { ui.label("ERROR"); } else { - let pubkeyhex = app.person_view_pubkey.as_ref().unwrap(); - let person = app.person_view_person.as_ref().unwrap(); - let name = app.person_view_name.as_ref().unwrap(); + let pubkeyhex = app.person_view_pubkey.as_ref().unwrap().to_owned(); + let person = app.person_view_person.as_ref().unwrap().to_owned(); + let name = app.person_view_name.as_ref().unwrap().to_owned(); ui.add_space(24.0); @@ -167,11 +178,16 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Fra ui.horizontal(|ui| { // Avatar first - ui.image(&app.placeholder_avatar, Vec2 { x: 36.0, y: 36.0 }); + let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &pubkeyhex) { + avatar + } else { + app.placeholder_avatar.clone() + }; + ui.image(&avatar, Vec2 { x: 36.0, y: 36.0 }); ui.vertical(|ui| { - ui.label(RichText::new(GossipUi::hex_pubkey_short(&person.pubkey)).weak()); - GossipUi::render_person_name_line(ui, Some(person)); + ui.label(RichText::new(GossipUi::hex_pubkey_short(&pubkeyhex)).weak()); + GossipUi::render_person_name_line(ui, Some(&person)); }); }); @@ -186,11 +202,11 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Fra #[allow(clippy::collapsible_else_if)] if person.followed == 0 { if ui.button("FOLLOW").clicked() { - GLOBALS.people.blocking_write().follow(pubkeyhex, true); + GLOBALS.people.blocking_write().follow(&pubkeyhex, true); } } else { if ui.button("UNFOLLOW").clicked() { - GLOBALS.people.blocking_write().follow(pubkeyhex, false); + GLOBALS.people.blocking_write().follow(&pubkeyhex, false); } } }