diff --git a/Cargo.lock b/Cargo.lock index dd8c4f08..b5a1451f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4917,6 +4917,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", + "thiserror 2.0.16", "tokio", "tracing-subscriber", "tsp_sdk", diff --git a/Cargo.toml b/Cargo.toml index 5e3c9963..5b9a92aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ reqwest = { version = "0.12", default-features = false, features = [ "http2", "macos-system-configuration", ] } +thiserror = "2.0.16" [features] diff --git a/resources/icons/rotate-anti-clockwise.svg b/resources/icons/rotate-anti-clockwise.svg new file mode 100644 index 00000000..6d93719f --- /dev/null +++ b/resources/icons/rotate-anti-clockwise.svg @@ -0,0 +1,6 @@ + + + +rotate + + \ No newline at end of file diff --git a/resources/icons/rotate-clockwise.svg b/resources/icons/rotate-clockwise.svg new file mode 100644 index 00000000..86c48699 --- /dev/null +++ b/resources/icons/rotate-clockwise.svg @@ -0,0 +1,6 @@ + + + +rotate1 + + \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index b269e2ae..93eba9cb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,14 +19,13 @@ use crate::{ persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, - shared::callout_tooltip::{ + shared::{callout_tooltip::{ CalloutTooltipWidgetRefExt, TooltipAction, - }, - sliding_sync::current_user_id, - utils::RoomNameId, - verification::VerificationAction, - verification_modal::{VerificationModalAction, VerificationModalWidgetRefExt}, + }, image_viewer::{ImageViewerAction, LoadState}}, sliding_sync::current_user_id, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + VerificationModalAction, + VerificationModalWidgetRefExt, + } }; live_design! { @@ -43,6 +42,7 @@ live_design! { use crate::shared::popup_list::*; use crate::home::new_message_context_menu::*; use crate::shared::callout_tooltip::CalloutTooltip; + use crate::shared::image_viewer::ImageViewer; use link::tsp_link::TspVerificationModal; @@ -99,6 +99,12 @@ live_design! { login_screen = {} } + image_viewer_modal = { + content: { + width: Fill, height: Fill, + image_viewer_modal_inner = {} + } + } {} // Context menus should be shown in front of other UI elements, @@ -401,7 +407,17 @@ impl MatchEvent for App { self.ui.modal(ids!(verification_modal)).close(cx); continue; } - + match action.downcast_ref() { + Some(ImageViewerAction::Show(LoadState::Loading(_, _))) => { + self.ui.modal(ids!(image_viewer_modal)).open(cx); + continue; + } + Some(ImageViewerAction::Hide) => { + self.ui.modal(ids!(image_viewer_modal)).close(cx); + continue; + } + _ => {} + } // Handle actions to open/close the TSP verification modal. #[cfg(feature = "tsp")] { use std::ops::Deref; diff --git a/src/home/link_preview.rs b/src/home/link_preview.rs index d7c9a36a..229dca6d 100644 --- a/src/home/link_preview.rs +++ b/src/home/link_preview.rs @@ -624,7 +624,7 @@ fn insert_into_cache( if let Some(sender) = update_sender { // Reuse TimelineUpdate MediaFetched to trigger redraw in the timeline. - let _ = sender.send(TimelineUpdate::MediaFetched); + let _ = sender.send(TimelineUpdate::LinkPreviewFetched); } SignalToUI::set_ui_signal(); } diff --git a/src/home/mod.rs b/src/home/mod.rs index 27e6a8b1..65e62a66 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -24,6 +24,7 @@ pub mod welcome_screen; pub mod event_reaction_list; pub mod new_message_context_menu; pub mod link_preview; +pub mod room_image_viewer; pub fn live_design(cx: &mut Cx) { search_messages::live_design(cx); diff --git a/src/home/room_image_viewer.rs b/src/home/room_image_viewer.rs new file mode 100644 index 00000000..30a3091b --- /dev/null +++ b/src/home/room_image_viewer.rs @@ -0,0 +1,63 @@ +use makepad_widgets::*; +use matrix_sdk_ui::timeline::EventTimelineItem; +use matrix_sdk::{ + media::MediaFormat, + ruma::events::room::{message::MessageType, MediaSource}, +}; +use reqwest::StatusCode; + +use crate::{media_cache::{MediaCache, MediaCacheEntry}, shared::image_viewer::{ImageViewerAction, ImageViewerError, LoadState}}; + +/// Populates the image viewer modal with the given media content. +/// +/// * If the media is already cached, it will be immediately displayed. +/// * If the media is not cached, it will be fetched from the server. +/// * If the media fetch fails, an error message will be displayed. +pub fn populate_matrix_image_modal( + cx: &mut Cx, + media_source: MediaSource, + media_cache: &mut MediaCache, +) { + let MediaSource::Plain(mxc_uri) = media_source else { + return; + }; + // Try to get media from cache or trigger fetch + let media_entry = media_cache.try_get_media_or_fetch(mxc_uri.clone(), MediaFormat::File); + + // Handle the different media states + match media_entry { + (MediaCacheEntry::Loaded(data), MediaFormat::File) => { + cx.action(ImageViewerAction::Show(LoadState::Loaded(data))); + } + (MediaCacheEntry::Failed(status_code), MediaFormat::File) => { + let error = match status_code { + StatusCode::NOT_FOUND => ImageViewerError::NotFound, + StatusCode::INTERNAL_SERVER_ERROR => ImageViewerError::ConnectionFailed, + StatusCode::PARTIAL_CONTENT => ImageViewerError::BadData, + StatusCode::UNAUTHORIZED => ImageViewerError::Unauthorized, + _ => ImageViewerError::Unknown, + }; + cx.action(ImageViewerAction::Show(LoadState::Error(error))); + // Remove failed media entry from cache for MediaFormat::File so as to start all over again from loading Thumbnail. + media_cache.remove_cache_entry(&mxc_uri, Some(MediaFormat::File)); + } + _ => {} + } +} + +/// Gets image name and file size in bytes from an event timeline item. +pub fn get_image_name_and_filesize(event_tl_item: &EventTimelineItem) -> (String, u64) { + if let Some(message) = event_tl_item.content().as_message() { + if let MessageType::Image(image_content) = message.msgtype() { + let name = message.body().to_string(); + let size = image_content + .info + .as_ref() + .and_then(|info| info.size) + .map(u64::from) + .unwrap_or(0); + return (name, size); + } + } + ("Unknown Image".to_string(), 0) +} diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 906ae97e..89b7480c 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -7,32 +7,30 @@ use bytesize::ByteSize; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - room::RoomMember, RoomDisplayName, ruma::{ - events::{ + OwnedServerName, RoomDisplayName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ + EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, events::{ receipt::Receipt, room::{ - message::{ + ImageInfo, MediaSource, message::{ AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent - }, - ImageInfo, MediaSource + } }, sticker::{StickerEventContent, StickerMediaSource}, - }, - matrix_uri::MatrixId, uint, EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId - }, OwnedServerName + }, matrix_uri::MatrixId, uint + } }; use matrix_sdk_ui::timeline::{ self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, MemberProfileChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem }; use crate::{ - app::AppStateAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef, tombstone_footer::SuccessorRoomDetails}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ + app::AppStateAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, room_image_viewer::{get_image_name_and_filesize, populate_matrix_image_modal}, rooms_list::RoomsListRef, tombstone_footer::SuccessorRoomDetails}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, room::{room_input_bar::RoomInputBarState, typing_notice::TypingNoticeWidgetExt}, shared::{ - avatar::AvatarWidgetRefExt, callout_tooltip::{CalloutTooltipOptions, TooltipAction, TooltipPosition}, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupItem, PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt + avatar::AvatarWidgetRefExt, callout_tooltip::{CalloutTooltipOptions, TooltipAction, TooltipPosition}, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupItem, PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} }; @@ -206,7 +204,7 @@ live_design! { height: Fit flow: Down, padding: 0.0 - { + username_view = { flow: Right, width: Fill, height: Fit, @@ -600,7 +598,7 @@ impl Widget for RoomScreen { // we want to handle those before processing any updates that might change // the set of timeline indices (which would invalidate the index values in any actions). if let Event::Actions(actions) = event { - for (_, wr) in portal_list.items_with_actions(actions) { + for (index, wr) in portal_list.items_with_actions(actions) { let reaction_list = wr.reaction_list(ids!(reaction_list)); if let RoomScreenTooltipActions::HoverInReactionButton { widget_rect, @@ -664,6 +662,17 @@ impl Widget for RoomScreen { TooltipAction::HoverOut ); } + let content_message = wr.text_or_image(ids!(content.message)); + if let TextOrImageAction::Clicked(mxc_uri) = actions.find_widget_action(content_message.widget_uid()).cast() { + let texture = content_message.get_texture(cx); + self.handle_image_click( + cx, + mxc_uri, + texture, + index, + ); + continue; + } } self.handle_message_actions(cx, actions, &portal_list, &loading_pane); @@ -1355,8 +1364,12 @@ impl RoomScreen { // Store room members directly in TimelineUiState tl.room_members = Some(Arc::new(members)); }, - TimelineUpdate::MediaFetched => { + TimelineUpdate::MediaFetched(request) => { log!("process_timeline_updates(): media fetched for room {}", tl.room_id); + // Set Image to image viewer modal if the media is not a thumbnail. + if let (MediaFormat::File, media_source) = (request.format, request.source) { + populate_matrix_image_modal(cx, media_source, &mut tl.media_cache); + } // Here, to be most efficient, we could redraw only the media items in the timeline, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } @@ -1423,6 +1436,7 @@ impl RoomScreen { .update_tombstone_footer(cx, &tl.room_id, Some(&successor_room_details)); tl.tombstone_info = Some(successor_room_details); } + TimelineUpdate::LinkPreviewFetched => {} } } @@ -1570,6 +1584,39 @@ impl RoomScreen { } } + /// Handles image clicks in message content by opening the image viewer. + fn handle_image_click( + &mut self, + cx: &mut Cx, + mxc_uri: Option, + texture: Option, + item_id: usize, + ) { + let Some(media_source) = mxc_uri else { + return; + }; + let Some(tl_state) = self.tl_state.as_mut() else { return }; + let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) else { return }; + + let timestamp_millis = event_tl_item.timestamp(); + let (image_name, image_file_size) = get_image_name_and_filesize(event_tl_item); + cx.action(ImageViewerAction::Show(LoadState::Loading( + texture.clone(), + Some(ImageViewerMetaData { + image_name, + image_file_size, + timestamp: unix_time_millis_to_datetime(timestamp_millis), + avatar_parameter: Some(( + tl_state.room_id.clone(), + event_tl_item.clone(), + )), + }), + ))); + + populate_matrix_image_modal(cx, media_source, &mut tl_state.media_cache); + } + + /// Handles any [`MessageAction`]s received by this RoomScreen. fn handle_message_actions( &mut self, @@ -2435,9 +2482,9 @@ pub enum TimelineUpdate { RoomMembersListFetched { members: Vec, }, - /// A notice that one or more requested media items (images, videos, etc.) + /// A notice with an option of Media Request Parameters that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. - MediaFetched, + MediaFetched(MediaRequestParameters), /// A notice that one or more members of a this room are currently typing. TypingUsers { /// The list of users (their displayable name) who are currently typing in this room. @@ -2458,6 +2505,8 @@ pub enum TimelineUpdate { /// A notice that the given room has been tombstoned (closed) /// and replaced by the given successor room. Tombstoned(SuccessorRoomDetails), + /// A notice that link preview data for a URL has been fetched and is now available. + LinkPreviewFetched, } thread_local! { @@ -3307,7 +3356,7 @@ fn populate_image_message_content( let mut fetch_and_show_image_uri = |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { match media_cache.try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) { (MediaCacheEntry::Loaded(data), _media_format) => { - let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { + let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)),|cx, img| { utils::load_png_or_jpg(&img, cx, &data) .map(|()| img.size_in_pixels(cx).unwrap_or_default()) }); @@ -3323,7 +3372,7 @@ fn populate_image_message_content( (MediaCacheEntry::Requested, _media_format) => { // If the image is being fetched, we try to show its blurhash. if let (Some(ref blurhash), Some(width), Some(height)) = (image_info.blurhash.clone(), image_info.width, image_info.height) { - let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { + let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)), |cx, img| { let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) else { return Err(image_cache::ImageError::EmptyData) }; @@ -3367,7 +3416,7 @@ fn populate_image_message_content( } fully_drawn = false; } - (MediaCacheEntry::Failed, _media_format) => { + (MediaCacheEntry::Failed(_status_code), _media_format) => { if text_or_image_ref.view(ids!(default_image_view)).visible() { fully_drawn = true; return; diff --git a/src/home/rooms_list_header.rs b/src/home/rooms_list_header.rs index 766f9784..57d5d316 100644 --- a/src/home/rooms_list_header.rs +++ b/src/home/rooms_list_header.rs @@ -8,7 +8,7 @@ use std::mem::discriminant; use makepad_widgets::*; use matrix_sdk_ui::sync_service::State; -use crate::{home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, shared::popup_list::{PopupItem, PopupKind, enqueue_popup_notification}}; +use crate::{home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, shared::{image_viewer::{ImageViewerAction, ImageViewerError, LoadState}, popup_list::{PopupItem, PopupKind, enqueue_popup_notification}}}; live_design! { use link::theme::*; @@ -119,6 +119,8 @@ impl Widget for RoomsListHeader { auto_dismissal_duration: None, kind: PopupKind::Error, }); + // Since there is no timeout for fetching media, send an action to ImageViewer when syncing is offline. + cx.action(ImageViewerAction::Show(LoadState::Error(ImageViewerError::Offline))); } self.sync_state = new_state.clone(); self.redraw(cx); diff --git a/src/media_cache.rs b/src/media_cache.rs index ae071011..4e094d2e 100644 --- a/src/media_cache.rs +++ b/src/media_cache.rs @@ -1,6 +1,7 @@ use std::{collections::{btree_map::Entry, BTreeMap}, ops::{Deref, DerefMut}, sync::{Arc, Mutex}, time::SystemTime}; use makepad_widgets::{error, log, SignalToUI}; -use matrix_sdk::{media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}, ruma::{events::room::MediaSource, OwnedMxcUri}}; +use matrix_sdk::{media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}, ruma::{events::room::MediaSource, OwnedMxcUri}, Error, HttpError}; +use reqwest::StatusCode; use crate::{home::room_screen::TimelineUpdate, sliding_sync::{self, MatrixRequest}}; /// The value type in the media cache, one per Matrix URI. @@ -17,8 +18,8 @@ pub enum MediaCacheEntry { Requested, /// The media has been successfully loaded from the server. Loaded(Arc<[u8]>), - /// The media failed to load from the server. - Failed, + /// The media failed to load from the server with reqwest status code. + Failed(StatusCode), } /// A reference to a media cache entry and its associated format. @@ -163,12 +164,84 @@ impl MediaCache { ); post_request_retval } + + /// Removes a specific media format from the cache for the given MXC URI. + /// If `format` is None, removes the entire cache entry for the URI. + /// Returns the removed cache entry if found, None otherwise. + pub fn remove_cache_entry(&mut self, mxc_uri: &OwnedMxcUri, format: Option) -> Option { + match format { + Some(MediaFormat::Thumbnail(_)) => { + if let Some(cache_value) = self.cache.get_mut(mxc_uri) { + if let Some((removed_entry, _)) = cache_value.thumbnail.take() { + // If both thumbnail and full_file are None, remove the entire entry + if cache_value.full_file.is_none() { + self.cache.remove(mxc_uri); + } + return Some(removed_entry); + } + } + None + } + Some(MediaFormat::File) => { + if let Some(cache_value) = self.cache.get_mut(mxc_uri) { + if let Some(removed_entry) = cache_value.full_file.take() { + // If both thumbnail and full_file are None, remove the entire entry + if cache_value.thumbnail.is_none() { + self.cache.remove(mxc_uri); + } + return Some(removed_entry); + } + } + None + } + None => { + // Remove the entire entry for this MXC URI + self.cache.remove(mxc_uri).map(|cache_value| { + // Return the full_file entry if it exists, otherwise the thumbnail entry + cache_value.full_file + .or_else(|| cache_value.thumbnail.map(|(entry, _)| entry)) + .unwrap_or_else(|| Arc::new(Mutex::new(MediaCacheEntry::Requested))) + }) + } + } + } +} + +/// Converts a Matrix SDK error to a MediaCacheEntry::Failed with appropriate status codes. +fn error_to_media_cache_entry(error: Error, request: &MediaRequestParameters) -> MediaCacheEntry { + match error { + Error::Http(http_error) => { + if let Some(client_error) = http_error.as_client_api_error() { + error!("Client error for media cache: {client_error} for request: {:?}", request); + MediaCacheEntry::Failed(client_error.status_code) + } else { + match *http_error { + HttpError::Reqwest(reqwest_error) => { + // Checking if the connection is timeout is not important as Matrix SDK has implemented maximum timeout duration. + if !reqwest_error.is_connect() { + MediaCacheEntry::Failed(StatusCode::INTERNAL_SERVER_ERROR) + } else if reqwest_error.is_status() { + MediaCacheEntry::Failed(reqwest_error + .status() + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)) + } else { + MediaCacheEntry::Failed(StatusCode::INTERNAL_SERVER_ERROR) + } + } + _ => MediaCacheEntry::Failed(StatusCode::NOT_FOUND), + } + } + } + Error::InsufficientData => MediaCacheEntry::Failed(StatusCode::PARTIAL_CONTENT), + Error::AuthenticationRequired => MediaCacheEntry::Failed(StatusCode::UNAUTHORIZED), + _ => MediaCacheEntry::Failed(StatusCode::INTERNAL_SERVER_ERROR) + } } /// Insert data into a previously-requested media cache entry. fn insert_into_cache>>( value_ref: &Mutex, - _request: MediaRequestParameters, + request: MediaRequestParameters, data: matrix_sdk::Result, update_sender: Option>, ) { @@ -178,7 +251,7 @@ fn insert_into_cache>>( // debugging: dump out the media image to disk if false { - if let MediaSource::Plain(mxc_uri) = _request.source { + if let MediaSource::Plain(mxc_uri) = &request.source { log!("Fetched media for {mxc_uri}"); let mut path = crate::temp_storage::get_temp_dir_path().clone(); let filename = format!("{}_{}_{}", @@ -194,16 +267,13 @@ fn insert_into_cache>>( } MediaCacheEntry::Loaded(data) } - Err(e) => { - error!("Failed to fetch media for {:?}: {e:?}", _request.source); - MediaCacheEntry::Failed - } + Err(e) => error_to_media_cache_entry(e, &request) }; *value_ref.lock().unwrap() = new_value; if let Some(sender) = update_sender { - let _ = sender.send(TimelineUpdate::MediaFetched); + let _ = sender.send(TimelineUpdate::MediaFetched(request.clone())); } SignalToUI::set_ui_signal(); } diff --git a/src/shared/image_viewer.rs b/src/shared/image_viewer.rs new file mode 100644 index 00000000..e9fc7f13 --- /dev/null +++ b/src/shared/image_viewer.rs @@ -0,0 +1,1125 @@ +//! Image viewer widget for displaying Image with zooming and panning. +//! +//! There are 2 types of ImageViewerAction handled by this widget. They are "Show" and "Hide". +//! ImageViewerRef has 4 public methods, `configure_zoom`, `show_loading`, `show_loaded` and `reset`. +use std::sync::{mpsc::Receiver, Arc}; + +use chrono::{DateTime, Local}; +use makepad_widgets::{ + event::TouchUpdateEvent, + image_cache::{ImageBuffer, ImageError}, + rotated_image::RotatedImageWidgetExt, + *, +}; +use matrix_sdk::ruma::OwnedRoomId; +use matrix_sdk_ui::timeline::EventTimelineItem; +use thiserror::Error; +use crate::shared::{avatar::AvatarWidgetExt, timestamp::TimestampWidgetRefExt}; + +/// Loads the given image `data` into an `ImageBuffer` as either a PNG or JPEG, using the `imghdr` library to determine which format it is. +/// +/// Returns an error if either load fails or if the image format is unknown. +pub fn get_png_or_jpg_image_buffer(data: Vec) -> Result { + match imghdr::from_bytes(&data) { + Some(imghdr::Type::Png) => { + ImageBuffer::from_png(&data) + }, + Some(imghdr::Type::Jpeg) => { + ImageBuffer::from_jpg(&data) + }, + Some(_unsupported) => { + Err(ImageError::UnsupportedFormat) + } + None => { + Err(ImageError::UnsupportedFormat) + } + } +} + +/// Configuration for zoom and pan settings in the image viewer +#[derive(Clone, Debug)] +pub struct ImageViewerZoomConfig { + /// Minimum zoom level (default: 0.5) + pub min_zoom: f32, + /// Maximum zoom level (default: 4.0) + pub max_zoom: f32, + /// Zoom scale factor for zoom in/out operations (default: 1.2) + pub zoom_scale_factor: f32, + /// Pan sensitivity multiplier for drag operations (default: 2.0) + pub pan_sensitivity: f64, +} + +impl Default for ImageViewerZoomConfig { + fn default() -> Self { + Self { + min_zoom: 0.5, + max_zoom: 4.0, + zoom_scale_factor: 1.2, + pan_sensitivity: 2.0, + } + } +} + +/// Error types for image loading operations +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum ImageViewerError { + #[error("Image appears to be empty or corrupted")] + BadData, + #[error("Full image was not found")] + NotFound, + #[error("Check your internet connection")] + ConnectionFailed, + #[error("You don't have permission to view this image")] + Unauthorized, + #[error("Server temporarily unavailable")] + ServerError, + #[error("This image format isn't supported")] + UnsupportedFormat, + #[error("Unable to load image")] + Unknown, + #[error("Please reconnect your internet to load the image")] + Offline, +} + +/// The Drag state of the image viewer modal +struct DragState { + /// The starting position of the drag. + drag_start: DVec2, + /// The zoom level of the image. + /// The larger the value, the more zoomed in the image is. + zoom_level: f32, + /// The pan offset of the image. + pan_offset: Option, +} + +impl Default for DragState { + /// Resets all the drag state to its default values. This is called when the image changes. + fn default() -> Self { + Self { + drag_start: DVec2::default(), + zoom_level: 1.0, + pan_offset: None, + } + } +} + +live_design! { + use link::theme::*; + use link::widgets::*; + use crate::shared::styles::*; + use crate::shared::icon_button::RobrixIconButton; + use crate::shared::avatar::Avatar; + use crate::shared::timestamp::Timestamp; + + pub MagnifyingGlass = { + width: Fit, height: Fit + flow: Overlay + visible: true + + magnifying_glass_button = { + width: Fit, height: Fit, + spacing: 0, + margin: 8, + padding: 3 + draw_bg: { + color: (COLOR_PRIMARY) + } + draw_icon: { + svg_file: (ICON_ZOOM), + fn get_color(self) -> vec4 { + return #x0; + } + } + icon_walk: {width: 30, height: 30} + } + + sign_label = { + width: Fill, height: Fill, + align: { x: 0.4, y: 0.35 } + + magnifying_glass_sign =