Skip to content

Commit ddd04b9

Browse files
committed
Image Viewer with zoom and pann
1 parent d029bbc commit ddd04b9

File tree

8 files changed

+511
-205
lines changed

8 files changed

+511
-205
lines changed

src/app.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,11 @@ use crate::{
1717
}, join_leave_room_modal::{
1818
JoinLeaveRoomModalAction,
1919
JoinLeaveRoomModalWidgetRefExt,
20-
}, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, shared::callout_tooltip::{
21-
},
22-
login::login_screen::LoginAction,
23-
persistence,
24-
shared::{callout_tooltip::{
20+
}, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, shared::{callout_tooltip::{
2521
CalloutTooltipOptions,
2622
CalloutTooltipWidgetRefExt,
2723
TooltipAction,
28-
}, sliding_sync::current_user_id, utils::{
24+
}, image_viewer_modal::ImageViewerModalWidgetRefExt}, sliding_sync::current_user_id, utils::{
2925
room_name_or_id,
3026
OwnedRoomIdRon,
3127
}, verification::VerificationAction, verification_modal::{
@@ -167,7 +163,7 @@ live_design! {
167163
flow: Overlay,
168164

169165
home_screen_view = <View> {
170-
visible: true
166+
visible: false
171167
home_screen = <HomeScreen> {}
172168
}
173169
join_leave_modal = <Modal> {
@@ -176,7 +172,7 @@ live_design! {
176172
}
177173
}
178174
login_screen_view = <View> {
179-
visible: false
175+
visible: true
180176
login_screen = <LoginScreen> {}
181177
}
182178
<PopupList> {}

src/home/room_screen.rs

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ use bytesize::ByteSize;
77
use imbl::Vector;
88
use makepad_widgets::{image_cache::ImageBuffer, *};
99
use matrix_sdk::{
10-
room::{reply::{EnforceThread, Reply}, RoomMember},
11-
ruma::{
10+
media::MediaFormat, room::{reply::{EnforceThread, Reply}, RoomMember}, ruma::{
1211
events::{
1312
receipt::Receipt,
1413
room::{
@@ -20,19 +19,18 @@ use matrix_sdk::{
2019
sticker::{StickerEventContent, StickerMediaSource},
2120
},
2221
matrix_uri::MatrixId, uint, EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId
23-
},
24-
OwnedServerName,
22+
}, OwnedServerName
2523
};
2624
use matrix_sdk_ui::timeline::{
2725
self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, MemberProfileChange, MsgLikeContent, MsgLikeKind, PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem
2826
};
2927

3028
use crate::{
31-
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_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, editing_pane::EditingPaneState, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, location::init_location_subscriber, media_cache::MediaCacheEntry, profile::{
29+
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_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, editing_pane::EditingPaneState, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, location::init_location_subscriber, media_cache::{MediaCache, MediaCacheEntry}, profile::{
3230
user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt},
3331
user_profile_cache,
3432
}, shared::{
35-
avatar::AvatarWidgetRefExt, callout_tooltip::TooltipAction, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{enqueue_popup_notification, PopupItem, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt, styles::COLOR_FG_DANGER_RED, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt, typing_animation::TypingAnimationWidgetExt
33+
avatar::AvatarWidgetRefExt, callout_tooltip::TooltipAction, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer_modal::{get_global_image_viewer_modal, load_image_data, update_state_views, LoadState, IMAGE_LOAD_TIMEOUT}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{enqueue_popup_notification, PopupItem, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt, styles::COLOR_FG_DANGER_RED, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt, typing_animation::TypingAnimationWidgetExt
3634
}, sliding_sync::{get_client, submit_async_request, take_timeline_endpoints, BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineRequestSender, UserPowerLevels}, utils::{self, room_name_or_id, unix_time_millis_to_datetime, ImageFormat, MEDIA_THUMBNAIL_FORMAT}
3735
};
3836
use crate::home::event_reaction_list::ReactionListWidgetRefExt;
@@ -84,8 +82,7 @@ live_design! {
8482
use crate::home::room_read_receipt::*;
8583
use crate::rooms_list::*;
8684
use crate::shared::restore_status_view::*;
87-
use crate::shared::image_viewer_modal::ImageViewerModal;
88-
85+
8986
IMG_DEFAULT_AVATAR = dep("crate://self/resources/img/default_avatar.png")
9087

9188
ICO_LOCATION_PERSON = dep("crate://self/resources/icons/location-person.svg")
@@ -753,10 +750,10 @@ live_design! {
753750
// The user profile sliding pane should be displayed on top of other "static" subviews
754751
// (on top of all other views that are always visible).
755752
user_profile_sliding_pane = <UserProfileSlidingPane> { }
756-
753+
757754
// The loading pane appears while the user is waiting for something in the room screen
758755
// to finish loading, e.g., when loading an older replied-to message.
759-
//loading_pane = <LoadingPane> { }
756+
loading_pane = <LoadingPane> { }
760757

761758

762759
/*
@@ -813,6 +810,8 @@ pub struct RoomScreen {
813810
#[rust] is_loaded: bool,
814811
/// Whether or not all rooms have been loaded (received from the homeserver).
815812
#[rust] all_rooms_loaded: bool,
813+
/// Timer for displaying `timeout` in the image viewer modal.
814+
#[rust] image_viewer_timeout_timer: Timer
816815
}
817816
impl Drop for RoomScreen {
818817
fn drop(&mut self) {
@@ -980,6 +979,15 @@ impl Widget for RoomScreen {
980979
);
981980
}
982981
}
982+
983+
if let TextOrImageAction::Clicked(room_id, mxc_uri) = action.as_widget_action().cast() {
984+
if let Some(tl) = &mut self.tl_state {
985+
// Only handle the action if it matches the current room
986+
if tl.room_id == room_id {
987+
populate_image_modal(cx, &mut self.image_viewer_timeout_timer, Some(mxc_uri), tl);
988+
}
989+
}
990+
}
983991
}
984992

985993
/*
@@ -1322,6 +1330,7 @@ impl Widget for RoomScreen {
13221330
event_tl_item,
13231331
msg_like_content,
13241332
prev_event,
1333+
&mut tl_state.media_cache,
13251334
&tl_state.user_power,
13261335
item_drawn_status,
13271336
room_screen_widget_uid,
@@ -1676,6 +1685,10 @@ impl RoomScreen {
16761685
log!("Timeline::handle_event(): media fetched for room {}", tl.room_id);
16771686
// Here, to be most efficient, we could redraw only the media items in the timeline,
16781687
// but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view.
1688+
if let LoadState::Loaded = populate_image_modal(cx, &mut self.image_viewer_timeout_timer, None, tl) {
1689+
let image_viewer_modal = get_global_image_viewer_modal(cx);
1690+
image_viewer_modal.set_image_loaded();
1691+
}
16791692
}
16801693
TimelineUpdate::MessageEdited { timeline_event_id, result } => {
16811694
self.view.editing_pane(id!(editing_pane))
@@ -2310,6 +2323,7 @@ impl RoomScreen {
23102323
profile_drawn_since_last_update: RangeSet::new(),
23112324
update_receiver,
23122325
request_sender,
2326+
media_cache: MediaCache::new(Some(update_sender)),
23132327
replying_to: None,
23142328
saved_state: SavedState::default(),
23152329
message_highlight_animation_state: MessageHighlightAnimationState::default(),
@@ -2318,10 +2332,6 @@ impl RoomScreen {
23182332
scrolled_past_read_marker: false,
23192333
latest_own_user_receipt: None,
23202334
};
2321-
2322-
// Add this timeline's update sender to the global MediaCache
2323-
crate::media_cache::get_media_cache().lock().unwrap().add_timeline_update_sender(update_sender);
2324-
23252335
(tl_state, true)
23262336
};
23272337

@@ -2830,6 +2840,10 @@ struct TimelineUiState {
28302840
/// to the background async task that handles this room's timeline updates.
28312841
request_sender: TimelineRequestSender,
28322842

2843+
/// The cache of media items (images, videos, etc.) that appear in this timeline.
2844+
///
2845+
/// Currently this excludes avatars, as those are shared across multiple rooms.
2846+
media_cache: MediaCache,
28332847

28342848
/// Info about the event currently being replied to, if any.
28352849
replying_to: Option<(EventTimelineItem, EmbeddedEvent)>,
@@ -2993,6 +3007,7 @@ fn populate_message_view(
29933007
event_tl_item: &EventTimelineItem,
29943008
msg_like_content: &MsgLikeContent,
29953009
prev_event: Option<&Arc<TimelineItem>>,
3010+
media_cache: &mut MediaCache,
29963011
user_power_levels: &UserPowerLevels,
29973012
item_drawn_status: ItemDrawnStatus,
29983013
room_screen_widget_uid: WidgetUid,
@@ -3191,6 +3206,7 @@ fn populate_message_view(
31913206
image_info,
31923207
image.source.clone(),
31933208
msg.body(),
3209+
media_cache,
31943210
);
31953211
new_drawn_status.content_drawn = is_image_fully_drawn;
31963212
(item, false)
@@ -3343,6 +3359,7 @@ fn populate_message_view(
33433359
Some(Box::new(image_info.clone())),
33443360
MediaSource::Plain(owned_mxc_url.clone()),
33453361
body,
3362+
media_cache,
33463363
);
33473364
new_drawn_status.content_drawn = is_image_fully_drawn;
33483365
(item, false)
@@ -3510,6 +3527,7 @@ fn populate_image_message_content(
35103527
image_info_source: Option<Box<ImageInfo>>,
35113528
original_source: MediaSource,
35123529
body: &str,
3530+
media_cache: &mut MediaCache,
35133531
) -> bool {
35143532
// We don't use thumbnails, as their resolution is too low to be visually useful.
35153533
// We also don't trust the provided mimetype, as it can be incorrect.
@@ -3534,19 +3552,16 @@ fn populate_image_message_content(
35343552
// A closure that fetches and shows the image from the given `mxc_uri`,
35353553
// marking it as fully drawn if the image was available.
35363554
let mut fetch_and_show_image_uri = |cx: &mut Cx2d, mxc_uri: OwnedMxcUri, image_info: Box<ImageInfo>| {
3537-
match crate::media_cache::get_media_cache().lock().unwrap().try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) {
3555+
match media_cache.try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) {
35383556
(MediaCacheEntry::Loaded(data), _media_format) => {
3539-
let show_image_result = text_or_image_ref.show_image(cx, mxc_uri.clone(),|cx, img| {
3557+
let show_image_result = text_or_image_ref.show_image(cx, mxc_uri.clone(), |cx, img| {
35403558
utils::load_png_or_jpg(&img, cx, &data)
35413559
.map(|()| img.size_in_pixels(cx).unwrap_or_default())
35423560
});
35433561
if let Err(e) = show_image_result {
35443562
let err_str = format!("{body}\n\nFailed to display image: {e:?}");
35453563
error!("{err_str}");
35463564
text_or_image_ref.show_text(cx, &err_str);
3547-
} else {
3548-
// Add click handler for the image
3549-
let _mxc_uri_clone = mxc_uri.clone();
35503565
}
35513566

35523567
// We're done drawing the image, so mark it as fully drawn.
@@ -4396,4 +4411,56 @@ pub fn clear_timeline_states(_cx: &mut Cx) {
43964411
TIMELINE_STATES.with_borrow_mut(|states| {
43974412
states.clear();
43984413
});
4399-
}
4414+
}
4415+
4416+
/// Populates the image viewer modal with the given timeline item's image.
4417+
///
4418+
/// This function will return `LoadState::Loading` if the image is not yet
4419+
/// available, `LoadState::Loaded` if the image is successfully loaded and
4420+
/// displayed, or `LoadState::Error` if the image fails to load.
4421+
///
4422+
/// If the image is not yet available, the timer passed in as `timer` will be
4423+
/// started with a timeout of `IMAGE_LOAD_TIMEOUT` seconds. When the timer
4424+
/// is triggered, this function will be called again with the same arguments.
4425+
///
4426+
/// The `mxc_uri` argument should be set to `None` if the timeline item does
4427+
/// not have an associated image.
4428+
///
4429+
/// The `tl` argument should point to the timeline state for the current room.
4430+
fn populate_image_modal(cx: &mut Cx, timer: &mut Timer, mxc_uri: Option<OwnedMxcUri>, tl: &mut TimelineUiState) -> LoadState {
4431+
if let Some(mxc_uri) = mxc_uri {
4432+
*timer = cx.start_timeout(IMAGE_LOAD_TIMEOUT);
4433+
let image_viewer_modal = get_global_image_viewer_modal(cx);
4434+
image_viewer_modal.initialized(tl.room_id.clone(), mxc_uri, *timer);
4435+
}
4436+
let image_viewer_modal = get_global_image_viewer_modal(cx);
4437+
if image_viewer_modal.get_media_or_fetch(tl.room_id.clone()) {
4438+
let Some(view_set) = image_viewer_modal.get_view_set() else { return LoadState::Error; };
4439+
let Some(mxc_uri) = image_viewer_modal.get_mxc_uri() else { return LoadState::Error; };
4440+
match tl.media_cache.try_get_media_or_fetch(mxc_uri, MediaFormat::File) {
4441+
(MediaCacheEntry::Loaded(data), MediaFormat::File) => {
4442+
let Some(image_ref) = image_viewer_modal.get_zoomable_image() else { return LoadState::Error; };
4443+
match load_image_data(cx, image_ref.clone(), view_set.clone(), &data) {
4444+
Ok(_) => {
4445+
cx.stop_timer(*timer);
4446+
return LoadState::Loaded;
4447+
},
4448+
Err(_) => {
4449+
cx.stop_timer(*timer);
4450+
update_state_views(cx, view_set, LoadState::Error);
4451+
return LoadState::Error;
4452+
}
4453+
}
4454+
}
4455+
(MediaCacheEntry::Requested, _)
4456+
| (MediaCacheEntry::Loaded(_), MediaFormat::Thumbnail(_)) => {
4457+
update_state_views(cx, view_set, LoadState::Loading);
4458+
}
4459+
(MediaCacheEntry::Failed, _) => {
4460+
cx.stop_timer(*timer);
4461+
update_state_views(cx, view_set, LoadState::Error);
4462+
}
4463+
}
4464+
}
4465+
LoadState::Loading
4466+
}

src/media_cache.rs

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::{collections::{btree_map::Entry, BTreeMap}, ops::{Deref, DerefMut}, sync::{Arc, Mutex, OnceLock}, time::SystemTime};
1+
use std::{collections::{btree_map::Entry, BTreeMap}, ops::{Deref, DerefMut}, sync::{Arc, Mutex}, time::SystemTime};
22
use makepad_widgets::{error, log, SignalToUI};
33
use matrix_sdk::{media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}, ruma::{events::room::MediaSource, OwnedMxcUri}};
44
use crate::{home::room_screen::TimelineUpdate, sliding_sync::{self, MatrixRequest}};
@@ -32,8 +32,8 @@ pub type MediaCacheEntryRef = Arc<Mutex<MediaCacheEntry>>;
3232
pub struct MediaCache {
3333
/// The actual cached data.
3434
cache: BTreeMap<OwnedMxcUri, MediaCacheValue>,
35-
/// A list of channels to send updates to various timelines when a media request has completed.
36-
timeline_update_senders: Vec<crossbeam_channel::Sender<TimelineUpdate>>,
35+
/// A channel to send updates to a particular timeline when a media request has completed.
36+
timeline_update_sender: Option<crossbeam_channel::Sender<TimelineUpdate>>,
3737
}
3838
impl Deref for MediaCache {
3939
type Target = BTreeMap<OwnedMxcUri, MediaCacheValue>;
@@ -48,24 +48,20 @@ impl DerefMut for MediaCache {
4848
}
4949

5050
impl MediaCache {
51-
/// Creates a new media cache.
52-
pub const fn new() -> Self {
51+
/// Creates a new media cache that will use the given media format
52+
/// when fetching media from the server.
53+
///
54+
/// It will also optionally send updates to the given timeline update sender
55+
/// when a media request has completed.
56+
pub const fn new(
57+
timeline_update_sender: Option<crossbeam_channel::Sender<TimelineUpdate>>,
58+
) -> Self {
5359
Self {
5460
cache: BTreeMap::new(),
55-
timeline_update_senders: Vec::new(),
61+
timeline_update_sender,
5662
}
5763
}
5864

59-
/// Add a timeline update sender to receive notifications when media requests complete.
60-
pub fn add_timeline_update_sender(&mut self, sender: crossbeam_channel::Sender<TimelineUpdate>) {
61-
self.timeline_update_senders.push(sender);
62-
}
63-
64-
/// Remove a timeline update sender.
65-
pub fn remove_timeline_update_sender(&mut self, sender: &crossbeam_channel::Sender<TimelineUpdate>) {
66-
self.timeline_update_senders.retain(|s| !std::ptr::eq(s, sender));
67-
}
68-
6965
/// Tries to get the media from the cache, or submits an async request to fetch it.
7066
///
7167
/// This method *does not* block or wait for the media to be fetched,
@@ -162,7 +158,7 @@ impl MediaCache {
162158
},
163159
on_fetched: insert_into_cache,
164160
destination: entry_ref,
165-
update_sender: self.timeline_update_senders.clone(),
161+
update_sender: self.timeline_update_sender.clone(),
166162
}
167163
);
168164
post_request_retval
@@ -174,7 +170,7 @@ fn insert_into_cache<D: Into<Arc<[u8]>>>(
174170
value_ref: &Mutex<MediaCacheEntry>,
175171
_request: MediaRequestParameters,
176172
data: matrix_sdk::Result<D>,
177-
update_senders: Vec<crossbeam_channel::Sender<TimelineUpdate>>,
173+
update_sender: Option<crossbeam_channel::Sender<TimelineUpdate>>,
178174
) {
179175
let new_value = match data {
180176
Ok(data) => {
@@ -206,16 +202,8 @@ fn insert_into_cache<D: Into<Arc<[u8]>>>(
206202

207203
*value_ref.lock().unwrap() = new_value;
208204

209-
for sender in update_senders {
205+
if let Some(sender) = update_sender {
210206
let _ = sender.send(TimelineUpdate::MediaFetched);
211207
}
212208
SignalToUI::set_ui_signal();
213209
}
214-
215-
/// Global media cache instance shared across all rooms.
216-
static MEDIA_CACHE: OnceLock<Arc<Mutex<MediaCache>>> = OnceLock::new();
217-
218-
/// Get a reference to the global media cache.
219-
pub fn get_media_cache() -> &'static Arc<Mutex<MediaCache>> {
220-
MEDIA_CACHE.get_or_init(|| Arc::new(Mutex::new(MediaCache::new())))
221-
}

0 commit comments

Comments
 (0)