notedeck

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

commit 87f385b683be3881b6500ae87fdec565083e01d1
parent 1c16ddf9afccdf3646e9ab06507c140f01f7ed40
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 16 Feb 2024 15:42:21 -0800

profile picture image cache

coding from a plane so this is helping alot with PFPs

Diffstat:
M.gitignore | 1+
MMakefile | 3+++
Msrc/app.rs | 78+++++++++++++++++++++++++++++++++++++-----------------------------------------
Msrc/error.rs | 14+++++++++++++-
Msrc/images.rs | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Asrc/imgcache.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 1+
Msrc/time.rs | 2+-
8 files changed, 173 insertions(+), 50 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -2,6 +2,7 @@ .buildcmd target .git +cache /dist .direnv/ src/camera.rs diff --git a/Makefile b/Makefile @@ -1,4 +1,7 @@ +all: + cargo check + tags: fake find . -type d -name target -prune -o -type f -name '*.rs' -print | xargs ctags diff --git a/src/app.rs b/src/app.rs @@ -3,16 +3,18 @@ use crate::error::Error; use crate::fonts::{setup_fonts, NamedFontFamily}; use crate::frame_history::FrameHistory; use crate::images::fetch_img; +use crate::imgcache::ImageCache; use crate::notecache::NoteCache; use crate::timeline; use crate::ui::padding; use crate::Result; use egui::containers::scroll_area::ScrollBarVisibility; +use std::borrow::Cow; use egui::widgets::Spinner; use egui::{ - Color32, Context, Frame, Hyperlink, Image, Label, Margin, RichText, Style, TextureHandle, - Visuals, + Color32, Context, Frame, Hyperlink, Image, Label, Margin, RichText, Sense, Style, + TextureHandle, Vec2, Visuals, }; use enostr::{ClientMessage, Filter, Pubkey, RelayEvent, RelayMessage}; @@ -33,22 +35,6 @@ use enostr::RelayPool; const PURPLE: Color32 = Color32::from_rgb(0xCC, 0x43, 0xC5); const DARK_BG: Color32 = egui::Color32::from_rgb(40, 44, 52); -#[derive(Hash, Eq, PartialEq, Clone, Debug)] -enum UrlKey<'a> { - Orig(&'a str), - Failed(&'a str), -} - -impl UrlKey<'_> { - fn to_u64(&self) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.hash(&mut hasher); - hasher.finish() - } -} - -type ImageCache = HashMap<u64, Promise<Result<TextureHandle>>>; - #[derive(Debug, Eq, PartialEq, Clone)] pub enum DamusState { Initializing, @@ -454,12 +440,15 @@ impl Damus { vec![get_home_filter()] }; + let imgcache_dir = data_path.as_ref().join("cache/img"); + std::fs::create_dir_all(imgcache_dir.clone()); + let mut config = Config::new(); config.set_ingester_threads(2); Self { state: DamusState::Initializing, pool: RelayPool::new(), - img_cache: HashMap::new(), + img_cache: ImageCache::new(imgcache_dir), note_cache: HashMap::new(), initial_filter, n_panels: 1, @@ -477,48 +466,55 @@ impl Damus { } } -fn render_pfp(ui: &mut egui::Ui, img_cache: &mut ImageCache, url: &str) { +fn paint_circle(ui: &mut egui::Ui, size: f32) { + let (rect, _response) = ui.allocate_at_least(Vec2::new(size, size), Sense::hover()); + ui.painter() + .circle_filled(rect.center(), size / 2.0, ui.visuals().weak_text_color()); +} + +fn render_pfp(ui: &mut egui::Ui, damus: &mut Damus, url: &str) { #[cfg(feature = "profiling")] puffin::profile_function!(); - let urlkey = UrlKey::Orig(url).to_u64(); - let m_cached_promise = img_cache.get(&urlkey); + let ui_size = 30.0; + + // We will want to downsample these so it's not blurry on hi res displays + let img_size = (ui_size * 2.0) as u32; + + let m_cached_promise = damus.img_cache.map().get(url); if m_cached_promise.is_none() { - debug!("urlkey: {:?}", &urlkey); - img_cache.insert(urlkey, fetch_img(ui.ctx(), url)); + let res = fetch_img(&damus.img_cache, ui.ctx(), url, img_size); + damus.img_cache.map_mut().insert(url.to_owned(), res); } - let pfp_size = 40.0; - - match img_cache[&urlkey].ready() { + match damus.img_cache.map()[url].ready() { None => { - ui.add(Spinner::new().size(pfp_size)); + ui.add(Spinner::new().size(ui_size)); } + + // Failed to fetch profile! Some(Err(_err)) => { - let failed_key = UrlKey::Failed(url).to_u64(); - //debug!("has failed promise? {}", img_cache.contains_key(&failed_key)); - let m_failed_promise = img_cache.get_mut(&failed_key); + let m_failed_promise = damus.img_cache.map().get(url); if m_failed_promise.is_none() { - warn!("failed key: {:?}", &failed_key); - let no_pfp = fetch_img(ui.ctx(), no_pfp_url()); - img_cache.insert(failed_key, no_pfp); + let no_pfp = fetch_img(&damus.img_cache, ui.ctx(), no_pfp_url(), img_size); + damus.img_cache.map_mut().insert(url.to_owned(), no_pfp); } - match img_cache[&failed_key].ready() { + match damus.img_cache.map().get(url).unwrap().ready() { None => { - ui.add(Spinner::new().size(pfp_size)); + paint_circle(ui, ui_size); } Some(Err(_e)) => { //error!("Image load error: {:?}", e); - ui.label("❌"); + paint_circle(ui, ui_size); } Some(Ok(img)) => { - pfp_image(ui, img, pfp_size); + pfp_image(ui, img, ui_size); } } } Some(Ok(img)) => { - pfp_image(ui, img, pfp_size); + pfp_image(ui, img, ui_size); } } } @@ -744,8 +740,8 @@ fn render_note(ui: &mut egui::Ui, damus: &mut Damus, note_key: NoteKey) -> Resul { // these have different lifetimes and types, // so the calls must be separate - Some(pic) => render_pfp(ui, &mut damus.img_cache, pic), - None => render_pfp(ui, &mut damus.img_cache, no_pfp_url()), + Some(pic) => render_pfp(ui, damus, pic), + None => render_pfp(ui, damus, no_pfp_url()), } ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { diff --git a/src/error.rs b/src/error.rs @@ -1,8 +1,10 @@ -use std::fmt; +use std::{fmt, io}; #[derive(Debug)] pub enum Error { NoActiveSubscription, + LoadFailed, + Io(io::Error), Nostr(enostr::Error), Ndb(nostrdb::Error), Image(image::error::ImageError), @@ -15,10 +17,14 @@ impl fmt::Display for Error { Self::NoActiveSubscription => { write!(f, "subscription not active in timeline") } + Self::LoadFailed => { + write!(f, "load failed") + } Self::Nostr(e) => write!(f, "{e}"), Self::Ndb(e) => write!(f, "{e}"), Self::Image(e) => write!(f, "{e}"), Self::Generic(e) => write!(f, "{e}"), + Self::Io(e) => write!(f, "{e}"), } } } @@ -46,3 +52,9 @@ impl From<enostr::Error> for Error { Error::Nostr(err) } } + +impl From<io::Error> for Error { + fn from(err: io::Error) -> Self { + Error::Io(err) + } +} diff --git a/src/images.rs b/src/images.rs @@ -1,8 +1,16 @@ use crate::error::Error; use crate::result::Result; +use crate::imgcache::ImageCache; use egui::{Color32, ColorImage, SizeHint, TextureHandle}; use image::imageops::FilterType; use poll_promise::Promise; +use tokio::fs; +use std::path; +use std::collections::HashMap; + +//pub type ImageCacheKey = String; +//pub type ImageCacheValue = Promise<Result<TextureHandle>>; +//pub type ImageCache = HashMap<String, ImageCacheValue>; pub fn round_image(image: &mut ColorImage) { #[cfg(feature = "profiling")] @@ -78,12 +86,11 @@ fn process_pfp_bitmap(size: u32, image: &mut image::DynamicImage) -> ColorImage color_image } -fn parse_img_response(response: ehttp::Response) -> Result<ColorImage> { +fn parse_img_response(response: ehttp::Response, size: u32) -> Result<ColorImage> { #[cfg(feature = "profiling")] puffin::profile_function!(); let content_type = response.content_type().unwrap_or_default(); - let size: u32 = 100; if content_type.starts_with("image/svg") { #[cfg(feature = "profiling")] @@ -105,24 +112,70 @@ fn parse_img_response(response: ehttp::Response) -> Result<ColorImage> { } } -pub fn fetch_img(ctx: &egui::Context, url: &str) -> Promise<Result<TextureHandle>> { +fn fetch_img_from_disk(ctx: &egui::Context, url: &str, path: &path::Path) -> Promise<Result<TextureHandle>> { + let ctx = ctx.clone(); + let url = url.to_owned(); + let path = path.to_owned(); + Promise::spawn_async(async move { + let data = fs::read(path).await?; + let image_buffer = image::load_from_memory(&data)?; + + // TODO: remove unwrap here + let flat_samples = image_buffer.as_flat_samples_u8().unwrap(); + let img = ColorImage::from_rgba_unmultiplied( + [ + image_buffer.width() as usize, + image_buffer.height() as usize, + ], + flat_samples.as_slice(), + ); + + Ok(ctx.load_texture(&url, img, Default::default())) + }) +} + +pub fn fetch_img( + img_cache: &ImageCache, + ctx: &egui::Context, + url: &str, + size: u32, +) -> Promise<Result<TextureHandle>> { + let key = ImageCache::key(url); + let path = img_cache.cache_dir.join(&key); + + if path.exists() { + fetch_img_from_disk(ctx, url, &path) + } else { + fetch_img_from_net(&img_cache.cache_dir, ctx, url, size) + } + // TODO: fetch image from local cache - fetch_img_from_net(ctx, url) } -fn fetch_img_from_net(ctx: &egui::Context, url: &str) -> Promise<Result<TextureHandle>> { +fn fetch_img_from_net(cache_path: &path::Path, ctx: &egui::Context, url: &str, size: u32) -> Promise<Result<TextureHandle>> { let (sender, promise) = Promise::new(); let request = ehttp::Request::get(url); let ctx = ctx.clone(); let cloned_url = url.to_owned(); + let cache_path = cache_path.to_owned(); ehttp::fetch(request, move |response| { let handle = response .map_err(Error::Generic) - .and_then(parse_img_response) - .map(|img| ctx.load_texture(&cloned_url, img, Default::default())); + .and_then(|resp| parse_img_response(resp, size)) + .map(|img| { + let texture_handle = ctx.load_texture(&cloned_url, img.clone(), Default::default()); + + // write to disk + std::thread::spawn(move || { + ImageCache::write(&cache_path, &cloned_url, img) + }); + + texture_handle + }); sender.send(handle); // send the results back to the UI thread. ctx.request_repaint(); }); + promise } diff --git a/src/imgcache.rs b/src/imgcache.rs @@ -0,0 +1,57 @@ +use crate::{Error, Result}; +use egui::TextureHandle; +use poll_promise::Promise; + +use egui::ColorImage; +use hex; +use std::borrow::Cow; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::fs::File; +use std::hash::{Hash, Hasher}; +use std::io; +use std::path; +use tokio::fs; + +pub type ImageCacheValue = Promise<Result<TextureHandle>>; +pub type ImageCacheMap = HashMap<String, ImageCacheValue>; + +pub struct ImageCache { + pub cache_dir: path::PathBuf, + url_imgs: ImageCacheMap, +} + +impl ImageCache { + pub fn new(cache_dir: path::PathBuf) -> Self { + Self { + cache_dir, + url_imgs: HashMap::new(), + } + } + + pub fn write(cache_dir: &path::Path, url: &str, data: ColorImage) -> Result<()> { + let file_path = cache_dir.join(&Self::key(url)); + let file = File::options().write(true).create(true).open(file_path)?; + let encoder = image::codecs::webp::WebPEncoder::new_lossless(file); + encoder.encode( + data.as_raw(), + data.size[0] as u32, + data.size[1] as u32, + image::ColorType::Rgba8, + ); + + Ok(()) + } + + pub fn key(url: &str) -> String { + base32::encode(base32::Alphabet::Crockford, url.as_bytes()) + } + + pub fn map(&self) -> &ImageCacheMap { + &self.url_imgs + } + + pub fn map_mut(&mut self) -> &mut ImageCacheMap { + &mut self.url_imgs + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -7,6 +7,7 @@ mod abbrev; mod fonts; mod images; mod result; +mod imgcache; mod filter; mod ui; mod timecache; diff --git a/src/time.rs b/src/time.rs @@ -1,4 +1,4 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; /// Show a relative time string based on some timestamp pub fn time_ago_since(timestamp: u64) -> String {