notedeck

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

commit fd7238bc4c072a8e023a0433c43e94a56a9211df
parent 9e4c9205699770855bbffac04e7331a62248e5d4
Author: William Casarin <jb55@jb55.com>
Date:   Thu,  4 Dec 2025 00:39:48 -0800

Merge large job pool refactor by kernel #1210

kernelkind (38):
      feat(blur): add BlurCache
      feat(cargo): add crossbeam
      feat(gif): add AnimatedImgTexCache
      feat(gif): new Animation
      feat(gif): new ProcessedGifFrame
      feat(hyper): install crypto provider
      feat(images): new TextureState
      feat(img-parsing): new parse_img_response
      feat(job-cache): new JobCache
      feat(jobs): add `schedule_receivable`
      feat(jobs): mpsc -> mpmc
      feat(jobs): structs for new JobCache
      feat(jobs-media): basic media jobs structs
      feat(media-job): pre & post actions
      feat(media-jobs): add media JobCache to app
      feat(media-loading): Images helpers
      feat(media-loading): add TexturesCache to Images
      feat(media-loading): new Images::latest_texture
      feat(media-loading): new TexturesCache
      feat(media-loading): wire new media loading cache
      feat(media-rendering): add helpers for various media rendering configs
      feat(network): add hyper
      feat(network): hyper networking
      feat(static-imgs): add StaticImgTexCache
      refactor(gif): nevernest process_gif_frame
      refactor(jobs): move related jobs things to own module
      refactor(search): remove unnecessary params
      refactor: appease clippy
      refactor: remove unnecessary old JobsCache code
      tmp(refactor): Animation -> AnimationOld
      tmp(refactor): Images::latest_texture -> latest_texture_old
      tmp(refactor): MediaRenderState -> MediaRenderStateOld
      tmp(refactor): ObfuscatedTexture -> ObfuscatedTextureOld
      tmp(refactor): ProcessedGifFrame -> ProcessedGifFrameOld
      tmp(refactor): TextureState -> TextureStateOld
      tmp(refactor): TexturesCache -> TexturesCacheOld
      tmp(refactor): parse_img_response -> parse_img_response_old
      tmp(refactor): rename current jobs stuff to Old

