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:
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 {