notedeck

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

commit b2abe495ca2e98f93df66976dba991bebeeef4f0
parent 7d2112b47254082648bda804cbb39311ef91c0c1
Author: kernelkind <kernelkind@gmail.com>
Date:   Fri, 18 Apr 2025 22:38:04 -0500

implement blurring

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck/src/imgcache.rs | 20++++----------------
Mcrates/notedeck/src/note/action.rs | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck/src/note/mod.rs | 2+-
Mcrates/notedeck_columns/src/actionbar.rs | 12+++++-------
Mcrates/notedeck_columns/src/ui/note/post.rs | 64+++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/notedeck_ui/src/images.rs | 100++++++++++++++++++++++++++++---------------------------------------------------
Mcrates/notedeck_ui/src/note/contents.rs | 57+++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/notedeck_ui/src/note/media.rs | 680+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/notedeck_ui/src/note/mod.rs | 45++++++++++++++++++++++++++-------------------
Mcrates/notedeck_ui/src/profile/picture.rs | 66++++++++++++++++++++++++++++++++++++++++++++----------------------
10 files changed, 874 insertions(+), 237 deletions(-)

diff --git a/crates/notedeck/src/imgcache.rs b/crates/notedeck/src/imgcache.rs @@ -13,13 +13,10 @@ use std::time::{Duration, Instant, SystemTime}; use hex::ToHex; use sha2::Digest; -use std::path::{self}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::path::{self, Path}; use tracing::warn; -pub type MediaCacheValue = Promise<Option<Result<TexturedImage>>>; -pub type MediaCacheMap = HashMap<String, MediaCacheValue>; - #[derive(Default)] pub struct TexturesCache { cache: hashbrown::HashMap<String, TextureStateInternal>, @@ -39,7 +36,6 @@ impl TexturesCache { 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); @@ -222,7 +218,7 @@ pub struct ImageFrame { pub struct MediaCache { pub cache_dir: path::PathBuf, - url_imgs: MediaCacheMap, + pub textures_cache: TexturesCache, pub cache_type: MediaCacheType, } @@ -237,7 +233,7 @@ impl MediaCache { let cache_dir = parent_dir.join(Self::rel_dir(cache_type)); Self { cache_dir, - url_imgs: HashMap::new(), + textures_cache: TexturesCache::default(), cache_type, } } @@ -337,14 +333,6 @@ impl MediaCache { } Ok(()) } - - pub fn map(&self) -> &MediaCacheMap { - &self.url_imgs - } - - pub fn map_mut(&mut self) -> &mut MediaCacheMap { - &mut self.url_imgs - } } fn color_image_to_rgba(color_image: ColorImage) -> image::RgbaImage { diff --git a/crates/notedeck/src/note/action.rs b/crates/notedeck/src/note/action.rs @@ -1,6 +1,7 @@ use super::context::ContextSelection; -use crate::zaps::NoteZapTargetOwned; +use crate::{zaps::NoteZapTargetOwned, Images, MediaCacheType, TexturedImage}; use enostr::{NoteId, Pubkey}; +use poll_promise::Promise; #[derive(Debug)] pub enum NoteAction { @@ -24,6 +25,9 @@ pub enum NoteAction { /// User has clicked the zap action Zap(ZapAction), + + /// User clicked on media + Media(MediaAction), } #[derive(Debug, Eq, PartialEq, Clone)] @@ -37,3 +41,62 @@ pub struct ZapTargetAmount { pub target: NoteZapTargetOwned, pub specified_msats: Option<u64>, // if None use default amount } + +pub enum MediaAction { + FetchImage { + url: String, + cache_type: MediaCacheType, + no_pfp_promise: Promise<Option<Result<TexturedImage, crate::Error>>>, + }, + DoneLoading { + url: String, + cache_type: MediaCacheType, + }, +} + +impl std::fmt::Debug for MediaAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::FetchImage { + url, + cache_type, + no_pfp_promise, + } => 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") + .field("url", url) + .field("cache_type", cache_type) + .finish(), + } + } +} + +impl MediaAction { + pub fn process(self, images: &mut Images) { + match self { + 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); + } + } + } +} diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -1,7 +1,7 @@ mod action; mod context; -pub use action::{NoteAction, ZapAction, ZapTargetAmount}; +pub use action::{MediaAction, NoteAction, ZapAction, ZapTargetAmount}; pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; use crate::JobPool; diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -34,7 +34,7 @@ fn execute_note_action( accounts: &mut Accounts, global_wallet: &mut GlobalWallet, zaps: &mut Zaps, - _images: &mut Images, + images: &mut Images, ui: &mut egui::Ui, ) -> Option<TimelineOpenResult> { match action { @@ -42,13 +42,11 @@ fn execute_note_action( router.route_to(Route::reply(note_id)); None } - NoteAction::Profile(pubkey) => { let kind = TimelineKind::Profile(pubkey); router.route_to(Route::Timeline(kind.clone())); timeline_cache.open(ndb, note_cache, txn, pool, &kind) } - NoteAction::Note(note_id) => 'ex: { let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id) else { @@ -62,18 +60,15 @@ fn execute_note_action( timeline_cache.open(ndb, note_cache, txn, pool, &kind) } - NoteAction::Hashtag(htag) => { let kind = TimelineKind::Hashtag(htag.clone()); router.route_to(Route::Timeline(kind.clone())); timeline_cache.open(ndb, note_cache, txn, pool, &kind) } - NoteAction::Quote(note_id) => { router.route_to(Route::quote(note_id)); None } - NoteAction::Zap(zap_action) => 's: { let Some(cur_acc) = accounts.get_selected_account_mut() else { break 's None; @@ -106,7 +101,6 @@ fn execute_note_action( None } - NoteAction::Context(context) => { match ndb.get_note_by_key(txn, context.note_key) { Err(err) => tracing::error!("{err}"), @@ -116,6 +110,10 @@ fn execute_note_action( } None } + NoteAction::Media(media_action) => { + media_action.process(images); + None + } } } diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -8,17 +8,16 @@ use crate::Result; use egui::{ text::{CCursorRange, LayoutJob}, text_edit::TextEditOutput, - vec2, widgets::text_edit::TextEdit, Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer, }; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; -use notedeck::{Images, MediaCacheType}; +use notedeck_ui::blur::PixelDimensions; +use notedeck_ui::images::{get_render_state, RenderState}; use notedeck_ui::jobs::JobsCache; use notedeck_ui::{ gif::{handle_repaint, retrieve_latest_texture}, - images::render_images, note::render_note_preview, NoteOptions, ProfilePic, }; @@ -441,6 +440,14 @@ 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_ui::images::ImageType::Content, + ); + render_post_view_media( ui, &mut self.draft.upload_errors, @@ -448,10 +455,9 @@ impl<'a, 'd> PostView<'a, 'd> { i, width, height, - self.note_context.img_cache, - cache_type, + cur_state, url, - ); + ) } to_remove.reverse(); for i in to_remove { @@ -539,34 +545,34 @@ fn render_post_view_media( cur_index: usize, width: u32, height: u32, - images: &mut Images, - cache_type: MediaCacheType, + render_state: RenderState, url: &str, ) { - render_images( - ui, - images, - url, - notedeck_ui::images::ImageType::Content, - cache_type, - |ui| { + match render_state.texture_state { + notedeck::TextureState::Pending => { ui.spinner(); - }, - |_, e| { + } + notedeck::TextureState::Error(e) => { upload_errors.push(e.to_string()); error!("{e}"); - }, - |ui, url, renderable_media, gifs| { - let media_size = vec2(width as f32, height as f32); - let max_size = vec2(300.0, 300.0); - let size = if media_size.x > max_size.x || media_size.y > max_size.y { - max_size + } + notedeck::TextureState::Loaded(renderable_media) => { + let max_size = 300; + let size = if width > max_size || height > max_size { + PixelDimensions { x: 300, y: 300 } } else { - media_size - }; + PixelDimensions { + x: width, + y: height, + } + } + .to_points(ui.pixels_per_point()) + .to_vec(); - let texture_handle = - handle_repaint(ui, retrieve_latest_texture(url, gifs, renderable_media)); + let texture_handle = handle_repaint( + ui, + retrieve_latest_texture(url, render_state.gifs, renderable_media), + ); let img_resp = ui.add( egui::Image::new(texture_handle) .max_size(size) @@ -583,8 +589,8 @@ fn render_post_view_media( to_remove.push(cur_index); } ui.advance_cursor_after_rect(img_resp.rect); - }, - ); + } + } } fn post_button(interactive: bool) -> impl egui::Widget { diff --git a/crates/notedeck_ui/src/images.rs b/crates/notedeck_ui/src/images.rs @@ -1,10 +1,10 @@ -use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; +use egui::{pos2, Color32, ColorImage, Context, Rect, Sense, SizeHint}; use image::codecs::gif::GifDecoder; use image::imageops::FilterType; use image::{AnimationDecoder, DynamicImage, FlatSamples, Frame}; use notedeck::{ - Animation, GifStateMap, ImageFrame, Images, MediaCache, MediaCacheType, TextureFrame, - TexturedImage, + Animation, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache, MediaCacheType, + TextureFrame, TextureState, TexturedImage, }; use poll_promise::Promise; use std::collections::VecDeque; @@ -424,77 +424,47 @@ fn fetch_img_from_net( promise } -#[allow(clippy::too_many_arguments)] -pub fn render_images( - ui: &mut egui::Ui, - images: &mut Images, +pub fn get_render_state<'a>( + ctx: &Context, + images: &'a mut Images, + cache_type: MediaCacheType, url: &str, img_type: ImageType, - cache_type: MediaCacheType, - show_waiting: impl FnOnce(&mut egui::Ui), - show_error: impl FnOnce(&mut egui::Ui, String), - show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), -) -> egui::Response { +) -> RenderState<'a> { let cache = match cache_type { MediaCacheType::Image => &mut images.static_imgs, MediaCacheType::Gif => &mut images.gifs, }; - render_media_cache( - ui, - cache, - &mut images.gif_states, - url, - img_type, - cache_type, - show_waiting, - show_error, - show_success, - ) + let cur_state = cache.textures_cache.handle_and_get_or_insert(url, || { + crate::images::fetch_img(&cache.cache_dir, ctx, url, img_type, cache_type) + }); + + RenderState { + texture_state: cur_state, + gifs: &mut images.gif_states, + } } -#[allow(clippy::too_many_arguments)] -fn render_media_cache( - ui: &mut egui::Ui, - cache: &mut MediaCache, - gif_states: &mut GifStateMap, - url: &str, - img_type: ImageType, - cache_type: MediaCacheType, - show_waiting: impl FnOnce(&mut egui::Ui), - show_error: impl FnOnce(&mut egui::Ui, String), - show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), -) -> egui::Response { - let m_cached_promise = cache.map().get(url); +pub struct LoadableRenderState<'a> { + pub texture_state: LoadableTextureState<'a>, + pub gifs: &'a mut GifStateMap, +} - if m_cached_promise.is_none() { - let res = crate::images::fetch_img(&cache.cache_dir, ui.ctx(), url, img_type, cache_type); - cache.map_mut().insert(url.to_owned(), res); - } +pub struct RenderState<'a> { + pub texture_state: TextureState<'a>, + pub gifs: &'a mut GifStateMap, +} - egui::Frame::NONE - .show(ui, |ui| { - match cache.map_mut().get_mut(url).and_then(|p| p.ready_mut()) { - None => show_waiting(ui), - Some(Some(Err(err))) => { - let err = err.to_string(); - let no_pfp = crate::images::fetch_img( - &cache.cache_dir, - ui.ctx(), - notedeck::profile::no_pfp_url(), - ImageType::Profile(128), - cache_type, - ); - cache.map_mut().insert(url.to_owned(), no_pfp); - show_error(ui, err) - } - Some(Some(Ok(renderable_media))) => { - show_success(ui, url, renderable_media, gif_states) - } - Some(None) => { - tracing::error!("Promise already taken"); - } - } - }) - .response +pub fn fetch_no_pfp_promise( + ctx: &Context, + cache: &MediaCache, +) -> Promise<Option<Result<TexturedImage, notedeck::Error>>> { + crate::images::fetch_img( + &cache.cache_dir, + ctx, + notedeck::profile::no_pfp_url(), + ImageType::Profile(128), + MediaCacheType::Image, + ) } diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -1,4 +1,8 @@ +use std::cell::OnceCell; + use crate::{ + blur::imeta_blurhashes, + contacts::trust_media_from_pk2, jobs::JobsCache, note::{NoteAction, NoteOptions, NoteResponse, NoteView}, }; @@ -8,9 +12,9 @@ use enostr::KeypairUnowned; use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; use tracing::warn; -use notedeck::{supported_mime_hosted_at_url, MediaCacheType, NoteContext}; +use notedeck::NoteContext; -use super::media::image_carousel; +use super::media::{find_renderable_media, image_carousel, RenderableMedia}; pub struct NoteContents<'a, 'd> { note_context: &'a mut NoteContext<'d>, @@ -116,7 +120,6 @@ pub fn render_note_contents( ) -> NoteResponse { let note_key = note.key().expect("todo: implement non-db notes"); let selectable = options.has_selectable_text(); - let mut images: Vec<(String, MediaCacheType)> = vec![]; let mut note_action: Option<NoteAction> = None; let mut inline_note: Option<(&[u8; 32], &str)> = None; let hide_media = options.has_hide_media(); @@ -131,6 +134,9 @@ pub fn render_note_contents( let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click()); } + let mut supported_medias: Vec<RenderableMedia> = vec![]; + let blurhashes = OnceCell::new(); + let response = ui.horizontal_wrapped(|ui| { let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) { blocks @@ -199,15 +205,19 @@ pub fn render_note_contents( BlockType::Url => { let mut found_supported = || -> bool { let url = block.as_str(); - if let Some(cache_type) = - supported_mime_hosted_at_url(&mut note_context.img_cache.urls, url) - { - images.push((url.to_string(), cache_type)); - true - } else { - false - } + + let blurs = blurhashes.get_or_init(|| imeta_blurhashes(note)); + + let Some(media_type) = + find_renderable_media(&mut note_context.img_cache.urls, blurs, url) + else { + return false; + }; + + supported_medias.push(media_type); + true }; + if hide_media || !found_supported() { ui.add(Hyperlink::from_label_and_url( RichText::new(block.as_str()).color(link_color), @@ -266,14 +276,33 @@ pub fn render_note_contents( None }; - if !images.is_empty() && !options.has_textmode() { + let mut media_action = None; + if !supported_medias.is_empty() && !options.has_textmode() { ui.add_space(2.0); let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); - image_carousel(ui, note_context.img_cache, images, carousel_id); + + let trusted_media = trust_media_from_pk2( + note_context.ndb, + txn, + cur_acc.as_ref().map(|k| k.pubkey.bytes()), + note.pubkey(), + ); + + media_action = image_carousel( + ui, + note_context.img_cache, + note_context.job_pool, + jobs, + supported_medias, + carousel_id, + trusted_media, + ); ui.add_space(2.0); } - let note_action = preview_note_action.or(note_action); + let note_action = preview_note_action + .or(note_action) + .or(media_action.map(NoteAction::Media)); NoteResponse::new(response.response).with_action(note_action) } diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -1,24 +1,35 @@ -use std::collections::HashMap; +use std::{collections::HashMap, path::Path}; -use egui::{Button, Color32, Image, Response, Sense, Window}; -use notedeck::{GifState, Images, MediaCacheType, TexturedImage}; +use egui::{ + Button, Color32, Context, CornerRadius, FontId, Image, Response, Sense, TextureHandle, Window, +}; +use notedeck::{ + fonts::get_font_size, note::MediaAction, show_one_error_message, supported_mime_hosted_at_url, + GifState, GifStateMap, Images, JobPool, MediaCache, MediaCacheType, NotedeckTextStyle, + TexturedImage, TexturesCache, UrlMimes, +}; use crate::{ + blur::{compute_blurhash, Blur, ObfuscationType, PointDimensions}, gif::{handle_repaint, retrieve_latest_texture}, - images::{render_images, ImageType}, + images::{fetch_no_pfp_promise, get_render_state, ImageType}, + jobs::{BlurhashParams, Job, JobId, JobParams, JobState, JobsCache}, + AnimationHelper, PulseAlpha, }; pub(crate) fn image_carousel( ui: &mut egui::Ui, img_cache: &mut Images, - images: Vec<(String, MediaCacheType)>, + job_pool: &mut JobPool, + jobs: &mut JobsCache, + medias: Vec<RenderableMedia>, carousel_id: egui::Id, -) { + trusted_media: bool, +) -> Option<MediaAction> { // let's make sure everything is within our area let height = 360.0; - let width = ui.available_size().x; - let spinsz = if height > width { width } else { height }; + let width = ui.available_width(); let show_popup = ui.ctx().memory(|mem| { mem.data @@ -26,60 +37,72 @@ pub(crate) fn image_carousel( .unwrap_or(false) }); - let current_image = show_popup.then(|| { - ui.ctx().memory(|mem| { + let current_image = 'scope: { + if !show_popup { + break 'scope None; + } + + let Some(media) = medias.first() else { + break 'scope None; + }; + + Some(ui.ctx().memory(|mem| { mem.data .get_temp::<(String, MediaCacheType)>(carousel_id.with("current_image")) - .unwrap_or_else(|| (images[0].0.clone(), images[0].1)) - }) - }); + .unwrap_or_else(|| (media.url.to_owned(), media.media_type)) + })) + }; + let mut action = None; ui.add_sized([width, height], |ui: &mut egui::Ui| { egui::ScrollArea::horizontal() .id_salt(carousel_id) .show(ui, |ui| { ui.horizontal(|ui| { - for (image, cache_type) in images { - render_images( + for media in medias { + let RenderableMedia { + url, + media_type, + 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, - img_cache, - &image, - ImageType::Content, - cache_type, - |ui| { - ui.allocate_space(egui::vec2(spinsz, spinsz)); - }, - |ui, _| { - ui.allocate_space(egui::vec2(spinsz, spinsz)); - }, - |ui, url, renderable_media, gifs| { - let texture = handle_repaint( - ui, - retrieve_latest_texture(&image, gifs, renderable_media), - ); - let img_resp = ui.add( - Button::image( - Image::new(texture) - .max_height(height) - .corner_radius(5.0) - .fit_to_original_size(1.0), - ) - .frame(false), - ); - - if img_resp.clicked() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), true); - mem.data.insert_temp( - carousel_id.with("current_image"), - (image.clone(), cache_type), - ); - }); - } - - copy_link(url, img_resp); - }, + job_pool, + jobs, + trusted_media, + height, + &mut cache.textures_cache, + url, + media_type, + &cache.cache_dir, + blur_type, ); + if let Some(cur_action) = render_media( + ui, + &mut img_cache.gif_states, + media_state, + url, + media_type, + height, + carousel_id, + ) { + let cur_action = cur_action.to_media_action( + ui.ctx(), + url, + media_type, + cache, + ImageType::Content, + ); + if let Some(cur_action) = cur_action { + action = Some(cur_action); + } + } } }) .response @@ -92,6 +115,53 @@ pub(crate) fn image_carousel( show_full_screen_media(ui, &image_url, cache_type, img_cache, carousel_id); } } + action +} + +enum MediaUIAction { + Unblur, + Error, + DoneLoading, +} + +impl MediaUIAction { + pub fn to_media_action( + &self, + ctx: &egui::Context, + url: &str, + cache_type: MediaCacheType, + cache: &mut MediaCache, + img_type: ImageType, + ) -> Option<MediaAction> { + match self { + MediaUIAction::Unblur => Some(MediaAction::FetchImage { + url: url.to_owned(), + cache_type, + no_pfp_promise: crate::images::fetch_img( + &cache.cache_dir, + ctx, + url, + img_type, + cache_type, + ), + }), + MediaUIAction::Error => { + if !matches!(img_type, ImageType::Profile(_)) { + return None; + }; + + Some(MediaAction::FetchImage { + url: url.to_owned(), + cache_type, + no_pfp_promise: fetch_no_pfp_promise(ctx, cache), + }) + } + MediaUIAction::DoneLoading => Some(MediaAction::DoneLoading { + url: url.to_owned(), + cache_type, + }), + } + } } fn show_full_screen_media( @@ -107,23 +177,133 @@ fn show_full_screen_media( .fixed_pos(ui.ctx().screen_rect().min) .frame(egui::Frame::NONE) .show(ui.ctx(), |ui| { - ui.centered_and_justified(|ui| { - render_images( - ui, + ui.centered_and_justified(|ui| 's: { + let cur_state = get_render_state( + ui.ctx(), img_cache, + cache_type, image_url, ImageType::Content, - cache_type, - |_| {}, - |_, _| {}, - |ui, url, renderable_media, gifs| { - render_full_screen_media(ui, renderable_media, gifs, url, carousel_id); - }, ); - }); + + let notedeck::TextureState::Loaded(textured_image) = cur_state.texture_state else { + break 's; + }; + + render_full_screen_media( + ui, + textured_image, + cur_state.gifs, + image_url, + carousel_id, + ); + }) }); } +#[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, + height: f32, + cache: &'a mut TexturesCache, + url: &'a str, + cache_type: MediaCacheType, + cache_dir: &Path, + obfuscation_type: ObfuscationType<'a>, +) -> MediaRenderState<'a> { + let render_type = if media_trusted { + cache.handle_and_get_or_insert_loadable(url, || { + crate::images::fetch_img(cache_dir, ui.ctx(), url, ImageType::Content, 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, + height, + )); + }; + + match render_type { + notedeck::LoadableTextureState::Pending => MediaRenderState::Shimmering(get_obfuscated( + ui, + url, + obfuscation_type, + job_pool, + jobs, + height, + )), + notedeck::LoadableTextureState::Error(e) => MediaRenderState::Error(e), + notedeck::LoadableTextureState::Loading { actual_image_tex } => { + let obfuscation = get_obfuscated(ui, url, obfuscation_type, job_pool, jobs, height); + 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: ObfuscationType<'a>, + job_pool: &'a mut JobPool, + jobs: &'a mut JobsCache, + height: f32, +) -> 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: ui.available_width(), + y: height, + }; + + 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 render_full_screen_media( ui: &mut egui::Ui, renderable_media: &mut TexturedImage, @@ -181,7 +361,7 @@ fn render_full_screen_media( ); let texture_size = texture.size_vec2(); - let screen_size = screen_rect.size(); + let screen_size = ui.ctx().screen_rect().size(); let scale = (screen_size.x / texture_size.x) .min(screen_size.y / texture_size.y) .min(1.0); @@ -281,3 +461,377 @@ fn copy_link(url: &str, img_resp: Response) { } }); } + +#[allow(clippy::too_many_arguments)] +fn render_media( + ui: &mut egui::Ui, + gifs: &mut GifStateMap, + render_state: MediaRenderState, + url: &str, + cache_type: MediaCacheType, + height: f32, + carousel_id: egui::Id, +) -> Option<MediaUIAction> { + match render_state { + MediaRenderState::ActualImage(image) => { + render_success_media(ui, url, image, gifs, cache_type, height, carousel_id); + None + } + MediaRenderState::Transitioning { image, obfuscation } => match obfuscation { + ObfuscatedTexture::Blur(texture) => { + if render_blur_transition(ui, url, height, texture, image.get_first_texture()) { + Some(MediaUIAction::DoneLoading) + } else { + None + } + } + ObfuscatedTexture::Default => { + ui.add(texture_to_image(image.get_first_texture(), height)); + Some(MediaUIAction::DoneLoading) + } + }, + MediaRenderState::Error(e) => { + ui.allocate_space(egui::vec2(height, height)); + show_one_error_message(ui, &format!("Could not render media {url}: {e}")); + Some(MediaUIAction::Error) + } + MediaRenderState::Shimmering(obfuscated_texture) => { + match obfuscated_texture { + ObfuscatedTexture::Blur(texture_handle) => { + shimmer_blurhash(texture_handle, ui, url, height); + } + ObfuscatedTexture::Default => { + render_default_blur_bg(ui, height, url, true); + } + } + None + } + MediaRenderState::Obfuscated(obfuscated_texture) => { + let resp = match obfuscated_texture { + ObfuscatedTexture::Blur(texture_handle) => { + let resp = ui.add(texture_to_image(texture_handle, height)); + render_blur_text(ui, url, resp.rect) + } + ObfuscatedTexture::Default => render_default_blur(ui, height, url), + }; + + if resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + Some(MediaUIAction::Unblur) + } else { + None + } + } + } +} + +fn render_blur_text(ui: &mut egui::Ui, url: &str, render_rect: egui::Rect) -> egui::Response { + let helper = AnimationHelper::new_from_rect(ui, ("show_media", url), render_rect); + + let painter = ui.painter_at(helper.get_animation_rect()); + + let text_style = NotedeckTextStyle::Button; + + let icon_data = egui::include_image!("../../../../assets/icons/eye-slash-dark.png"); + + let icon_size = helper.scale_1d_pos(30.0); + let animation_fontid = FontId::new( + helper.scale_1d_pos(get_font_size(ui.ctx(), &text_style)), + text_style.font_family(), + ); + let info_galley = painter.layout( + "Media from someone you don't follow".to_owned(), + animation_fontid.clone(), + ui.visuals().text_color(), + render_rect.width() / 2.0, + ); + + let load_galley = painter.layout_no_wrap( + "Tap to Load".to_owned(), + animation_fontid, + egui::Color32::BLACK, + // ui.visuals().widgets.inactive.bg_fill, + ); + + let items_height = info_galley.rect.height() + load_galley.rect.height() + icon_size; + + let spacing = helper.scale_1d_pos(8.0); + let icon_rect = { + let mut center = helper.get_animation_rect().center(); + center.y -= (items_height / 2.0) + (spacing * 3.0) - (icon_size / 2.0); + + egui::Rect::from_center_size(center, egui::vec2(icon_size, icon_size)) + }; + + egui::Image::new(icon_data) + .max_width(icon_size) + .paint_at(ui, icon_rect); + + let info_galley_pos = { + let mut pos = icon_rect.center(); + pos.x -= info_galley.rect.width() / 2.0; + pos.y = icon_rect.bottom() + spacing; + pos + }; + + let load_galley_pos = { + let mut pos = icon_rect.center(); + pos.x -= load_galley.rect.width() / 2.0; + pos.y = icon_rect.bottom() + info_galley.rect.height() + (4.0 * spacing); + pos + }; + + let button_rect = egui::Rect::from_min_size(load_galley_pos, load_galley.size()).expand(8.0); + + let button_fill = egui::Color32::from_rgba_unmultiplied(0xFF, 0xFF, 0xFF, 0x1F); + + painter.rect( + button_rect, + egui::CornerRadius::same(8), + button_fill, + egui::Stroke::NONE, + egui::StrokeKind::Middle, + ); + + painter.galley(info_galley_pos, info_galley, egui::Color32::WHITE); + painter.galley(load_galley_pos, load_galley, egui::Color32::WHITE); + + helper.take_animation_response() +} + +fn render_default_blur(ui: &mut egui::Ui, height: f32, url: &str) -> egui::Response { + let rect = render_default_blur_bg(ui, height, url, false); + render_blur_text(ui, url, rect) +} + +fn render_default_blur_bg(ui: &mut egui::Ui, height: f32, url: &str, shimmer: bool) -> egui::Rect { + let (rect, _) = ui.allocate_exact_size(egui::vec2(height, height), egui::Sense::click()); + + let painter = ui.painter_at(rect); + + let mut color = crate::colors::MID_GRAY; + if shimmer { + let [r, g, b, _a] = color.to_srgba_unmultiplied(); + let cur_alpha = get_blur_current_alpha(ui, url); + color = Color32::from_rgba_unmultiplied(r, g, b, cur_alpha) + } + + painter.rect_filled(rect, CornerRadius::same(8), color); + + rect +} + +pub(crate) struct RenderableMedia<'a> { + url: &'a str, + media_type: MediaCacheType, + obfuscation_type: ObfuscationType<'a>, +} + +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, + blurhashes: &'a HashMap<&'a str, Blur<'a>>, + url: &'a str, +) -> Option<RenderableMedia<'a>> { + let media_type = supported_mime_hosted_at_url(urls, url)?; + + let obfuscation_type = match blurhashes.get(url) { + Some(blur) => ObfuscationType::Blurhash(blur), + None => ObfuscationType::Default, + }; + + Some(RenderableMedia { + url, + media_type, + obfuscation_type, + }) +} + +fn render_success_media( + ui: &mut egui::Ui, + url: &str, + tex: &mut TexturedImage, + gifs: &mut GifStateMap, + cache_type: MediaCacheType, + height: f32, + carousel_id: egui::Id, +) { + let texture = handle_repaint(ui, retrieve_latest_texture(url, gifs, tex)); + let img = texture_to_image(texture, height); + let img_resp = ui.add(Button::image(img).frame(false)); + + if img_resp.clicked() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), true); + mem.data.insert_temp( + carousel_id.with("current_image"), + (url.to_owned(), cache_type), + ); + }); + } + + copy_link(url, img_resp); +} + +fn texture_to_image(tex: &TextureHandle, max_height: f32) -> egui::Image { + Image::new(tex) + .max_height(max_height) + .corner_radius(5.0) + .maintain_aspect_ratio(true) +} + +static BLUR_SHIMMER_ID: fn(&str) -> egui::Id = |url| egui::Id::new(("blur_shimmer", url)); + +fn get_blur_current_alpha(ui: &mut egui::Ui, url: &str) -> u8 { + let id = BLUR_SHIMMER_ID(url); + + let (alpha_min, alpha_max) = if ui.visuals().dark_mode { + (150, 255) + } else { + (220, 255) + }; + PulseAlpha::new(ui.ctx(), id, alpha_min, alpha_max) + .with_speed(0.3) + .start_max_alpha() + .animate() +} + +fn shimmer_blurhash(tex: &TextureHandle, ui: &mut egui::Ui, url: &str, max_height: f32) { + let cur_alpha = get_blur_current_alpha(ui, url); + + let scaled = ScaledTexture::new(tex, max_height); + let img = scaled.get_image(); + show_blurhash_with_alpha(ui, img, cur_alpha); +} + +fn fade_color(alpha: u8) -> egui::Color32 { + Color32::from_rgba_unmultiplied(255, 255, 255, alpha) +} + +fn show_blurhash_with_alpha(ui: &mut egui::Ui, img: Image, alpha: u8) { + let cur_color = fade_color(alpha); + + let img = img.tint(cur_color); + + ui.add(img); +} + +type FinishedTransition = bool; + +// return true if transition is finished +fn render_blur_transition( + ui: &mut egui::Ui, + url: &str, + max_height: f32, + blur_texture: &TextureHandle, + image_texture: &TextureHandle, +) -> FinishedTransition { + let scaled_texture = ScaledTexture::new(image_texture, max_height); + + let blur_img = texture_to_image(blur_texture, max_height); + match get_blur_transition_state(ui.ctx(), url) { + BlurTransitionState::StoppingShimmer { cur_alpha } => { + show_blurhash_with_alpha(ui, blur_img, cur_alpha); + false + } + BlurTransitionState::FadingBlur => render_blur_fade(ui, url, blur_img, &scaled_texture), + } +} + +struct ScaledTexture<'a> { + tex: &'a TextureHandle, + max_height: f32, + pub scaled_size: egui::Vec2, +} + +impl<'a> ScaledTexture<'a> { + pub fn new(tex: &'a TextureHandle, max_height: f32) -> Self { + let scaled_size = { + let mut size = tex.size_vec2(); + + if size.y > max_height { + let old_y = size.y; + size.y = max_height; + size.x *= max_height / old_y; + } + + size + }; + + Self { + tex, + max_height, + scaled_size, + } + } + + pub fn get_image(&self) -> Image { + texture_to_image(self.tex, self.max_height) + .max_size(self.scaled_size) + .shrink_to_fit() + } +} + +fn render_blur_fade( + ui: &mut egui::Ui, + url: &str, + blur_img: Image, + image_texture: &ScaledTexture, +) -> FinishedTransition { + let blur_fade_id = ui.id().with(("blur_fade", url)); + + let cur_alpha = { + PulseAlpha::new(ui.ctx(), blur_fade_id, 0, 255) + .start_max_alpha() + .with_speed(0.3) + .animate() + }; + + let img = image_texture.get_image(); + + let blur_img = blur_img.tint(fade_color(cur_alpha)); + + let alloc_size = image_texture.scaled_size; + + let (rect, _) = ui.allocate_exact_size(alloc_size, egui::Sense::hover()); + + img.paint_at(ui, rect); + blur_img.paint_at(ui, rect); + + cur_alpha == 0 +} + +fn get_blur_transition_state(ctx: &Context, url: &str) -> BlurTransitionState { + let shimmer_id = BLUR_SHIMMER_ID(url); + + let max_alpha = 255.0; + let cur_shimmer_alpha = ctx.animate_value_with_time(shimmer_id, max_alpha, 0.3); + if cur_shimmer_alpha == max_alpha { + BlurTransitionState::FadingBlur + } else { + let cur_alpha = (cur_shimmer_alpha).clamp(0.0, max_alpha) as u8; + BlurTransitionState::StoppingShimmer { cur_alpha } + } +} + +enum BlurTransitionState { + StoppingShimmer { cur_alpha: u8 }, + FadingBlur, +} diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -12,6 +12,7 @@ use crate::{ pub use contents::{render_note_contents, render_note_preview, NoteContents}; pub use context::NoteContextButton; +use notedeck::note::MediaAction; use notedeck::note::ZapTargetAmount; pub use options::NoteOptions; pub use reply_description::reply_desc; @@ -233,7 +234,8 @@ impl<'a, 'd> NoteView<'a, 'd> { note_key: NoteKey, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, ui: &mut egui::Ui, - ) -> egui::Response { + ) -> (egui::Response, Option<MediaAction>) { + let mut action = None; if !self.options().has_wide() { ui.spacing_mut().item_spacing.x = 16.0; } else { @@ -243,7 +245,7 @@ impl<'a, 'd> NoteView<'a, 'd> { let pfp_size = self.options().pfp_size(); let sense = Sense::click(); - match profile + let resp = match profile .as_ref() .ok() .and_then(|p| p.record().profile()?.picture()) @@ -263,11 +265,11 @@ impl<'a, 'd> NoteView<'a, 'd> { anim_speed, ); - ui.put( - rect, - &mut ProfilePic::new(self.note_context.img_cache, pic).size(size), - ) - .on_hover_ui_at_pointer(|ui| { + let mut pfp = ProfilePic::new(self.note_context.img_cache, pic).size(size); + let pfp_resp = ui.put(rect, &mut pfp); + + action = action.or(pfp.action); + pfp_resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); ui.add(ProfilePreview::new( profile.as_ref().unwrap(), @@ -288,17 +290,16 @@ impl<'a, 'd> NoteView<'a, 'd> { let size = (pfp_size + NoteView::expand_size()) as f32; let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); - ui.put( - rect, - &mut ProfilePic::new( - self.note_context.img_cache, - notedeck::profile::no_pfp_url(), - ) - .size(pfp_size as f32), - ) - .interact(sense) + let mut pfp = + ProfilePic::new(self.note_context.img_cache, notedeck::profile::no_pfp_url()) + .size(pfp_size as f32); + let resp = ui.put(rect, &mut pfp).interact(sense); + action = action.or(pfp.action); + + resp } - } + }; + (resp, action) } pub fn show_impl(&mut self, ui: &mut egui::Ui) -> NoteResponse { @@ -404,8 +405,11 @@ impl<'a, 'd> NoteView<'a, 'd> { let response = if self.options().has_wide() { ui.vertical(|ui| { ui.horizontal(|ui| { - if self.pfp(note_key, &profile, ui).clicked() { + let (pfp_resp, action) = self.pfp(note_key, &profile, ui); + if pfp_resp.clicked() { note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey()))); + } else if let Some(action) = action { + note_action = Some(NoteAction::Media(action)); }; let size = ui.available_size(); @@ -488,8 +492,11 @@ impl<'a, 'd> NoteView<'a, 'd> { } else { // main design ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { - if self.pfp(note_key, &profile, ui).clicked() { + let (pfp_resp, action) = self.pfp(note_key, &profile, ui); + if pfp_resp.clicked() { note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey()))); + } else if let Some(action) = action { + note_action = Some(NoteAction::Media(action)); }; ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { diff --git a/crates/notedeck_ui/src/profile/picture.rs b/crates/notedeck_ui/src/profile/picture.rs @@ -1,7 +1,8 @@ use crate::gif::{handle_repaint, retrieve_latest_texture}; -use crate::images::{render_images, ImageType}; -use egui::{vec2, Sense, Stroke, TextureHandle}; +use crate::images::{fetch_no_pfp_promise, get_render_state, ImageType}; +use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle}; +use notedeck::note::MediaAction; use notedeck::{supported_mime_hosted_at_url, Images}; pub struct ProfilePic<'cache, 'url> { @@ -9,11 +10,16 @@ pub struct ProfilePic<'cache, 'url> { url: &'url str, size: f32, border: Option<Stroke>, + pub action: Option<MediaAction>, } impl egui::Widget for &mut ProfilePic<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - render_pfp(ui, self.cache, self.url, self.size, self.border) + let inner = render_pfp(ui, self.cache, self.url, self.size, self.border); + + self.action = inner.inner; + + inner.response } } @@ -25,6 +31,7 @@ impl<'cache, 'url> ProfilePic<'cache, 'url> { url, size, border: None, + action: None, } } @@ -91,31 +98,46 @@ fn render_pfp( url: &str, ui_size: f32, border: Option<Stroke>, -) -> egui::Response { +) -> InnerResponse<Option<MediaAction>> { // We will want to downsample these so it's not blurry on hi res displays let img_size = 128u32; let cache_type = supported_mime_hosted_at_url(&mut img_cache.urls, url) .unwrap_or(notedeck::MediaCacheType::Image); - render_images( - ui, - img_cache, - url, - ImageType::Profile(img_size), - cache_type, - |ui| { - paint_circle(ui, ui_size, border); - }, - |ui, _| { - paint_circle(ui, ui_size, border); - }, - |ui, url, renderable_media, gifs| { - let texture_handle = - handle_repaint(ui, retrieve_latest_texture(url, gifs, renderable_media)); - pfp_image(ui, texture_handle, ui_size, border); - }, - ) + egui::Frame::NONE.show(ui, |ui| { + let cur_state = get_render_state( + ui.ctx(), + img_cache, + cache_type, + url, + ImageType::Profile(img_size), + ); + + match cur_state.texture_state { + notedeck::TextureState::Pending => { + paint_circle(ui, ui_size, border); + None + } + notedeck::TextureState::Error(e) => { + paint_circle(ui, ui_size, border); + tracing::error!("Failed to fetch profile at url {url}: {e}"); + Some(MediaAction::FetchImage { + url: url.to_owned(), + cache_type, + no_pfp_promise: fetch_no_pfp_promise(ui.ctx(), img_cache.get_cache(cache_type)), + }) + } + notedeck::TextureState::Loaded(textured_image) => { + let texture_handle = handle_repaint( + ui, + retrieve_latest_texture(url, cur_state.gifs, textured_image), + ); + pfp_image(ui, texture_handle, ui_size, border); + None + } + } + }) } #[profiling::function]