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 @@
+
+
+
\ 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 @@
+
+
+
\ 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 =