diff --git a/android/src/app.rs b/android/src/app.rs index 5ccc747..62e164a 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -400,6 +400,7 @@ impl SleekApp { for req in self.state.media.drain_pending() { match req { MediaFetch::Image(url) => self.net.send(NetCmd::FetchImage { url }), + MediaFetch::ImageFull(url) => self.net.send(NetCmd::FetchFullImage { url }), MediaFetch::Video(url) => self.net.send(NetCmd::FetchVideo { url }), MediaFetch::LinkPreview(url) => self.net.send(NetCmd::FetchLinkPreview { url }), } @@ -494,6 +495,19 @@ impl SleekApp { NetEvent::ImageFetchFailed { url } => { self.state.media.set_image_failed(url); } + NetEvent::FullImageFetched { + url, + width, + height, + rgba, + } => { + self.state + .media + .set_full_image_ready(url, CachedPixels::new(width, height, rgba)); + } + NetEvent::FullImageFetchFailed { url } => { + self.state.media.set_full_image_failed(url); + } NetEvent::VideoFetched { url, bytes } => { self.state.media.set_video_ready(url, bytes); } diff --git a/android/src/net.rs b/android/src/net.rs index c7a907c..a302854 100644 --- a/android/src/net.rs +++ b/android/src/net.rs @@ -76,6 +76,8 @@ pub enum NetCmd { }, /// Download an image for inline chat preview. FetchImage { url: String }, + /// Download an image at (near) full resolution for the lightbox. + FetchFullImage { url: String }, /// Download a video for muted inline playback (vidya player). FetchVideo { url: String }, /// Fetch Open Graph metadata for a link card. @@ -202,6 +204,16 @@ pub enum NetEvent { ImageFetchFailed { url: String, }, + /// Remote image decoded at (near) full resolution for the lightbox. + FullImageFetched { + url: String, + width: usize, + height: usize, + rgba: std::sync::Arc<[u8]>, + }, + FullImageFetchFailed { + url: String, + }, /// Remote video bytes for muted inline playback (vidya player). VideoFetched { url: String, @@ -713,7 +725,7 @@ async fn apply_cmd( // Fire-and-forget so IRC event loop stays responsive. let tx = event_tx.clone(); tokio::spawn(async move { - match fetch_image_bytes(&url).await { + match fetch_image_bytes(&url, crate::preview::MAX_IMAGE_DIM).await { Ok((width, height, rgba)) => { let _ = tx.send(NetEvent::ImageFetched { url, @@ -729,6 +741,25 @@ async fn apply_cmd( } }); } + NetCmd::FetchFullImage { url } => { + let tx = event_tx.clone(); + tokio::spawn(async move { + match fetch_image_bytes(&url, crate::preview::MAX_FULL_IMAGE_DIM).await { + Ok((width, height, rgba)) => { + let _ = tx.send(NetEvent::FullImageFetched { + url, + width, + height, + rgba, + }); + } + Err(e) => { + log::debug!("full image fetch {url}: {e}"); + let _ = tx.send(NetEvent::FullImageFetchFailed { url }); + } + } + }); + } NetCmd::FetchVideo { url } => { let tx = event_tx.clone(); tokio::spawn(async move { @@ -1204,9 +1235,14 @@ async fn upload_media( .ok_or_else(|| "Upload response missing url".into()) } -/// Fetch and decode a remote image for chat inline preview (SSRF-safe). -async fn fetch_image_bytes(url: &str) -> Result<(usize, usize, std::sync::Arc<[u8]>), String> { - use crate::preview::{MAX_IMAGE_BYTES, MAX_IMAGE_DIM}; +/// Fetch and decode a remote image (SSRF-safe). `max_dim` downscales the long +/// edge: chat thumbs pass [`crate::preview::MAX_IMAGE_DIM`], the lightbox passes +/// [`crate::preview::MAX_FULL_IMAGE_DIM`] so the full-screen view stays sharp. +async fn fetch_image_bytes( + url: &str, + max_dim: u32, +) -> Result<(usize, usize, std::sync::Arc<[u8]>), String> { + use crate::preview::MAX_IMAGE_BYTES; if !(url.starts_with("https://") || url.starts_with("http://")) { return Err("Only http(s) image URLs".into()); @@ -1263,8 +1299,8 @@ async fn fetch_image_bytes(url: &str) -> Result<(usize, usize, std::sync::Arc<[u } let dyn_img = image::load_from_memory(&bytes).map_err(|e| format!("Decode: {e}"))?; - let dyn_img = if dyn_img.width() > MAX_IMAGE_DIM || dyn_img.height() > MAX_IMAGE_DIM { - dyn_img.thumbnail(MAX_IMAGE_DIM, MAX_IMAGE_DIM) + let dyn_img = if dyn_img.width() > max_dim || dyn_img.height() > max_dim { + dyn_img.thumbnail(max_dim, max_dim) } else { dyn_img }; diff --git a/android/src/preview.rs b/android/src/preview.rs index 6cdb9b3..81865e6 100644 --- a/android/src/preview.rs +++ b/android/src/preview.rs @@ -13,6 +13,9 @@ pub const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024; pub const MAX_VIDEO_BYTES: usize = 25 * 1024 * 1024; /// Downscale long edge for chat thumbs (pixels). pub const MAX_IMAGE_DIM: u32 = 720; +/// Downscale long edge for the full-screen lightbox (pixels). Much larger than +/// the chat thumb so the zoomed view is sharp; still capped to bound memory. +pub const MAX_FULL_IMAGE_DIM: u32 = 4096; /// What kind of embed a message body should show under the text. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/android/src/state.rs b/android/src/state.rs index ac98a12..da7cf80 100644 --- a/android/src/state.rs +++ b/android/src/state.rs @@ -899,6 +899,8 @@ pub enum LinkState { #[derive(Debug, Clone)] pub enum MediaFetch { Image(String), + /// Full-resolution image for the lightbox (chat thumbs are downscaled). + ImageFull(String), Video(String), LinkPreview(String), } @@ -925,6 +927,8 @@ impl std::fmt::Debug for VideoState { #[derive(Debug, Default)] pub struct MediaCache { pub images: HashMap, + /// Full-resolution images for the lightbox, keyed by the same URL. + pub full_images: HashMap, pub videos: HashMap, /// Per-bubble playback widget state (vidya), keyed by `url\\0msgid`. #[cfg(feature = "video-previews")] @@ -946,6 +950,19 @@ impl MediaCache { self.images.get(url) } + /// Ensure a full-resolution image fetch is in flight for the lightbox. + pub fn touch_image_full(&mut self, url: &str) -> Option<&ImageState> { + if url.is_empty() || !(url.starts_with("https://") || url.starts_with("http://")) { + return None; + } + if !self.full_images.contains_key(url) { + self.full_images + .insert(url.to_string(), ImageState::Loading); + self.pending.push(MediaFetch::ImageFull(url.to_string())); + } + self.full_images.get(url) + } + /// Ensure a video fetch is in flight for muted inline playback. pub fn touch_video(&mut self, url: &str) -> Option<&VideoState> { if url.is_empty() || !(url.starts_with("https://") || url.starts_with("http://")) { @@ -996,6 +1013,14 @@ impl MediaCache { self.images.insert(url, ImageState::Failed); } + pub fn set_full_image_ready(&mut self, url: String, pixels: CachedPixels) { + self.full_images.insert(url, ImageState::Ready(pixels)); + } + + pub fn set_full_image_failed(&mut self, url: String) { + self.full_images.insert(url, ImageState::Failed); + } + pub fn set_video_ready(&mut self, url: String, bytes: std::sync::Arc<[u8]>) { self.videos.insert(url, VideoState::Ready(bytes)); } @@ -1022,6 +1047,10 @@ impl MediaCache { pub fn has_loading(&self) -> bool { self.images.values().any(|s| matches!(s, ImageState::Loading)) + || self + .full_images + .values() + .any(|s| matches!(s, ImageState::Loading)) || self.videos.values().any(|s| matches!(s, VideoState::Loading)) || self.links.values().any(|s| matches!(s, LinkState::Loading)) } @@ -2195,6 +2224,9 @@ impl AppState { if url.is_empty() { return; } + // Kick off the full-res fetch; the lightbox shows the (downscaled) + // inline thumb until this lands. + self.media.touch_image_full(&url); self.image_lightbox = Some(url); } diff --git a/android/src/ui/widgets.rs b/android/src/ui/widgets.rs index 914c3a7..0bf6107 100644 --- a/android/src/ui/widgets.rs +++ b/android/src/ui/widgets.rs @@ -2768,15 +2768,24 @@ pub fn image_lightbox_overlay(ctx: &egui::Context, th: &Theme, state: &mut AppSt close = true; } - // Keep the decoded image warm while the lightbox is open. + // Keep the decoded images warm while the lightbox is open. state.media.touch_image(&url); + state.media.touch_image_full(&url); - let ready = match state.media.images.get_mut(url.as_str()) { + // Prefer the full-resolution fetch; fall back to the (downscaled) inline + // thumb while it is still loading or if it failed. + let ready = match state.media.full_images.get_mut(url.as_str()) { Some(ImageState::Ready(pixels)) => { let tex = pixels.texture(ctx, &url).clone(); Some((tex, pixels.width, pixels.height)) } - _ => None, + _ => match state.media.images.get_mut(url.as_str()) { + Some(ImageState::Ready(pixels)) => { + let tex = pixels.texture(ctx, &url).clone(); + Some((tex, pixels.width, pixels.height)) + } + _ => None, + }, }; // Area paints over the full screen (including under system bars). Pad