notedeck

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

commit a524bbd5a4601e8d7f2813871634caff476735b1
parent 615e27c1de35d89e144898403007224c6f8f3707
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 26 Feb 2025 12:17:34 -0800

Merge GIF support by kernel

kernelkind (20):
      use bincode
      update ehttp to 0.5.0
      introduce UrlMimes
      use mime_guess
      add SupportedMimeType
      rename ImageCache -> MediaCache
      Use TexturedImage in MediaCache
      render MediaCache method
      move MediaCache rendering to render_media_cache call
      support multiple media cache files
      introduce Images
      render Images method
      migrate to using Images instead of MediaCache directly
      URL mime hosted completeness
      handle UrlCache io
      introduce gif animation
      handle gif state
      integrate gifs
      use SupportedMimeType for media_upload
      render gif in PostView

Diffstat:
MCargo.lock | 32++++++++++++++++----------------
MCargo.toml | 4+++-
Mcrates/notedeck/Cargo.toml | 3+++
Mcrates/notedeck/src/app.rs | 8++++----
Mcrates/notedeck/src/context.rs | 4++--
Mcrates/notedeck/src/imgcache.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/notedeck/src/lib.rs | 7++++++-
Acrates/notedeck/src/urls.rs | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/accounts/mod.rs | 4++--
Mcrates/notedeck_columns/src/app.rs | 6++++--
Acrates/notedeck_columns/src/gif.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/images.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/notedeck_columns/src/lib.rs | 1+
Mcrates/notedeck_columns/src/media_upload.rs | 64+++++++++++-----------------------------------------------------
Mcrates/notedeck_columns/src/timeline/route.rs | 6+++---
Mcrates/notedeck_columns/src/ui/accounts.rs | 8++++----
Mcrates/notedeck_columns/src/ui/add_column.rs | 6+++---
Mcrates/notedeck_columns/src/ui/column/header.rs | 6+++---
Acrates/notedeck_columns/src/ui/images.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/ui/mention.rs | 9+++++----
Mcrates/notedeck_columns/src/ui/mod.rs | 1+
Mcrates/notedeck_columns/src/ui/note/contents.rs | 94++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/notedeck_columns/src/ui/note/mod.rs | 6+++---
Mcrates/notedeck_columns/src/ui/note/post.rs | 97++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/notedeck_columns/src/ui/note/quote_repost.rs | 6+++---
Mcrates/notedeck_columns/src/ui/note/reply.rs | 6+++---
Mcrates/notedeck_columns/src/ui/note/reply_description.rs | 6+++---
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 6+++---
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 6+++---
Mcrates/notedeck_columns/src/ui/profile/picture.rs | 66++++++++++++++++++++++++++++--------------------------------------
Mcrates/notedeck_columns/src/ui/profile/preview.rs | 10+++++-----
Mcrates/notedeck_columns/src/ui/search_results.rs | 8++++----
Mcrates/notedeck_columns/src/ui/side_panel.rs | 6+++---
Mcrates/notedeck_columns/src/ui/thread.rs | 6+++---
Mcrates/notedeck_columns/src/ui/timeline.rs | 13+++++++------
35 files changed, 1068 insertions(+), 347 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1269,7 +1269,7 @@ checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1" dependencies = [ "ahash", "egui", - "ehttp 0.5.0", + "ehttp", "enum-map", "image", "log", @@ -1328,19 +1328,6 @@ dependencies = [ [[package]] name = "ehttp" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80b69a6f9168b96c0ae04763bec27a8b06b34343c334dd2703a4ec21f0f5e110" -dependencies = [ - "js-sys", - "ureq", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "ehttp" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59a81c221a1e4dad06cb9c9deb19aea1193a5eea084e8cd42d869068132bf876" @@ -2384,7 +2371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -2521,6 +2508,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] name = "mime_guess2" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2747,12 +2744,15 @@ name = "notedeck" version = "0.3.1" dependencies = [ "base32", + "bincode", "dirs", "eframe", "egui", + "ehttp", "enostr", "hex", "image", + "mime_guess", "nostrdb", "poll-promise", "puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)", @@ -2808,7 +2808,7 @@ dependencies = [ "egui_nav", "egui_tabs", "egui_virtual_list", - "ehttp 0.2.0", + "ehttp", "enostr", "hex", "image", diff --git a/Cargo.toml b/Cargo.toml @@ -21,7 +21,7 @@ egui_extras = { version = "0.29.1", features = ["all_loaders"] } egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "ac7d663307b76634757024b438dd4b899790da99" } egui_tabs = "0.2.0" egui_virtual_list = "0.5.0" -ehttp = "0.2.0" +ehttp = "0.5.0" enostr = { path = "crates/enostr" } ewebsock = { version = "0.2.0", features = ["tls"] } hex = "0.4.3" @@ -56,6 +56,8 @@ urlencoding = "2.1.3" uuid = { version = "1.10.0", features = ["v4"] } security-framework = "2.11.0" sha2 = "0.10.8" +bincode = "1.3.3" +mime_guess = "2.0.5" [patch.crates-io] egui = { git = "https://github.com/damus-io/egui", branch = "update_layouter_0.29.1" } diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml @@ -25,6 +25,9 @@ thiserror = { workspace = true } puffin = { workspace = true, optional = true } puffin_egui = { workspace = true, optional = true } sha2 = { workspace = true } +bincode = { workspace = true } +ehttp = {workspace = true } +mime_guess = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -1,6 +1,6 @@ use crate::persist::{AppSizeHandler, ZoomHandler}; use crate::{ - Accounts, AppContext, Args, DataPath, DataPathType, Directory, FileKeyStorage, ImageCache, + Accounts, AppContext, Args, DataPath, DataPathType, Directory, FileKeyStorage, Images, KeyStorageType, NoteCache, RelayDebugView, ThemeHandler, UnknownIds, }; use egui::ThemePreference; @@ -19,7 +19,7 @@ pub trait App { /// Main notedeck app framework pub struct Notedeck { ndb: Ndb, - img_cache: ImageCache, + img_cache: Images, unknown_ids: UnknownIds, pool: RelayPool, note_cache: NoteCache, @@ -129,7 +129,7 @@ impl Notedeck { let _ = std::fs::create_dir_all(&dbpath_str); - let img_cache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir()); + let img_cache_dir = path.path(DataPathType::Cache); let _ = std::fs::create_dir_all(img_cache_dir.clone()); let map_size = if cfg!(target_os = "windows") { @@ -184,7 +184,7 @@ impl Notedeck { } } - let img_cache = ImageCache::new(img_cache_dir); + let img_cache = Images::new(img_cache_dir); let note_cache = NoteCache::default(); let unknown_ids = UnknownIds::default(); let zoom = ZoomHandler::new(&path); diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs @@ -1,4 +1,4 @@ -use crate::{Accounts, Args, DataPath, ImageCache, NoteCache, ThemeHandler, UnknownIds}; +use crate::{Accounts, Args, DataPath, Images, NoteCache, ThemeHandler, UnknownIds}; use enostr::RelayPool; use nostrdb::Ndb; @@ -7,7 +7,7 @@ use nostrdb::Ndb; pub struct AppContext<'a> { pub ndb: &'a mut Ndb, - pub img_cache: &'a mut ImageCache, + pub img_cache: &'a mut Images, pub unknown_ids: &'a mut UnknownIds, pub pool: &'a mut RelayPool, pub note_cache: &'a mut NoteCache, diff --git a/crates/notedeck/src/imgcache.rs b/crates/notedeck/src/imgcache.rs @@ -1,11 +1,15 @@ +use crate::urls::{UrlCache, UrlMimes}; use crate::Result; use egui::TextureHandle; +use image::{Delay, Frame}; use poll_promise::Promise; use egui::ColorImage; use std::collections::HashMap; use std::fs::{create_dir_all, File}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant, SystemTime}; use hex::ToHex; use sha2::Digest; @@ -13,15 +17,56 @@ use std::path; use std::path::PathBuf; use tracing::warn; -pub type ImageCacheValue = Promise<Result<TextureHandle>>; -pub type ImageCacheMap = HashMap<String, ImageCacheValue>; +pub type MediaCacheValue = Promise<Result<TexturedImage>>; +pub type MediaCacheMap = HashMap<String, MediaCacheValue>; -pub struct ImageCache { +pub enum TexturedImage { + Static(TextureHandle), + Animated(Animation), +} + +pub struct Animation { + pub first_frame: TextureFrame, + pub other_frames: Vec<TextureFrame>, + pub receiver: Option<Receiver<TextureFrame>>, +} + +impl Animation { + pub fn get_frame(&self, index: usize) -> Option<&TextureFrame> { + if index == 0 { + Some(&self.first_frame) + } else { + self.other_frames.get(index - 1) + } + } + + pub fn num_frames(&self) -> usize { + self.other_frames.len() + 1 + } +} + +pub struct TextureFrame { + pub delay: Duration, + pub texture: TextureHandle, +} + +pub struct ImageFrame { + pub delay: Duration, + pub image: ColorImage, +} + +pub struct MediaCache { pub cache_dir: path::PathBuf, - url_imgs: ImageCacheMap, + url_imgs: MediaCacheMap, } -impl ImageCache { +#[derive(Clone)] +pub enum MediaCacheType { + Image, + Gif, +} + +impl MediaCache { pub fn new(cache_dir: path::PathBuf) -> Self { Self { cache_dir, @@ -29,35 +74,15 @@ impl ImageCache { } } - pub fn rel_dir() -> &'static str { - "img" - } - - /* - pub fn fetch(image: &str) -> Result<Image> { - let m_cached_promise = img_cache.map().get(image); - if m_cached_promise.is_none() { - let res = crate::images::fetch_img( - img_cache, - ui.ctx(), - &image, - ImageType::Content(width.round() as u32, height.round() as u32), - ); - img_cache.map_mut().insert(image.to_owned(), res); + pub fn rel_dir(cache_type: MediaCacheType) -> &'static str { + match cache_type { + MediaCacheType::Image => "img", + MediaCacheType::Gif => "gif", } } - */ pub fn write(cache_dir: &path::Path, url: &str, data: ColorImage) -> Result<()> { - let file_path = cache_dir.join(Self::key(url)); - if let Some(p) = file_path.parent() { - create_dir_all(p)?; - } - let file = File::options() - .write(true) - .create(true) - .truncate(true) - .open(file_path)?; + let file = Self::create_file(cache_dir, url)?; let encoder = image::codecs::webp::WebPEncoder::new_lossless(file); encoder.encode( @@ -70,6 +95,33 @@ impl ImageCache { Ok(()) } + fn create_file(cache_dir: &path::Path, url: &str) -> Result<File> { + let file_path = cache_dir.join(Self::key(url)); + if let Some(p) = file_path.parent() { + create_dir_all(p)?; + } + Ok(File::options() + .write(true) + .create(true) + .truncate(true) + .open(file_path)?) + } + + pub fn write_gif(cache_dir: &path::Path, url: &str, data: Vec<ImageFrame>) -> Result<()> { + let file = Self::create_file(cache_dir, url)?; + + let mut encoder = image::codecs::gif::GifEncoder::new(file); + for img in data { + let buf = color_image_to_rgba(img.image); + let frame = Frame::from_parts(buf, 0, 0, Delay::from_saturating_duration(img.delay)); + if let Err(e) = encoder.encode_frame(frame) { + tracing::error!("problem encoding frame: {e}"); + } + } + + Ok(()) + } + pub fn key(url: &str) -> String { let k: String = sha2::Sha256::digest(url.as_bytes()).encode_hex(); PathBuf::from(&k[0..2]) @@ -118,11 +170,58 @@ impl ImageCache { Ok(()) } - pub fn map(&self) -> &ImageCacheMap { + pub fn map(&self) -> &MediaCacheMap { &self.url_imgs } - pub fn map_mut(&mut self) -> &mut ImageCacheMap { + pub fn map_mut(&mut self) -> &mut MediaCacheMap { &mut self.url_imgs } } + +fn color_image_to_rgba(color_image: ColorImage) -> image::RgbaImage { + let width = color_image.width() as u32; + let height = color_image.height() as u32; + + let rgba_pixels: Vec<u8> = color_image + .pixels + .iter() + .flat_map(|color| color.to_array()) // Convert Color32 to `[u8; 4]` + .collect(); + + image::RgbaImage::from_raw(width, height, rgba_pixels) + .expect("Failed to create RgbaImage from ColorImage") +} + +pub struct Images { + pub static_imgs: MediaCache, + pub gifs: MediaCache, + pub urls: UrlMimes, + pub gif_states: GifStateMap, +} + +impl Images { + /// path to directory to place [`MediaCache`]s + pub fn new(path: path::PathBuf) -> Self { + Self { + static_imgs: MediaCache::new(path.join(MediaCache::rel_dir(MediaCacheType::Image))), + gifs: MediaCache::new(path.join(MediaCache::rel_dir(MediaCacheType::Gif))), + urls: UrlMimes::new(UrlCache::new(path.join(UrlCache::rel_dir()))), + gif_states: Default::default(), + } + } + + pub fn migrate_v0(&self) -> Result<()> { + self.static_imgs.migrate_v0()?; + self.gifs.migrate_v0() + } +} + +pub type GifStateMap = HashMap<String, GifState>; + +pub struct GifState { + pub last_frame_rendered: Instant, + pub last_frame_duration: Duration, + pub next_frame_time: Option<SystemTime>, + pub last_frame_index: usize, +} diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -21,6 +21,7 @@ mod timecache; mod timed_serializer; pub mod ui; mod unknowns; +mod urls; mod user_account; pub use accounts::{AccountData, Accounts, AccountsAction, AddAccountAction, SwitchAccountAction}; @@ -30,7 +31,10 @@ pub use context::AppContext; pub use error::{Error, FilterError}; pub use filter::{FilterState, FilterStates, UnifiedSubscription}; pub use fonts::NamedFontFamily; -pub use imgcache::ImageCache; +pub use imgcache::{ + Animation, GifState, GifStateMap, ImageFrame, Images, MediaCache, MediaCacheType, + MediaCacheValue, TextureFrame, TexturedImage, +}; pub use muted::{MuteFun, Muted}; pub use note::{NoteRef, RootIdError, RootNoteId, RootNoteIdBuf}; pub use notecache::{CachedNote, NoteCache}; @@ -46,6 +50,7 @@ pub use theme::ColorTheme; pub use time::time_ago_since; pub use timecache::TimeCached; pub use unknowns::{get_unknown_note_ids, NoteRefsUnkIdAction, SingleUnkIdAction, UnknownIds}; +pub use urls::{supported_mime_hosted_at_url, SupportedMimeType, UrlMimes}; pub use user_account::UserAccount; // export libs diff --git a/crates/notedeck/src/urls.rs b/crates/notedeck/src/urls.rs @@ -0,0 +1,298 @@ +use std::{ + collections::HashMap, + fs::File, + io::{Read, Write}, + path::PathBuf, + sync::{Arc, RwLock}, + time::{Duration, SystemTime}, +}; + +use egui::TextBuffer; +use poll_promise::Promise; +use url::Url; + +use crate::{Error, MediaCacheType}; + +const FILE_NAME: &str = "urls.bin"; +const SAVE_INTERVAL: Duration = Duration::from_secs(60); + +type UrlsToMime = HashMap<String, String>; + +/// caches mime type for a URL. saves to disk on interval [`SAVE_INTERVAL`] +pub struct UrlCache { + last_saved: SystemTime, + path: PathBuf, + cache: Arc<RwLock<UrlsToMime>>, + from_disk_promise: Option<Promise<Option<UrlsToMime>>>, +} + +impl UrlCache { + pub fn rel_dir() -> &'static str { + FILE_NAME + } + + pub fn new(path: PathBuf) -> Self { + Self { + last_saved: SystemTime::now(), + path: path.clone(), + cache: Default::default(), + from_disk_promise: Some(read_from_disk(path)), + } + } + + pub fn get_type(&self, url: &str) -> Option<String> { + self.cache.read().ok()?.get(url).cloned() + } + + pub fn set_type(&mut self, url: String, mime_type: String) { + if let Ok(mut locked_cache) = self.cache.write() { + locked_cache.insert(url, mime_type); + } + } + + pub fn handle_io(&mut self) { + if let Some(promise) = &mut self.from_disk_promise { + if let Some(maybe_cache) = promise.ready_mut() { + if let Some(cache) = maybe_cache.take() { + merge_cache(self.cache.clone(), cache) + } + + self.from_disk_promise = None; + } + } + + if let Ok(cur_duration) = SystemTime::now().duration_since(self.last_saved) { + if cur_duration >= SAVE_INTERVAL { + save_to_disk(self.path.clone(), self.cache.clone()); + self.last_saved = SystemTime::now(); + } + } + } +} + +fn merge_cache(cur_cache: Arc<RwLock<UrlsToMime>>, from_disk: UrlsToMime) { + std::thread::spawn(move || { + if let Ok(mut locked_cache) = cur_cache.write() { + locked_cache.extend(from_disk); + } + }); +} + +fn read_from_disk(path: PathBuf) -> Promise<Option<UrlsToMime>> { + let (sender, promise) = Promise::new(); + + std::thread::spawn(move || { + let result: Result<UrlsToMime, Error> = (|| { + let mut file = File::open(path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + let data: UrlsToMime = + bincode::deserialize(&buffer).map_err(|e| Error::Generic(e.to_string()))?; + Ok(data) + })(); + + match result { + Ok(data) => sender.send(Some(data)), + Err(e) => { + tracing::error!("problem deserializing UrlMimes: {e}"); + sender.send(None) + } + } + }); + + promise +} + +fn save_to_disk(path: PathBuf, cache: Arc<RwLock<UrlsToMime>>) { + std::thread::spawn(move || { + let result: Result<(), Error> = (|| { + if let Ok(cache) = cache.read() { + let cache = &*cache; + let encoded = + bincode::serialize(cache).map_err(|e| Error::Generic(e.to_string()))?; + let mut file = File::create(&path)?; + file.write_all(&encoded)?; + file.sync_all()?; + tracing::info!("Saved UrlCache to disk."); + Ok(()) + } else { + Err(Error::Generic( + "Could not read UrlMimes behind RwLock".to_owned(), + )) + } + })(); + + if let Err(e) = result { + tracing::error!("Failed to save UrlMimes: {}", e); + } + }); +} + +fn ehttp_get_mime_type(url: &str, sender: poll_promise::Sender<MimeResult>) { + let request = ehttp::Request::head(url); + + let url = url.to_owned(); + ehttp::fetch( + request, + move |response: Result<ehttp::Response, String>| match response { + Ok(resp) => { + if let Some(content_type) = resp.headers.get("content-type") { + sender.send(MimeResult::Ok(extract_mime_type(content_type).to_owned())); + } else { + sender.send(MimeResult::Err(HttpError::MissingHeader)); + tracing::error!("Content-Type header not found for {url}"); + } + } + Err(err) => { + sender.send(MimeResult::Err(HttpError::HttpFailure)); + tracing::error!("failed ehttp for UrlMimes: {err}"); + } + }, + ); +} + +#[derive(Debug)] +enum HttpError { + HttpFailure, + MissingHeader, +} + +type MimeResult = Result<String, HttpError>; + +fn extract_mime_type(content_type: &str) -> &str { + content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() +} + +pub struct UrlMimes { + pub cache: UrlCache, + in_flight: HashMap<String, Promise<MimeResult>>, +} + +impl UrlMimes { + pub fn new(url_cache: UrlCache) -> Self { + Self { + cache: url_cache, + in_flight: Default::default(), + } + } + + pub fn get(&mut self, url: &str) -> Option<String> { + if let Some(mime_type) = self.cache.get_type(url) { + Some(mime_type) + } else if let Some(promise) = self.in_flight.get_mut(url) { + if let Some(mime_result) = promise.ready_mut() { + match mime_result { + Ok(mime_type) => { + let mime_type = mime_type.take(); + self.cache.set_type(url.to_owned(), mime_type.clone()); + self.in_flight.remove(url); + Some(mime_type) + } + Err(HttpError::HttpFailure) => { + // allow retrying + self.in_flight.remove(url); + None + } + Err(HttpError::MissingHeader) => { + // response was malformed, don't retry + None + } + } + } else { + None + } + } else { + let (sender, promise) = Promise::new(); + ehttp_get_mime_type(url, sender); + self.in_flight.insert(url.to_owned(), promise); + None + } + } +} + +#[derive(Debug)] +pub struct SupportedMimeType { + mime: mime_guess::Mime, +} + +impl SupportedMimeType { + pub fn from_extension(extension: &str) -> Result<Self, Error> { + if let Some(mime) = mime_guess::from_ext(extension) + .first() + .filter(is_mime_supported) + { + Ok(Self { mime }) + } else { + Err(Error::Generic("Unsupported mime type".to_owned())) + } + } + + pub fn from_mime(mime: mime_guess::mime::Mime) -> Result<Self, Error> { + if is_mime_supported(&mime) { + Ok(Self { mime }) + } else { + Err(Error::Generic("Unsupported mime type".to_owned())) + } + } + + pub fn to_mime(&self) -> &str { + self.mime.essence_str() + } + + pub fn to_cache_type(&self) -> MediaCacheType { + if self.mime == mime_guess::mime::IMAGE_GIF { + MediaCacheType::Gif + } else { + MediaCacheType::Image + } + } +} + +fn is_mime_supported(mime: &mime_guess::Mime) -> bool { + mime.type_() == mime_guess::mime::IMAGE +} + +fn url_has_supported_mime(url: &str) -> MimeHostedAtUrl { + if let Ok(url) = Url::parse(url) { + if let Some(path) = url.path_segments() { + if let Some(file_name) = path.last() { + if let Some(ext) = std::path::Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + { + if let Ok(supported) = SupportedMimeType::from_extension(ext) { + return MimeHostedAtUrl::Yes(supported.to_cache_type()); + } else { + return MimeHostedAtUrl::No; + } + } + } + } + } + MimeHostedAtUrl::Maybe +} + +pub fn supported_mime_hosted_at_url(urls: &mut UrlMimes, url: &str) -> Option<MediaCacheType> { + match url_has_supported_mime(url) { + MimeHostedAtUrl::Yes(cache_type) => Some(cache_type), + MimeHostedAtUrl::Maybe => urls + .get(url) + .and_then(|s| s.parse::<mime_guess::mime::Mime>().ok()) + .and_then(|mime: mime_guess::mime::Mime| { + SupportedMimeType::from_mime(mime) + .ok() + .map(|s| s.to_cache_type()) + }), + MimeHostedAtUrl::No => None, + } +} + +enum MimeHostedAtUrl { + Yes(MediaCacheType), + Maybe, + No, +} diff --git a/crates/notedeck_columns/src/accounts/mod.rs b/crates/notedeck_columns/src/accounts/mod.rs @@ -2,7 +2,7 @@ use enostr::FullKeypair; use nostrdb::Ndb; use notedeck::{ - Accounts, AccountsAction, AddAccountAction, ImageCache, SingleUnkIdAction, SwitchAccountAction, + Accounts, AccountsAction, AddAccountAction, Images, SingleUnkIdAction, SwitchAccountAction, }; use crate::app::get_active_columns_mut; @@ -27,7 +27,7 @@ pub fn render_accounts_route( ui: &mut egui::Ui, ndb: &Ndb, col: usize, - img_cache: &mut ImageCache, + img_cache: &mut Images, accounts: &mut Accounts, decks: &mut DecksCache, login_state: &mut AcquireKeyState, diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -13,7 +13,7 @@ use crate::{ Result, }; -use notedeck::{Accounts, AppContext, DataPath, DataPathType, FilterState, ImageCache, UnknownIds}; +use notedeck::{Accounts, AppContext, DataPath, DataPathType, FilterState, UnknownIds}; use enostr::{ClientMessage, Keypair, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use uuid::Uuid; @@ -172,6 +172,8 @@ fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) { } fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) { + app_ctx.img_cache.urls.cache.handle_io(); + match damus.state { DamusState::Initializing => { damus.state = DamusState::Initialized; @@ -464,7 +466,7 @@ impl Damus { let decks_cache = DecksCache::default(); let path = DataPath::new(&data_path); - let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir()); + let imgcache_dir = path.path(DataPathType::Cache); let _ = std::fs::create_dir_all(imgcache_dir.clone()); let debug = true; diff --git a/crates/notedeck_columns/src/gif.rs b/crates/notedeck_columns/src/gif.rs @@ -0,0 +1,122 @@ +use std::{ + sync::mpsc::TryRecvError, + time::{Instant, SystemTime}, +}; + +use egui::TextureHandle; +use notedeck::{GifState, GifStateMap, TexturedImage}; + +pub struct LatextTexture<'a> { + pub texture: &'a TextureHandle, + pub request_next_repaint: Option<SystemTime>, +} + +/// This is necessary because other repaint calls can effectively steal our repaint request. +/// So we must keep on requesting to repaint at our desired time to ensure our repaint goes through. +/// See [`egui::Context::request_repaint_after`] +pub fn handle_repaint<'a>(ui: &egui::Ui, latest: LatextTexture<'a>) -> &'a TextureHandle { + if let Some(repaint) = latest.request_next_repaint { + if let Ok(dur) = repaint.duration_since(SystemTime::now()) { + ui.ctx().request_repaint_after(dur); + } + } + latest.texture +} + +#[must_use = "caller should pass the return value to `gif::handle_repaint`"] +pub fn retrieve_latest_texture<'a>( + url: &str, + gifs: &'a mut GifStateMap, + cached_image: &'a mut TexturedImage, +) -> LatextTexture<'a> { + match cached_image { + TexturedImage::Static(texture) => LatextTexture { + texture, + request_next_repaint: None, + }, + TexturedImage::Animated(animation) => { + if let Some(receiver) = &animation.receiver { + loop { + match receiver.try_recv() { + Ok(frame) => animation.other_frames.push(frame), + Err(TryRecvError::Empty) => { + break; + } + Err(TryRecvError::Disconnected) => { + animation.receiver = None; + break; + } + } + } + } + + let now = Instant::now(); + let (texture, maybe_new_state, request_next_repaint) = match gifs.get(url) { + Some(prev_state) => { + let should_advance = + now - prev_state.last_frame_rendered >= prev_state.last_frame_duration; + + if should_advance { + let maybe_new_index = if animation.receiver.is_some() + || prev_state.last_frame_index < animation.num_frames() - 1 + { + prev_state.last_frame_index + 1 + } else { + 0 + }; + + match animation.get_frame(maybe_new_index) { + Some(frame) => { + let next_frame_time = SystemTime::now().checked_add(frame.delay); + ( + &frame.texture, + Some(GifState { + last_frame_rendered: now, + last_frame_duration: frame.delay, + next_frame_time, + last_frame_index: maybe_new_index, + }), + next_frame_time, + ) + } + None => { + let (tex, state) = + match animation.get_frame(prev_state.last_frame_index) { + Some(frame) => (&frame.texture, None), + None => (&animation.first_frame.texture, None), + }; + + (tex, state, prev_state.next_frame_time) + } + } + } else { + let (tex, state) = match animation.get_frame(prev_state.last_frame_index) { + Some(frame) => (&frame.texture, None), + None => (&animation.first_frame.texture, None), + }; + (tex, state, prev_state.next_frame_time) + } + } + None => ( + &animation.first_frame.texture, + Some(GifState { + last_frame_rendered: now, + last_frame_duration: animation.first_frame.delay, + next_frame_time: None, + last_frame_index: 0, + }), + None, + ), + }; + + if let Some(new_state) = maybe_new_state { + gifs.insert(url.to_owned(), new_state); + } + + LatextTexture { + texture, + request_next_repaint, + } + } + } +} diff --git a/crates/notedeck_columns/src/images.rs b/crates/notedeck_columns/src/images.rs @@ -1,16 +1,28 @@ -use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint, TextureHandle}; +use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; +use image::codecs::gif::GifDecoder; use image::imageops::FilterType; -use notedeck::ImageCache; +use image::AnimationDecoder; +use image::DynamicImage; +use image::FlatSamples; +use image::Frame; +use notedeck::Animation; +use notedeck::ImageFrame; +use notedeck::MediaCache; +use notedeck::MediaCacheType; use notedeck::Result; +use notedeck::TextureFrame; +use notedeck::TexturedImage; use poll_promise::Promise; +use std::collections::VecDeque; +use std::io::Cursor; use std::path; use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::mpsc::SyncSender; +use std::thread; +use std::time::Duration; use tokio::fs; -//pub type ImageCacheKey = String; -//pub type ImageCacheValue = Promise<Result<TextureHandle>>; -//pub type ImageCache = HashMap<String, ImageCacheValue>; - // NOTE(jb55): chatgpt wrote this because I was too dumb to pub fn aspect_fill( ui: &mut egui::Ui, @@ -102,7 +114,7 @@ pub fn round_image(image: &mut ColorImage) { } } -fn process_pfp_bitmap(imgtyp: ImageType, image: &mut image::DynamicImage) -> ColorImage { +fn process_pfp_bitmap(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { #[cfg(feature = "profiling")] puffin::profile_function!(); @@ -125,10 +137,10 @@ fn process_pfp_bitmap(imgtyp: ImageType, image: &mut image::DynamicImage) -> Col if image.width() > smaller { let excess = image.width() - smaller; - *image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); + image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); } else if image.height() > smaller { let excess = image.height() - smaller; - *image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); + image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); } let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) @@ -166,8 +178,8 @@ fn parse_img_response(response: ehttp::Response, imgtyp: ImageType) -> Result<Co } else if content_type.starts_with("image/") { #[cfg(feature = "profiling")] puffin::profile_scope!("load_from_memory"); - let mut dyn_image = image::load_from_memory(&response.bytes)?; - Ok(process_pfp_bitmap(imgtyp, &mut dyn_image)) + let dyn_image = image::load_from_memory(&response.bytes)?; + Ok(process_pfp_bitmap(imgtyp, dyn_image)) } else { Err(format!("Expected image, found content-type {:?}", content_type).into()) } @@ -177,28 +189,162 @@ fn fetch_img_from_disk( ctx: &egui::Context, url: &str, path: &path::Path, -) -> Promise<Result<TextureHandle>> { + cache_type: MediaCacheType, +) -> Promise<Result<TexturedImage>> { 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).map_err(notedeck::Error::Image)?; - - // 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())) + match cache_type { + MediaCacheType::Image => { + let data = fs::read(path).await?; + let image_buffer = + image::load_from_memory(&data).map_err(notedeck::Error::Image)?; + + let img = buffer_to_color_image( + image_buffer.as_flat_samples_u8(), + image_buffer.width(), + image_buffer.height(), + ); + Ok(TexturedImage::Static(ctx.load_texture( + &url, + img, + Default::default(), + ))) + } + MediaCacheType::Gif => { + let gif_bytes = fs::read(path.clone()).await?; // Read entire file into a Vec<u8> + generate_gif(ctx, url, &path, gif_bytes, false, |i| { + buffer_to_color_image(i.as_flat_samples_u8(), i.width(), i.height()) + }) + } + } }) } +fn generate_gif( + ctx: egui::Context, + url: String, + path: &path::Path, + data: Vec<u8>, + write_to_disk: bool, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, +) -> Result<TexturedImage> { + let decoder = { + let reader = Cursor::new(data.as_slice()); + GifDecoder::new(reader)? + }; + let (tex_input, tex_output) = mpsc::sync_channel(4); + let (maybe_encoder_input, maybe_encoder_output) = if write_to_disk { + let (inp, out) = mpsc::sync_channel(4); + (Some(inp), Some(out)) + } else { + (None, None) + }; + + let mut frames: VecDeque<Frame> = decoder + .into_frames() + .collect::<std::result::Result<VecDeque<_>, image::ImageError>>() + .map_err(|e| notedeck::Error::Generic(e.to_string()))?; + + let first_frame = frames.pop_front().map(|frame| { + generate_animation_frame( + &ctx, + &url, + 0, + frame, + maybe_encoder_input.as_ref(), + process_to_egui, + ) + }); + + let cur_url = url.clone(); + thread::spawn(move || { + for (index, frame) in frames.into_iter().enumerate() { + let texture_frame = generate_animation_frame( + &ctx, + &cur_url, + index, + frame, + maybe_encoder_input.as_ref(), + process_to_egui, + ); + + if tex_input.send(texture_frame).is_err() { + tracing::error!("AnimationTextureFrame mpsc stopped abruptly"); + break; + } + } + }); + + if let Some(encoder_output) = maybe_encoder_output { + let path = path.to_owned(); + + thread::spawn(move || { + let mut imgs = Vec::new(); + while let Ok(img) = encoder_output.recv() { + imgs.push(img); + } + + if let Err(e) = MediaCache::write_gif(&path, &url, imgs) { + tracing::error!("Could not write gif to disk: {e}"); + } + }); + } + + first_frame.map_or_else( + || { + Err(notedeck::Error::Generic( + "first frame not found for gif".to_owned(), + )) + }, + |first_frame| { + Ok(TexturedImage::Animated(Animation { + other_frames: Default::default(), + receiver: Some(tex_output), + first_frame, + })) + }, + ) +} + +fn generate_animation_frame( + ctx: &egui::Context, + url: &str, + index: usize, + frame: image::Frame, + maybe_encoder_input: Option<&SyncSender<ImageFrame>>, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + 'static, +) -> TextureFrame { + let delay = Duration::from(frame.delay()); + let img = DynamicImage::ImageRgba8(frame.into_buffer()); + let color_img = process_to_egui(img); + + if let Some(sender) = maybe_encoder_input { + if let Err(e) = sender.send(ImageFrame { + delay, + image: color_img.clone(), + }) { + tracing::error!("ImageFrame mpsc unexpectedly closed: {e}"); + } + } + + TextureFrame { + delay, + texture: ctx.load_texture(format!("{}{}", url, index), color_img, Default::default()), + } +} + +fn buffer_to_color_image( + samples: Option<FlatSamples<&[u8]>>, + width: u32, + height: u32, +) -> ColorImage { + // TODO(jb55): remove unwrap here + let flat_samples = samples.unwrap(); + ColorImage::from_rgba_unmultiplied([width as usize, height as usize], flat_samples.as_slice()) +} + pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>> { std::fs::read(path).map_err(|e| notedeck::Error::Generic(e.to_string())) } @@ -213,18 +359,19 @@ pub enum ImageType { } pub fn fetch_img( - img_cache: &ImageCache, + img_cache: &MediaCache, ctx: &egui::Context, url: &str, imgtyp: ImageType, -) -> Promise<Result<TextureHandle>> { - let key = ImageCache::key(url); + cache_type: MediaCacheType, +) -> Promise<Result<TexturedImage>> { + let key = MediaCache::key(url); let path = img_cache.cache_dir.join(key); if path.exists() { - fetch_img_from_disk(ctx, url, &path) + fetch_img_from_disk(ctx, url, &path, cache_type) } else { - fetch_img_from_net(&img_cache.cache_dir, ctx, url, imgtyp) + fetch_img_from_net(&img_cache.cache_dir, ctx, url, imgtyp, cache_type) } // TODO: fetch image from local cache @@ -235,24 +382,43 @@ fn fetch_img_from_net( ctx: &egui::Context, url: &str, imgtyp: ImageType, -) -> Promise<Result<TextureHandle>> { + cache_type: MediaCacheType, +) -> Promise<Result<TexturedImage>> { 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(notedeck::Error::Generic) - .and_then(|resp| parse_img_response(resp, imgtyp)) - .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 - }); + let handle = response.map_err(notedeck::Error::Generic).and_then(|resp| { + match cache_type { + MediaCacheType::Image => { + let img = parse_img_response(resp, imgtyp); + img.map(|img| { + let texture_handle = + ctx.load_texture(&cloned_url, img.clone(), Default::default()); + + // write to disk + std::thread::spawn(move || { + MediaCache::write(&cache_path, &cloned_url, img) + }); + + TexturedImage::Static(texture_handle) + }) + } + MediaCacheType::Gif => { + let gif_bytes = resp.bytes; + generate_gif( + ctx.clone(), + cloned_url, + &cache_path, + gif_bytes, + true, + move |img| process_pfp_bitmap(imgtyp, img), + ) + } + } + }); sender.send(handle); // send the results back to the UI thread. ctx.request_repaint(); diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -15,6 +15,7 @@ mod deck_state; mod decks; mod draft; mod frame_history; +mod gif; mod images; mod key_parsing; pub mod login_manager; diff --git a/crates/notedeck_columns/src/media_upload.rs b/crates/notedeck_columns/src/media_upload.rs @@ -1,8 +1,9 @@ -use std::{collections::BTreeMap, path::PathBuf}; +use std::path::PathBuf; use base64::{prelude::BASE64_URL_SAFE, Engine}; use ehttp::Request; use nostrdb::{Note, NoteBuilder}; +use notedeck::SupportedMimeType; use poll_promise::Promise; use sha2::{Digest, Sha256}; use url::Url; @@ -104,15 +105,13 @@ fn create_nip96_request( body.extend(file_contents); body.extend(format!("\r\n--{}--\r\n", boundary).as_bytes()); - let headers = { - let mut map = BTreeMap::new(); - map.insert( - "Content-Type".to_owned(), - format!("multipart/form-data; boundary={boundary}"), - ); - map.insert("Authorization".to_owned(), format!("Nostr {nip98_base64}")); - map - }; + let headers = ehttp::Headers::new(&[ + ( + "Content-Type", + format!("multipart/form-data; boundary={boundary}").as_str(), + ), + ("Authorization", format!("Nostr {nip98_base64}").as_str()), + ]); Request { method: "POST".to_string(), @@ -234,13 +233,13 @@ fn find_nip94_ev_in_json(json: String) -> Result<Nip94Event, Error> { pub struct MediaPath { full_path: PathBuf, file_name: String, - media_type: SupportedMediaType, + media_type: SupportedMimeType, } impl MediaPath { pub fn new(path: PathBuf) -> Result<Self, Error> { if let Some(ex) = path.extension().and_then(|f| f.to_str()) { - let media_type = SupportedMediaType::from_extension(ex)?; + let media_type = SupportedMimeType::from_extension(ex)?; let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -261,47 +260,6 @@ impl MediaPath { } } -#[derive(Debug)] -pub enum SupportedMediaType { - Png, - Jpeg, - Webp, -} - -impl SupportedMediaType { - pub fn mime_extension(&self) -> &str { - match &self { - SupportedMediaType::Png => "png", - SupportedMediaType::Jpeg => "jpeg", - SupportedMediaType::Webp => "webp", - } - } - - pub fn to_mime(&self) -> String { - format!("{}/{}", self.mime_type(), self.mime_extension()) - } - - fn mime_type(&self) -> String { - match &self { - SupportedMediaType::Png | SupportedMediaType::Jpeg | SupportedMediaType::Webp => { - "image" - } - } - .to_string() - } - - fn from_extension(ext: &str) -> Result<Self, Error> { - match ext.to_lowercase().as_str() { - "jpeg" | "jpg" => Ok(SupportedMediaType::Jpeg), - "png" => Ok(SupportedMediaType::Png), - "webp" => Ok(SupportedMediaType::Webp), - unsupported_type => Err(Error::Generic(format!( - "{unsupported_type} is not a valid file type to upload." - ))), - } - } -} - #[derive(Clone, Debug, serde::Deserialize)] pub struct Nip94Event { pub url: String, diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -7,12 +7,12 @@ use crate::{ use enostr::Pubkey; use nostrdb::Ndb; -use notedeck::{Accounts, ImageCache, MuteFun, NoteCache, UnknownIds}; +use notedeck::{Accounts, Images, MuteFun, NoteCache, UnknownIds}; #[allow(clippy::too_many_arguments)] pub fn render_timeline_route( ndb: &Ndb, - img_cache: &mut ImageCache, + img_cache: &mut Images, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, timeline_cache: &mut TimelineCache, @@ -102,7 +102,7 @@ pub fn render_profile_route( accounts: &Accounts, ndb: &Ndb, timeline_cache: &mut TimelineCache, - img_cache: &mut ImageCache, + img_cache: &mut Images, note_cache: &mut NoteCache, unknown_ids: &mut UnknownIds, col: usize, diff --git a/crates/notedeck_columns/src/ui/accounts.rs b/crates/notedeck_columns/src/ui/accounts.rs @@ -3,14 +3,14 @@ use egui::{ Align, Button, Frame, Image, InnerResponse, Layout, RichText, ScrollArea, Ui, UiBuilder, Vec2, }; use nostrdb::{Ndb, Transaction}; -use notedeck::{Accounts, ImageCache}; +use notedeck::{Accounts, Images}; use super::profile::preview::SimpleProfilePreview; pub struct AccountsView<'a> { ndb: &'a Ndb, accounts: &'a Accounts, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, } #[derive(Clone, Debug)] @@ -27,7 +27,7 @@ enum ProfilePreviewAction { } impl<'a> AccountsView<'a> { - pub fn new(ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut ImageCache) -> Self { + pub fn new(ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut Images) -> Self { AccountsView { ndb, accounts, @@ -54,7 +54,7 @@ impl<'a> AccountsView<'a> { ui: &mut Ui, accounts: &Accounts, ndb: &Ndb, - img_cache: &mut ImageCache, + img_cache: &mut Images, ) -> Option<AccountsViewResponse> { let mut return_op: Option<AccountsViewResponse> = None; ui.allocate_ui_with_layout( diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -17,7 +17,7 @@ use crate::{ Damus, }; -use notedeck::{AppContext, ImageCache, NotedeckTextStyle, UserAccount}; +use notedeck::{AppContext, Images, NotedeckTextStyle, UserAccount}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use super::{anim::AnimationHelper, padding, ProfilePreview}; @@ -163,7 +163,7 @@ impl AddColumnOption { pub struct AddColumnView<'a> { key_state_map: &'a mut HashMap<Id, AcquireKeyState>, ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, cur_account: Option<&'a UserAccount>, } @@ -171,7 +171,7 @@ impl<'a> AddColumnView<'a> { pub fn new( key_state_map: &'a mut HashMap<Id, AcquireKeyState>, ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, cur_account: Option<&'a UserAccount>, ) -> Self { Self { diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -16,11 +16,11 @@ use egui::Margin; use egui::{RichText, Stroke, UiBuilder}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{ImageCache, NotedeckTextStyle}; +use notedeck::{Images, NotedeckTextStyle}; pub struct NavTitle<'a> { ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, columns: &'a Columns, routes: &'a [Route], col_id: usize, @@ -29,7 +29,7 @@ pub struct NavTitle<'a> { impl<'a> NavTitle<'a> { pub fn new( ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, columns: &'a Columns, routes: &'a [Route], col_id: usize, diff --git a/crates/notedeck_columns/src/ui/images.rs b/crates/notedeck_columns/src/ui/images.rs @@ -0,0 +1,75 @@ +use notedeck::{GifStateMap, Images, MediaCache, MediaCacheType, TexturedImage}; + +use crate::images::ImageType; + +use super::ProfilePic; + +#[allow(clippy::too_many_arguments)] +pub fn render_images( + ui: &mut egui::Ui, + images: &mut Images, + url: &str, + img_type: ImageType, + cache_type: MediaCacheType, + show_waiting: impl FnOnce(&mut egui::Ui), + show_error: impl FnOnce(&mut egui::Ui, String), + show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), +) -> egui::Response { + let cache = match cache_type { + MediaCacheType::Image => &mut images.static_imgs, + MediaCacheType::Gif => &mut images.gifs, + }; + + render_media_cache( + ui, + cache, + &mut images.gif_states, + url, + img_type, + cache_type, + show_waiting, + show_error, + show_success, + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_media_cache( + ui: &mut egui::Ui, + cache: &mut MediaCache, + gif_states: &mut GifStateMap, + url: &str, + img_type: ImageType, + cache_type: MediaCacheType, + show_waiting: impl FnOnce(&mut egui::Ui), + show_error: impl FnOnce(&mut egui::Ui, String), + show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), +) -> egui::Response { + let m_cached_promise = cache.map().get(url); + + if m_cached_promise.is_none() { + let res = crate::images::fetch_img(cache, ui.ctx(), url, img_type, cache_type.clone()); + cache.map_mut().insert(url.to_owned(), res); + } + + egui::Frame::none() + .show(ui, |ui| { + match cache.map_mut().get_mut(url).and_then(|p| p.ready_mut()) { + None => show_waiting(ui), + Some(Err(err)) => { + let err = err.to_string(); + let no_pfp = crate::images::fetch_img( + cache, + ui.ctx(), + ProfilePic::no_pfp_url(), + ImageType::Profile(128), + cache_type, + ); + cache.map_mut().insert(url.to_owned(), no_pfp); + show_error(ui, err) + } + Some(Ok(renderable_media)) => show_success(ui, url, renderable_media, gif_states), + } + }) + .response +} diff --git a/crates/notedeck_columns/src/ui/mention.rs b/crates/notedeck_columns/src/ui/mention.rs @@ -3,11 +3,11 @@ use crate::{actionbar::NoteAction, profile::get_display_name, timeline::Timeline use egui::Sense; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::ImageCache; +use notedeck::Images; pub struct Mention<'a> { ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, txn: &'a Transaction, pk: &'a [u8; 32], selectable: bool, @@ -17,7 +17,7 @@ pub struct Mention<'a> { impl<'a> Mention<'a> { pub fn new( ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, txn: &'a Transaction, pk: &'a [u8; 32], ) -> Self { @@ -62,9 +62,10 @@ impl egui::Widget for Mention<'_> { } } +#[allow(clippy::too_many_arguments)] fn mention_ui( ndb: &Ndb, - img_cache: &mut ImageCache, + img_cache: &mut Images, txn: &Transaction, pk: &[u8; 32], ui: &mut egui::Ui, diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -5,6 +5,7 @@ pub mod anim; pub mod column; pub mod configure_deck; pub mod edit_deck; +pub mod images; pub mod mention; pub mod note; pub mod preview; diff --git a/crates/notedeck_columns/src/ui/note/contents.rs b/crates/notedeck_columns/src/ui/note/contents.rs @@ -1,18 +1,19 @@ +use crate::gif::{handle_repaint, retrieve_latest_texture}; +use crate::ui::images::render_images; use crate::ui::{ self, note::{NoteOptions, NoteResponse}, - ProfilePic, }; use crate::{actionbar::NoteAction, images::ImageType, timeline::TimelineKind}; use egui::{Color32, Hyperlink, Image, RichText}; use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; use tracing::warn; -use notedeck::{ImageCache, NoteCache}; +use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteCache}; pub struct NoteContents<'a> { ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note_cache: &'a mut NoteCache, txn: &'a Transaction, note: &'a Note<'a>, @@ -22,9 +23,10 @@ pub struct NoteContents<'a> { } impl<'a> NoteContents<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( ndb: &'a Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note_cache: &'a mut NoteCache, txn: &'a Transaction, note: &'a Note, @@ -72,7 +74,7 @@ pub fn render_note_preview( ui: &mut egui::Ui, ndb: &Ndb, note_cache: &mut NoteCache, - img_cache: &mut ImageCache, + img_cache: &mut Images, txn: &Transaction, id: &[u8; 32], parent: NoteKey, @@ -126,15 +128,11 @@ pub fn render_note_preview( .inner } -fn is_image_link(url: &str) -> bool { - url.ends_with("png") || url.ends_with("jpg") || url.ends_with("jpeg") -} - #[allow(clippy::too_many_arguments)] fn render_note_contents( ui: &mut egui::Ui, ndb: &Ndb, - img_cache: &mut ImageCache, + img_cache: &mut Images, note_cache: &mut NoteCache, txn: &Transaction, note: &Note, @@ -145,7 +143,7 @@ fn render_note_contents( puffin::profile_function!(); let selectable = options.has_selectable_text(); - let mut images: Vec<String> = vec![]; + let mut images: Vec<(String, MediaCacheType)> = vec![]; let mut note_action: Option<NoteAction> = None; let mut inline_note: Option<(&[u8; 32], &str)> = None; let hide_media = options.has_hide_media(); @@ -211,9 +209,14 @@ fn render_note_contents( } BlockType::Url => { - let lower_url = block.as_str().to_lowercase(); - if !hide_media && is_image_link(&lower_url) { - images.push(block.as_str().to_string()); + if !hide_media { + let url = block.as_str().to_string(); + + if let Some(cache_type) = + supported_mime_hosted_at_url(&mut img_cache.urls, &url) + { + images.push((url, cache_type)); + } } else { #[cfg(feature = "profiling")] puffin::profile_scope!("url contents"); @@ -279,8 +282,8 @@ fn rot13(input: &str) -> String { fn image_carousel( ui: &mut egui::Ui, - img_cache: &mut ImageCache, - images: Vec<String>, + img_cache: &mut Images, + images: Vec<(String, MediaCacheType)>, carousel_id: egui::Id, ) { // let's make sure everything is within our area @@ -294,56 +297,39 @@ fn image_carousel( .id_salt(carousel_id) .show(ui, |ui| { ui.horizontal(|ui| { - for image in images { - // If the cache is empty, initiate the fetch - let m_cached_promise = img_cache.map().get(&image); - if m_cached_promise.is_none() { - let res = crate::images::fetch_img( - img_cache, - ui.ctx(), - &image, - ImageType::Content(width.round() as u32, height.round() as u32), - ); - img_cache.map_mut().insert(image.to_owned(), res); - } - - // What is the state of the fetch? - match img_cache.map()[&image].ready() { - // Still waiting - None => { + for (image, cache_type) in images { + render_images( + ui, + img_cache, + &image, + ImageType::Content(width.round() as u32, height.round() as u32), + cache_type, + |ui| { ui.allocate_space(egui::vec2(spinsz, spinsz)); - //ui.add(egui::Spinner::new().size(spinsz)); - } - // Failed to fetch image! - Some(Err(_err)) => { - // FIXME - use content-specific error instead - let no_pfp = crate::images::fetch_img( - img_cache, - ui.ctx(), - ProfilePic::no_pfp_url(), - ImageType::Profile(128), - ); - img_cache.map_mut().insert(image.to_owned(), no_pfp); - // spin until next pass + }, + |ui, _| { ui.allocate_space(egui::vec2(spinsz, spinsz)); - //ui.add(egui::Spinner::new().size(spinsz)); - } - // Use the previously resolved image - Some(Ok(img)) => { + }, + |ui, url, renderable_media, gifs| { + let texture = handle_repaint( + ui, + retrieve_latest_texture(&image, gifs, renderable_media), + ); let img_resp = ui.add( - Image::new(img) + Image::new(texture) .max_height(height) .rounding(5.0) .fit_to_original_size(1.0), ); + img_resp.context_menu(|ui| { if ui.button("Copy Link").clicked() { - ui.ctx().copy_text(image); + ui.ctx().copy_text(url.to_owned()); ui.close_menu(); } }); - } - } + }, + ); } }) .response diff --git a/crates/notedeck_columns/src/ui/note/mod.rs b/crates/notedeck_columns/src/ui/note/mod.rs @@ -25,14 +25,14 @@ use egui::emath::{pos2, Vec2}; use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; use enostr::{NoteId, Pubkey}; use nostrdb::{Ndb, Note, NoteKey, Transaction}; -use notedeck::{CachedNote, ImageCache, NoteCache, NotedeckTextStyle}; +use notedeck::{CachedNote, Images, NoteCache, NotedeckTextStyle}; use super::profile::preview::one_line_display_name_widget; pub struct NoteView<'a> { ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, parent: Option<NoteKey>, note: &'a nostrdb::Note<'a>, flags: NoteOptions, @@ -74,7 +74,7 @@ impl<'a> NoteView<'a> { pub fn new( ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note: &'a nostrdb::Note<'a>, mut flags: NoteOptions, ) -> Self { diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,8 +1,9 @@ use crate::draft::{Draft, Drafts, MentionHint}; -use crate::images::fetch_img; +use crate::gif::{handle_repaint, retrieve_latest_texture}; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::profile::get_display_name; +use crate::ui::images::render_images; use crate::ui::search_results::SearchResultsView; use crate::ui::{self, note::NoteOptions, Preview, PreviewConfig}; use crate::Result; @@ -13,7 +14,7 @@ use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer}; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; -use notedeck::{ImageCache, NoteCache}; +use notedeck::{supported_mime_hosted_at_url, Images, NoteCache}; use tracing::error; use super::contents::render_note_preview; @@ -22,7 +23,7 @@ pub struct PostView<'a> { ndb: &'a Ndb, draft: &'a mut Draft, post_type: PostType, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note_cache: &'a mut NoteCache, poster: FilledKeypair<'a>, id_source: Option<egui::Id>, @@ -88,7 +89,7 @@ impl<'a> PostView<'a> { ndb: &'a Ndb, draft: &'a mut Draft, post_type: PostType, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note_cache: &'a mut NoteCache, poster: FilledKeypair<'a>, inner_rect: egui::Rect, @@ -384,49 +385,59 @@ impl<'a> PostView<'a> { } else { (300, 300) }; - let m_cached_promise = self.img_cache.map().get(&media.url); - if m_cached_promise.is_none() { - let promise = fetch_img( + + if let Some(cache_type) = + supported_mime_hosted_at_url(&mut self.img_cache.urls, &media.url) + { + render_images( + ui, self.img_cache, - ui.ctx(), &media.url, crate::images::ImageType::Content(width, height), + cache_type, + |ui| { + ui.spinner(); + }, + |_, e| { + self.draft.upload_errors.push(e.to_string()); + error!("{e}"); + }, + |ui, url, renderable_media, gifs| { + let media_size = vec2(width as f32, height as f32); + let max_size = vec2(300.0, 300.0); + let size = if media_size.x > max_size.x || media_size.y > max_size.y { + max_size + } else { + media_size + }; + + let texture_handle = handle_repaint( + ui, + retrieve_latest_texture(url, gifs, renderable_media), + ); + let img_resp = ui.add( + egui::Image::new(texture_handle) + .max_size(size) + .rounding(12.0), + ); + + let remove_button_rect = { + let top_left = img_resp.rect.left_top(); + let spacing = 13.0; + let center = Pos2::new(top_left.x + spacing, top_left.y + spacing); + egui::Rect::from_center_size(center, egui::vec2(26.0, 26.0)) + }; + if show_remove_upload_button(ui, remove_button_rect).clicked() { + to_remove.push(i); + } + ui.advance_cursor_after_rect(img_resp.rect); + }, ); - self.img_cache - .map_mut() - .insert(media.url.to_owned(), promise); - } - - match self.img_cache.map()[&media.url].ready() { - Some(Ok(texture)) => { - let media_size = vec2(width as f32, height as f32); - let max_size = vec2(300.0, 300.0); - let size = if media_size.x > max_size.x || media_size.y > max_size.y { - max_size - } else { - media_size - }; - - let img_resp = ui.add(egui::Image::new(texture).max_size(size).rounding(12.0)); - - let remove_button_rect = { - let top_left = img_resp.rect.left_top(); - let spacing = 13.0; - let center = Pos2::new(top_left.x + spacing, top_left.y + spacing); - egui::Rect::from_center_size(center, egui::vec2(26.0, 26.0)) - }; - if show_remove_upload_button(ui, remove_button_rect).clicked() { - to_remove.push(i); - } - ui.advance_cursor_after_rect(img_resp.rect); - } - Some(Err(e)) => { - self.draft.upload_errors.push(e.to_string()); - error!("{e}"); - } - None => { - ui.spinner(); - } + } else { + self.draft + .upload_errors + .push("Uploaded media is not supported.".to_owned()); + error!("Unsupported mime type at url: {}", &media.url); } } to_remove.reverse(); diff --git a/crates/notedeck_columns/src/ui/note/quote_repost.rs b/crates/notedeck_columns/src/ui/note/quote_repost.rs @@ -1,6 +1,6 @@ use enostr::{FilledKeypair, NoteId}; use nostrdb::Ndb; -use notedeck::{ImageCache, NoteCache}; +use notedeck::{Images, NoteCache}; use crate::{ draft::Draft, @@ -13,7 +13,7 @@ pub struct QuoteRepostView<'a> { ndb: &'a Ndb, poster: FilledKeypair<'a>, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, draft: &'a mut Draft, quoting_note: &'a nostrdb::Note<'a>, id_source: Option<egui::Id>, @@ -27,7 +27,7 @@ impl<'a> QuoteRepostView<'a> { ndb: &'a Ndb, poster: FilledKeypair<'a>, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, draft: &'a mut Draft, quoting_note: &'a nostrdb::Note<'a>, inner_rect: egui::Rect, diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs @@ -4,13 +4,13 @@ use crate::ui::note::{NoteOptions, PostResponse, PostType}; use enostr::{FilledKeypair, NoteId}; use nostrdb::Ndb; -use notedeck::{ImageCache, NoteCache}; +use notedeck::{Images, NoteCache}; pub struct PostReplyView<'a> { ndb: &'a Ndb, poster: FilledKeypair<'a>, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, draft: &'a mut Draft, note: &'a nostrdb::Note<'a>, id_source: Option<egui::Id>, @@ -25,7 +25,7 @@ impl<'a> PostReplyView<'a> { poster: FilledKeypair<'a>, draft: &'a mut Draft, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note: &'a nostrdb::Note<'a>, inner_rect: egui::Rect, note_options: NoteOptions, diff --git a/crates/notedeck_columns/src/ui/note/reply_description.rs b/crates/notedeck_columns/src/ui/note/reply_description.rs @@ -4,7 +4,7 @@ use crate::{ }; use egui::{Label, RichText, Sense}; use nostrdb::{Ndb, Note, NoteReply, Transaction}; -use notedeck::{ImageCache, NoteCache}; +use notedeck::{Images, NoteCache}; #[must_use = "Please handle the resulting note action"] pub fn reply_desc( @@ -12,7 +12,7 @@ pub fn reply_desc( txn: &Transaction, note_reply: &NoteReply, ndb: &Ndb, - img_cache: &mut ImageCache, + img_cache: &mut Images, note_cache: &mut NoteCache, note_options: NoteOptions, ) -> Option<NoteAction> { @@ -29,7 +29,7 @@ pub fn reply_desc( // note link renderer helper let note_link = |ui: &mut egui::Ui, note_cache: &mut NoteCache, - img_cache: &mut ImageCache, + img_cache: &mut Images, text: &str, note: &Note<'_>| { let r = ui.add( diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -1,7 +1,7 @@ use core::f32; use egui::{vec2, Button, Layout, Margin, RichText, Rounding, ScrollArea, TextEdit}; -use notedeck::{ImageCache, NotedeckTextStyle}; +use notedeck::{Images, NotedeckTextStyle}; use crate::{colors, profile_state::ProfileState}; @@ -9,11 +9,11 @@ use super::{banner, unwrap_profile_url, ProfilePic}; pub struct EditProfileView<'a> { state: &'a mut ProfileState, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, } impl<'a> EditProfileView<'a> { - pub fn new(state: &'a mut ProfileState, img_cache: &'a mut ImageCache) -> Self { + pub fn new(state: &'a mut ProfileState, img_cache: &'a mut Images) -> Self { Self { state, img_cache } } diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -23,7 +23,7 @@ use crate::{ NostrName, }; -use notedeck::{Accounts, ImageCache, MuteFun, NoteCache, NotedeckTextStyle, UnknownIds}; +use notedeck::{Accounts, Images, MuteFun, NoteCache, NotedeckTextStyle, UnknownIds}; pub struct ProfileView<'a> { pubkey: &'a Pubkey, @@ -33,7 +33,7 @@ pub struct ProfileView<'a> { note_options: NoteOptions, ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, } @@ -52,7 +52,7 @@ impl<'a> ProfileView<'a> { timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, note_options: NoteOptions, diff --git a/crates/notedeck_columns/src/ui/profile/picture.rs b/crates/notedeck_columns/src/ui/profile/picture.rs @@ -1,13 +1,15 @@ +use crate::gif::{handle_repaint, retrieve_latest_texture}; use crate::images::ImageType; +use crate::ui::images::render_images; use crate::ui::{Preview, PreviewConfig}; use egui::{vec2, Sense, Stroke, TextureHandle}; use nostrdb::{Ndb, Transaction}; use tracing::info; -use notedeck::{AppContext, ImageCache}; +use notedeck::{supported_mime_hosted_at_url, AppContext, Images}; pub struct ProfilePic<'cache, 'url> { - cache: &'cache mut ImageCache, + cache: &'cache mut Images, url: &'url str, size: f32, border: Option<Stroke>, @@ -20,7 +22,7 @@ impl egui::Widget for ProfilePic<'_, '_> { } impl<'cache, 'url> ProfilePic<'cache, 'url> { - pub fn new(cache: &'cache mut ImageCache, url: &'url str) -> Self { + pub fn new(cache: &'cache mut Images, url: &'url str) -> Self { let size = Self::default_size(); ProfilePic { cache, @@ -35,7 +37,7 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { } pub fn from_profile( - cache: &'cache mut ImageCache, + cache: &'cache mut Images, profile: &nostrdb::ProfileRecord<'url>, ) -> Option<Self> { profile @@ -80,7 +82,7 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { fn render_pfp( ui: &mut egui::Ui, - img_cache: &mut ImageCache, + img_cache: &mut Images, url: &str, ui_size: f32, border: Option<Stroke>, @@ -91,39 +93,27 @@ fn render_pfp( // We will want to downsample these so it's not blurry on hi res displays let img_size = 128u32; - let m_cached_promise = img_cache.map().get(url); - if m_cached_promise.is_none() { - let res = crate::images::fetch_img(img_cache, ui.ctx(), url, ImageType::Profile(img_size)); - img_cache.map_mut().insert(url.to_owned(), res); - } - - match img_cache.map()[url].ready() { - None => paint_circle(ui, ui_size, border), - - // Failed to fetch profile! - Some(Err(_err)) => { - let m_failed_promise = img_cache.map().get(url); - if m_failed_promise.is_none() { - let no_pfp = crate::images::fetch_img( - img_cache, - ui.ctx(), - ProfilePic::no_pfp_url(), - ImageType::Profile(img_size), - ); - img_cache.map_mut().insert(url.to_owned(), no_pfp); - } - - match img_cache.map().get(url).unwrap().ready() { - None => paint_circle(ui, ui_size, border), - Some(Err(_e)) => { - //error!("Image load error: {:?}", e); - paint_circle(ui, ui_size, border) - } - Some(Ok(img)) => pfp_image(ui, img, ui_size, border), - } - } - Some(Ok(img)) => pfp_image(ui, img, ui_size, border), - } + let cache_type = supported_mime_hosted_at_url(&mut img_cache.urls, url) + .unwrap_or(notedeck::MediaCacheType::Image); + + render_images( + ui, + img_cache, + url, + ImageType::Profile(img_size), + cache_type, + |ui| { + paint_circle(ui, ui_size, border); + }, + |ui, _| { + paint_circle(ui, ui_size, border); + }, + |ui, url, renderable_media, gifs| { + let texture_handle = + handle_repaint(ui, retrieve_latest_texture(url, gifs, renderable_media)); + pfp_image(ui, texture_handle, ui_size, border); + }, + ) } fn pfp_image( diff --git a/crates/notedeck_columns/src/ui/profile/preview.rs b/crates/notedeck_columns/src/ui/profile/preview.rs @@ -4,18 +4,18 @@ use egui::{Frame, Label, RichText, Widget}; use egui_extras::Size; use nostrdb::ProfileRecord; -use notedeck::{ImageCache, NotedeckTextStyle, UserAccount}; +use notedeck::{Images, NotedeckTextStyle, UserAccount}; use super::{about_section_widget, banner, display_name_widget, get_display_name, get_profile_url}; pub struct ProfilePreview<'a, 'cache> { profile: &'a ProfileRecord<'a>, - cache: &'cache mut ImageCache, + cache: &'cache mut Images, banner_height: Size, } impl<'a, 'cache> ProfilePreview<'a, 'cache> { - pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut ImageCache) -> Self { + pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut Images) -> Self { let banner_height = Size::exact(80.0); ProfilePreview { profile, @@ -69,14 +69,14 @@ impl egui::Widget for ProfilePreview<'_, '_> { pub struct SimpleProfilePreview<'a, 'cache> { profile: Option<&'a ProfileRecord<'a>>, - cache: &'cache mut ImageCache, + cache: &'cache mut Images, is_nsec: bool, } impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { pub fn new( profile: Option<&'a ProfileRecord<'a>>, - cache: &'cache mut ImageCache, + cache: &'cache mut Images, is_nsec: bool, ) -> Self { SimpleProfilePreview { diff --git a/crates/notedeck_columns/src/ui/search_results.rs b/crates/notedeck_columns/src/ui/search_results.rs @@ -1,6 +1,6 @@ use egui::{vec2, FontId, Pos2, Rect, ScrollArea, Vec2b}; use nostrdb::{Ndb, ProfileRecord, Transaction}; -use notedeck::{fonts::get_font_size, ImageCache, NotedeckTextStyle}; +use notedeck::{fonts::get_font_size, Images, NotedeckTextStyle}; use tracing::error; use crate::{ @@ -13,13 +13,13 @@ use super::{profile::get_profile_url, ProfilePic}; pub struct SearchResultsView<'a> { ndb: &'a Ndb, txn: &'a Transaction, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, results: &'a Vec<&'a [u8; 32]>, } impl<'a> SearchResultsView<'a> { pub fn new( - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, ndb: &'a Ndb, txn: &'a Transaction, results: &'a Vec<&'a [u8; 32]>, @@ -84,7 +84,7 @@ impl<'a> SearchResultsView<'a> { fn user_result<'a>( profile: &'a ProfileRecord<'_>, - cache: &'a mut ImageCache, + cache: &'a mut Images, index: usize, width: f32, ) -> impl egui::Widget + 'a { diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs @@ -15,7 +15,7 @@ use crate::{ support::Support, }; -use notedeck::{Accounts, ImageCache, NotedeckTextStyle, ThemeHandler, UserAccount}; +use notedeck::{Accounts, Images, NotedeckTextStyle, ThemeHandler, UserAccount}; use super::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, @@ -29,7 +29,7 @@ static ICON_WIDTH: f32 = 40.0; pub struct DesktopSidePanel<'a> { ndb: &'a nostrdb::Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, selected_account: Option<&'a UserAccount>, decks_cache: &'a DecksCache, } @@ -70,7 +70,7 @@ impl SidePanelResponse { impl<'a> DesktopSidePanel<'a> { pub fn new( ndb: &'a nostrdb::Ndb, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, selected_account: Option<&'a UserAccount>, decks_cache: &'a DecksCache, ) -> Self { diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -5,7 +5,7 @@ use crate::{ }; use nostrdb::{Ndb, Transaction}; -use notedeck::{ImageCache, MuteFun, NoteCache, RootNoteId, UnknownIds}; +use notedeck::{Images, MuteFun, NoteCache, RootNoteId, UnknownIds}; use tracing::error; use super::timeline::TimelineTabView; @@ -15,7 +15,7 @@ pub struct ThreadView<'a> { ndb: &'a Ndb, note_cache: &'a mut NoteCache, unknown_ids: &'a mut UnknownIds, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, selected_note_id: &'a [u8; 32], note_options: NoteOptions, id_source: egui::Id, @@ -29,7 +29,7 @@ impl<'a> ThreadView<'a> { ndb: &'a Ndb, note_cache: &'a mut NoteCache, unknown_ids: &'a mut UnknownIds, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, selected_note_id: &'a [u8; 32], note_options: NoteOptions, is_muted: &'a MuteFun, diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -12,7 +12,7 @@ use egui::{vec2, Direction, Layout, Pos2, Stroke}; use egui_tabs::TabColor; use nostrdb::{Ndb, Transaction}; use notedeck::note::root_note_id_from_selected_id; -use notedeck::{ImageCache, MuteFun, NoteCache}; +use notedeck::{Images, MuteFun, NoteCache}; use tracing::{error, warn}; use super::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}; @@ -22,19 +22,20 @@ pub struct TimelineView<'a> { timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note_options: NoteOptions, reverse: bool, is_muted: &'a MuteFun, } impl<'a> TimelineView<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( timeline_id: &'a TimelineKind, timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, note_options: NoteOptions, is_muted: &'a MuteFun, ) -> TimelineView<'a> { @@ -78,7 +79,7 @@ fn timeline_ui( timeline_id: &TimelineKind, timeline_cache: &mut TimelineCache, note_cache: &mut NoteCache, - img_cache: &mut ImageCache, + img_cache: &mut Images, reversed: bool, note_options: NoteOptions, is_muted: &MuteFun, @@ -321,7 +322,7 @@ pub struct TimelineTabView<'a> { txn: &'a Transaction, ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, is_muted: &'a MuteFun, } @@ -334,7 +335,7 @@ impl<'a> TimelineTabView<'a> { txn: &'a Transaction, ndb: &'a Ndb, note_cache: &'a mut NoteCache, - img_cache: &'a mut ImageCache, + img_cache: &'a mut Images, is_muted: &'a MuteFun, ) -> Self { Self {