Diffstat:
MCargo.lock | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCargo.toml | 5+++++
Mcrates/notedeck/Cargo.toml | 6++++++
Mcrates/notedeck/src/app.rs | 24++++++++++++++++++++++--
Mcrates/notedeck/src/context.rs | 5+++--
Mcrates/notedeck/src/imgcache.rs | 287++++++++++++++++++-------------------------------------------------------------
Dcrates/notedeck/src/job_pool.rs | 100-------------------------------------------------------------------------------
Dcrates/notedeck/src/jobs.rs | 153-------------------------------------------------------------------------------
Acrates/notedeck/src/jobs/cache.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/jobs/job_pool.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/jobs/media.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/jobs/mod.rs | 14++++++++++++++
Acrates/notedeck/src/jobs/types.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/lib.rs | 16+++++++---------
Mcrates/notedeck/src/media/action.rs | 52+++++++++++++++++++++++++---------------------------
Mcrates/notedeck/src/media/blur.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/notedeck/src/media/gif.rs | 416++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck/src/media/images.rs | 273+++----------------------------------------------------------------------------
Acrates/notedeck/src/media/latest.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/media/mod.rs | 8+++++++-
Acrates/notedeck/src/media/network.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/media/static_imgs.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/note/mod.rs | 4++--
Mcrates/notedeck_chrome/src/chrome.rs | 5++++-
Mcrates/notedeck_clndash/src/ui.rs | 13++++++++-----
Mcrates/notedeck_columns/src/accounts/mod.rs | 7+++----
Mcrates/notedeck_columns/src/actionbar.rs | 9++++++---
Mcrates/notedeck_columns/src/app.rs | 20+++++++++++++-------
Mcrates/notedeck_columns/src/media_upload.rs | 1-
Mcrates/notedeck_columns/src/nav.rs | 63++++++++++++++++++++++++++++++---------------------------------
Mcrates/notedeck_columns/src/timeline/route.rs | 44+++++++++-----------------------------------
Mcrates/notedeck_columns/src/ui/accounts.rs | 16++++++++++++++--
Mcrates/notedeck_columns/src/ui/add_column.rs | 10++++++++--
Mcrates/notedeck_columns/src/ui/column/header.rs | 21++++++++++++---------
Mcrates/notedeck_columns/src/ui/mentions_picker.rs | 10+++++++---
Mcrates/notedeck_columns/src/ui/note/custom_zap.rs | 16++++++++++++----
Mcrates/notedeck_columns/src/ui/note/post.rs | 67+++++++++++++++++++++++++++++++------------------------------------
Mcrates/notedeck_columns/src/ui/note/quote_repost.rs | 6+-----
Mcrates/notedeck_columns/src/ui/note/reply.rs | 8++------
Mcrates/notedeck_columns/src/ui/onboarding.rs | 10+++-------
Mcrates/notedeck_columns/src/ui/profile/contacts_list.rs | 7++++++-
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 9+++++++--
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 18+++++++++---------
Mcrates/notedeck_columns/src/ui/search/mod.rs | 23+++++++----------------
Mcrates/notedeck_columns/src/ui/settings.rs | 18+++++-------------
Mcrates/notedeck_columns/src/ui/side_panel.rs | 7+++++--
Mcrates/notedeck_columns/src/ui/thread.rs | 20+++-----------------
Mcrates/notedeck_columns/src/ui/timeline.rs | 47+++++++++++------------------------------------
Mcrates/notedeck_dave/src/lib.rs | 10++--------
Mcrates/notedeck_dave/src/ui/dave.rs | 55+++++++++++++++++++++++++++++++------------------------
Mcrates/notedeck_ui/src/media/viewer.rs | 34++++++++++++++++++++++++++--------
Mcrates/notedeck_ui/src/mention.rs | 9+++++++--
Mcrates/notedeck_ui/src/nip51_set.rs | 21++++++++-------------
Mcrates/notedeck_ui/src/note/contents.rs | 28++++++++--------------------
Mcrates/notedeck_ui/src/note/media.rs | 265+++++++++++--------------------------------------------------------------------
Mcrates/notedeck_ui/src/note/mod.rs | 60+++++++++++++++++++++++++++---------------------------------
Mcrates/notedeck_ui/src/note/reply_description.rs | 15++++-----------
Mcrates/notedeck_ui/src/profile/picture.rs | 48+++++++++++++++++++++++-------------------------
Mcrates/notedeck_ui/src/profile/preview.rs | 21+++++++++++++++++----
59 files changed, 2129 insertions(+), 1580 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -519,6 +519,29 @@ dependencies = [ ] [[package]] +name = "aws-lc-rs" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5932a7d9d28b0d2ea34c6b3779d35e3dd6f6345317c34e73438c4f1f29144151" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1826f2e4cfc2cd19ee53c42fbf68e2f81ec21108e0b7ecf6a71cf062137360fc" +dependencies = [ + "bindgen 0.72.1", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] name = "backoff" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -620,6 +643,26 @@ dependencies = [ ] [[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.104", +] + +[[package]] name = "bip39" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1052,6 +1095,15 @@ dependencies = [ ] [[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] name = "codespan-reporting" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1155,6 +1207,19 @@ dependencies = [ ] [[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] name = "crossbeam-channel" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1183,6 +1248,15 @@ dependencies = [ ] [[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1412,6 +1486,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2007,6 +2087,12 @@ dependencies = [ ] [[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] name = "futures" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2338,6 +2424,25 @@ dependencies = [ ] [[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] name = "half" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2488,6 +2593,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] name = "human_format" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2495,18 +2606,22 @@ checksum = "5c3b1f728c459d27b12448862017b96ad4767b1ec2ec5e6434e99f1577f085b8" [[package]] name = "hyper" -version = "1.6.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2521,6 +2636,7 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -3522,7 +3638,7 @@ name = "nostrdb" version = "0.8.0" source = "git+https://github.com/damus-io/nostrdb-rs?rev=6956b9f955463404b8eff3b7abe0cc3092cb5958#6956b9f955463404b8eff3b7abe0cc3092cb5958" dependencies = [ - "bindgen", + "bindgen 0.69.5", "cc", "flatbuffers", "futures", @@ -3543,6 +3659,7 @@ dependencies = [ "bitflags 2.9.1", "blurhash", "chrono", + "crossbeam", "crossbeam-channel", "dirs", "eframe", @@ -3556,6 +3673,10 @@ dependencies = [ "fluent-resmgr", "hashbrown 0.15.4", "hex", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", "image", "indexmap 2.9.0", "jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3573,6 +3694,7 @@ dependencies = [ "puffin_egui", "rand 0.9.2", "regex", + "rustls", "secp256k1 0.30.0", "serde", "serde_json", @@ -5200,6 +5322,7 @@ version = "0.23.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -5237,6 +5360,7 @@ version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -6284,7 +6408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "319c70195101a93f56db4c74733e272d720768e13471f400c78406a326b172b0" dependencies = [ "cc", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -33,6 +33,11 @@ egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b #egui_virtual_list = "0.6.0" egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" } ehttp = "0.5.0" +hyper = { version = "1.7.0", features = ["full"] } +hyper-util = {version = "0.1" , features = ["tokio"]} +hyper-rustls = "0.27.7" +http-body-util = "0.1.3" +rustls = "0.23.28" enostr = { path = "crates/enostr" } ewebsock = { version = "0.2.0", features = ["tls"] } fluent = "0.17.0" diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml @@ -53,6 +53,12 @@ chrono = { workspace = true } indexmap = {workspace = true} rand = {workspace = true} crossbeam-channel = "0.5" +crossbeam = "0.8.4" +hyper = { workspace = true } +hyper-util = { workspace = true } +http-body-util = { workspace = true } +hyper-rustls = { workspace = true } +rustls = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -3,13 +3,13 @@ use crate::i18n::Localization; use crate::persist::{AppSizeHandler, SettingsHandler}; use crate::wallet::GlobalWallet; use crate::zaps::Zaps; -use crate::Error; -use crate::JobPool; use crate::NotedeckOptions; use crate::{ frame_history::FrameHistory, AccountStorage, Accounts, AppContext, Args, DataPath, DataPathType, Directory, Images, NoteAction, NoteCache, RelayDebugView, UnknownIds, }; +use crate::{Error, JobCache}; +use crate::{JobPool, MediaJobs}; use egui::Margin; use egui::ThemePreference; use egui_winit::clipboard::Clipboard; @@ -77,6 +77,7 @@ pub struct Notedeck { zaps: Zaps, frame_history: FrameHistory, job_pool: JobPool, + media_jobs: MediaJobs, i18n: Localization, #[cfg(target_os = "android")] @@ -122,6 +123,13 @@ impl eframe::App for Notedeck { self.frame_history .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage); + self.media_jobs.run_received(&mut self.job_pool, |id| { + crate::run_media_job_pre_action(id, &mut self.img_cache.textures); + }); + self.media_jobs.deliver_all_completed(|completed| { + crate::deliver_completed_media_job(completed, &mut self.img_cache.textures) + }); + // handle account updates self.accounts.update(&mut self.ndb, &mut self.pool, ctx); @@ -177,6 +185,8 @@ impl Notedeck { #[cfg(feature = "puffin")] setup_puffin(); + install_crypto(); + // Skip the first argument, which is the program name. let (parsed_args, unrecognized_args) = Args::parse(&args[1..]); @@ -288,6 +298,9 @@ impl Notedeck { } } + let (send_new_jobs, receive_new_jobs) = std::sync::mpsc::channel(); + let media_job_cache = JobCache::new(receive_new_jobs, send_new_jobs); + Self { ndb, img_cache, @@ -306,6 +319,7 @@ impl Notedeck { clipboard: Clipboard::new(None), zaps, job_pool, + media_jobs: media_job_cache, i18n, #[cfg(target_os = "android")] android_app: None, @@ -371,6 +385,7 @@ impl Notedeck { zaps: &mut self.zaps, frame_history: &mut self.frame_history, job_pool: &mut self.job_pool, + media_jobs: &mut self.media_jobs, i18n: &mut self.i18n, #[cfg(target_os = "android")] android: self.android_app.as_ref().unwrap().clone(), @@ -401,3 +416,8 @@ impl Notedeck { &self.unrecognized_args } } + +pub fn install_crypto() { + let provider = rustls::crypto::aws_lc_rs::default_provider(); + let _ = provider.install_default(); +} diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs @@ -1,7 +1,7 @@ use crate::{ account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization, - wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, SettingsHandler, - UnknownIds, + wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, MediaJobs, NoteCache, + SettingsHandler, UnknownIds, }; use egui_winit::clipboard::Clipboard; @@ -28,6 +28,7 @@ pub struct AppContext<'a> { pub zaps: &'a mut Zaps, pub frame_history: &'a mut FrameHistory, pub job_pool: &'a mut JobPool, + pub media_jobs: &'a mut MediaJobs, pub i18n: &'a mut Localization, #[cfg(target_os = "android")] diff --git a/crates/notedeck/src/imgcache.rs b/crates/notedeck/src/imgcache.rs @@ -1,6 +1,10 @@ -use crate::media::gif::ensure_latest_texture_from_cache; +use crate::jobs::MediaJobSender; +use crate::media::gif::AnimatedImgTexCache; use crate::media::images::ImageType; -use crate::media::AnimationMode; +use crate::media::static_imgs::StaticImgTexCache; +use crate::media::{ + AnimationMode, BlurCache, NoLoadingLatestTex, TrustedMediaLatestTex, UntrustedMediaLatestTex, +}; use crate::urls::{UrlCache, UrlMimes}; use crate::ImageMetadata; use crate::ObfuscationType; @@ -8,13 +12,11 @@ use crate::RenderableMedia; use crate::Result; use egui::TextureHandle; use image::{Delay, Frame}; -use poll_promise::Promise; use egui::ColorImage; use std::collections::HashMap; use std::fs::{self, create_dir_all, File}; -use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; use std::{io, thread}; @@ -25,177 +27,38 @@ use std::path::PathBuf; use std::path::{self, Path}; use tracing::warn; -#[derive(Default)] pub struct TexturesCache { - pub cache: hashbrown::HashMap<String, TextureStateInternal>, + pub static_image: StaticImgTexCache, + pub blurred: BlurCache, + pub animated: AnimatedImgTexCache, } impl TexturesCache { - pub fn handle_and_get_or_insert_loadable( - &mut self, - url: &str, - closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>, - ) -> LoadableTextureState<'_> { - let internal = self.handle_and_get_state_internal(url, true, closure); - - internal.into() - } - - pub fn handle_and_get_or_insert( - &mut self, - url: &str, - closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>, - ) -> TextureState<'_> { - let internal = self.handle_and_get_state_internal(url, false, closure); - - internal.into() - } - - fn handle_and_get_state_internal( - &mut self, - url: &str, - use_loading: bool, - closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>, - ) -> &mut TextureStateInternal { - let state = match self.cache.raw_entry_mut().from_key(url) { - hashbrown::hash_map::RawEntryMut::Occupied(entry) => { - let state = entry.into_mut(); - handle_occupied(state, use_loading); - - state - } - hashbrown::hash_map::RawEntryMut::Vacant(entry) => { - let res = closure(); - let (_, state) = entry.insert(url.to_owned(), TextureStateInternal::Pending(res)); - - state - } - }; - - state - } - - pub fn insert_pending(&mut self, url: &str, promise: Promise<Option<Result<TexturedImage>>>) { - self.cache - .insert(url.to_owned(), TextureStateInternal::Pending(promise)); - } - - pub fn move_to_loaded(&mut self, url: &str) { - let hashbrown::hash_map::RawEntryMut::Occupied(entry) = - self.cache.raw_entry_mut().from_key(url) - else { - return; - }; - - entry.replace_entry_with(|_, v| { - let TextureStateInternal::Loading(textured) = v else { - return Some(v); - }; - - Some(TextureStateInternal::Loaded(textured)) - }); - } - - pub fn get_and_handle(&mut self, url: &str) -> Option<LoadableTextureState<'_>> { - self.cache.get_mut(url).map(|state| { - handle_occupied(state, true); - state.into() - }) - } -} - -fn handle_occupied(state: &mut TextureStateInternal, use_loading: bool) { - let TextureStateInternal::Pending(promise) = state else { - return; - }; - - let Some(res) = promise.ready_mut() else { - return; - }; - - let Some(res) = res.take() else { - tracing::error!("Failed to take the promise"); - *state = - TextureStateInternal::Error(crate::Error::Generic("Promise already taken".to_owned())); - return; - }; - - match res { - Ok(textured) => { - *state = if use_loading { - TextureStateInternal::Loading(textured) - } else { - TextureStateInternal::Loaded(textured) - } + pub fn new(base_dir: PathBuf) -> Self { + Self { + static_image: StaticImgTexCache::new( + base_dir.join(MediaCache::rel_dir(MediaCacheType::Image)), + ), + blurred: Default::default(), + animated: AnimatedImgTexCache::new( + base_dir.join(MediaCache::rel_dir(MediaCacheType::Gif)), + ), } - Err(e) => *state = TextureStateInternal::Error(e), } } -pub enum LoadableTextureState<'a> { +pub enum TextureState<T> { Pending, - Error(&'a crate::Error), - Loading { - actual_image_tex: &'a mut TexturedImage, - }, // the texture is in the loading state, for transitioning between the pending and loaded states - Loaded(&'a mut TexturedImage), -} - -pub enum TextureState<'a> { - Pending, - Error(&'a crate::Error), - Loaded(&'a mut TexturedImage), -} - -impl<'a> TextureState<'a> { - pub fn is_loaded(&self) -> bool { - matches!(self, Self::Loaded(_)) - } -} - -impl<'a> From<&'a mut TextureStateInternal> for TextureState<'a> { - fn from(value: &'a mut TextureStateInternal) -> Self { - match value { - TextureStateInternal::Pending(_) => TextureState::Pending, - TextureStateInternal::Error(error) => TextureState::Error(error), - TextureStateInternal::Loading(textured_image) => TextureState::Loaded(textured_image), - TextureStateInternal::Loaded(textured_image) => TextureState::Loaded(textured_image), - } - } -} - -pub enum TextureStateInternal { - Pending(Promise<Option<Result<TexturedImage>>>), Error(crate::Error), - Loading(TexturedImage), // the image is in the loading state, for transitioning between blur and image - Loaded(TexturedImage), + Loaded(T), } -impl<'a> From<&'a mut TextureStateInternal> for LoadableTextureState<'a> { - fn from(value: &'a mut TextureStateInternal) -> Self { - match value { - TextureStateInternal::Pending(_) => LoadableTextureState::Pending, - TextureStateInternal::Error(error) => LoadableTextureState::Error(error), - TextureStateInternal::Loading(textured_image) => LoadableTextureState::Loading { - actual_image_tex: textured_image, - }, - TextureStateInternal::Loaded(textured_image) => { - LoadableTextureState::Loaded(textured_image) - } - } - } -} - -pub enum TexturedImage { - Static(TextureHandle), - Animated(Animation), -} - -impl TexturedImage { - pub fn get_first_texture(&self) -> &TextureHandle { +impl<T> std::fmt::Debug for TextureState<T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - TexturedImage::Static(texture_handle) => texture_handle, - TexturedImage::Animated(animation) => &animation.first_frame.texture, + Self::Pending => write!(f, "Pending"), + Self::Error(_) => f.debug_tuple("Error").field(&"").finish(), + Self::Loaded(_) => f.debug_tuple("Loaded").field(&"").finish(), } } } @@ -203,7 +66,6 @@ impl TexturedImage { pub struct Animation { pub first_frame: TextureFrame, pub other_frames: Vec<TextureFrame>, - pub receiver: Option<Receiver<TextureFrame>>, } impl Animation { @@ -232,7 +94,6 @@ pub struct ImageFrame { pub struct MediaCache { pub cache_dir: path::PathBuf, - pub textures_cache: TexturesCache, pub cache_type: MediaCacheType, pub cache_size: Arc<Mutex<Option<u64>>>, } @@ -266,7 +127,6 @@ impl MediaCache { Self { cache_dir, - textures_cache: TexturesCache::default(), cache_type, cache_size, } @@ -370,7 +230,6 @@ impl MediaCache { } fn clear(&mut self) { - self.textures_cache.cache.clear(); *self.cache_size.try_lock().unwrap() = Some(0); } } @@ -413,6 +272,7 @@ pub struct Images { pub base_path: path::PathBuf, pub static_imgs: MediaCache, pub gifs: MediaCache, + pub textures: TexturesCache, pub urls: UrlMimes, /// cached imeta data pub metadata: HashMap<String, ImageMetadata>, @@ -429,6 +289,7 @@ impl Images { urls: UrlMimes::new(UrlCache::new(path.join(UrlCache::rel_dir()))), gif_states: Default::default(), metadata: Default::default(), + textures: TexturesCache::new(path.clone()), } } @@ -460,40 +321,22 @@ impl Images { }) } - pub fn latest_texture( - &mut self, + pub fn latest_texture<'a>( + &'a mut self, + jobs: &MediaJobSender, ui: &mut egui::Ui, url: &str, img_type: ImageType, animation_mode: AnimationMode, - ) -> Option<TextureHandle> { + ) -> Option<&'a TextureHandle> { let cache_type = crate::urls::supported_mime_hosted_at_url(&mut self.urls, url)?; - let cache_dir = self.get_cache(cache_type).cache_dir.clone(); - let is_loaded = self - .get_cache_mut(cache_type) - .textures_cache - .handle_and_get_or_insert(url, || { - crate::media::images::fetch_img(&cache_dir, ui.ctx(), url, img_type, cache_type) - }) - .is_loaded(); - - if !is_loaded { - return None; - } - - let cache = match cache_type { - MediaCacheType::Image => &mut self.static_imgs, - MediaCacheType::Gif => &mut self.gifs, - }; - - ensure_latest_texture_from_cache( - ui, - url, + let mut loader = NoLoadingLatestTex::new( + &self.textures.static_image, + &self.textures.animated, &mut self.gif_states, - &mut cache.textures_cache, - animation_mode, - ) + ); + loader.latest(jobs, ui.ctx(), url, cache_type, img_type, animation_mode) } pub fn get_cache(&self, cache_type: MediaCacheType) -> &MediaCache { @@ -529,6 +372,36 @@ impl Images { Ok(()) } + + pub fn trusted_texture_loader(&mut self) -> TrustedMediaLatestTex<'_> { + TrustedMediaLatestTex::new( + NoLoadingLatestTex::new( + &self.textures.static_image, + &self.textures.animated, + &mut self.gif_states, + ), + &self.textures.blurred, + ) + } + + pub fn untrusted_texture_loader(&mut self) -> UntrustedMediaLatestTex<'_> { + UntrustedMediaLatestTex::new(&self.textures.blurred) + } + + pub fn no_img_loading_tex_loader(&'_ mut self) -> NoLoadingLatestTex<'_> { + NoLoadingLatestTex::new( + &self.textures.static_image, + &self.textures.animated, + &mut self.gif_states, + ) + } + + pub fn user_trusts_img(&self, url: &str, media_type: MediaCacheType) -> bool { + match media_type { + MediaCacheType::Image => self.textures.static_image.contains(url), + MediaCacheType::Gif => self.textures.animated.contains(url), + } + } } pub type GifStateMap = HashMap<String, GifState>; @@ -544,31 +417,3 @@ pub struct LatestTexture { pub texture: TextureHandle, pub request_next_repaint: Option<SystemTime>, } - -#[profiling::function] -pub fn get_render_state<'a>( - ctx: &egui::Context, - images: &'a mut Images, - cache_type: MediaCacheType, - url: &str, - img_type: ImageType, -) -> RenderState<'a> { - let cache = match cache_type { - MediaCacheType::Image => &mut images.static_imgs, - MediaCacheType::Gif => &mut images.gifs, - }; - - let texture_state = cache.textures_cache.handle_and_get_or_insert(url, || { - crate::media::images::fetch_img(&cache.cache_dir, ctx, url, img_type, cache_type) - }); - - RenderState { - texture_state, - gifs: &mut images.gif_states, - } -} - -pub struct RenderState<'a> { - pub texture_state: TextureState<'a>, - pub gifs: &'a mut GifStateMap, -} diff --git a/crates/notedeck/src/job_pool.rs b/crates/notedeck/src/job_pool.rs @@ -1,100 +0,0 @@ -use std::{ - future::Future, - sync::{ - mpsc::{self, Sender}, - Arc, Mutex, - }, -}; -use tokio::sync::oneshot; - -type Job = Box<dyn FnOnce() + Send + 'static>; - -pub struct JobPool { - tx: Sender<Job>, -} - -impl Default for JobPool { - fn default() -> Self { - JobPool::new(2) - } -} - -impl JobPool { - pub fn new(num_threads: usize) -> Self { - let (tx, rx) = mpsc::channel::<Job>(); - - // TODO(jb55) why not mpmc here !??? - let arc_rx = Arc::new(Mutex::new(rx)); - for _ in 0..num_threads { - let arc_rx_clone = arc_rx.clone(); - std::thread::spawn(move || loop { - let job = { - let Ok(unlocked) = arc_rx_clone.lock() else { - continue; - }; - let Ok(job) = unlocked.recv() else { - continue; - }; - - job - }; - - job(); - }); - } - - Self { tx } - } - - pub fn schedule<F, T>(&self, job: F) -> impl Future<Output = T> - where - F: FnOnce() -> T + Send + 'static, - T: Send + 'static, - { - let (tx_result, rx_result) = oneshot::channel::<T>(); - - let job = Box::new(move || { - let output = job(); - let _ = tx_result.send(output); - }); - - self.tx - .send(job) - .expect("receiver should not be deallocated"); - - async move { - rx_result.await.unwrap_or_else(|_| { - panic!("Worker thread or channel dropped before returning the result.") - }) - } - } -} - -#[cfg(test)] -mod tests { - use crate::job_pool::JobPool; - - fn test_fn(a: u32, b: u32) -> u32 { - a + b - } - - #[tokio::test] - async fn test() { - let pool = JobPool::default(); - - // Now each job can return different T - let future_str = pool.schedule(|| -> String { "hello from string job".into() }); - - let a = 5; - let b = 6; - let future_int = pool.schedule(move || -> u32 { test_fn(a, b) }); - - println!("(Meanwhile we can do more async work) ..."); - - let s = future_str.await; - let i = future_int.await; - - println!("Got string: {:?}", s); - println!("Got integer: {}", i); - } -} diff --git a/crates/notedeck/src/jobs.rs b/crates/notedeck/src/jobs.rs @@ -1,153 +0,0 @@ -use crate::JobPool; -use egui::TextureHandle; -use hashbrown::{hash_map::RawEntryMut, HashMap}; -use poll_promise::Promise; - -#[derive(Default)] -pub struct JobsCache { - jobs: HashMap<JobIdOwned, JobState>, -} - -pub enum JobState { - Pending(Promise<Option<Result<Job, JobError>>>), - Error(JobError), - Completed(Job), -} - -pub enum JobError { - InvalidParameters, -} - -#[derive(Debug)] -pub enum JobParams<'a> { - Blurhash(BlurhashParams<'a>), -} - -#[derive(Debug)] -pub enum JobParamsOwned { - Blurhash(BlurhashParamsOwned), -} - -impl<'a> From<BlurhashParams<'a>> for BlurhashParamsOwned { - fn from(params: BlurhashParams<'a>) -> Self { - BlurhashParamsOwned { - blurhash: params.blurhash.to_owned(), - url: params.url.to_owned(), - ctx: params.ctx.clone(), - } - } -} - -impl<'a> From<JobParams<'a>> for JobParamsOwned { - fn from(params: JobParams<'a>) -> Self { - match params { - JobParams::Blurhash(bp) => JobParamsOwned::Blurhash(bp.into()), - } - } -} - -#[derive(Debug)] -pub struct BlurhashParams<'a> { - pub blurhash: &'a str, - pub url: &'a str, - pub ctx: &'a egui::Context, -} - -#[derive(Debug)] -pub struct BlurhashParamsOwned { - pub blurhash: String, - pub url: String, - pub ctx: egui::Context, -} - -impl JobsCache { - pub fn get_or_insert_with< - 'a, - F: FnOnce(Option<JobParamsOwned>) -> Result<Job, JobError> + Send + 'static, - >( - &'a mut self, - job_pool: &mut JobPool, - jobid: &JobId, - params: Option<JobParams>, - run_job: F, - ) -> &'a mut JobState { - match self.jobs.raw_entry_mut().from_key(jobid) { - RawEntryMut::Occupied(entry) => 's: { - let mut state = entry.into_mut(); - - let JobState::Pending(promise) = &mut state else { - break 's state; - }; - - let Some(res) = promise.ready_mut() else { - break 's state; - }; - - let Some(res) = res.take() else { - tracing::error!("Failed to take the promise for job: {:?}", jobid); - break 's state; - }; - - *state = match res { - Ok(j) => JobState::Completed(j), - Err(e) => JobState::Error(e), - }; - - state - } - RawEntryMut::Vacant(entry) => { - let owned_params = params.map(JobParams::into); - let wrapped: Box<dyn FnOnce() -> Option<Result<Job, JobError>> + Send + 'static> = - Box::new(move || Some(run_job(owned_params))); - - let promise = Promise::spawn_async(job_pool.schedule(wrapped)); - - let (_, state) = entry.insert(jobid.into(), JobState::Pending(promise)); - - state - } - } - } - - pub fn get(&self, jobid: &JobId) -> Option<&JobState> { - self.jobs.get(jobid) - } -} - -impl<'a> From<&JobId<'a>> for JobIdOwned { - fn from(jobid: &JobId<'a>) -> Self { - match jobid { - JobId::Blurhash(s) => JobIdOwned::Blurhash(s.to_string()), - } - } -} - -impl hashbrown::Equivalent<JobIdOwned> for JobId<'_> { - fn equivalent(&self, key: &JobIdOwned) -> bool { - match (self, key) { - (JobId::Blurhash(a), JobIdOwned::Blurhash(b)) => *a == b.as_str(), - } - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Hash)] -enum JobIdOwned { - Blurhash(String), // image URL -} - -#[derive(Debug, Hash)] -pub enum JobId<'a> { - Blurhash(&'a str), // image URL -} - -pub enum Job { - Blurhash(Option<TextureHandle>), -} - -impl std::fmt::Debug for Job { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Job::Blurhash(_) => write!(f, "Blurhash"), - } - } -} diff --git a/crates/notedeck/src/jobs/cache.rs b/crates/notedeck/src/jobs/cache.rs @@ -0,0 +1,201 @@ +use std::{ + collections::HashSet, + fmt::Debug, + hash::Hash, + sync::mpsc::{Receiver, Sender}, +}; + +use crossbeam::queue::SegQueue; + +use crate::jobs::types::{ + AsyncJob, JobAccess, JobComplete, JobId, JobIdAccessible, JobOutput, JobPackage, JobRun, + NoOutputRun, RunType, +}; +use crate::jobs::JobPool; + +type CompletionQueue<K, T> = std::sync::Arc<SegQueue<JobComplete<K, T>>>; + +pub struct JobCache<K, T: 'static> { + receive_new_jobs: Receiver<JobPackage<K, T>>, + running: HashSet<JobId<K>>, + send_new_jobs: Sender<JobPackage<K, T>>, + completed: CompletionQueue<K, T>, +} + +impl<K, T> JobCache<K, T> +where + K: Hash + Eq + Clone + Debug + Send + 'static, + T: Send + 'static, +{ + pub fn new( + receive_new_jobs: Receiver<JobPackage<K, T>>, + send_new_jobs: Sender<JobPackage<K, T>>, + ) -> Self { + Self { + receive_new_jobs, + send_new_jobs, + completed: Default::default(), + running: Default::default(), + } + } + + pub fn run_received(&mut self, pool: &mut JobPool, mut pre_action: impl FnMut(&JobId<K>)) { + for pkg in self.receive_new_jobs.try_iter() { + let id = &pkg.id; + if JobAccess::Public == id.access && self.running.contains(&id.job_id) { + tracing::warn!("Ignoring request to run {id:?} since it's already running"); + continue; + } + self.running.insert(id.job_id.clone()); + + let job_run = match pkg.run { + RunType::NoOutput(run) => { + no_output_run(pool, run); + continue; + } + RunType::Output(job_run) => job_run, + }; + + pre_action(&id.job_id); + + run_received_job( + job_run, + pool, + self.send_new_jobs.clone(), + self.completed.clone(), + pkg.id, + ); + } + } + + pub fn deliver_all_completed(&mut self, mut deliver_complete: impl FnMut(JobComplete<K, T>)) { + while let Some(res) = self.completed.pop() { + tracing::trace!("Got completed: {:?}", res.job_id); + let id = res.job_id.clone(); + deliver_complete(res); + self.running.remove(&id); + } + } + + pub fn sender(&self) -> &Sender<JobPackage<K, T>> { + &self.send_new_jobs + } +} + +fn run_received_job<K, T>( + job_run: JobRun<T>, + pool: &mut JobPool, + send_new_jobs: Sender<JobPackage<K, T>>, + completion_queue: CompletionQueue<K, T>, + id: JobIdAccessible<K>, +) where + K: Hash + Eq + Clone + Debug + Send + 'static, + T: Send + 'static, +{ + match job_run { + JobRun::Sync(run) => { + run_sync(pool, send_new_jobs, completion_queue, id, run); + } + JobRun::Async(run) => { + run_async(send_new_jobs, completion_queue, id, run); + } + } +} + +fn run_sync<F, K, T>( + job_pool: &mut JobPool, + send_new_jobs: Sender<JobPackage<K, T>>, + completion_queue: CompletionQueue<K, T>, + id: JobIdAccessible<K>, + run_job: F, +) where + F: FnOnce() -> JobOutput<T> + Send + 'static, + K: Hash + Eq + Clone + Debug + Send + 'static, + T: Send + 'static, +{ + let id_c = id.clone(); + let wrapped: Box<dyn FnOnce() + Send + 'static> = Box::new(move || { + let res = run_job(); + match res { + JobOutput::Complete(complete_response) => { + completion_queue.push(JobComplete { + job_id: id.job_id.clone(), + response: complete_response.response, + }); + if let Some(run) = complete_response.run_no_output { + if let Err(e) = send_new_jobs.send(JobPackage { + id: id.into_internal(), + run: RunType::NoOutput(run), + }) { + tracing::error!("{e}"); + } + } + } + JobOutput::Next(job_run) => { + if let Err(e) = send_new_jobs.send(JobPackage { + id: id.into_internal(), + run: RunType::Output(job_run), + }) { + tracing::error!("{e}"); + } + } + } + }); + + tracing::trace!("Spawning sync job: {id_c:?}"); + job_pool.schedule_no_output(wrapped); +} + +fn run_async<K, T>( + send_new_jobs: Sender<JobPackage<K, T>>, + completion_queue: CompletionQueue<K, T>, + id: JobIdAccessible<K>, + run_job: AsyncJob<T>, +) where + K: Hash + Eq + Clone + Debug + Send + 'static, + T: Send + 'static, +{ + tracing::trace!("Spawning async job: {id:?}"); + tokio::spawn(async move { + { + let res = run_job.await; + match res { + JobOutput::Complete(complete_response) => { + completion_queue.push(JobComplete { + job_id: id.job_id.clone(), + response: complete_response.response, + }); + if let Some(run) = complete_response.run_no_output { + if let Err(e) = send_new_jobs.send(JobPackage { + id: id.into_internal(), + run: RunType::NoOutput(run), + }) { + tracing::error!("{e}"); + } + } + } + JobOutput::Next(job_run) => { + if let Err(e) = send_new_jobs.send(JobPackage { + id: id.into_internal(), + run: RunType::Output(job_run), + }) { + tracing::error!("{e}"); + } + } + } + } + }); +} + +fn no_output_run(pool: &mut JobPool, run: NoOutputRun) { + match run { + NoOutputRun::Sync(c) => { + tracing::trace!("Spawning no output sync job"); + pool.schedule_no_output(c); + } + NoOutputRun::Async(f) => { + tracing::trace!("Spawning no output async sync job"); + tokio::spawn(f); + } + } +} diff --git a/crates/notedeck/src/jobs/job_pool.rs b/crates/notedeck/src/jobs/job_pool.rs @@ -0,0 +1,102 @@ +use crossbeam::channel; +use std::future::Future; +use tokio::sync::oneshot::{self, Receiver}; + +type Job = Box<dyn FnOnce() + Send + 'static>; + +pub struct JobPool { + tx: channel::Sender<Job>, +} + +impl Default for JobPool { + fn default() -> Self { + JobPool::new(2) + } +} + +impl JobPool { + pub fn new(num_threads: usize) -> Self { + let (tx, rx) = channel::unbounded::<Job>(); + for i in 0..num_threads { + let rx = rx.clone(); + std::thread::spawn(move || { + for job in rx.iter() { + tracing::trace!("Starting job on thread {i}"); + job(); + tracing::trace!("Finished job on thread {i}"); + } + }); + } + + Self { tx } + } + + pub fn schedule<F, T>(&self, job: F) -> impl Future<Output = T> + where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, + { + let rx_result = self.schedule_receivable(job); + async move { + rx_result.await.unwrap_or_else(|_| { + panic!("Worker thread or channel dropped before returning the result.") + }) + } + } + + pub fn schedule_receivable<F, T>(&self, job: F) -> Receiver<T> + where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, + { + let (tx_result, rx_result) = oneshot::channel::<T>(); + + let job = Box::new(move || { + let output = job(); + let _ = tx_result.send(output); + }); + + self.push_job(job); + + rx_result + } + + pub fn schedule_no_output(&self, job: impl FnOnce() + Send + 'static) { + self.push_job(Box::new(job)); + } + + fn push_job(&self, job: Job) { + if let Err(e) = self.tx.send(job) { + tracing::error!("job queue closed unexpectedly: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use crate::jobs::JobPool; + + fn test_fn(a: u32, b: u32) -> u32 { + a + b + } + + #[tokio::test] + async fn test() { + let pool = JobPool::default(); + + // Now each job can return different T + let future_str = pool.schedule(|| -> String { "hello from string job".into() }); + + let a = 5; + let b = 6; + let future_int = pool.schedule(move || -> u32 { test_fn(a, b) }); + + println!("(Meanwhile we can do more async work) ..."); + + let s = future_str.await; + let i = future_int.await; + + println!("Got string: {:?}", s); + println!("Got integer: {}", i); + } +} diff --git a/crates/notedeck/src/jobs/media.rs b/crates/notedeck/src/jobs/media.rs @@ -0,0 +1,78 @@ +use std::sync::mpsc::Sender; + +use egui::TextureHandle; + +use crate::jobs::JobCache; +use crate::{Animation, Error, TextureState, TexturesCache}; + +use crate::jobs::types::{JobComplete, JobId, JobPackage}; + +pub type MediaJobs = JobCache<MediaJobKind, MediaJobResult>; +pub type MediaJobSender = Sender<JobPackage<MediaJobKind, MediaJobResult>>; + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub enum MediaJobKind { + Blurhash, + StaticImg, + AnimatedImg, +} + +pub enum MediaJobResult { + StaticImg(Result<TextureHandle, Error>), + Blurhash(Result<TextureHandle, Error>), + Animation(Result<Animation, Error>), +} + +pub fn deliver_completed_media_job( + completed: JobComplete<MediaJobKind, MediaJobResult>, + tex_cache: &mut TexturesCache, +) { + let id = completed.job_id.id; + let id_c = id.clone(); + match completed.response { + MediaJobResult::StaticImg(job_complete) => { + let r = match job_complete { + Ok(t) => TextureState::Loaded(t), + Err(e) => TextureState::Error(e), + }; + tex_cache.static_image.cache.insert(id, r); + } + MediaJobResult::Animation(animation) => { + let r = match animation { + Ok(a) => TextureState::Loaded(a), + Err(e) => TextureState::Error(e), + }; + + tex_cache.animated.cache.insert(id, r); + } + MediaJobResult::Blurhash(texture_handle) => { + let r = match texture_handle { + Ok(t) => TextureState::Loaded(t), + Err(e) => TextureState::Error(e), + }; + tex_cache.blurred.cache.insert(id, r.into()); + } + } + tracing::trace!("Delivered job for {id_c}"); +} + +pub fn run_media_job_pre_action(job_id: &JobId<MediaJobKind>, tex_cache: &mut TexturesCache) { + let id = job_id.id.clone(); + match job_id.job_kind { + MediaJobKind::Blurhash => { + tex_cache + .blurred + .cache + .insert(id, TextureState::Pending.into()); + } + MediaJobKind::StaticImg => { + tex_cache + .static_image + .cache + .insert(id, TextureState::Pending); + } + MediaJobKind::AnimatedImg => { + tex_cache.animated.cache.insert(id, TextureState::Pending); + } + } +} diff --git a/crates/notedeck/src/jobs/mod.rs b/crates/notedeck/src/jobs/mod.rs @@ -0,0 +1,14 @@ +mod cache; +mod job_pool; +mod media; +pub(crate) mod types; + +pub use crate::jobs::types::{ + CompleteResponse, JobOutput, JobPackage, JobRun, NoOutputRun, RunType, +}; +pub use cache::JobCache; +pub use job_pool::JobPool; +pub use media::{ + deliver_completed_media_job, run_media_job_pre_action, MediaJobKind, MediaJobResult, + MediaJobSender, MediaJobs, +}; diff --git a/crates/notedeck/src/jobs/types.rs b/crates/notedeck/src/jobs/types.rs @@ -0,0 +1,100 @@ +use std::{future::Future, pin::Pin}; + +pub enum JobOutput<T> { + Complete(CompleteResponse<T>), + Next(JobRun<T>), +} + +impl<T> JobOutput<T> { + pub fn complete(response: T) -> Self { + JobOutput::Complete(CompleteResponse::new(response)) + } +} + +pub struct CompleteResponse<T> { + pub(crate) response: T, + pub(crate) run_no_output: Option<NoOutputRun>, +} + +pub struct JobComplete<K, T> { + pub job_id: JobId<K>, + pub response: T, +} + +impl<T> CompleteResponse<T> { + pub fn new(response: T) -> Self { + Self { + response, + run_no_output: None, + } + } + + pub fn run_no_output(mut self, run: NoOutputRun) -> Self { + self.run_no_output = Some(run); + self + } +} + +pub enum NoOutputRun { + Sync(Box<dyn FnOnce() + Send + 'static>), + Async(Pin<Box<dyn Future<Output = ()> + Send + 'static>>), +} + +pub(crate) type SyncJob<T> = Box<dyn FnOnce() -> JobOutput<T> + Send + 'static>; +pub(crate) type AsyncJob<T> = Pin<Box<dyn Future<Output = JobOutput<T>> + Send + 'static>>; + +pub enum JobRun<T> { + Sync(SyncJob<T>), + Async(AsyncJob<T>), +} + +pub struct JobPackage<K, T> { + pub(crate) id: JobIdAccessible<K>, + pub(crate) run: RunType<T>, +} + +impl<K, T> JobPackage<K, T> { + pub fn new(id: String, job_kind: K, run: RunType<T>) -> Self { + Self { + id: JobIdAccessible::new_public(id, job_kind), + run, + } + } +} + +pub enum RunType<T> { + NoOutput(NoOutputRun), + Output(JobRun<T>), +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub struct JobId<K> { + pub id: String, + pub job_kind: K, +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub(crate) enum JobAccess { + Public, // Jobs requested outside the cache + Internal, // Jobs requested inside the cache +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub(crate) struct JobIdAccessible<K> { + pub access: JobAccess, + pub job_id: JobId<K>, +} + +impl<K> JobIdAccessible<K> { + pub fn new_public(id: String, job_kind: K) -> Self { + Self { + job_id: JobId { id, job_kind }, + access: JobAccess::Public, + } + } + + pub fn into_internal(mut self) -> Self { + self.access = JobAccess::Internal; + self + } +} diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -13,8 +13,7 @@ pub mod fonts; mod frame_history; pub mod i18n; mod imgcache; -mod job_pool; -mod jobs; +pub mod jobs; pub mod media; mod muted; pub mod name; @@ -55,17 +54,16 @@ pub use filter::{FilterState, FilterStates, UnifiedSubscription}; pub use fonts::NamedFontFamily; pub use i18n::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization}; pub use imgcache::{ - get_render_state, Animation, GifState, GifStateMap, ImageFrame, Images, LatestTexture, - LoadableTextureState, MediaCache, MediaCacheType, RenderState, TextureFrame, TextureState, - TexturedImage, TexturesCache, + Animation, GifState, GifStateMap, ImageFrame, Images, LatestTexture, MediaCache, + MediaCacheType, TextureFrame, TextureState, TexturesCache, }; -pub use job_pool::JobPool; pub use jobs::{ - BlurhashParams, Job, JobError, JobId, JobParams, JobParamsOwned, JobState, JobsCache, + deliver_completed_media_job, run_media_job_pre_action, JobCache, JobPool, MediaJobSender, + MediaJobs, }; pub use media::{ - compute_blurhash, update_imeta_blurhashes, ImageMetadata, ImageType, MediaAction, - ObfuscationType, PixelDimensions, PointDimensions, RenderableMedia, + update_imeta_blurhashes, ImageMetadata, ImageType, MediaAction, ObfuscationType, + PixelDimensions, PointDimensions, RenderableMedia, }; pub use muted::{MuteFun, Muted}; pub use name::NostrName; diff --git a/crates/notedeck/src/media/action.rs b/crates/notedeck/src/media/action.rs @@ -1,5 +1,4 @@ -use crate::{Images, MediaCacheType, TexturedImage}; -use poll_promise::Promise; +use crate::{jobs::MediaJobSender, ImageType, Images, MediaCacheType}; /// Tracks where media was on the screen so that /// we can do fun animations when opening the @@ -41,8 +40,8 @@ pub enum MediaAction { FetchImage { url: String, cache_type: MediaCacheType, - no_pfp_promise: Promise<Option<Result<TexturedImage, crate::Error>>>, }, + // A media is "done loading" when it has the actual media and it has reached the peak of a shimmer, to transition smoothly DoneLoading { url: String, cache_type: MediaCacheType, @@ -60,15 +59,10 @@ impl std::fmt::Debug for MediaAction { .field("clicked_index", clicked_index) .field("media", medias) .finish(), - Self::FetchImage { - url, - cache_type, - no_pfp_promise, - } => f + Self::FetchImage { url, cache_type } => f .debug_struct("FetchNoPfpImage") .field("url", url) .field("cache_type", cache_type) - .field("no_pfp_promise ready", &no_pfp_promise.ready().is_some()) .finish(), Self::DoneLoading { url, cache_type } => f .debug_struct("DoneLoading") @@ -89,7 +83,12 @@ impl MediaAction { /// Default processing logic for Media Actions. We don't handle ViewMedias here since /// this may be app specific ? - pub fn process_default_media_actions(self, images: &mut Images) { + pub fn process_default_media_actions( + self, + images: &mut Images, + jobs: &MediaJobSender, + ctx: &egui::Context, + ) { match self { MediaAction::ViewMedias(_urls) => { // NOTE(jb55): don't assume we want to show a fullscreen @@ -104,23 +103,22 @@ impl MediaAction { //mview_state.set_urls(urls); } - MediaAction::FetchImage { - url, - cache_type, - no_pfp_promise: promise, - } => { - images - .get_cache_mut(cache_type) - .textures_cache - .insert_pending(&url, promise); - } - MediaAction::DoneLoading { url, cache_type } => { - let cache = match cache_type { - MediaCacheType::Image => &mut images.static_imgs, - MediaCacheType::Gif => &mut images.gifs, - }; - - cache.textures_cache.move_to_loaded(&url); + MediaAction::FetchImage { url, cache_type } => match cache_type { + MediaCacheType::Image => { + images + .textures + .static_image + .request(jobs, ctx, &url, ImageType::Content(None)) + } + MediaCacheType::Gif => { + images + .textures + .animated + .request(jobs, ctx, &url, ImageType::Content(None)) + } + }, + MediaAction::DoneLoading { url, cache_type: _ } => { + images.textures.blurred.finished_transitioning(&url); } } } diff --git a/crates/notedeck/src/media/blur.rs b/crates/notedeck/src/media/blur.rs @@ -1,10 +1,15 @@ use std::collections::HashMap; +use egui::TextureHandle; use nostrdb::Note; use crate::{ - jobs::{Job, JobError, JobParamsOwned}, + jobs::{ + CompleteResponse, JobOutput, JobPackage, JobRun, MediaJobKind, MediaJobResult, + MediaJobSender, RunType, + }, media::load_texture_checked, + TextureState, }; #[derive(Clone)] @@ -165,33 +170,6 @@ pub enum ObfuscationType { Default, } -pub fn compute_blurhash( - params: Option<JobParamsOwned>, - dims: PixelDimensions, -) -> Result<Job, JobError> { - #[allow(irrefutable_let_patterns)] - let Some(JobParamsOwned::Blurhash(params)) = params - else { - return Err(JobError::InvalidParameters); - }; - - let maybe_handle = match generate_blurhash_texturehandle( - &params.ctx, - &params.blurhash, - &params.url, - dims.x, - dims.y, - ) { - Ok(tex) => Some(tex), - Err(e) => { - tracing::error!("failed to render blurhash: {e}"); - None - } - }; - - Ok(Job::Blurhash(maybe_handle)) -} - fn generate_blurhash_texturehandle( ctx: &egui::Context, blurhash: &str, @@ -205,3 +183,81 @@ fn generate_blurhash_texturehandle( let img = egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &bytes); Ok(load_texture_checked(ctx, url, img, Default::default())) } + +#[derive(Default)] +pub struct BlurCache { + pub(crate) cache: HashMap<String, BlurState>, +} + +pub struct BlurState { + pub tex_state: TextureState<TextureHandle>, + pub finished_transitioning: bool, +} + +impl From<TextureState<TextureHandle>> for BlurState { + fn from(value: TextureState<TextureHandle>) -> Self { + BlurState { + tex_state: value, + finished_transitioning: false, + } + } +} + +impl BlurCache { + pub fn get(&self, url: &str) -> Option<&BlurState> { + self.cache.get(url) + } + + pub fn get_or_request( + &self, + jobs: &MediaJobSender, + ui: &egui::Ui, + url: &str, + blurhash: &ImageMetadata, + size: egui::Vec2, + ) -> &BlurState { + if let Some(res) = self.cache.get(url) { + return res; + } + + let available_points = PointDimensions { + x: size.x, + y: size.y, + }; + let pixel_sizes = blurhash.scaled_pixel_dimensions(ui, available_points); + let blurhash = blurhash.blurhash.to_owned(); + let url = url.to_owned(); + let ctx = ui.ctx().clone(); + + if let Err(e) = jobs.send(JobPackage::new( + url.to_owned(), + MediaJobKind::Blurhash, + RunType::Output(JobRun::Sync(Box::new(move || { + tracing::trace!("Starting blur job for {url}"); + let res = generate_blurhash_texturehandle( + &ctx, + &blurhash, + &url, + pixel_sizes.x, + pixel_sizes.y, + ); + JobOutput::Complete(CompleteResponse::new(MediaJobResult::Blurhash(res))) + }))), + )) { + tracing::error!("{e}"); + } + + &BlurState { + tex_state: TextureState::Pending, + finished_transitioning: false, + } + } + + pub fn finished_transitioning(&mut self, url: &str) { + let Some(state) = self.cache.get_mut(url) else { + return; + }; + + state.finished_transitioning = true; + } +} diff --git a/crates/notedeck/src/media/gif.rs b/crates/notedeck/src/media/gif.rs @@ -1,114 +1,45 @@ use std::{ - sync::mpsc::TryRecvError, + collections::{HashMap, VecDeque}, + io::Cursor, + path::PathBuf, time::{Instant, SystemTime}, }; -use crate::media::AnimationMode; -use crate::Animation; -use crate::{GifState, GifStateMap, TextureState, TexturedImage, TexturesCache}; -use egui::TextureHandle; +use crate::GifState; +use crate::{ + jobs::{ + CompleteResponse, JobOutput, JobPackage, JobRun, MediaJobKind, MediaJobResult, + MediaJobSender, NoOutputRun, RunType, + }, + media::{ + images::{buffer_to_color_image, process_image}, + load_texture_checked, + }, + Error, ImageFrame, ImageType, MediaCache, TextureFrame, TextureState, +}; +use crate::{media::AnimationMode, Animation}; +use egui::{ColorImage, TextureHandle}; +use image::{codecs::gif::GifDecoder, AnimationDecoder, DynamicImage, Frame}; use std::time::Duration; -pub fn ensure_latest_texture_from_cache( - ui: &egui::Ui, - url: &str, - gifs: &mut GifStateMap, - textures: &mut TexturesCache, - animation_mode: AnimationMode, -) -> Option<TextureHandle> { - let tstate = textures.cache.get_mut(url)?; - - let TextureState::Loaded(img) = tstate.into() else { - return None; - }; - - Some(ensure_latest_texture(ui, url, gifs, img, animation_mode)) -} - -struct ProcessedGifFrame { - texture: TextureHandle, - maybe_new_state: Option<GifState>, - repaint_at: Option<SystemTime>, +pub(crate) struct ProcessedGifFrame<'a> { + pub texture: &'a TextureHandle, + pub maybe_new_state: Option<GifState>, + pub repaint_at: Option<SystemTime>, } /// Process a gif state frame, and optionally present a new /// state and when to repaint it -fn process_gif_frame( - animation: &Animation, +pub(crate) fn process_gif_frame<'a>( + animation: &'a Animation, frame_state: Option<&GifState>, animation_mode: AnimationMode, -) -> ProcessedGifFrame { +) -> ProcessedGifFrame<'a> { let now = Instant::now(); - match frame_state { - Some(prev_state) => { - let should_advance = animation_mode.can_animate() - && (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 = match animation_mode { - AnimationMode::Continuous { fps } => match fps { - Some(fps) => { - let max_delay_ms = Duration::from_millis((1000.0 / fps) as u64); - SystemTime::now().checked_add(frame.delay.max(max_delay_ms)) - } - None => SystemTime::now().checked_add(frame.delay), - }, - - AnimationMode::NoAnimation | AnimationMode::Reactive => None, - }; - - ProcessedGifFrame { - texture: frame.texture.clone(), - maybe_new_state: Some(GifState { - last_frame_rendered: now, - last_frame_duration: frame.delay, - next_frame_time, - last_frame_index: maybe_new_index, - }), - repaint_at: next_frame_time, - } - } - None => { - let (texture, maybe_new_state) = - match animation.get_frame(prev_state.last_frame_index) { - Some(frame) => (frame.texture.clone(), None), - None => (animation.first_frame.texture.clone(), None), - }; - - ProcessedGifFrame { - texture, - maybe_new_state, - repaint_at: prev_state.next_frame_time, - } - } - } - } else { - let (texture, maybe_new_state) = - match animation.get_frame(prev_state.last_frame_index) { - Some(frame) => (frame.texture.clone(), None), - None => (animation.first_frame.texture.clone(), None), - }; - - ProcessedGifFrame { - texture, - maybe_new_state, - repaint_at: prev_state.next_frame_time, - } - } - } - None => ProcessedGifFrame { - texture: animation.first_frame.texture.clone(), + let Some(prev_state) = frame_state else { + return ProcessedGifFrame { + texture: &animation.first_frame.texture, maybe_new_state: Some(GifState { last_frame_rendered: now, last_frame_duration: animation.first_frame.delay, @@ -116,49 +47,274 @@ fn process_gif_frame( last_frame_index: 0, }), repaint_at: None, + }; + }; + + let should_advance = animation_mode.can_animate() + && (now - prev_state.last_frame_rendered >= prev_state.last_frame_duration); + + if !should_advance { + let (texture, maybe_new_state) = match animation.get_frame(prev_state.last_frame_index) { + Some(frame) => (&frame.texture, None), + None => (&animation.first_frame.texture, None), + }; + + return ProcessedGifFrame { + texture, + maybe_new_state, + repaint_at: prev_state.next_frame_time, + }; + } + + let maybe_new_index = if prev_state.last_frame_index < animation.num_frames() - 1 { + prev_state.last_frame_index + 1 + } else { + 0 + }; + + let Some(frame) = animation.get_frame(maybe_new_index) else { + let (texture, maybe_new_state) = match animation.get_frame(prev_state.last_frame_index) { + Some(frame) => (&frame.texture, None), + None => (&animation.first_frame.texture, None), + }; + + return ProcessedGifFrame { + texture, + maybe_new_state, + repaint_at: prev_state.next_frame_time, + }; + }; + + let next_frame_time = match animation_mode { + AnimationMode::Continuous { fps } => match fps { + Some(fps) => { + let max_delay_ms = Duration::from_millis((1000.0 / fps) as u64); + SystemTime::now().checked_add(frame.delay.max(max_delay_ms)) + } + None => SystemTime::now().checked_add(frame.delay), }, + + AnimationMode::NoAnimation | AnimationMode::Reactive => None, + }; + + ProcessedGifFrame { + texture: &frame.texture, + maybe_new_state: Some(GifState { + last_frame_rendered: now, + last_frame_duration: frame.delay, + next_frame_time, + last_frame_index: maybe_new_index, + }), + repaint_at: next_frame_time, } } -pub fn ensure_latest_texture( - ui: &egui::Ui, - url: &str, - gifs: &mut GifStateMap, - img: &mut TexturedImage, - animation_mode: AnimationMode, -) -> TextureHandle { - match img { - TexturedImage::Static(handle) => handle.clone(), - 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; - } - } - } - } +pub struct AnimatedImgTexCache { + pub(crate) cache: HashMap<String, TextureState<Animation>>, + animated_img_cache_path: PathBuf, +} - let next_state = process_gif_frame(animation, gifs.get(url), animation_mode); +impl AnimatedImgTexCache { + pub fn new(animated_img_cache_path: PathBuf) -> Self { + Self { + cache: Default::default(), + animated_img_cache_path, + } + } + + pub fn contains(&self, url: &str) -> bool { + self.cache.contains_key(url) + } - if let Some(new_state) = next_state.maybe_new_state { - gifs.insert(url.to_owned(), new_state); + pub fn get(&self, url: &str) -> Option<&TextureState<Animation>> { + self.cache.get(url) + } + + pub fn request( + &self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + imgtype: ImageType, + ) { + let _ = self.get_or_request(jobs, ctx, url, imgtype); + } + + pub fn get_or_request( + &self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + imgtype: ImageType, + ) -> &TextureState<Animation> { + if let Some(res) = self.cache.get(url) { + return res; + }; + + let key = MediaCache::key(url); + let path = self.animated_img_cache_path.join(key); + let ctx = ctx.clone(); + let url = url.to_owned(); + if path.exists() { + if let Err(e) = jobs.send(JobPackage::new( + url.to_owned(), + MediaJobKind::AnimatedImg, + RunType::Output(JobRun::Sync(Box::new(move || { + from_disk_job_run(ctx, url, path) + }))), + )) { + tracing::error!("{e}"); } + } else { + let anim_path = self.animated_img_cache_path.clone(); + if let Err(e) = jobs.send(JobPackage::new( + url.to_owned(), + MediaJobKind::AnimatedImg, + RunType::Output(JobRun::Async(Box::pin(from_net_run( + ctx, url, anim_path, imgtype, + )))), + )) { + tracing::error!("{e}"); + } + } + + &TextureState::Pending + } +} - if let Some(repaint) = next_state.repaint_at { - tracing::trace!("requesting repaint for {url} after {repaint:?}"); - if let Ok(dur) = repaint.duration_since(SystemTime::now()) { - ui.ctx().request_repaint_after(dur); +fn from_disk_job_run(ctx: egui::Context, url: String, path: PathBuf) -> JobOutput<MediaJobResult> { + tracing::trace!("Starting animated from disk job for {url}"); + let gif_bytes = match std::fs::read(path.clone()) { + Ok(b) => b, + Err(e) => { + return JobOutput::Complete(CompleteResponse::new(MediaJobResult::Animation(Err( + Error::Io(e), + )))) + } + }; + JobOutput::Complete(CompleteResponse::new(MediaJobResult::Animation( + generate_anim_pkg(ctx.clone(), url.to_owned(), gif_bytes, |img| { + buffer_to_color_image(img.as_flat_samples_u8(), img.width(), img.height()) + }) + .map(|f| f.anim), + ))) +} + +async fn from_net_run( + ctx: egui::Context, + url: String, + path: PathBuf, + imgtype: ImageType, +) -> JobOutput<MediaJobResult> { + let res = match crate::media::network::http_req(&url).await { + Ok(r) => r, + Err(e) => { + return JobOutput::complete(MediaJobResult::Animation(Err(crate::Error::Generic( + format!("Http error: {e}"), + )))); + } + }; + + JobOutput::Next(JobRun::Sync(Box::new(move || { + tracing::trace!("Starting animated img from net job for {url}"); + let animation = + match generate_anim_pkg(ctx.clone(), url.to_owned(), res.bytes, move |img| { + process_image(imgtype, img) + }) { + Ok(a) => a, + Err(e) => { + return JobOutput::Complete(CompleteResponse::new(MediaJobResult::Animation( + Err(e), + ))); } - } + }; + JobOutput::Complete( + CompleteResponse::new(MediaJobResult::Animation(Ok(animation.anim))).run_no_output( + NoOutputRun::Sync(Box::new(move || { + tracing::trace!("writing animated texture to file for {url}"); + if let Err(e) = MediaCache::write_gif(&path, &url, animation.img_frames) { + tracing::error!("Could not write gif to disk: {e}"); + } + })), + ), + ) + }))) +} + +fn generate_anim_pkg( + ctx: egui::Context, + url: String, + gif_bytes: Vec<u8>, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, +) -> Result<AnimationPackage, Error> { + let decoder = { + let reader = Cursor::new(gif_bytes.as_slice()); + GifDecoder::new(reader)? + }; + + let frames: VecDeque<Frame> = decoder + .into_frames() + .collect::<std::result::Result<VecDeque<_>, image::ImageError>>() + .map_err(|e| crate::Error::Generic(e.to_string()))?; + + let mut imgs = Vec::new(); + let mut other_frames = Vec::new(); - next_state.texture + let mut first_frame = None; + for (i, frame) in frames.into_iter().enumerate() { + let delay = frame.delay(); + let img = generate_color_img_frame(frame, process_to_egui); + imgs.push(ImageFrame { + delay: delay.into(), + image: img.clone(), + }); + + let tex_frame = generate_animation_frame(&ctx, &url, i, delay.into(), img); + + if first_frame.is_none() { + first_frame = Some(tex_frame); + } else { + other_frames.push(tex_frame); } } + + let Some(first_frame) = first_frame else { + return Err(crate::Error::Generic( + "first frame not found for gif".to_owned(), + )); + }; + + Ok(AnimationPackage { + anim: Animation { + first_frame, + other_frames, + }, + img_frames: imgs, + }) +} + +struct AnimationPackage { + anim: Animation, + img_frames: Vec<ImageFrame>, +} + +fn generate_color_img_frame( + frame: image::Frame, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, +) -> ColorImage { + let img = DynamicImage::ImageRgba8(frame.into_buffer()); + process_to_egui(img) +} + +fn generate_animation_frame( + ctx: &egui::Context, + url: &str, + index: usize, + delay: Duration, + color_img: ColorImage, +) -> TextureFrame { + TextureFrame { + delay, + texture: load_texture_checked(ctx, format!("{url}{index}"), color_img, Default::default()), + } } diff --git a/crates/notedeck/src/media/images.rs b/crates/notedeck/src/media/images.rs @@ -1,19 +1,8 @@ -use crate::media::load_texture_checked; -use crate::{Animation, ImageFrame, MediaCache, MediaCacheType, TextureFrame, TexturedImage}; -use egui::{pos2, Color32, ColorImage, Context, Rect, Sense, SizeHint}; -use image::codecs::gif::GifDecoder; +use crate::media::network::HyperHttpResponse; +use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; use image::imageops::FilterType; -use image::{AnimationDecoder, DynamicImage, FlatSamples, Frame}; -use poll_promise::Promise; -use std::collections::VecDeque; -use std::io::Cursor; +use image::FlatSamples; use std::path::PathBuf; -use std::path::{self, Path}; -use std::sync::mpsc; -use std::sync::mpsc::SyncSender; -use std::thread; -use std::time::Duration; -use tokio::fs; // NOTE(jb55): chatgpt wrote this because I was too dumb to pub fn aspect_fill( @@ -137,7 +126,7 @@ fn resize_image_if_too_big( /// - resize if any larger, using [`resize_image_if_too_big`] /// #[profiling::function] -fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { +pub fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { const MAX_IMG_LENGTH: u32 = 2048; const FILTER_TYPE: FilterType = FilterType::CatmullRom; @@ -184,11 +173,11 @@ fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImag } #[profiling::function] -fn parse_img_response( - response: ehttp::Response, +pub fn parse_img_response( + response: HyperHttpResponse, imgtyp: ImageType, ) -> Result<ColorImage, crate::Error> { - let content_type = response.content_type().unwrap_or_default(); + let content_type = response.content_type.unwrap_or_default(); let size_hint = match imgtyp { ImageType::Profile(size) => SizeHint::Size(size, size), ImageType::Content(Some((w, h))) => SizeHint::Size(w, h), @@ -211,167 +200,7 @@ fn parse_img_response( } } -fn fetch_img_from_disk( - ctx: &egui::Context, - url: &str, - path: &path::Path, - cache_type: MediaCacheType, -) -> Promise<Option<Result<TexturedImage, crate::Error>>> { - let ctx = ctx.clone(); - let url = url.to_owned(); - let path = path.to_owned(); - - Promise::spawn_async(async move { - Some(async_fetch_img_from_disk(ctx, url, &path, cache_type).await) - }) -} - -async fn async_fetch_img_from_disk( - ctx: egui::Context, - url: String, - path: &path::Path, - cache_type: MediaCacheType, -) -> Result<TexturedImage, crate::Error> { - match cache_type { - MediaCacheType::Image => { - let data = fs::read(path).await?; - let image_buffer = image::load_from_memory(&data).map_err(crate::Error::Image)?; - - let img = buffer_to_color_image( - image_buffer.as_flat_samples_u8(), - image_buffer.width(), - image_buffer.height(), - ); - Ok(TexturedImage::Static(load_texture_checked( - &ctx, - &url, - img, - Default::default(), - ))) - } - MediaCacheType::Gif => { - let gif_bytes = fs::read(path).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, crate::Error> { - 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| crate::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::debug!("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(crate::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: load_texture_checked(ctx, format!("{url}{index}"), color_img, Default::default()), - } -} - -fn buffer_to_color_image( +pub fn buffer_to_color_image( samples: Option<FlatSamples<&[u8]>>, width: u32, height: u32, @@ -393,89 +222,3 @@ pub enum ImageType { /// Content Image with optional size hint Content(Option<(u32, u32)>), } - -pub fn fetch_img( - img_cache_path: &Path, - ctx: &egui::Context, - url: &str, - imgtyp: ImageType, - cache_type: MediaCacheType, -) -> Promise<Option<Result<TexturedImage, crate::Error>>> { - let key = MediaCache::key(url); - let path = img_cache_path.join(key); - - if path.exists() { - fetch_img_from_disk(ctx, url, &path, cache_type) - } else { - fetch_img_from_net(img_cache_path, ctx, url, imgtyp, cache_type) - } - - // TODO: fetch image from local cache -} - -fn fetch_img_from_net( - cache_path: &path::Path, - ctx: &egui::Context, - url: &str, - imgtyp: ImageType, - cache_type: MediaCacheType, -) -> Promise<Option<Result<TexturedImage, crate::Error>>> { - 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(crate::Error::Generic).and_then(|resp| { - match cache_type { - MediaCacheType::Image => { - let img = parse_img_response(resp, imgtyp); - img.map(|img| { - let texture_handle = load_texture_checked( - &ctx, - &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_image(imgtyp, img), - ) - } - } - }); - - sender.send(Some(handle)); // send the results back to the UI thread. - ctx.request_repaint(); - }); - - promise -} - -pub fn fetch_no_pfp_promise( - ctx: &Context, - cache: &MediaCache, -) -> Promise<Option<Result<TexturedImage, crate::Error>>> { - crate::media::images::fetch_img( - &cache.cache_dir, - ctx, - crate::profile::no_pfp_url(), - ImageType::Profile(128), - MediaCacheType::Image, - ) -} diff --git a/crates/notedeck/src/media/latest.rs b/crates/notedeck/src/media/latest.rs @@ -0,0 +1,229 @@ +use std::time::SystemTime; + +use egui::TextureHandle; + +use crate::jobs::MediaJobSender; +use crate::{ + media::{ + gif::{process_gif_frame, AnimatedImgTexCache}, + static_imgs::StaticImgTexCache, + AnimationMode, BlurCache, + }, + Error, GifStateMap, ImageType, MediaCacheType, ObfuscationType, TextureState, +}; + +pub enum MediaRenderState<'a> { + ActualImage(&'a TextureHandle), + Transitioning { + image: &'a TextureHandle, + obfuscation: ObfuscatedTexture<'a>, + }, + Error(&'a crate::Error), + Shimmering(ObfuscatedTexture<'a>), + Obfuscated(ObfuscatedTexture<'a>), +} + +pub enum ObfuscatedTexture<'a> { + Blur(&'a TextureHandle), + Default, +} + +pub struct NoLoadingLatestTex<'a> { + static_cache: &'a StaticImgTexCache, + animated_cache: &'a AnimatedImgTexCache, + gif_state: &'a mut GifStateMap, +} + +impl<'a> NoLoadingLatestTex<'a> { + pub fn new( + static_cache: &'a StaticImgTexCache, + animated_cache: &'a AnimatedImgTexCache, + gif_state: &'a mut GifStateMap, + ) -> Self { + Self { + static_cache, + animated_cache, + gif_state, + } + } + + pub fn latest( + &mut self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + cache_type: MediaCacheType, + imgtype: ImageType, + animation_mode: AnimationMode, + ) -> Option<&'a TextureHandle> { + let LatestImageTex::Loaded(tex) = + self.latest_state(jobs, ctx, url, cache_type, imgtype, animation_mode) + else { + return None; + }; + + Some(tex) + } + + pub fn latest_state( + &mut self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + cache_type: MediaCacheType, + imgtype: ImageType, + animation_mode: AnimationMode, + ) -> LatestImageTex<'a> { + match cache_type { + MediaCacheType::Image => { + match self.static_cache.get_or_request(jobs, ctx, url, imgtype) { + TextureState::Pending => LatestImageTex::Pending, + TextureState::Error(error) => LatestImageTex::Error(error), + TextureState::Loaded(t) => LatestImageTex::Loaded(t), + } + } + MediaCacheType::Gif => { + match self.animated_cache.get_or_request(jobs, ctx, url, imgtype) { + TextureState::Pending => LatestImageTex::Pending, + TextureState::Error(error) => LatestImageTex::Error(error), + TextureState::Loaded(animation) => { + let next_state = + process_gif_frame(animation, self.gif_state.get(url), animation_mode); + + if let Some(new_state) = next_state.maybe_new_state { + self.gif_state.insert(url.to_owned(), new_state); + } + + if let Some(repaint) = next_state.repaint_at { + tracing::trace!("requesting repaint for {url} after {repaint:?}"); + if let Ok(dur) = repaint.duration_since(SystemTime::now()) { + ctx.request_repaint_after(dur); + } + } + + LatestImageTex::Loaded(next_state.texture) + } + } + } + } + } +} + +pub enum LatestImageTex<'a> { + Pending, + Error(&'a Error), + Loaded(&'a TextureHandle), +} + +pub struct UntrustedMediaLatestTex<'a> { + blur_cache: &'a BlurCache, +} + +/// Media is untrusted and should only show a blur of the underlying media +impl<'a> UntrustedMediaLatestTex<'a> { + pub fn new(blur_cache: &'a BlurCache) -> Self { + Self { blur_cache } + } + + pub fn latest( + &self, + jobs: &MediaJobSender, + ui: &egui::Ui, + url: &str, + obfuscation_type: &'a ObfuscationType, + size: egui::Vec2, + ) -> MediaRenderState<'a> { + MediaRenderState::Obfuscated(self.latest_internal(jobs, ui, url, obfuscation_type, size)) + } + + fn latest_internal( + &self, + jobs: &MediaJobSender, + ui: &egui::Ui, + url: &str, + obfuscation_type: &'a ObfuscationType, + size: egui::Vec2, + ) -> ObfuscatedTexture<'a> { + let ObfuscationType::Blurhash(meta) = obfuscation_type else { + return ObfuscatedTexture::Default; + }; + + let state = self.blur_cache.get_or_request(jobs, ui, url, meta, size); + + match &state.tex_state { + TextureState::Pending | TextureState::Error(_) => ObfuscatedTexture::Default, + TextureState::Loaded(t) => ObfuscatedTexture::Blur(t), + } + } +} + +/// Media is trusted and should be loaded ASAP +pub struct TrustedMediaLatestTex<'a> { + img_no_loading: NoLoadingLatestTex<'a>, + blur_cache: &'a BlurCache, +} + +impl<'a> TrustedMediaLatestTex<'a> { + pub fn new(img_no_loading: NoLoadingLatestTex<'a>, blur_cache: &'a BlurCache) -> Self { + Self { + img_no_loading, + blur_cache, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn latest( + &mut self, + jobs: &MediaJobSender, + ui: &egui::Ui, + url: &str, + cache_type: MediaCacheType, + imgtype: ImageType, + animation_mode: AnimationMode, + obfuscation_type: &'a ObfuscationType, + size: egui::Vec2, + ) -> MediaRenderState<'a> { + let actual_latest_tex = self.img_no_loading.latest_state( + jobs, + ui.ctx(), + url, + cache_type, + imgtype, + animation_mode, + ); + + match actual_latest_tex { + LatestImageTex::Pending => (), + LatestImageTex::Error(error) => return MediaRenderState::Error(error), + LatestImageTex::Loaded(texture_handle) => { + let Some(blur) = self.blur_cache.get(url) else { + return MediaRenderState::ActualImage(texture_handle); + }; + + if blur.finished_transitioning { + return MediaRenderState::ActualImage(texture_handle); + }; + + let obfuscation = match &blur.tex_state { + TextureState::Pending | TextureState::Error(_) => ObfuscatedTexture::Default, + TextureState::Loaded(t) => ObfuscatedTexture::Blur(t), + }; + + return MediaRenderState::Transitioning { + image: texture_handle, + obfuscation, + }; + } + }; + + MediaRenderState::Shimmering( + UntrustedMediaLatestTex::new(self.blur_cache).latest_internal( + jobs, + ui, + url, + obfuscation_type, + size, + ), + ) + } +} diff --git a/crates/notedeck/src/media/mod.rs b/crates/notedeck/src/media/mod.rs @@ -3,15 +3,21 @@ pub mod blur; pub mod gif; pub mod images; pub mod imeta; +pub mod latest; +pub mod network; pub mod renderable; +pub mod static_imgs; pub use action::{MediaAction, MediaInfo, ViewMediaInfo}; pub use blur::{ - compute_blurhash, update_imeta_blurhashes, ImageMetadata, ObfuscationType, PixelDimensions, + update_imeta_blurhashes, BlurCache, ImageMetadata, ObfuscationType, PixelDimensions, PointDimensions, }; use egui::{ColorImage, TextureHandle}; pub use images::ImageType; +pub use latest::{ + MediaRenderState, NoLoadingLatestTex, TrustedMediaLatestTex, UntrustedMediaLatestTex, +}; pub use renderable::RenderableMedia; #[derive(Copy, Clone, Debug)] diff --git a/crates/notedeck/src/media/network.rs b/crates/notedeck/src/media/network.rs @@ -0,0 +1,177 @@ +use std::{error::Error, fmt}; + +use http_body_util::{BodyExt, Empty}; +use hyper::{ + body::Bytes, + header::{self}, + Request, Uri, +}; +use hyper_rustls::HttpsConnectorBuilder; +use hyper_util::{client::legacy::Client, rt::TokioExecutor}; +use url::Url; + +const MAX_BODY_BYTES: usize = 20 * 1024 * 1024; + +pub async fn http_req(url: &str) -> Result<HyperHttpResponse, HyperHttpError> { + let mut current_uri: Uri = url.parse().map_err(|_| HyperHttpError::Uri)?; + + let https = { + let builder = match HttpsConnectorBuilder::new().with_native_roots() { + Ok(builder) => builder, + Err(err) => { + tracing::warn!( + "Failed to load native root certificates ({err}). Falling back to WebPKI store." + ); + HttpsConnectorBuilder::new().with_webpki_roots() + } + }; + + builder.https_or_http().enable_http1().build() + }; + + let client: Client<_, Empty<Bytes>> = Client::builder(TokioExecutor::new()).build(https); + + const MAX_REDIRECTS: usize = 5; + let mut redirects = 0; + + let res = loop { + let authority = current_uri.authority().ok_or(HyperHttpError::Host)?.clone(); + + // Fetch the url... + let req = Request::builder() + .uri(current_uri.clone()) + .header(hyper::header::HOST, authority.as_str()) + .body(Empty::<Bytes>::new()) + .map_err(|e| HyperHttpError::Hyper(Box::new(e)))?; + + let res = client + .request(req) + .await + .map_err(|e| HyperHttpError::Hyper(Box::new(e)))?; + + if res.status().is_redirection() { + if redirects >= MAX_REDIRECTS { + return Err(HyperHttpError::TooManyRedirects); + } + + let location_header = res + .headers() + .get(header::LOCATION) + .ok_or(HyperHttpError::MissingRedirectLocation)? + .clone(); + + let location = location_header + .to_str() + .map_err(|_| HyperHttpError::InvalidRedirectLocation)? + .to_string(); + + res.into_body() + .collect() + .await + .map_err(|e| HyperHttpError::Hyper(Box::new(e)))?; + + current_uri = resolve_redirect(&current_uri, &location)?; + redirects += 1; + continue; + } else { + break res; + } + }; + + let content_type = res + .headers() + .get(hyper::header::CONTENT_TYPE) + .and_then(|t| t.to_str().ok()) + .map(|s| s.to_string()); + + let content_length = res + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|s| s.to_str().ok()) + .and_then(|s| s.parse::<usize>().ok()); + + if let Some(len) = content_length { + if len > MAX_BODY_BYTES { + return Err(HyperHttpError::BodyTooLarge); + } + } + + let mut body = res.into_body(); + let mut bytes = Vec::with_capacity(content_length.unwrap_or(0).min(MAX_BODY_BYTES)); + + while let Some(frame_result) = body.frame().await { + let frame = frame_result.map_err(|e| HyperHttpError::Hyper(Box::new(e)))?; + let Ok(chunk) = frame.into_data() else { + continue; + }; + + if bytes.len() + chunk.len() > MAX_BODY_BYTES { + return Err(HyperHttpError::BodyTooLarge); + } + + bytes.extend_from_slice(&chunk); + } + + Ok(HyperHttpResponse { + content_type, + bytes, + }) +} + +#[derive(Debug)] +pub enum HyperHttpError { + Hyper(Box<dyn std::error::Error + Send + Sync>), + Host, + Uri, + BodyTooLarge, + TooManyRedirects, + MissingRedirectLocation, + InvalidRedirectLocation, +} + +#[derive(Debug)] +pub struct HyperHttpResponse { + pub content_type: Option<String>, + pub bytes: Vec<u8>, +} + +impl Error for HyperHttpError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Hyper(e) => Some(&**e), + _ => None, + } + } +} + +impl fmt::Display for HyperHttpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Hyper(e) => write!(f, "Hyper error: {}", e), + Self::Host => write!(f, "Missing host in URL"), + Self::Uri => write!(f, "Invalid URI"), + Self::BodyTooLarge => write!(f, "Body too large"), + Self::TooManyRedirects => write!(f, "Too many redirect responses"), + Self::MissingRedirectLocation => write!(f, "Redirect response missing Location header"), + Self::InvalidRedirectLocation => write!(f, "Invalid redirect Location header"), + } + } +} + +fn resolve_redirect(current: &Uri, location: &str) -> Result<Uri, HyperHttpError> { + if let Ok(uri) = location.parse::<Uri>() { + if uri.scheme().is_some() { + return Ok(uri); + } + } + + let base = Url::parse(&current.to_string()).map_err(|_| HyperHttpError::Uri)?; + let joined = base + .join(location) + .map_err(|_| HyperHttpError::InvalidRedirectLocation)?; + + joined + .as_str() + .parse::<Uri>() + .map_err(|_| HyperHttpError::InvalidRedirectLocation) +} diff --git a/crates/notedeck/src/media/static_imgs.rs b/crates/notedeck/src/media/static_imgs.rs @@ -0,0 +1,172 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use egui::TextureHandle; + +use crate::{jobs::NoOutputRun, TextureState}; +use crate::{ + jobs::{ + CompleteResponse, JobOutput, JobPackage, JobRun, MediaJobKind, MediaJobResult, + MediaJobSender, RunType, + }, + ImageType, +}; +use crate::{ + media::{ + images::{buffer_to_color_image, parse_img_response}, + load_texture_checked, + network::http_req, + }, + MediaCache, +}; + +pub struct StaticImgTexCache { + pub(crate) cache: HashMap<String, TextureState<TextureHandle>>, + static_img_cache_path: PathBuf, +} + +impl StaticImgTexCache { + pub fn new(static_img_cache_path: PathBuf) -> Self { + Self { + cache: Default::default(), + static_img_cache_path, + } + } + + pub fn contains(&self, url: &str) -> bool { + self.cache.contains_key(url) + } + + pub fn get(&self, url: &str) -> Option<&TextureState<TextureHandle>> { + self.cache.get(url) + } + + pub fn request( + &self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + imgtype: ImageType, + ) { + let _ = self.get_or_request(jobs, ctx, url, imgtype); + } + + pub fn get_or_request( + &self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + imgtype: ImageType, + ) -> &TextureState<TextureHandle> { + if let Some(res) = self.cache.get(url) { + return res; + } + + let key = MediaCache::key(url); + let path = self.static_img_cache_path.join(key); + + if path.exists() { + let ctx = ctx.clone(); + let url = url.to_owned(); + if let Err(e) = jobs.send(JobPackage::new( + url.to_owned(), + MediaJobKind::StaticImg, + RunType::Output(JobRun::Sync(Box::new(move || { + JobOutput::Complete(CompleteResponse::new(MediaJobResult::StaticImg( + fetch_static_img_from_disk(ctx.clone(), &url, &path), + ))) + }))), + )) { + tracing::error!("{e}"); + } + } else { + let url = url.to_owned(); + let ctx = ctx.clone(); + if let Err(e) = jobs.send(JobPackage::new( + url.to_owned(), + MediaJobKind::StaticImg, + RunType::Output(JobRun::Async(Box::pin(fetch_static_img_from_net( + url, + ctx, + self.static_img_cache_path.clone(), + imgtype, + )))), + )) { + tracing::error!("{e}"); + } + } + + &TextureState::Pending + } +} + +pub fn fetch_static_img_from_disk( + ctx: egui::Context, + url: &str, + path: &Path, +) -> Result<egui::TextureHandle, crate::Error> { + tracing::trace!("Starting job static img from disk for {url}"); + let data = std::fs::read(path)?; + let image_buffer = image::load_from_memory(&data).map_err(crate::Error::Image); + + let image_buffer = match image_buffer { + Ok(i) => i, + Err(e) => { + tracing::error!("could not load img buffer"); + return Err(e); + } + }; + + let img = buffer_to_color_image( + image_buffer.as_flat_samples_u8(), + image_buffer.width(), + image_buffer.height(), + ); + + Ok(load_texture_checked(&ctx, url, img, Default::default())) +} + +async fn fetch_static_img_from_net( + url: String, + ctx: egui::Context, + path: PathBuf, + imgtype: ImageType, +) -> JobOutput<MediaJobResult> { + tracing::trace!("fetch static img from net: starting job. sending http request for {url}"); + let res = match http_req(&url).await { + Ok(r) => r, + Err(e) => { + return JobOutput::complete(MediaJobResult::StaticImg(Err(crate::Error::Generic( + format!("Http error: {e}"), + )))); + } + }; + + tracing::trace!("static img from net: parsing http request from {url}"); + JobOutput::Next(JobRun::Sync(Box::new(move || { + let img = match parse_img_response(res, imgtype) { + Ok(i) => i, + Err(e) => { + return JobOutput::Complete(CompleteResponse::new(MediaJobResult::StaticImg(Err( + e, + )))) + } + }; + + let texture_handle = + load_texture_checked(&ctx, url.clone(), img.clone(), Default::default()); + + JobOutput::Complete( + CompleteResponse::new(MediaJobResult::StaticImg(Ok(texture_handle))).run_no_output( + NoOutputRun::Sync(Box::new(move || { + tracing::trace!("static img from net: Saving output from {url}"); + if let Err(e) = MediaCache::write(&path, &url, img) { + tracing::error!("{e}"); + } + })), + ), + ) + }))) +} diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -4,9 +4,9 @@ mod context; pub use action::{NoteAction, ReactAction, ScrollInfo, ZapAction, ZapTargetAmount}; pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; +use crate::jobs::MediaJobSender; use crate::Accounts; use crate::GlobalWallet; -use crate::JobPool; use crate::Localization; use crate::UnknownIds; use crate::{notecache::NoteCache, zaps::Zaps, Images}; @@ -27,7 +27,7 @@ pub struct NoteContext<'d> { pub note_cache: &'d mut NoteCache, pub zaps: &'d mut Zaps, pub pool: &'d mut RelayPool, - pub job_pool: &'d mut JobPool, + pub jobs: &'d MediaJobSender, pub unknown_ids: &'d mut UnknownIds, pub clipboard: &'d mut egui_winit::clipboard::Clipboard, } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -484,6 +484,7 @@ fn chrome_handle_app_action( ctx.zaps, ctx.img_cache, &mut columns.view_state, + ctx.media_jobs.sender(), ui, ); @@ -540,6 +541,7 @@ fn columns_route_to_profile( ctx.zaps, ctx.img_cache, &mut columns.view_state, + ctx.media_jobs.sender(), ui, ); @@ -589,7 +591,8 @@ fn topdown_sidebar( get_profile_url_owned(None) }; - let pfp_resp = ui.add(&mut ProfilePic::new(ctx.img_cache, profile_url).size(64.0)); + let pfp_resp = ui + .add(&mut ProfilePic::new(ctx.img_cache, ctx.media_jobs.sender(), profile_url).size(64.0)); ui.horizontal_wrapped(|ui| { ui.add(egui::Label::new( diff --git a/crates/notedeck_clndash/src/ui.rs b/crates/notedeck_clndash/src/ui.rs @@ -49,23 +49,26 @@ pub fn note_hover_ui( note_cache: ctx.note_cache, zaps: ctx.zaps, pool: ctx.pool, - job_pool: ctx.job_pool, + jobs: ctx.media_jobs.sender(), unknown_ids: ctx.unknown_ids, clipboard: ctx.clipboard, i18n: ctx.i18n, global_wallet: ctx.global_wallet, }; - let mut jobs = notedeck::JobsCache::default(); let options = notedeck_ui::NoteOptions::default(); - notedeck_ui::ProfilePic::from_profile_or_default(note_context.img_cache, author.as_ref()) - .ui(ui); + notedeck_ui::ProfilePic::from_profile_or_default( + note_context.img_cache, + note_context.jobs, + author.as_ref(), + ) + .ui(ui); let nostr_name = notedeck::name::get_display_name(author.as_ref()); ui.label(format!("{} zapped you", nostr_name.name())); - return notedeck_ui::NoteView::new(&mut note_context, &note, options, &mut jobs) + return notedeck_ui::NoteView::new(&mut note_context, &note, options) .preview_style() .hide_media(true) .show(ui) diff --git a/crates/notedeck_columns/src/accounts/mod.rs b/crates/notedeck_columns/src/accounts/mod.rs @@ -1,7 +1,7 @@ use enostr::{FullKeypair, Pubkey}; use nostrdb::{Ndb, Transaction}; -use notedeck::{Accounts, AppContext, JobsCache, Localization, SingleUnkIdAction, UnknownIds}; +use notedeck::{Accounts, AppContext, Localization, SingleUnkIdAction, UnknownIds}; use notedeck_ui::nip51_set::Nip51SetUiCache; pub use crate::accounts::route::AccountsResponse; @@ -77,7 +77,6 @@ pub struct AddAccountAction { pub fn render_accounts_route( ui: &mut egui::Ui, app_ctx: &mut AppContext, - jobs: &mut JobsCache, login_state: &mut AcquireKeyState, onboarding: &mut Onboarding, follow_packs_ui: &mut Nip51SetUiCache, @@ -87,6 +86,7 @@ pub fn render_accounts_route( AccountsRoute::Accounts => AccountsView::new( app_ctx.ndb, app_ctx.accounts, + app_ctx.media_jobs.sender(), app_ctx.img_cache, app_ctx.i18n, ) @@ -107,8 +107,7 @@ pub fn render_accounts_route( app_ctx.ndb, app_ctx.img_cache, app_ctx.i18n, - app_ctx.job_pool, - jobs, + app_ctx.media_jobs.sender(), ) .ui(ui) .map_output(|r| match r { diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -17,8 +17,8 @@ use nostrdb::{IngestMetadata, Ndb, NoteBuilder, NoteKey, Transaction}; use notedeck::{ get_wallet_for, note::{reaction_sent_id, ReactAction, ZapTargetAmount}, - Accounts, GlobalWallet, Images, NoteAction, NoteCache, NoteZapTargetOwned, UnknownIds, - ZapAction, ZapTarget, ZappingError, Zaps, + Accounts, GlobalWallet, Images, MediaJobSender, NoteAction, NoteCache, NoteZapTargetOwned, + UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps, }; use notedeck_ui::media::MediaViewerFlags; use tracing::error; @@ -59,6 +59,7 @@ fn execute_note_action( images: &mut Images, view_state: &mut ViewState, router_type: RouterType, + jobs: &MediaJobSender, ui: &mut egui::Ui, col: usize, ) -> NoteActionResponse { @@ -207,7 +208,7 @@ fn execute_note_action( .set(MediaViewerFlags::Open, true); }); - media_action.process_default_media_actions(images) + media_action.process_default_media_actions(images, jobs, ui.ctx()) } } @@ -235,6 +236,7 @@ pub fn execute_and_process_note_action( zaps: &mut Zaps, images: &mut Images, view_state: &mut ViewState, + jobs: &MediaJobSender, ui: &mut egui::Ui, ) -> Option<RouterAction> { let router_type = { @@ -261,6 +263,7 @@ pub fn execute_and_process_note_action( images, view_state, router_type, + jobs, ui, col, ); diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -21,7 +21,8 @@ use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPo use nostrdb::Transaction; use notedeck::{ tr, ui::is_narrow, Accounts, AppAction, AppContext, AppResponse, DataPath, DataPathType, - FilterState, Images, JobsCache, Localization, NotedeckOptions, SettingsHandler, UnknownIds, + FilterState, Images, Localization, MediaJobSender, NotedeckOptions, SettingsHandler, + UnknownIds, }; use notedeck_ui::{ media::{MediaViewer, MediaViewerFlags, MediaViewerState}, @@ -48,7 +49,6 @@ pub struct Damus { pub timeline_cache: TimelineCache, pub subscriptions: Subscriptions, pub support: Support, - pub jobs: JobsCache, pub threads: Threads, //frame_history: crate::frame_history::FrameHistory, @@ -442,7 +442,12 @@ fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui:: render_damus_desktop(damus, app_ctx, ui) }; - fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache); + fullscreen_media_viewer_ui( + ui, + &mut damus.view_state.media_viewer, + app_ctx.img_cache, + app_ctx.media_jobs.sender(), + ); // We use this for keeping timestamps and things up to date //ui.ctx().request_repaint_after(Duration::from_secs(5)); @@ -457,6 +462,7 @@ fn fullscreen_media_viewer_ui( ui: &mut egui::Ui, state: &mut MediaViewerState, img_cache: &mut Images, + jobs: &MediaJobSender, ) { if !state.should_show(ui) { if state.scene_rect.is_some() { @@ -468,7 +474,9 @@ fn fullscreen_media_viewer_ui( return; } - let resp = MediaViewer::new(state).fullscreen(true).ui(img_cache, ui); + let resp = MediaViewer::new(state) + .fullscreen(true) + .ui(img_cache, jobs, ui); if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { fullscreen_media_close(state); @@ -568,7 +576,6 @@ impl Damus { let support = Support::new(app_context.path); let note_options = get_note_options(parsed_args, app_context.settings); - let jobs = JobsCache::default(); let threads = Threads::default(); Self { @@ -583,7 +590,6 @@ impl Damus { support, decks_cache, unrecognized_args, - jobs, threads, onboarding: Onboarding::default(), hovered_column: None, @@ -635,7 +641,6 @@ impl Damus { options, decks_cache, unrecognized_args: BTreeSet::default(), - jobs: JobsCache::default(), threads: Threads::default(), onboarding: Onboarding::default(), hovered_column: None, @@ -915,6 +920,7 @@ fn timelines_view( ctx.i18n, ctx.ndb, ctx.img_cache, + ctx.media_jobs.sender(), ) .show(ui); diff --git a/crates/notedeck_columns/src/media_upload.rs b/crates/notedeck_columns/src/media_upload.rs @@ -352,7 +352,6 @@ mod tests { // just a random image to test image upload let file_path = PathBuf::from_str("../../../assets/damus_rounded_80.png").unwrap(); let selected_media = SelectedMedia::from_path(file_path).unwrap(); - let img_bytes = include_bytes!("../../../assets/damus_rounded_80.png"); let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); let kp = FullKeypair::generate(); println!("Using pubkey: {:?}", kp.pubkey); diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -551,6 +551,7 @@ fn process_render_nav_action( ctx.zaps, ctx.img_cache, &mut app.view_state, + ctx.media_jobs.sender(), ui, ) } @@ -627,7 +628,7 @@ fn render_nav_body( note_cache: ctx.note_cache, zaps: ctx.zaps, pool: ctx.pool, - job_pool: ctx.job_pool, + jobs: ctx.media_jobs.sender(), unknown_ids: ctx.unknown_ids, clipboard: ctx.clipboard, i18n: ctx.i18n, @@ -650,7 +651,6 @@ fn render_nav_body( depth, ui, &mut note_context, - &mut app.jobs, scroll_to_top, ); @@ -670,13 +670,11 @@ fn render_nav_body( app.note_options, ui, &mut note_context, - &mut app.jobs, ), Route::Accounts(amr) => { let resp = render_accounts_route( ui, ctx, - &mut app.jobs, &mut app.view_state.login, &mut app.onboarding, &mut app.view_state.follow_packs, @@ -706,7 +704,6 @@ fn render_nav_body( ctx.settings.get_settings_mut(), &mut note_context, &mut app.note_options, - &mut app.jobs, ) .ui(ui) .map_output(RenderNavAction::SettingsAction), @@ -751,7 +748,6 @@ fn render_nav_body( &note, inner_rect, options, - &mut app.jobs, col, ) .show(ui) @@ -786,7 +782,6 @@ fn render_nav_body( &note, inner_rect, app.note_options, - &mut app.jobs, col, ) .show(ui); @@ -818,7 +813,6 @@ fn render_nav_body( kp, inner_rect, app.note_options, - &mut app.jobs, ) .ui(&txn, ui); @@ -852,15 +846,9 @@ fn render_nav_body( search_buffer.focus_state = FocusState::ShouldRequestFocus; } - SearchView::new( - &txn, - app.note_options, - search_buffer, - &mut note_context, - &mut app.jobs, - ) - .show(ui) - .map_output(RenderNavAction::NoteAction) + SearchView::new(&txn, app.note_options, search_buffer, &mut note_context) + .show(ui) + .map_output(RenderNavAction::NoteAction) } Route::NewDeck => { let id = ui.id().with("new-deck"); @@ -933,22 +921,28 @@ fn render_nav_body( return BodyResponse::none(); }; - EditProfileView::new(ctx.i18n, state, ctx.img_cache, ctx.clipboard) - .ui(ui) - .map_output_maybe(|save| { - if save { - app.view_state - .pubkey_to_profile_state - .get(kp.pubkey) - .map(|state| { - RenderNavAction::ProfileAction(ProfileAction::SaveChanges( - SaveProfileChanges::new(kp.to_full(), state.clone()), - )) - }) - } else { - None - } - }) + EditProfileView::new( + ctx.i18n, + state, + ctx.img_cache, + ctx.clipboard, + ctx.media_jobs.sender(), + ) + .ui(ui) + .map_output_maybe(|save| { + if save { + app.view_state + .pubkey_to_profile_state + .get(kp.pubkey) + .map(|state| { + RenderNavAction::ProfileAction(ProfileAction::SaveChanges( + SaveProfileChanges::new(kp.to_full(), state.clone()), + )) + }) + } else { + None + } + }) } Route::Following(pubkey) => { let cache_id = egui::Id::new(("following_contacts_cache", pubkey)); @@ -1068,6 +1062,7 @@ fn render_nav_body( &txn, &target.zap_recipient, default_msats, + ctx.media_jobs.sender(), ) .ui(ui), ) @@ -1218,6 +1213,7 @@ pub fn render_nav( std::slice::from_ref(route), col, ctx.i18n, + ctx.media_jobs.sender(), ) .show_move_button(!narrow) .show_delete_button(!narrow) @@ -1262,6 +1258,7 @@ pub fn render_nav( nav.routes(), col, ctx.i18n, + ctx.media_jobs.sender(), ) .show_move_button(!narrow) .show_delete_button(!narrow) diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -6,7 +6,7 @@ use crate::{ }; use enostr::Pubkey; -use notedeck::{JobsCache, NoteContext}; +use notedeck::NoteContext; use notedeck_ui::NoteOptions; #[allow(clippy::too_many_arguments)] @@ -18,7 +18,6 @@ pub fn render_timeline_route( depth: usize, ui: &mut egui::Ui, note_context: &mut NoteContext, - jobs: &mut JobsCache, scroll_to_top: bool, ) -> BodyResponse<RenderNavAction> { match kind { @@ -30,35 +29,20 @@ pub fn render_timeline_route( | TimelineKind::Hashtag(_) | TimelineKind::Generic(_) => { let resp = - ui::TimelineView::new(kind, timeline_cache, note_context, note_options, jobs, col) - .ui(ui); + ui::TimelineView::new(kind, timeline_cache, note_context, note_options, col).ui(ui); resp.map_output(RenderNavAction::NoteAction) } TimelineKind::Profile(pubkey) => { if depth > 1 { - render_profile_route( - pubkey, - timeline_cache, - col, - ui, - note_options, - note_context, - jobs, - ) + render_profile_route(pubkey, timeline_cache, col, ui, note_options, note_context) } else { // we render profiles like timelines if they are at the root - let resp = ui::TimelineView::new( - kind, - timeline_cache, - note_context, - note_options, - jobs, - col, - ) - .scroll_to_top(scroll_to_top) - .ui(ui); + let resp = + ui::TimelineView::new(kind, timeline_cache, note_context, note_options, col) + .scroll_to_top(scroll_to_top) + .ui(ui); resp.map_output(RenderNavAction::NoteAction) } @@ -74,7 +58,6 @@ pub fn render_thread_route( mut note_options: NoteOptions, ui: &mut egui::Ui, note_context: &mut NoteContext, - jobs: &mut JobsCache, ) -> BodyResponse<RenderNavAction> { // don't truncate thread notes for now, since they are // default truncated everywher eelse @@ -88,7 +71,6 @@ pub fn render_thread_route( selection.selected_or_root(), note_options, note_context, - jobs, col, ) .ui(ui) @@ -103,17 +85,9 @@ pub fn render_profile_route( ui: &mut egui::Ui, note_options: NoteOptions, note_context: &mut NoteContext, - jobs: &mut JobsCache, ) -> BodyResponse<RenderNavAction> { - let profile_view = ProfileView::new( - pubkey, - col, - timeline_cache, - note_options, - note_context, - jobs, - ) - .ui(ui); + let profile_view = + ProfileView::new(pubkey, col, timeline_cache, note_options, note_context).ui(ui); profile_view.map_output_maybe(|action| match action { ui::profile::ProfileViewAction::EditProfile => note_context diff --git a/crates/notedeck_columns/src/ui/accounts.rs b/crates/notedeck_columns/src/ui/accounts.rs @@ -3,7 +3,7 @@ use egui::{ }; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{tr, Accounts, Images, Localization}; +use notedeck::{tr, Accounts, Images, Localization, MediaJobSender}; use notedeck_ui::colors::PINK; use notedeck_ui::profile::preview::SimpleProfilePreview; @@ -15,6 +15,7 @@ pub struct AccountsView<'a> { ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut Images, + jobs: &'a MediaJobSender, i18n: &'a mut Localization, } @@ -35,6 +36,7 @@ impl<'a> AccountsView<'a> { pub fn new( ndb: &'a Ndb, accounts: &'a Accounts, + jobs: &'a MediaJobSender, img_cache: &'a mut Images, i18n: &'a mut Localization, ) -> Self { @@ -43,6 +45,7 @@ impl<'a> AccountsView<'a> { accounts, img_cache, i18n, + jobs, } } @@ -57,7 +60,14 @@ impl<'a> AccountsView<'a> { let scroll_out = scroll_area() .id_salt(AccountsView::scroll_id()) .show(ui, |ui| { - Self::show_accounts(ui, self.accounts, self.ndb, self.img_cache, self.i18n) + Self::show_accounts( + ui, + self.accounts, + self.ndb, + self.img_cache, + self.jobs, + self.i18n, + ) }); out.set_scroll_id(&scroll_out); @@ -77,6 +87,7 @@ impl<'a> AccountsView<'a> { accounts: &Accounts, ndb: &Ndb, img_cache: &mut Images, + jobs: &MediaJobSender, i18n: &mut Localization, ) -> Option<AccountsViewResponse> { let mut return_op: Option<AccountsViewResponse> = None; @@ -103,6 +114,7 @@ impl<'a> AccountsView<'a> { let preview = SimpleProfilePreview::new( profile.as_ref(), img_cache, + jobs, i18n, has_nsec, ); diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -17,7 +17,9 @@ use crate::{ Damus, }; -use notedeck::{tr, AppContext, Images, Localization, NotedeckTextStyle, UserAccount}; +use notedeck::{ + tr, AppContext, Images, Localization, MediaJobSender, NotedeckTextStyle, UserAccount, +}; use notedeck_ui::{anim::ICON_EXPANSION_MULTIPLE, app_images}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; @@ -168,6 +170,7 @@ pub struct AddColumnView<'a> { img_cache: &'a mut Images, cur_account: &'a UserAccount, i18n: &'a mut Localization, + jobs: &'a MediaJobSender, } impl<'a> AddColumnView<'a> { @@ -177,6 +180,7 @@ impl<'a> AddColumnView<'a> { img_cache: &'a mut Images, cur_account: &'a UserAccount, i18n: &'a mut Localization, + jobs: &'a MediaJobSender, ) -> Self { Self { key_state_map, @@ -184,6 +188,7 @@ impl<'a> AddColumnView<'a> { img_cache, cur_account, i18n, + jobs, } } @@ -349,7 +354,7 @@ impl<'a> AddColumnView<'a> { bottom: 32, }) .show(ui, |ui| { - ProfilePreview::new(&profile, self.img_cache).ui(ui); + ProfilePreview::new(&profile, self.img_cache, self.jobs).ui(ui); }); } } @@ -673,6 +678,7 @@ pub fn render_add_column_routes( ctx.img_cache, ctx.accounts.get_selected_account(), ctx.i18n, + ctx.media_jobs.sender(), ); let resp = match route { AddColumnRoute::Base => add_column_view.ui(ui), diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -13,7 +13,7 @@ use egui::{Margin, Response, RichText, Sense, Stroke, UiBuilder}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use notedeck::tr; -use notedeck::{Images, Localization, NotedeckTextStyle}; +use notedeck::{Images, Localization, MediaJobSender, NotedeckTextStyle}; use notedeck_ui::app_images; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, @@ -28,6 +28,7 @@ pub struct NavTitle<'a> { col_id: usize, options: u32, i18n: &'a mut Localization, + jobs: &'a MediaJobSender, } impl<'a> NavTitle<'a> { @@ -42,6 +43,7 @@ impl<'a> NavTitle<'a> { routes: &'a [Route], col_id: usize, i18n: &'a mut Localization, + jobs: &'a MediaJobSender, ) -> Self { let options = Self::SHOW_MOVE | Self::SHOW_DELETE; NavTitle { @@ -52,6 +54,7 @@ impl<'a> NavTitle<'a> { col_id, options, i18n, + jobs, } } @@ -435,11 +438,8 @@ impl<'a> NavTitle<'a> { .as_ref() .ok() .and_then(move |p| { - Some( - ProfilePic::from_profile(self.img_cache, p)? - .size(pfp_size) - .sense(Sense::click()), - ) + ProfilePic::from_profile(self.img_cache, self.jobs, p) + .map(|pfp| pfp.size(pfp_size).sense(Sense::click())) }) } @@ -453,7 +453,7 @@ impl<'a> NavTitle<'a> { ui.add(&mut pfp) } else { ui.add( - &mut ProfilePic::new(self.img_cache, notedeck::profile::no_pfp_url()) + &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url()) .size(pfp_size) .sense(Sense::click()), ) @@ -515,7 +515,7 @@ impl<'a> NavTitle<'a> { ui.add(&mut pfp) } else { ui.add( - &mut ProfilePic::new(self.img_cache, notedeck::profile::no_pfp_url()) + &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url()) .size(pfp_size) .sense(Sense::click()), ) @@ -536,7 +536,10 @@ impl<'a> NavTitle<'a> { } } - ui.add(&mut ProfilePic::new(self.img_cache, notedeck::profile::no_pfp_url()).size(pfp_size)) + ui.add( + &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url()) + .size(pfp_size), + ) } fn title_label_value(title: &str) -> egui::Label { diff --git a/crates/notedeck_columns/src/ui/mentions_picker.rs b/crates/notedeck_columns/src/ui/mentions_picker.rs @@ -1,7 +1,7 @@ use egui::{vec2, FontId, Layout, Pos2, Rect, ScrollArea, UiBuilder, Vec2b}; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - fonts::get_font_size, name::get_display_name, profile::get_profile_url, Images, + fonts::get_font_size, name::get_display_name, profile::get_profile_url, Images, MediaJobSender, NotedeckTextStyle, }; use notedeck_ui::{ @@ -20,6 +20,7 @@ pub struct MentionPickerView<'a> { txn: &'a Transaction, img_cache: &'a mut Images, results: &'a Vec<&'a [u8; 32]>, + jobs: &'a MediaJobSender, } pub enum MentionPickerResponse { @@ -33,12 +34,14 @@ impl<'a> MentionPickerView<'a> { ndb: &'a Ndb, txn: &'a Transaction, results: &'a Vec<&'a [u8; 32]>, + jobs: &'a MediaJobSender, ) -> Self { Self { ndb, txn, img_cache, results, + jobs, } } @@ -55,7 +58,7 @@ impl<'a> MentionPickerView<'a> { }; if ui - .add(user_result(&profile, self.img_cache, i, width)) + .add(user_result(&profile, self.img_cache, self.jobs, i, width)) .clicked() { selection = Some(i) @@ -129,6 +132,7 @@ impl<'a> MentionPickerView<'a> { fn user_result<'a>( profile: &'a ProfileRecord<'_>, cache: &'a mut Images, + jobs: &'a MediaJobSender, index: usize, width: f32, ) -> impl egui::Widget + 'a { @@ -161,7 +165,7 @@ fn user_result<'a>( let pfp_resp = ui.put( icon_rect, - &mut ProfilePic::new(cache, get_profile_url(Some(profile))) + &mut ProfilePic::new(cache, jobs, get_profile_url(Some(profile))) .size(helper.scale_1d_pos(min_img_size)), ); diff --git a/crates/notedeck_columns/src/ui/note/custom_zap.rs b/crates/notedeck_columns/src/ui/note/custom_zap.rs @@ -6,7 +6,7 @@ use enostr::Pubkey; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ fonts::get_font_size, get_profile_url, name::get_display_name, tr, Images, Localization, - NotedeckTextStyle, + MediaJobSender, NotedeckTextStyle, }; use notedeck_ui::{ app_images, colors, profile::display_name_widget, widgets::styled_button_toggleable, @@ -20,6 +20,7 @@ pub struct CustomZapView<'a> { target_pubkey: &'a Pubkey, default_msats: u64, i18n: &'a mut Localization, + jobs: &'a MediaJobSender, } #[allow(clippy::new_without_default)] @@ -31,6 +32,7 @@ impl<'a> CustomZapView<'a> { txn: &'a Transaction, target_pubkey: &'a Pubkey, default_msats: u64, + jobs: &'a MediaJobSender, ) -> Self { Self { target_pubkey, @@ -39,6 +41,7 @@ impl<'a> CustomZapView<'a> { txn, default_msats, i18n, + jobs, } } @@ -59,7 +62,7 @@ impl<'a> CustomZapView<'a> { .get_profile_by_pubkey(self.txn, self.target_pubkey.bytes()) .ok(); let profile = profile.as_ref(); - show_profile(ui, self.images, profile); + show_profile(ui, self.images, self.jobs, profile); ui.add_space(8.0); @@ -167,13 +170,18 @@ fn show_title(ui: &mut egui::Ui, i18n: &mut Localization) { ); } -fn show_profile(ui: &mut egui::Ui, images: &mut Images, profile: Option<&ProfileRecord>) { +fn show_profile( + ui: &mut egui::Ui, + images: &mut Images, + jobs: &MediaJobSender, + profile: Option<&ProfileRecord>, +) { let max_size = 24.0; ui.allocate_ui_with_layout( vec2(ui.available_width(), max_size), Layout::left_to_right(egui::Align::Center).with_main_wrap(true), |ui| { - ui.add(&mut ProfilePic::new(images, get_profile_url(profile)).size(max_size)); + ui.add(&mut ProfilePic::new(images, jobs, get_profile_url(profile)).size(max_size)); ui.vertical(|ui| { ui.add(display_name_widget(&get_display_name(profile), false)); }); diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -13,12 +13,12 @@ use egui::{ }; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; -use notedeck::media::gif::ensure_latest_texture; +use notedeck::media::latest::LatestImageTex; use notedeck::media::AnimationMode; #[cfg(target_os = "android")] use notedeck::platform::android::try_open_file_picker; use notedeck::platform::get_next_selected_file; -use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState}; +use notedeck::PixelDimensions; use notedeck::{ name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext, }; @@ -39,7 +39,6 @@ pub struct PostView<'a, 'd> { poster: FilledKeypair<'a>, inner_rect: egui::Rect, note_options: NoteOptions, - jobs: &'a mut JobsCache, animation_mode: AnimationMode, } @@ -112,7 +111,6 @@ impl<'a, 'd> PostView<'a, 'd> { poster: FilledKeypair<'a>, inner_rect: egui::Rect, note_options: NoteOptions, - jobs: &'a mut JobsCache, ) -> Self { let animation_mode = if note_options.contains(NoteOptions::NoAnimations) { AnimationMode::NoAnimation @@ -127,7 +125,6 @@ impl<'a, 'd> PostView<'a, 'd> { inner_rect, note_options, animation_mode, - jobs, } } @@ -157,15 +154,20 @@ impl<'a, 'd> PostView<'a, 'd> { .as_ref() .ok() .and_then(|p| { - Some(ProfilePic::from_profile(self.note_context.img_cache, p)?.size(pfp_size)) + ProfilePic::from_profile(self.note_context.img_cache, self.note_context.jobs, p) + .map(|pfp| pfp.size(pfp_size)) }); if let Some(mut pfp) = poster_pfp { ui.add(&mut pfp); } else { ui.add( - &mut ProfilePic::new(self.note_context.img_cache, notedeck::profile::no_pfp_url()) - .size(pfp_size), + &mut ProfilePic::new( + self.note_context.img_cache, + self.note_context.jobs, + notedeck::profile::no_pfp_url(), + ) + .size(pfp_size), ); } @@ -307,6 +309,7 @@ impl<'a, 'd> PostView<'a, 'd> { self.note_context.ndb, txn, &res, + self.note_context.jobs, ) .show_in_rect(hint_rect, ui); @@ -443,7 +446,6 @@ impl<'a, 'd> PostView<'a, 'd> { id.bytes(), nostrdb::NoteKey::new(0), self.note_options, - self.jobs, ) }) .inner @@ -541,13 +543,19 @@ impl<'a, 'd> PostView<'a, 'd> { }; let url = &media.url; - let cur_state = get_render_state( - ui.ctx(), - self.note_context.img_cache, - cache_type, - url, - notedeck::ImageType::Content(Some((width, height))), - ); + + let cur_state = self + .note_context + .img_cache + .no_img_loading_tex_loader() + .latest_state( + self.note_context.jobs, + ui.ctx(), + url, + cache_type, + notedeck::ImageType::Content(Some((width, height))), + self.animation_mode, + ); render_post_view_media( ui, @@ -557,8 +565,6 @@ impl<'a, 'd> PostView<'a, 'd> { width, height, cur_state, - url, - self.animation_mode, ) } to_remove.reverse(); @@ -644,19 +650,17 @@ fn render_post_view_media( cur_index: usize, width: u32, height: u32, - render_state: RenderState, - url: &str, - animation_mode: AnimationMode, + render_state: LatestImageTex, ) { - match render_state.texture_state { - notedeck::TextureState::Pending => { + match render_state { + LatestImageTex::Pending => { ui.spinner(); } - notedeck::TextureState::Error(e) => { + LatestImageTex::Error(e) => { upload_errors.push(e.to_string()); error!("{e}"); } - notedeck::TextureState::Loaded(renderable_media) => { + LatestImageTex::Loaded(tex) => { let max_size = 300; let size = if width > max_size || height > max_size { PixelDimensions { x: 300, y: 300 } @@ -669,13 +673,7 @@ fn render_post_view_media( .to_points(ui.pixels_per_point()) .to_vec(); - let texture_handle = - ensure_latest_texture(ui, url, render_state.gifs, renderable_media, animation_mode); - let img_resp = ui.add( - egui::Image::new(&texture_handle) - .max_size(size) - .corner_radius(12.0), - ); + let img_resp = ui.add(egui::Image::new(tex).max_size(size).corner_radius(12.0)); let remove_button_rect = { let top_left = img_resp.rect.left_top(); @@ -837,7 +835,6 @@ mod preview { pub struct PostPreview { draft: Draft, poster: FullKeypair, - jobs: JobsCache, } impl PostPreview { @@ -867,7 +864,6 @@ mod preview { PostPreview { draft, poster: FullKeypair::generate(), - jobs: Default::default(), } } } @@ -883,7 +879,7 @@ mod preview { note_cache: app.note_cache, zaps: app.zaps, pool: app.pool, - job_pool: app.job_pool, + jobs: app.media_jobs.sender(), unknown_ids: app.unknown_ids, clipboard: app.clipboard, i18n: app.i18n, @@ -896,7 +892,6 @@ mod preview { self.poster.to_filled(), ui.available_rect_before_wrap(), NoteOptions::default(), - &mut self.jobs, ) .ui(&txn, ui); diff --git a/crates/notedeck_columns/src/ui/note/quote_repost.rs b/crates/notedeck_columns/src/ui/note/quote_repost.rs @@ -7,7 +7,7 @@ use crate::{ use egui::ScrollArea; use enostr::{FilledKeypair, NoteId}; -use notedeck::{JobsCache, NoteContext}; +use notedeck::NoteContext; use notedeck_ui::NoteOptions; pub struct QuoteRepostView<'a, 'd> { @@ -18,7 +18,6 @@ pub struct QuoteRepostView<'a, 'd> { scroll_id: egui::Id, inner_rect: egui::Rect, note_options: NoteOptions, - jobs: &'a mut JobsCache, } impl<'a, 'd> QuoteRepostView<'a, 'd> { @@ -30,7 +29,6 @@ impl<'a, 'd> QuoteRepostView<'a, 'd> { quoting_note: &'a nostrdb::Note<'a>, inner_rect: egui::Rect, note_options: NoteOptions, - jobs: &'a mut JobsCache, col: usize, ) -> Self { QuoteRepostView { @@ -41,7 +39,6 @@ impl<'a, 'd> QuoteRepostView<'a, 'd> { scroll_id: QuoteRepostView::scroll_id(col, quoting_note.id()), inner_rect, note_options, - jobs, } } @@ -78,7 +75,6 @@ impl<'a, 'd> QuoteRepostView<'a, 'd> { self.poster, self.inner_rect, self.note_options, - self.jobs, ) .ui_no_scroll(self.quoting_note.txn().unwrap(), ui); post_resp diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs @@ -7,7 +7,7 @@ use crate::ui::{ use egui::{Rect, Response, ScrollArea, Ui}; use enostr::{FilledKeypair, NoteId}; -use notedeck::{JobsCache, NoteContext}; +use notedeck::NoteContext; use notedeck_ui::{NoteOptions, NoteView, ProfilePic}; pub struct PostReplyView<'a, 'd> { @@ -18,7 +18,6 @@ pub struct PostReplyView<'a, 'd> { scroll_id: egui::Id, inner_rect: egui::Rect, note_options: NoteOptions, - jobs: &'a mut JobsCache, } impl<'a, 'd> PostReplyView<'a, 'd> { @@ -30,7 +29,6 @@ impl<'a, 'd> PostReplyView<'a, 'd> { note: &'a nostrdb::Note<'a>, inner_rect: egui::Rect, note_options: NoteOptions, - jobs: &'a mut JobsCache, col: usize, ) -> Self { PostReplyView { @@ -41,7 +39,6 @@ impl<'a, 'd> PostReplyView<'a, 'd> { scroll_id: PostReplyView::scroll_id(col, note.id()), inner_rect, note_options, - jobs, } } @@ -85,7 +82,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> { let quoted_note = egui::Frame::NONE .outer_margin(egui::Margin::same(note_offset)) .show(ui, |ui| { - NoteView::new(self.note_context, self.note, self.note_options, self.jobs) + NoteView::new(self.note_context, self.note, self.note_options) .truncate(false) .selectable_text(true) .actionbar(false) @@ -106,7 +103,6 @@ impl<'a, 'd> PostReplyView<'a, 'd> { self.poster, self.inner_rect, self.note_options, - self.jobs, ) .ui_no_scroll(self.note.txn().unwrap(), ui) }; diff --git a/crates/notedeck_columns/src/ui/onboarding.rs b/crates/notedeck_columns/src/ui/onboarding.rs @@ -2,7 +2,7 @@ use std::mem; use egui::{Layout, ScrollArea}; use nostrdb::Ndb; -use notedeck::{tr, Images, JobPool, JobsCache, Localization}; +use notedeck::{tr, Images, Localization, MediaJobSender}; use notedeck_ui::{ colors, nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetAction, Nip51SetWidgetFlags}, @@ -17,8 +17,7 @@ pub struct FollowPackOnboardingView<'a> { ndb: &'a Ndb, images: &'a mut Images, loc: &'a mut Localization, - job_pool: &'a mut JobPool, - jobs: &'a mut JobsCache, + jobs: &'a MediaJobSender, } pub enum OnboardingResponse { @@ -38,8 +37,7 @@ impl<'a> FollowPackOnboardingView<'a> { ndb: &'a Ndb, images: &'a mut Images, loc: &'a mut Localization, - job_pool: &'a mut JobPool, - jobs: &'a mut JobsCache, + jobs: &'a MediaJobSender, ) -> Self { Self { onboarding, @@ -47,7 +45,6 @@ impl<'a> FollowPackOnboardingView<'a> { ndb, images, loc, - job_pool, jobs, } } @@ -81,7 +78,6 @@ impl<'a> FollowPackOnboardingView<'a> { self.ndb, self.loc, self.images, - self.job_pool, self.jobs, ) .with_flags(Nip51SetWidgetFlags::TRUST_IMAGES) diff --git a/crates/notedeck_columns/src/ui/profile/contacts_list.rs b/crates/notedeck_columns/src/ui/profile/contacts_list.rs @@ -67,7 +67,12 @@ impl<'a, 'd, 'txn> ContactsListView<'a, 'd, 'txn> { ui.add_space(16.0); ui.add( - &mut ProfilePic::new(self.note_context.img_cache, profile_url).size(48.0), + &mut ProfilePic::new( + self.note_context.img_cache, + self.note_context.jobs, + profile_url, + ) + .size(48.0), ); ui.add_space(12.0); diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -3,7 +3,9 @@ use core::f32; use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; use egui_winit::clipboard::Clipboard; use enostr::ProfileState; -use notedeck::{profile::unwrap_profile_url, tr, Images, Localization, NotedeckTextStyle}; +use notedeck::{ + profile::unwrap_profile_url, tr, Images, Localization, MediaJobSender, NotedeckTextStyle, +}; use notedeck_ui::context_menu::{input_context, PasteBehavior}; use notedeck_ui::{profile::banner, ProfilePic}; @@ -14,6 +16,7 @@ pub struct EditProfileView<'a> { clipboard: &'a mut Clipboard, img_cache: &'a mut Images, i18n: &'a mut Localization, + jobs: &'a MediaJobSender, } impl<'a> EditProfileView<'a> { @@ -22,12 +25,14 @@ impl<'a> EditProfileView<'a> { state: &'a mut ProfileState, img_cache: &'a mut Images, clipboard: &'a mut Clipboard, + jobs: &'a MediaJobSender, ) -> Self { Self { i18n, state, img_cache, clipboard, + jobs, } } @@ -89,7 +94,7 @@ impl<'a> EditProfileView<'a> { let pfp_url = unwrap_profile_url(self.state.picture()); ui.put( pfp_rect, - &mut ProfilePic::new(self.img_cache, pfp_url) + &mut ProfilePic::new(self.img_cache, self.jobs, pfp_url) .size(size) .border(ProfilePic::border_stroke(ui)), ); diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -17,8 +17,8 @@ use crate::{ ui::timeline::{tabs_ui, TimelineTabView}, }; use notedeck::{ - name::get_display_name, profile::get_profile_url, IsFollowing, JobsCache, NoteAction, - NoteContext, NotedeckTextStyle, + name::get_display_name, profile::get_profile_url, IsFollowing, NoteAction, NoteContext, + NotedeckTextStyle, }; use notedeck_ui::{ app_images, @@ -32,7 +32,6 @@ pub struct ProfileView<'a, 'd> { timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, } pub enum ProfileViewAction { @@ -58,7 +57,6 @@ impl<'a, 'd> ProfileView<'a, 'd> { timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, ) -> Self { ProfileView { pubkey, @@ -66,7 +64,6 @@ impl<'a, 'd> ProfileView<'a, 'd> { timeline_cache, note_options, note_context, - jobs, } } @@ -125,7 +122,6 @@ impl<'a, 'd> ProfileView<'a, 'd> { self.note_options, &txn, self.note_context, - self.jobs, ) .show(ui) { @@ -191,9 +187,13 @@ fn profile_body( ui.horizontal(|ui| { ui.put( pfp_rect, - &mut ProfilePic::new(note_context.img_cache, get_profile_url(profile)) - .size(size) - .border(ProfilePic::border_stroke(ui)), + &mut ProfilePic::new( + note_context.img_cache, + note_context.jobs, + get_profile_url(profile), + ) + .size(size) + .border(ProfilePic::border_stroke(ui)), ); if ui diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -11,7 +11,7 @@ use egui_winit::clipboard::Clipboard; use nostrdb::{Filter, Ndb, ProfileRecord, Transaction}; use notedeck::{ fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural, Images, - JobsCache, Localization, NoteAction, NoteContext, NoteRef, NotedeckTextStyle, + Localization, MediaJobSender, NoteAction, NoteContext, NoteRef, NotedeckTextStyle, }; use notedeck_ui::{ @@ -33,7 +33,6 @@ pub struct SearchView<'a, 'd> { note_options: NoteOptions, txn: &'a Transaction, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, } impl<'a, 'd> SearchView<'a, 'd> { @@ -42,14 +41,12 @@ impl<'a, 'd> SearchView<'a, 'd> { note_options: NoteOptions, query: &'a mut SearchQueryState, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, ) -> Self { Self { txn, query, note_options, note_context, - jobs, } } @@ -174,6 +171,7 @@ impl<'a, 'd> SearchView<'a, 'd> { self.note_context.ndb, self.txn, &results, + self.note_context.jobs, ) .show_in_rect(ui.available_rect_before_wrap(), ui); @@ -256,12 +254,10 @@ impl<'a, 'd> SearchView<'a, 'd> { let resp = ui.add(recent_profile_item( profile.as_ref(), - &pubkey, - &self.query.string, is_selected, ui.available_width(), self.note_context.img_cache, - self.note_context.accounts, + self.note_context.jobs, )); if resp.clicked() { @@ -319,7 +315,7 @@ impl<'a, 'd> SearchView<'a, 'd> { self.query.remove_recent_search(i); } } - RecentSearchItem::Profile { pubkey, query } => { + RecentSearchItem::Profile { pubkey, query: _ } => { let profile = self .note_context .ndb @@ -327,12 +323,10 @@ impl<'a, 'd> SearchView<'a, 'd> { .ok(); let resp = ui.add(recent_profile_item( profile.as_ref(), - pubkey, - query, is_selected, ui.available_width(), self.note_context.img_cache, - self.note_context.accounts, + self.note_context.jobs, )); if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) { @@ -360,7 +354,6 @@ impl<'a, 'd> SearchView<'a, 'd> { self.note_options, self.txn, self.note_context, - self.jobs, ) .show(ui) }); @@ -709,12 +702,10 @@ fn search_hashtag( fn recent_profile_item<'a>( profile: Option<&'a ProfileRecord<'_>>, - _pubkey: &'a Pubkey, - _query: &'a str, is_selected: bool, width: f32, cache: &'a mut Images, - _accounts: &'a notedeck::Accounts, + jobs: &'a MediaJobSender, ) -> impl egui::Widget + 'a { move |ui: &mut egui::Ui| -> egui::Response { let min_img_size = 48.0; @@ -742,7 +733,7 @@ fn recent_profile_item<'a>( ui.put( pfp_rect, - &mut ProfilePic::new(cache, get_profile_url(profile)).size(min_img_size), + &mut ProfilePic::new(cache, jobs, get_profile_url(profile)).size(min_img_size), ); let name = get_display_name(profile).name(); diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -6,7 +6,7 @@ use egui_extras::{Size, StripBuilder}; use enostr::NoteId; use nostrdb::Transaction; use notedeck::{ - tr, ui::richtext_small, Images, JobsCache, LanguageIdentifier, Localization, NoteContext, + tr, ui::richtext_small, Images, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings, SettingsHandler, DEFAULT_MAX_HASHTAGS_PER_NOTE, DEFAULT_NOTE_BODY_FONT_SIZE, }; @@ -109,7 +109,6 @@ pub struct SettingsView<'a> { settings: &'a mut Settings, note_context: &'a mut NoteContext<'a>, note_options: &'a mut NoteOptions, - jobs: &'a mut JobsCache, } fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui)) @@ -136,13 +135,11 @@ impl<'a> SettingsView<'a> { settings: &'a mut Settings, note_context: &'a mut NoteContext<'a>, note_options: &'a mut NoteOptions, - jobs: &'a mut JobsCache, ) -> Self { Self { settings, note_context, note_options, - jobs, } } @@ -210,15 +207,10 @@ impl<'a> SettingsView<'a> { if notedeck::ui::is_narrow(ui.ctx()) { ui.set_max_width(ui.available_width()); - NoteView::new( - self.note_context, - &preview_note, - *self.note_options, - self.jobs, - ) - .actionbar(false) - .options_button(false) - .show(ui); + NoteView::new(self.note_context, &preview_note, *self.note_options) + .actionbar(false) + .options_button(false) + .show(ui); } }); ui.separator(); diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs @@ -12,7 +12,7 @@ use crate::{ route::Route, }; -use notedeck::{tr, Accounts, Localization, UserAccount}; +use notedeck::{tr, Accounts, Localization, MediaJobSender, UserAccount}; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, app_images, colors, ProfilePic, View, @@ -29,6 +29,7 @@ pub struct DesktopSidePanel<'a> { i18n: &'a mut Localization, ndb: &'a nostrdb::Ndb, img_cache: &'a mut notedeck::Images, + jobs: &'a MediaJobSender, } impl View for DesktopSidePanel<'_> { @@ -68,6 +69,7 @@ impl<'a> DesktopSidePanel<'a> { i18n: &'a mut Localization, ndb: &'a nostrdb::Ndb, img_cache: &'a mut notedeck::Images, + jobs: &'a MediaJobSender, ) -> Self { Self { selected_account, @@ -75,6 +77,7 @@ impl<'a> DesktopSidePanel<'a> { i18n, ndb, img_cache, + jobs, } } @@ -163,7 +166,7 @@ impl<'a> DesktopSidePanel<'a> { let pfp_resp = ui .add( - &mut ProfilePic::new(self.img_cache, profile_url) + &mut ProfilePic::new(self.img_cache, self.jobs, profile_url) .size(avatar_size) .sense(egui::Sense::click()), ) diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -2,7 +2,6 @@ use egui::InnerResponse; use egui_virtual_list::VirtualList; use nostrdb::{Note, Transaction}; use notedeck::note::root_note_id_from_selected_id; -use notedeck::JobsCache; use notedeck::{NoteAction, NoteContext}; use notedeck_ui::note::NoteResponse; use notedeck_ui::{NoteOptions, NoteView}; @@ -16,7 +15,6 @@ pub struct ThreadView<'a, 'd> { note_options: NoteOptions, col: usize, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, } impl<'a, 'd> ThreadView<'a, 'd> { @@ -26,7 +24,6 @@ impl<'a, 'd> ThreadView<'a, 'd> { selected_note_id: &'a [u8; 32], note_options: NoteOptions, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, col: usize, ) -> Self { ThreadView { @@ -34,7 +31,6 @@ impl<'a, 'd> ThreadView<'a, 'd> { selected_note_id, note_options, note_context, - jobs, col, } } @@ -136,15 +132,7 @@ impl<'a, 'd> ThreadView<'a, 'd> { ui.colored_label(ui.visuals().error_fg_color, "LOADING NOTES"); } - show_notes( - ui, - list, - &notes, - self.note_context, - self.note_options, - self.jobs, - txn, - ) + show_notes(ui, list, &notes, self.note_context, self.note_options, txn) } } @@ -155,7 +143,6 @@ fn show_notes( thread_notes: &ThreadNotes, note_context: &mut NoteContext<'_>, flags: NoteOptions, - jobs: &mut JobsCache, txn: &Transaction, ) -> Option<NoteAction> { let mut action = None; @@ -185,7 +172,7 @@ fn show_notes( return 1; } - let resp = note.show(note_context, flags, jobs, ui); + let resp = note.show(note_context, flags, ui); action = if cur_index == selected_note_index { resp.action.and_then(strip_note_action) @@ -326,11 +313,10 @@ impl<'a> ThreadNote<'a> { &self, note_context: &'a mut NoteContext<'_>, flags: NoteOptions, - jobs: &'a mut JobsCache, ui: &mut egui::Ui, ) -> NoteResponse { let inner = notedeck_ui::padding(8.0, ui, |ui| { - NoteView::new(note_context, &self.note, self.options(flags), jobs) + NoteView::new(note_context, &self.note, self.options(flags)) .selected_style(self.note_type.is_selected()) .unread_indicator(self.unread_and_have_replies) .show(ui) diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -6,7 +6,7 @@ use nostrdb::{Note, ProfileRecord, Transaction}; use notedeck::fonts::get_font_size; use notedeck::name::get_display_name; use notedeck::ui::is_narrow; -use notedeck::{tr_plural, JobsCache, Muted, NotedeckTextStyle}; +use notedeck::{tr_plural, Muted, NotedeckTextStyle}; use notedeck_ui::app_images::{like_image_filled, repost_image}; use notedeck_ui::{ProfilePic, ProfilePreview}; use std::f32::consts::PI; @@ -30,7 +30,6 @@ pub struct TimelineView<'a, 'd> { timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, col: usize, scroll_to_top: bool, } @@ -42,7 +41,6 @@ impl<'a, 'd> TimelineView<'a, 'd> { timeline_cache: &'a mut TimelineCache, note_context: &'a mut NoteContext<'d>, note_options: NoteOptions, - jobs: &'a mut JobsCache, col: usize, ) -> Self { let scroll_to_top = false; @@ -51,7 +49,6 @@ impl<'a, 'd> TimelineView<'a, 'd> { timeline_cache, note_options, note_context, - jobs, col, scroll_to_top, } @@ -64,7 +61,6 @@ impl<'a, 'd> TimelineView<'a, 'd> { self.timeline_cache, self.note_options, self.note_context, - self.jobs, self.col, self.scroll_to_top, ) @@ -93,7 +89,6 @@ fn timeline_ui( timeline_cache: &mut TimelineCache, mut note_options: NoteOptions, note_context: &mut NoteContext, - jobs: &mut JobsCache, col: usize, scroll_to_top: bool, ) -> BodyResponse<NoteAction> { @@ -185,14 +180,7 @@ fn timeline_ui( note_options.set(NoteOptions::Notification, true) } - TimelineTabView::new( - timeline.current_view(), - note_options, - &txn, - note_context, - jobs, - ) - .show(ui) + TimelineTabView::new(timeline.current_view(), note_options, &txn, note_context).show(ui) }); let at_top_after_scroll = scroll_output.state.offset.y == 0.0; @@ -378,7 +366,6 @@ pub struct TimelineTabView<'a, 'd> { note_options: NoteOptions, txn: &'a Transaction, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, } impl<'a, 'd> TimelineTabView<'a, 'd> { @@ -388,14 +375,12 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { note_options: NoteOptions, txn: &'a Transaction, note_context: &'a mut NoteContext<'d>, - jobs: &'a mut JobsCache, ) -> Self { Self { tab, note_options, txn, note_context, - jobs, } } @@ -473,19 +458,14 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { } match entry { - NoteUnit::Single(_) => render_note( - ui, - self.note_context, - self.note_options, - self.jobs, - &underlying_note, - ), + NoteUnit::Single(_) => { + render_note(ui, self.note_context, self.note_options, &underlying_note) + } NoteUnit::Composite(composite) => match composite { CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster( ui, self.note_context, self.note_options, - self.jobs, mute, self.txn, &underlying_note, @@ -495,7 +475,6 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { ui, self.note_context, self.note_options, - self.jobs, mute, self.txn, &underlying_note, @@ -676,12 +655,11 @@ fn render_note( ui: &mut egui::Ui, note_context: &mut NoteContext, note_options: NoteOptions, - jobs: &mut JobsCache, note: &Note, ) -> RenderEntryResponse { let mut action = None; notedeck_ui::padding(8.0, ui, |ui| { - let resp = NoteView::new(note_context, note, note_options, jobs).show(ui); + let resp = NoteView::new(note_context, note, note_options).show(ui); if let Some(note_action) = resp.action { action = Some(note_action); @@ -699,7 +677,6 @@ fn render_reaction_cluster( ui: &mut egui::Ui, note_context: &mut NoteContext, note_options: NoteOptions, - jobs: &mut JobsCache, mute: &std::sync::Arc<Muted>, txn: &Transaction, underlying_note: &Note, @@ -729,7 +706,6 @@ fn render_reaction_cluster( ui, note_context, note_options | NoteOptions::Notification, - jobs, underlying_note, profiles_to_show, CompositeType::Reaction, @@ -742,7 +718,6 @@ fn render_composite_entry( ui: &mut egui::Ui, note_context: &mut NoteContext, mut note_options: NoteOptions, - jobs: &mut JobsCache, underlying_note: &nostrdb::Note<'_>, profiles_to_show: Vec<ProfileEntry>, composite_type: CompositeType, @@ -793,6 +768,7 @@ fn render_composite_entry( profiles_to_show, &composite_type, note_context.img_cache, + note_context.jobs, note_options.contains(NoteOptions::Notification), ) }, @@ -867,7 +843,7 @@ fn render_composite_entry( ui.add_space(48.0); }; - NoteView::new(note_context, underlying_note, note_options, jobs).show(ui) + NoteView::new(note_context, underlying_note, note_options).show(ui) }) .inner; @@ -886,6 +862,7 @@ fn render_profiles( profiles_to_show: Vec<ProfileEntry>, composite_type: &CompositeType, img_cache: &mut notedeck::Images, + jobs: &notedeck::MediaJobSender, notification: bool, ) -> PfpsResponse { let mut action = None; @@ -932,7 +909,7 @@ fn render_profiles( profiling::scope!("actual rendering individual pfp"); let mut widget = - ProfilePic::from_profile_or_default(img_cache, entry.record.as_ref()) + ProfilePic::from_profile_or_default(img_cache, jobs, entry.record.as_ref()) .size(24.0) .sense(Sense::click()); let mut resp = ui.put(rect, &mut widget); @@ -941,7 +918,7 @@ fn render_profiles( if let Some(record) = entry.record.as_ref() { resp = resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); - ui.add(ProfilePreview::new(record, img_cache)); + ui.add(ProfilePreview::new(record, img_cache, jobs)); }); } @@ -977,7 +954,6 @@ fn render_repost_cluster( ui: &mut egui::Ui, note_context: &mut NoteContext, note_options: NoteOptions, - jobs: &mut JobsCache, mute: &std::sync::Arc<Muted>, txn: &Transaction, underlying_note: &Note, @@ -997,7 +973,6 @@ fn render_repost_cluster( ui, note_context, note_options, - jobs, underlying_note, profiles_to_show, CompositeType::Repost, diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -8,7 +8,7 @@ use egui_wgpu::RenderState; use enostr::KeypairUnowned; use futures::StreamExt; use nostrdb::Transaction; -use notedeck::{AppAction, AppContext, AppResponse, JobsCache}; +use notedeck::{AppAction, AppContext, AppResponse}; use std::collections::HashMap; use std::string::ToString; use std::sync::mpsc::{self, Receiver}; @@ -43,7 +43,6 @@ pub struct Dave { client: async_openai::Client<OpenAIConfig>, incoming_tokens: Option<Receiver<DaveApiResponse>>, model_config: ModelConfig, - jobs: JobsCache, } /// Calculate an anonymous user_id from a keypair @@ -108,7 +107,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr input, model_config, chat: vec![], - jobs: JobsCache::default(), } } @@ -189,11 +187,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr DaveResponse::default() */ - DaveUi::new(self.model_config.trial, &self.chat, &mut self.input).ui( - app_ctx, - &mut self.jobs, - ui, - ) + DaveUi::new(self.model_config.trial, &self.chat, &mut self.input).ui(app_ctx, ui) } fn handle_new_chat(&mut self) { diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -5,7 +5,7 @@ use crate::{ use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; use nostrdb::{Ndb, Transaction}; use notedeck::{ - tr, Accounts, AppContext, Images, JobsCache, Localization, NoteAction, NoteContext, + tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext, }; use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic}; @@ -86,12 +86,7 @@ impl<'a> DaveUi<'a> { } /// The main render function. Call this to render Dave - pub fn ui( - &mut self, - app_ctx: &mut AppContext, - jobs: &mut JobsCache, - ui: &mut egui::Ui, - ) -> DaveResponse { + pub fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { let action = top_buttons_ui(app_ctx, ui); egui::Frame::NONE @@ -118,7 +113,7 @@ impl<'a> DaveUi<'a> { .show(ui, |ui| { Self::chat_frame(ui.ctx()) .show(ui, |ui| { - ui.vertical(|ui| self.render_chat(app_ctx, jobs, ui)).inner + ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner }) .inner }) @@ -152,12 +147,7 @@ impl<'a> DaveUi<'a> { } /// Render a chat message (user, assistant, tool call/response, etc) - fn render_chat( - &self, - ctx: &mut AppContext, - jobs: &mut JobsCache, - ui: &mut egui::Ui, - ) -> Option<NoteAction> { + fn render_chat(&self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<NoteAction> { let mut action: Option<NoteAction> = None; for message in self.chat { let r = match message { @@ -182,7 +172,7 @@ impl<'a> DaveUi<'a> { // have a debug option to show this None } - Message::ToolCalls(toolcalls) => Self::tool_calls_ui(ctx, jobs, toolcalls, ui), + Message::ToolCalls(toolcalls) => Self::tool_calls_ui(ctx, toolcalls, ui), }; if r.is_some() { @@ -201,13 +191,18 @@ impl<'a> DaveUi<'a> { ui.add(search_icon(16.0, 16.0)); ui.add_space(8.0); - query_call_ui(ctx.img_cache, ctx.ndb, query_call, ui); + query_call_ui( + ctx.img_cache, + ctx.ndb, + query_call, + ctx.media_jobs.sender(), + ui, + ); } /// The ai has asked us to render some notes, so we do that here fn present_notes_ui( ctx: &mut AppContext, - jobs: &mut JobsCache, call: &PresentNotesCall, ui: &mut egui::Ui, ) -> Option<NoteAction> { @@ -218,7 +213,7 @@ impl<'a> DaveUi<'a> { note_cache: ctx.note_cache, zaps: ctx.zaps, pool: ctx.pool, - job_pool: ctx.job_pool, + jobs: ctx.media_jobs.sender(), unknown_ids: ctx.unknown_ids, clipboard: ctx.clipboard, i18n: ctx.i18n, @@ -249,7 +244,6 @@ impl<'a> DaveUi<'a> { &mut note_context, &note, NoteOptions::default(), - jobs, ) .preview_style() .hide_media(true) @@ -272,7 +266,6 @@ impl<'a> DaveUi<'a> { fn tool_calls_ui( ctx: &mut AppContext, - jobs: &mut JobsCache, toolcalls: &[ToolCall], ui: &mut egui::Ui, ) -> Option<NoteAction> { @@ -282,7 +275,7 @@ impl<'a> DaveUi<'a> { for call in toolcalls { match call.calls() { ToolCalls::PresentNotes(call) => { - let r = Self::present_notes_ui(ctx, jobs, call, ui); + let r = Self::present_notes_ui(ctx, call, ui); if r.is_some() { note_action = r; } @@ -399,7 +392,13 @@ fn new_chat_button() -> impl egui::Widget { } } -fn query_call_ui(cache: &mut notedeck::Images, ndb: &Ndb, query: &QueryCall, ui: &mut egui::Ui) { +fn query_call_ui( + cache: &mut notedeck::Images, + ndb: &Ndb, + query: &QueryCall, + jobs: &MediaJobSender, + ui: &mut egui::Ui, +) { ui.spacing_mut().item_spacing.x = 8.0; if let Some(pubkey) = query.author() { let txn = Transaction::new(ndb).unwrap(); @@ -409,6 +408,7 @@ fn query_call_ui(cache: &mut notedeck::Images, ndb: &Ndb, query: &QueryCall, ui: ui.add( &mut ProfilePic::from_profile_or_default( cache, + jobs, ndb.get_profile_by_pubkey(&txn, pubkey.bytes()) .ok() .as_ref(), @@ -489,7 +489,13 @@ fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAct let r = ui .put( rect, - &mut pfp_button(&txn, app_ctx.accounts, app_ctx.img_cache, app_ctx.ndb), + &mut pfp_button( + &txn, + app_ctx.accounts, + app_ctx.img_cache, + app_ctx.ndb, + app_ctx.media_jobs.sender(), + ), ) .on_hover_cursor(egui::CursorIcon::PointingHand); @@ -512,13 +518,14 @@ fn pfp_button<'me, 'a>( accounts: &Accounts, img_cache: &'me mut Images, ndb: &Ndb, + jobs: &'me MediaJobSender, ) -> ProfilePic<'me, 'a> { let account = accounts.get_selected_account(); let profile = ndb .get_profile_by_pubkey(txn, account.key.pubkey.bytes()) .ok(); - ProfilePic::from_profile_or_default(img_cache, profile.as_ref()) + ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref()) .size(24.0) .sense(egui::Sense::click()) } diff --git a/crates/notedeck_ui/src/media/viewer.rs b/crates/notedeck_ui/src/media/viewer.rs @@ -1,7 +1,7 @@ use bitflags::bitflags; use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect}; use notedeck::media::{AnimationMode, MediaInfo, ViewMediaInfo}; -use notedeck::{ImageType, Images}; +use notedeck::{ImageType, Images, MediaJobSender}; bitflags! { #[repr(transparent)] @@ -90,23 +90,33 @@ impl<'a> MediaViewer<'a> { self } - pub fn ui(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response { + pub fn ui( + &mut self, + images: &mut Images, + jobs: &MediaJobSender, + ui: &mut egui::Ui, + ) -> egui::Response { if self.state.flags.contains(MediaViewerFlags::Fullscreen) { egui::Window::new("Media Viewer") .title_bar(false) .fixed_size(ui.ctx().screen_rect().size()) .fixed_pos(ui.ctx().screen_rect().min) .frame(egui::Frame::NONE) - .show(ui.ctx(), |ui| self.ui_content(images, ui)) + .show(ui.ctx(), |ui| self.ui_content(images, jobs, ui)) .unwrap() // SAFETY: we are always open .inner .unwrap() } else { - self.ui_content(images, ui) + self.ui_content(images, jobs, ui) } } - fn ui_content(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response { + fn ui_content( + &mut self, + images: &mut Images, + jobs: &MediaJobSender, + ui: &mut egui::Ui, + ) -> egui::Response { let avail_rect = ui.available_rect_before_wrap(); let scene_rect = if let Some(scene_rect) = self.state.scene_rect { @@ -132,7 +142,7 @@ impl<'a> MediaViewer<'a> { let mut trans_rect = if transitioning { let clicked_img = &self.state.media_info.clicked_media(); let src_pos = &clicked_img.original_position; - let in_scene_pos = Self::first_image_rect(ui, clicked_img, images); + let in_scene_pos = Self::first_image_rect(ui, clicked_img, images, jobs); transition_scene_rect( &avail_rect, &zoom_range, @@ -161,7 +171,7 @@ impl<'a> MediaViewer<'a> { */ let resp = scene.show(ui, &mut trans_rect, |ui| { - Self::render_image_tiles(&self.state.media_info.medias, images, ui, open_amount); + Self::render_image_tiles(&self.state.media_info.medias, images, jobs, ui, open_amount); }); self.state.scene_rect = Some(trans_rect); @@ -174,9 +184,15 @@ impl<'a> MediaViewer<'a> { /// /// TODO(jb55): replace this with a "placed" variant once /// we have image layouts - fn first_image_rect(ui: &mut egui::Ui, media: &MediaInfo, images: &mut Images) -> Rect { + fn first_image_rect( + ui: &mut egui::Ui, + media: &MediaInfo, + images: &mut Images, + jobs: &MediaJobSender, + ) -> Rect { // fetch image texture let Some(texture) = images.latest_texture( + jobs, ui, &media.url, ImageType::Content(None), @@ -204,6 +220,7 @@ impl<'a> MediaViewer<'a> { fn render_image_tiles( infos: &[MediaInfo], images: &mut Images, + jobs: &MediaJobSender, ui: &mut egui::Ui, open_amount: f32, ) { @@ -214,6 +231,7 @@ impl<'a> MediaViewer<'a> { // we want to continually redraw things in the gallery let Some(texture) = images.latest_texture( + jobs, ui, url, ImageType::Content(None), diff --git a/crates/notedeck_ui/src/mention.rs b/crates/notedeck_ui/src/mention.rs @@ -2,11 +2,12 @@ use crate::ProfilePreview; use egui::Sense; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{name::get_display_name, Images, NoteAction, NotedeckTextStyle}; +use notedeck::{name::get_display_name, Images, MediaJobSender, NoteAction, NotedeckTextStyle}; pub struct Mention<'a> { ndb: &'a Ndb, img_cache: &'a mut Images, + jobs: &'a MediaJobSender, txn: &'a Transaction, pk: &'a [u8; 32], selectable: bool, @@ -17,6 +18,7 @@ impl<'a> Mention<'a> { pub fn new( ndb: &'a Ndb, img_cache: &'a mut Images, + jobs: &'a MediaJobSender, txn: &'a Transaction, pk: &'a [u8; 32], ) -> Self { @@ -29,6 +31,7 @@ impl<'a> Mention<'a> { pk, selectable, size, + jobs, } } @@ -46,6 +49,7 @@ impl<'a> Mention<'a> { mention_ui( self.ndb, self.img_cache, + self.jobs, self.txn, self.pk, ui, @@ -60,6 +64,7 @@ impl<'a> Mention<'a> { fn mention_ui( ndb: &Ndb, img_cache: &mut Images, + jobs: &MediaJobSender, txn: &Transaction, pk: &[u8; 32], ui: &mut egui::Ui, @@ -99,7 +104,7 @@ fn mention_ui( if let Some(rec) = profile.as_ref() { resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); - ui.add(ProfilePreview::new(rec, img_cache)); + ui.add(ProfilePreview::new(rec, img_cache, jobs)); }); } diff --git a/crates/notedeck_ui/src/nip51_set.rs b/crates/notedeck_ui/src/nip51_set.rs @@ -4,8 +4,8 @@ use enostr::Pubkey; use hashbrown::{hash_map::RawEntryMut, HashMap}; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - fonts::get_font_size, get_profile_url, name::get_display_name, tr, Images, JobPool, JobsCache, - Localization, Nip51Set, Nip51SetCache, NotedeckTextStyle, + fonts::get_font_size, get_profile_url, name::get_display_name, tr, Images, Localization, + MediaJobSender, Nip51Set, Nip51SetCache, NotedeckTextStyle, }; use crate::{ @@ -19,8 +19,7 @@ pub struct Nip51SetWidget<'a> { ndb: &'a Ndb, images: &'a mut Images, loc: &'a mut Localization, - job_pool: &'a mut JobPool, - jobs: &'a mut JobsCache, + jobs: &'a MediaJobSender, flags: Nip51SetWidgetFlags, } @@ -53,8 +52,7 @@ impl<'a> Nip51SetWidget<'a> { ndb: &'a Ndb, loc: &'a mut Localization, images: &'a mut Images, - job_pool: &'a mut JobPool, - jobs: &'a mut JobsCache, + jobs: &'a MediaJobSender, ) -> Self { Self { state, @@ -62,7 +60,6 @@ impl<'a> Nip51SetWidget<'a> { ndb, loc, images, - job_pool, jobs, flags: Nip51SetWidgetFlags::default(), } @@ -92,7 +89,6 @@ impl<'a> Nip51SetWidget<'a> { self.ui_state, self.ndb, self.images, - self.job_pool, self.jobs, self.loc, self.flags.contains(Nip51SetWidgetFlags::TRUST_IMAGES), @@ -157,8 +153,7 @@ fn render_pack( ui_state: &mut Nip51SetUiCache, ndb: &Ndb, images: &mut Images, - job_pool: &mut JobPool, - jobs: &mut JobsCache, + jobs: &MediaJobSender, loc: &mut Localization, image_trusted: bool, ) -> Option<Nip51SetWidgetAction> { @@ -175,7 +170,6 @@ fn render_pack( let media_rect = render_media( ui, images, - job_pool, jobs, &media, image_trusted, @@ -250,7 +244,7 @@ fn render_pack( }; ui.separator(); - if render_profile_item(ui, images, m_profile.as_ref(), cur_state) { + if render_profile_item(ui, images, jobs, m_profile.as_ref(), cur_state) { resp = Some(Nip51SetWidgetAction::ViewProfile(*pk)); } } @@ -263,6 +257,7 @@ const PFP_SIZE: f32 = 32.0; fn render_profile_item( ui: &mut egui::Ui, images: &mut Images, + jobs: &MediaJobSender, profile: Option<&ProfileRecord>, checked: &mut bool, ) -> bool { @@ -294,7 +289,7 @@ fn render_profile_item( let _ = ui.allocate_new_ui(UiBuilder::new().max_rect(pfp_rect), |ui| { let pfp_resp = ui.add( - &mut ProfilePic::new(images, get_profile_url(profile)) + &mut ProfilePic::new(images, jobs, get_profile_url(profile)) .sense(Sense::click()) .size(PFP_SIZE), ); diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -6,8 +6,8 @@ use crate::{ use egui::{Color32, Hyperlink, Label, RichText}; use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; use notedeck::Localization; +use notedeck::RenderableMedia; use notedeck::{time_format, update_imeta_blurhashes, NoteCache, NoteContext, NotedeckTextStyle}; -use notedeck::{JobsCache, RenderableMedia}; use tracing::warn; pub struct NoteContents<'a, 'd> { @@ -16,7 +16,6 @@ pub struct NoteContents<'a, 'd> { note: &'a Note<'a>, options: NoteOptions, pub action: Option<NoteAction>, - jobs: &'a mut JobsCache, } impl<'a, 'd> NoteContents<'a, 'd> { @@ -26,7 +25,6 @@ impl<'a, 'd> NoteContents<'a, 'd> { txn: &'a Transaction, note: &'a Note, options: NoteOptions, - jobs: &'a mut JobsCache, ) -> Self { NoteContents { note_context, @@ -34,21 +32,13 @@ impl<'a, 'd> NoteContents<'a, 'd> { note, options, action: None, - jobs, } } } impl egui::Widget for &mut NoteContents<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - let result = render_note_contents( - ui, - self.note_context, - self.txn, - self.note, - self.options, - self.jobs, - ); + let result = render_note_contents(ui, self.note_context, self.txn, self.note, self.options); self.action = result.action; result.response } @@ -83,7 +73,6 @@ pub fn render_note_preview( id: &[u8; 32], parent: NoteKey, note_options: NoteOptions, - jobs: &mut JobsCache, ) -> NoteResponse { let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) { // TODO: support other preview kinds @@ -112,7 +101,7 @@ pub fn render_note_preview( */ }; - NoteView::new(note_context, &note, note_options, jobs) + NoteView::new(note_context, &note, note_options) .preview_style() .parent(parent) .show(ui) @@ -125,9 +114,8 @@ fn render_note_contents( txn: &Transaction, note: &Note, options: NoteOptions, - jobs: &mut JobsCache, ) -> NoteResponse { - let response = render_undecorated_note_contents(ui, note_context, txn, note, options, jobs); + let response = render_undecorated_note_contents(ui, note_context, txn, note, options); ui.horizontal_wrapped(|ui| { note_bottom_metadata_ui( @@ -169,7 +157,6 @@ fn render_undecorated_note_contents<'a>( txn: &Transaction, note: &'a Note, options: NoteOptions, - jobs: &mut JobsCache, ) -> NoteResponse { let note_key = note.key().expect("todo: implement non-db notes"); let selectable = options.contains(NoteOptions::SelectableText); @@ -208,6 +195,7 @@ fn render_undecorated_note_contents<'a>( let act = crate::Mention::new( note_context.ndb, note_context.img_cache, + note_context.jobs, txn, profile.pubkey(), ) @@ -223,6 +211,7 @@ fn render_undecorated_note_contents<'a>( let act = crate::Mention::new( note_context.ndb, note_context.img_cache, + note_context.jobs, txn, npub.pubkey(), ) @@ -355,7 +344,7 @@ fn render_undecorated_note_contents<'a>( }); let preview_note_action = inline_note.and_then(|(id, _)| { - render_note_preview(ui, note_context, txn, id, note_key, options, jobs) + render_note_preview(ui, note_context, txn, id, note_key, options) .action .map(|a| match a { NoteAction::Note { note_id, .. } => NoteAction::Note { @@ -375,8 +364,7 @@ fn render_undecorated_note_contents<'a>( media_action = image_carousel( ui, note_context.img_cache, - note_context.job_pool, - jobs, + note_context.jobs, &supported_medias, carousel_id, note_context.i18n, diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -1,21 +1,18 @@ -use std::path::Path; - use bitflags::bitflags; use egui::{ vec2, Button, Color32, Context, CornerRadius, FontId, Image, InnerResponse, Response, TextureHandle, Vec2, }; +use notedeck::media::latest::ObfuscatedTexture; +use notedeck::MediaJobSender; use notedeck::{ - compute_blurhash, fonts::get_font_size, show_one_error_message, tr, BlurhashParams, - GifStateMap, Images, Job, JobId, JobParams, JobPool, JobState, JobsCache, Localization, - MediaAction, MediaCacheType, NotedeckTextStyle, ObfuscationType, PointDimensions, - RenderableMedia, TexturedImage, TexturesCache, + fonts::get_font_size, show_one_error_message, tr, Images, Localization, MediaAction, + MediaCacheType, NotedeckTextStyle, RenderableMedia, }; use crate::NoteOptions; -use notedeck::media::gif::ensure_latest_texture; -use notedeck::media::images::{fetch_no_pfp_promise, ImageType}; -use notedeck::media::AnimationMode; +use notedeck::media::images::ImageType; +use notedeck::media::{AnimationMode, MediaRenderState}; use notedeck::media::{MediaInfo, ViewMediaInfo}; use crate::{app_images, AnimationHelper, PulseAlpha}; @@ -30,8 +27,7 @@ pub enum MediaViewAction { pub fn image_carousel( ui: &mut egui::Ui, img_cache: &mut Images, - job_pool: &mut JobPool, - jobs: &mut JobsCache, + jobs: &MediaJobSender, medias: &[RenderableMedia], carousel_id: egui::Id, i18n: &mut Localization, @@ -65,10 +61,10 @@ pub fn image_carousel( let media_response = render_media( ui, img_cache, - job_pool, jobs, media, - note_options.contains(NoteOptions::TrustMedia), + note_options.contains(NoteOptions::TrustMedia) + || img_cache.user_trusts_img(&media.url, media.media_type), i18n, size, if note_options.contains(NoteOptions::NoAnimations) { @@ -96,7 +92,6 @@ pub fn image_carousel( if let Some((i, media_action)) = media_action { action = media_action.into_media_action( - ui.ctx(), medias, media_infos, i, @@ -119,8 +114,7 @@ pub fn image_carousel( pub fn render_media( ui: &mut egui::Ui, img_cache: &mut Images, - job_pool: &mut JobPool, - jobs: &mut JobsCache, + jobs: &MediaJobSender, media: &RenderableMedia, trusted_media: bool, i18n: &mut Localization, @@ -134,23 +128,6 @@ pub fn render_media( obfuscation_type: blur_type, } = media; - let cache = match media_type { - MediaCacheType::Image => &mut img_cache.static_imgs, - MediaCacheType::Gif => &mut img_cache.gifs, - }; - let media_state = get_content_media_render_state( - ui, - job_pool, - jobs, - trusted_media, - size, - &mut cache.textures_cache, - url, - *media_type, - &cache.cache_dir, - blur_type, - ); - let animation_mode = animation_mode.unwrap_or_else(|| { // if animations aren't disabled, we cap it at 24fps for gifs in carousels let fps = match media_type { @@ -159,17 +136,24 @@ pub fn render_media( }; AnimationMode::Continuous { fps } }); + let media_state = if trusted_media { + img_cache.trusted_texture_loader().latest( + jobs, + ui, + url, + *media_type, + ImageType::Content(None), + animation_mode, + blur_type, + size, + ) + } else { + img_cache + .untrusted_texture_loader() + .latest(jobs, ui, url, blur_type, size) + }; - render_media_internal( - ui, - &mut img_cache.gif_states, - media_state, - url, - size, - i18n, - scale_flags, - animation_mode, - ) + render_media_internal(ui, media_state, url, size, i18n, scale_flags) } pub enum MediaUIAction { @@ -182,7 +166,6 @@ pub enum MediaUIAction { impl MediaUIAction { pub fn into_media_action( self, - ctx: &egui::Context, medias: &[RenderableMedia], responses: Vec<MediaInfo>, selected: usize, @@ -203,17 +186,9 @@ impl MediaUIAction { let url = &medias[selected].url; let cache = img_cache.get_cache(medias[selected].media_type); let cache_type = cache.cache_type; - let no_pfp_promise = notedeck::media::images::fetch_img( - &cache.cache_dir, - ctx, - url, - img_type, - cache_type, - ); Some(MediaAction::FetchImage { url: url.to_owned(), cache_type, - no_pfp_promise, }) } @@ -227,7 +202,6 @@ impl MediaUIAction { Some(MediaAction::FetchImage { url: medias[selected].url.to_owned(), cache_type, - no_pfp_promise: fetch_no_pfp_promise(ctx, cache), }) } MediaUIAction::DoneLoading => Some(MediaAction::DoneLoading { @@ -238,115 +212,6 @@ impl MediaUIAction { } } -#[allow(clippy::too_many_arguments)] -pub fn get_content_media_render_state<'a>( - ui: &mut egui::Ui, - job_pool: &'a mut JobPool, - jobs: &'a mut JobsCache, - media_trusted: bool, - size: Vec2, - cache: &'a mut TexturesCache, - url: &'a str, - cache_type: MediaCacheType, - cache_dir: &Path, - obfuscation_type: &'a ObfuscationType, -) -> MediaRenderState<'a> { - let render_type = if media_trusted { - cache.handle_and_get_or_insert_loadable(url, || { - notedeck::media::images::fetch_img( - cache_dir, - ui.ctx(), - url, - ImageType::Content(None), - cache_type, - ) - }) - } else if let Some(render_type) = cache.get_and_handle(url) { - render_type - } else { - return MediaRenderState::Obfuscated(get_obfuscated( - ui, - url, - obfuscation_type, - job_pool, - jobs, - size, - )); - }; - - match render_type { - notedeck::LoadableTextureState::Pending => MediaRenderState::Shimmering(get_obfuscated( - ui, - url, - obfuscation_type, - job_pool, - jobs, - size, - )), - notedeck::LoadableTextureState::Error(e) => MediaRenderState::Error(e), - notedeck::LoadableTextureState::Loading { actual_image_tex } => { - let obfuscation = get_obfuscated(ui, url, obfuscation_type, job_pool, jobs, size); - MediaRenderState::Transitioning { - image: actual_image_tex, - obfuscation, - } - } - notedeck::LoadableTextureState::Loaded(textured_image) => { - MediaRenderState::ActualImage(textured_image) - } - } -} - -fn get_obfuscated<'a>( - ui: &mut egui::Ui, - url: &str, - obfuscation_type: &'a ObfuscationType, - job_pool: &'a mut JobPool, - jobs: &'a mut JobsCache, - size: Vec2, -) -> ObfuscatedTexture<'a> { - let ObfuscationType::Blurhash(renderable_blur) = obfuscation_type else { - return ObfuscatedTexture::Default; - }; - - let params = BlurhashParams { - blurhash: &renderable_blur.blurhash, - url, - ctx: ui.ctx(), - }; - - let available_points = PointDimensions { - x: size.x, - y: size.y, - }; - - let pixel_sizes = renderable_blur.scaled_pixel_dimensions(ui, available_points); - - let job_state = jobs.get_or_insert_with( - job_pool, - &JobId::Blurhash(url), - Some(JobParams::Blurhash(params)), - move |params| compute_blurhash(params, pixel_sizes), - ); - - let JobState::Completed(m_blur_job) = job_state else { - return ObfuscatedTexture::Default; - }; - - #[allow(irrefutable_let_patterns)] - let Job::Blurhash(m_texture_handle) = m_blur_job - else { - tracing::error!("Did not get the correct job type: {:?}", m_blur_job); - return ObfuscatedTexture::Default; - }; - - let Some(texture_handle) = m_texture_handle else { - return ObfuscatedTexture::Default; - }; - - ObfuscatedTexture::Blur(texture_handle) -} - fn copy_link(i18n: &mut Localization, url: &str, img_resp: &Response) { img_resp.context_menu(|ui| { if ui @@ -366,42 +231,27 @@ fn copy_link(i18n: &mut Localization, url: &str, img_resp: &Response) { #[allow(clippy::too_many_arguments)] fn render_media_internal( ui: &mut egui::Ui, - gifs: &mut GifStateMap, render_state: MediaRenderState, url: &str, size: egui::Vec2, i18n: &mut Localization, scale_flags: ScaledTextureFlags, - animation_mode: AnimationMode, ) -> egui::InnerResponse<Option<MediaUIAction>> { match render_state { MediaRenderState::ActualImage(image) => { - let resp = render_success_media( - ui, - url, - image, - gifs, - size, - i18n, - scale_flags, - animation_mode, - ); + let resp = render_success_media(ui, url, image, size, i18n, scale_flags); if resp.clicked() { egui::InnerResponse::new(Some(MediaUIAction::Clicked), resp) } else { egui::InnerResponse::new(None, resp) } } - MediaRenderState::Transitioning { image, obfuscation } => match obfuscation { - ObfuscatedTexture::Blur(texture) => { - let resp = render_blur_transition( - ui, - url, - size, - texture, - image.get_first_texture(), - scale_flags, - ); + MediaRenderState::Transitioning { + image: img_tex, + obfuscation, + } => match obfuscation { + ObfuscatedTexture::Blur(blur_tex) => { + let resp = render_blur_transition(ui, url, size, blur_tex, img_tex, scale_flags); if resp.inner { egui::InnerResponse::new(Some(MediaUIAction::DoneLoading), resp.response) } else { @@ -409,7 +259,7 @@ fn render_media_internal( } } ObfuscatedTexture::Default => { - let scaled = ScaledTexture::new(image.get_first_texture(), size, scale_flags); + let scaled = ScaledTexture::new(img_tex, size, scale_flags); let resp = ui.add(scaled.get_image()); egui::InnerResponse::new(Some(MediaUIAction::DoneLoading), resp) } @@ -592,57 +442,16 @@ fn render_default_blur_bg( response } -pub enum MediaRenderState<'a> { - ActualImage(&'a mut TexturedImage), - Transitioning { - image: &'a mut TexturedImage, - obfuscation: ObfuscatedTexture<'a>, - }, - Error(&'a notedeck::Error), - Shimmering(ObfuscatedTexture<'a>), - Obfuscated(ObfuscatedTexture<'a>), -} - -pub enum ObfuscatedTexture<'a> { - Blur(&'a TextureHandle), - Default, -} - -/* -pub(crate) fn find_renderable_media<'a>( - urls: &mut UrlMimes, - imeta: &'a HashMap<String, ImageMetadata>, - url: &'a str, -) -> Option<RenderableMedia> { - let media_type = supported_mime_hosted_at_url(urls, url)?; - - let obfuscation_type = match imeta.get(url) { - Some(blur) => ObfuscationType::Blurhash(blur.clone()), - None => ObfuscationType::Default, - }; - - Some(RenderableMedia { - url, - media_type, - obfuscation_type, - }) -} -*/ - #[allow(clippy::too_many_arguments)] fn render_success_media( ui: &mut egui::Ui, url: &str, - tex: &mut TexturedImage, - gifs: &mut GifStateMap, + tex: &TextureHandle, size: Vec2, i18n: &mut Localization, scale_flags: ScaledTextureFlags, - animation_mode: AnimationMode, ) -> Response { - let texture = ensure_latest_texture(ui, url, gifs, tex, animation_mode); - - let scaled = ScaledTexture::new(&texture, size, scale_flags); + let scaled = ScaledTexture::new(tex, size, scale_flags); let img_resp = ui.add(Button::image(scaled.get_image()).frame(false)); diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -9,15 +9,14 @@ use crate::{widgets::x_button, ProfilePic, ProfilePreview, PulseAlpha, Username} pub use contents::{render_note_preview, NoteContents}; pub use context::NoteContextButton; -use notedeck::get_current_wallet; use notedeck::note::{reaction_sent_id, ZapTargetAmount}; use notedeck::ui::is_narrow; use notedeck::Accounts; use notedeck::GlobalWallet; use notedeck::Images; -use notedeck::JobsCache; use notedeck::Localization; use notedeck::MediaAction; +use notedeck::{get_current_wallet, MediaJobSender}; pub use options::NoteOptions; pub use reply_description::reply_desc; @@ -35,7 +34,6 @@ pub struct NoteView<'a, 'd> { parent: Option<NoteKey>, note: &'a nostrdb::Note<'a>, flags: NoteOptions, - jobs: &'a mut JobsCache, } pub struct NoteResponse { @@ -83,7 +81,6 @@ impl<'a, 'd> NoteView<'a, 'd> { note_context: &'a mut NoteContext<'d>, note: &'a nostrdb::Note<'a>, flags: NoteOptions, - jobs: &'a mut JobsCache, ) -> Self { let parent: Option<NoteKey> = None; @@ -92,7 +89,6 @@ impl<'a, 'd> NoteView<'a, 'd> { parent, note, flags, - jobs, } } @@ -256,7 +252,6 @@ impl<'a, 'd> NoteView<'a, 'd> { txn, self.note, self.flags, - self.jobs, )); //}); }) @@ -291,13 +286,19 @@ impl<'a, 'd> NoteView<'a, 'd> { Some(pic) => show_actual_pfp( ui, self.note_context.img_cache, + self.note_context.jobs, pic, pfp_size, note_key, profile, ), - None => show_fallback_pfp(ui, self.note_context.img_cache, pfp_size), + None => show_fallback_pfp( + ui, + self.note_context.img_cache, + self.note_context.jobs, + pfp_size, + ), } } @@ -423,22 +424,15 @@ impl<'a, 'd> NoteView<'a, 'd> { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; - note_action = reply_desc( - ui, - txn, - &note_reply, - self.note_context, - self.flags, - self.jobs, - ) - .or(note_action.take()); + note_action = + reply_desc(ui, txn, &note_reply, self.note_context, self.flags) + .or(note_action.take()); }); }); }); } - let mut contents = - NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs); + let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags); ui.add(&mut contents); @@ -535,20 +529,13 @@ impl<'a, 'd> NoteView<'a, 'd> { return; } - note_action = reply_desc( - ui, - txn, - &note_reply, - self.note_context, - self.flags, - self.jobs, - ) - .or(note_action.take()); + note_action = + reply_desc(ui, txn, &note_reply, self.note_context, self.flags) + .or(note_action.take()); }); } - let mut contents = - NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs); + let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags); ui.add(&mut contents); note_action = contents.action.or(note_action); @@ -713,6 +700,7 @@ impl PfpResponse { fn show_actual_pfp( ui: &mut egui::Ui, images: &mut Images, + jobs: &MediaJobSender, pic: &str, pfp_size: i8, note_key: NoteKey, @@ -732,13 +720,13 @@ fn show_actual_pfp( let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - let mut pfp = ProfilePic::new(images, pic).size(size); + let mut pfp = ProfilePic::new(images, jobs, pic).size(size); let pfp_resp = ui.put(rect, &mut pfp); let action = pfp.action; pfp_resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); - ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images)); + ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images, jobs)); }); PfpResponse { @@ -748,14 +736,20 @@ fn show_actual_pfp( } } -fn show_fallback_pfp(ui: &mut egui::Ui, images: &mut Images, pfp_size: i8) -> PfpResponse { +fn show_fallback_pfp( + ui: &mut egui::Ui, + images: &mut Images, + jobs: &MediaJobSender, + pfp_size: i8, +) -> PfpResponse { let sense = Sense::click(); // This has to match the expand size from the above case to // prevent bounciness let size = (pfp_size + NoteView::expand_size()) as f32; let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); - let mut pfp = ProfilePic::new(images, notedeck::profile::no_pfp_url()).size(pfp_size as f32); + let mut pfp = + ProfilePic::new(images, jobs, notedeck::profile::no_pfp_url()).size(pfp_size as f32); let response = ui.put(rect, &mut pfp).interact(sense); PfpResponse { diff --git a/crates/notedeck_ui/src/note/reply_description.rs b/crates/notedeck_ui/src/note/reply_description.rs @@ -3,7 +3,7 @@ use nostrdb::{NoteReply, Transaction}; use super::NoteOptions; use crate::{note::NoteView, Mention}; -use notedeck::{tr, JobsCache, NoteAction, NoteContext}; +use notedeck::{tr, NoteAction, NoteContext}; // Rich text segment types for internationalized rendering #[derive(Debug, Clone)] @@ -106,7 +106,6 @@ fn render_text_segments( txn: &Transaction, note_context: &mut NoteContext, note_options: NoteOptions, - jobs: &mut JobsCache, size: f32, selectable: bool, ) -> Option<NoteAction> { @@ -126,6 +125,7 @@ fn render_text_segments( let action = Mention::new( note_context.ndb, note_context.img_cache, + note_context.jobs, txn, pubkey.expect("expected pubkey"), ) @@ -163,7 +163,7 @@ fn render_text_segments( if r.hovered() { r.on_hover_ui_at_pointer(|ui| { ui.set_max_width(400.0); - NoteView::new(note_context, &note, note_options, jobs) + NoteView::new(note_context, &note, note_options) .actionbar(false) .wide(true) .show(ui); @@ -197,7 +197,7 @@ fn render_text_segments( if r.hovered() { r.on_hover_ui_at_pointer(|ui| { ui.set_max_width(400.0); - NoteView::new(note_context, &note, note_options, jobs) + NoteView::new(note_context, &note, note_options) .actionbar(false) .wide(true) .show(ui); @@ -219,7 +219,6 @@ pub fn reply_desc( note_reply: &NoteReply, note_context: &mut NoteContext, note_options: NoteOptions, - jobs: &mut JobsCache, ) -> Option<NoteAction> { let size = 10.0; let selectable = false; @@ -242,7 +241,6 @@ pub fn reply_desc( txn, note_context, note_options, - jobs, size, selectable, ); @@ -271,7 +269,6 @@ pub fn reply_desc( txn, note_context, note_options, - jobs, size, selectable, ) @@ -294,7 +291,6 @@ pub fn reply_desc( txn, note_context, note_options, - jobs, size, selectable, ) @@ -324,7 +320,6 @@ pub fn reply_desc( txn, note_context, note_options, - jobs, size, selectable, ) @@ -345,7 +340,6 @@ pub fn reply_desc( txn, note_context, note_options, - jobs, size, selectable, ) @@ -366,7 +360,6 @@ pub fn reply_desc( txn, note_context, note_options, - jobs, size, selectable, ) diff --git a/crates/notedeck_ui/src/profile/picture.rs b/crates/notedeck_ui/src/profile/picture.rs @@ -1,14 +1,15 @@ use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle}; -use notedeck::get_render_state; -use notedeck::media::gif::ensure_latest_texture; -use notedeck::media::images::{fetch_no_pfp_promise, ImageType}; +use notedeck::media::images::ImageType; +use notedeck::media::latest::LatestImageTex; use notedeck::media::AnimationMode; use notedeck::MediaAction; +use notedeck::MediaJobSender; use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images}; pub struct ProfilePic<'cache, 'url> { cache: &'cache mut Images, + jobs: &'cache MediaJobSender, url: &'url str, size: f32, sense: Sense, @@ -22,6 +23,7 @@ impl egui::Widget for &mut ProfilePic<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { let inner = render_pfp( ui, + self.jobs, self.cache, self.url, self.size, @@ -37,12 +39,13 @@ impl egui::Widget for &mut ProfilePic<'_, '_> { } impl<'cache, 'url> ProfilePic<'cache, 'url> { - pub fn new(cache: &'cache mut Images, url: &'url str) -> Self { + pub fn new(cache: &'cache mut Images, jobs: &'cache MediaJobSender, url: &'url str) -> Self { let size = Self::default_size() as f32; let sense = Sense::hover(); ProfilePic { cache, + jobs, sense, url, size, @@ -68,17 +71,19 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { pub fn from_profile( cache: &'cache mut Images, + jobs: &'cache MediaJobSender, profile: &nostrdb::ProfileRecord<'url>, ) -> Option<Self> { profile .record() .profile() .and_then(|p| p.picture()) - .map(|url| ProfilePic::new(cache, url)) + .map(|url| ProfilePic::new(cache, jobs, url)) } pub fn from_profile_or_default( cache: &'cache mut Images, + jobs: &'cache MediaJobSender, profile: Option<&nostrdb::ProfileRecord<'url>>, ) -> Self { let url = profile @@ -87,7 +92,7 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { .and_then(|p| p.picture()) .unwrap_or(notedeck::profile::no_pfp_url()); - ProfilePic::new(cache, url) + ProfilePic::new(cache, jobs, url) } #[inline] @@ -119,8 +124,10 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { } #[profiling::function] +#[allow(clippy::too_many_arguments)] fn render_pfp( ui: &mut egui::Ui, + jobs: &MediaJobSender, img_cache: &mut Images, url: &str, ui_size: f32, @@ -134,38 +141,29 @@ fn render_pfp( let cache_type = supported_mime_hosted_at_url(&mut img_cache.urls, url) .unwrap_or(notedeck::MediaCacheType::Image); - let cur_state = get_render_state( + let cur_state = img_cache.no_img_loading_tex_loader().latest_state( + jobs, ui.ctx(), - img_cache, - cache_type, url, + cache_type, ImageType::Profile(img_size), + animation_mode, ); - match cur_state.texture_state { - notedeck::TextureState::Pending => { + match cur_state { + LatestImageTex::Pending => { profiling::scope!("Render pending"); egui::InnerResponse::new(None, paint_circle(ui, ui_size, border, sense)) } - notedeck::TextureState::Error(e) => { + LatestImageTex::Error(e) => { profiling::scope!("Render error"); let r = paint_circle(ui, ui_size, border, sense); show_one_error_message(ui, &format!("Failed to fetch profile at url {url}: {e}")); - egui::InnerResponse::new( - Some(MediaAction::FetchImage { - url: url.to_owned(), - cache_type, - no_pfp_promise: fetch_no_pfp_promise(ui.ctx(), img_cache.get_cache(cache_type)), - }), - r, - ) + egui::InnerResponse::new(None, r) } - notedeck::TextureState::Loaded(textured_image) => { + LatestImageTex::Loaded(texture_handle) => { profiling::scope!("Render loaded"); - let texture_handle = - ensure_latest_texture(ui, url, cur_state.gifs, textured_image, animation_mode); - - egui::InnerResponse::new(None, pfp_image(ui, &texture_handle, ui_size, border, sense)) + egui::InnerResponse::new(None, pfp_image(ui, texture_handle, ui_size, border, sense)) } } } diff --git a/crates/notedeck_ui/src/profile/preview.rs b/crates/notedeck_ui/src/profile/preview.rs @@ -4,7 +4,8 @@ use egui_extras::Size; use nostrdb::ProfileRecord; use notedeck::{ - name::get_display_name, profile::get_profile_url, tr, Images, Localization, NotedeckTextStyle, + name::get_display_name, profile::get_profile_url, tr, Images, Localization, MediaJobSender, + NotedeckTextStyle, }; use super::{about_section_widget, banner, display_name_widget}; @@ -12,14 +13,20 @@ use super::{about_section_widget, banner, display_name_widget}; pub struct ProfilePreview<'a, 'cache> { profile: &'a ProfileRecord<'a>, cache: &'cache mut Images, + jobs: &'cache MediaJobSender, banner_height: Size, } impl<'a, 'cache> ProfilePreview<'a, 'cache> { - pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut Images) -> Self { + pub fn new( + profile: &'a ProfileRecord<'a>, + cache: &'cache mut Images, + jobs: &'cache MediaJobSender, + ) -> Self { let banner_height = Size::exact(80.0); ProfilePreview { profile, + jobs, cache, banner_height, } @@ -40,7 +47,7 @@ impl<'a, 'cache> ProfilePreview<'a, 'cache> { ui.put( pfp_rect, - &mut ProfilePic::new(self.cache, get_profile_url(Some(self.profile))) + &mut ProfilePic::new(self.cache, self.jobs, get_profile_url(Some(self.profile))) .size(size) .border(ProfilePic::border_stroke(ui)), ); @@ -72,6 +79,7 @@ pub struct SimpleProfilePreview<'a, 'cache> { profile: Option<&'a ProfileRecord<'a>>, pub i18n: &'cache mut Localization, cache: &'cache mut Images, + jobs: &'cache MediaJobSender, is_nsec: bool, } @@ -79,6 +87,7 @@ impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { pub fn new( profile: Option<&'a ProfileRecord<'a>>, cache: &'cache mut Images, + jobs: &'cache MediaJobSender, i18n: &'cache mut Localization, is_nsec: bool, ) -> Self { @@ -87,6 +96,7 @@ impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { cache, is_nsec, i18n, + jobs, } } } @@ -95,7 +105,10 @@ impl egui::Widget for SimpleProfilePreview<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { Frame::new() .show(ui, |ui| { - ui.add(&mut ProfilePic::new(self.cache, get_profile_url(self.profile)).size(48.0)); + ui.add( + &mut ProfilePic::new(self.cache, self.jobs, get_profile_url(self.profile)) + .size(48.0), + ); ui.vertical(|ui| { ui.add(display_name_widget(&get_display_name(self.profile), true)); if !self.is_nsec {