Skip to content

Conversation

@alanpoon
Copy link
Contributor

@alanpoon alanpoon commented Aug 6, 2025

Fixes #327
Screenshot 2025-08-06 at 5 46 55 PM

in replacement of #443

Waiting for this PR to be merged: makepad/makepad#788.

@alanpoon alanpoon self-assigned this Aug 6, 2025
@alanpoon alanpoon added the blocked-on-makepad Blocked on a Makepad bug or missing Makepad feature label Aug 11, 2025
@alanpoon
Copy link
Contributor Author

Intention of adding an image magnifier and the ability to pan image.

The image's image_scale, image_pan will always be set to default in draw_walk. Hence unable to dynamically scale and pan image.
makepad/makepad#785

@kevinaboos
Copy link
Member

Intention of adding an image magnifier and the ability to pan image.

The image's image_scale, image_pan will always be set to default in draw_walk. Hence unable to dynamically scale and pan image. makepad/makepad#785

@alanpoon I just checked with Rik, and this line should not be present in the Image widget. It's some errant code left over from dealing with animations in a strange way.

You can submit a PR to makepad that removes that line, and then continue with this issue here.

@kevinaboos kevinaboos added waiting-on-author This issue is waiting on the original author for a response and removed blocked-on-makepad Blocked on a Makepad bug or missing Makepad feature labels Oct 17, 2025
@alanpoon
Copy link
Contributor Author

Sure, the makepad PR is here: makepad/makepad#788

@alanpoon
Copy link
Contributor Author

Screenshot 2025-10-19 at 10 58 51 AM Screenshot 2025-10-19 at 10 58 57 AM Screenshot 2025-10-19 at 10 59 17 AM

@alanpoon alanpoon added waiting-on-review This issue is waiting to be reviewed and removed waiting-on-author This issue is waiting on the original author for a response labels Oct 19, 2025
Copy link
Member

@kevinaboos kevinaboos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Alan, nice work here.

My main comment is that we should decouple the ImageViewer widget from the RoomScreen. It doesn't need to know anything about the RoomScreen except for being able to access the RoomScreen's MediaCache instance (technically even that is not required, see Aarav's PR for an example of just passing the image data directly from the media fetch background task to the ImageViewer via an action).

Also, Aarav and I had a lot of discussions about using actions to communicate with the ImageViewer widget in PR#443. I think you can combine your approach (of storing images in the RoomScreen's MediaCache) with the action-based design from #443 (in which you emit an action including the image data/status instead of directly accessing the widget and calling a function on it). Actions are more idiomatic and also allow easy communication from any context, both in a background task and in a RoomScreen TimelineUpdate handler.

@alanpoon
Copy link
Contributor Author

Requires makepad/makepad#806 to be merged to use the rotated_image widget

@alanpoon alanpoon added blocked-on-makepad Blocked on a Makepad bug or missing Makepad feature and removed waiting-on-author This issue is waiting on the original author for a response labels Oct 30, 2025
@kevinaboos
Copy link
Member

Requires makepad/makepad#806 to be merged to use the rotated_image widget

I've asked Rik to review & merge this.

@kevinaboos
Copy link
Member

Requires makepad/makepad#806 to be merged to use the rotated_image widget

I've asked Rik to review & merge this.

@alanpoon this PR has been merged, and I've also updated the main Robrix branch to depend on the latest makepad.

@kevinaboos kevinaboos added waiting-on-author This issue is waiting on the original author for a response and removed blocked-on-makepad Blocked on a Makepad bug or missing Makepad feature labels Nov 2, 2025
@alanpoon alanpoon force-pushed the image_viewer#327 branch 2 times, most recently from 231f433 to 130e1fe Compare November 3, 2025 15:46
@alanpoon alanpoon added waiting-on-review This issue is waiting to be reviewed waiting-on-author This issue is waiting on the original author for a response and removed waiting-on-author This issue is waiting on the original author for a response waiting-on-review This issue is waiting to be reviewed labels Nov 3, 2025
@alanpoon alanpoon added waiting-on-review This issue is waiting to be reviewed and removed waiting-on-author This issue is waiting on the original author for a response labels Nov 4, 2025
Comment on lines +102 to +122
content: {
width: Fill, height: Fill,
flow: Down
show_bg: true
draw_bg: {
color: #000
}

<View> {
width: Fill, height: Fill,
flow: Overlay
image_viewer_inner = <ImageViewer> {
align: {x: 0.5, y: 0.5}
padding: {bottom: 0}
}
image_detail = <RoomImageViewerDetail> {
width: Fill, height: Fill,
}
}

footer = <RoomImageViewerFooter> {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of this should be moved into the ImageViewer itself. The top-level app itself should not have to be concerned with any of these inner details.

let scope = &mut Scope::with_data(&mut self.app_state);
self.ui.handle_event(cx, event, scope);
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this for? I don't think any sort of weird hack should be required to draw the modal, right? Draw events aren't "consumed" by just one modal, it's not like a visibility-requiring hit event.

Comment on lines +523 to +545
// If the image viewer modal is really opened, handles non-Draw events using the modal.
let image_viewer_modal = self.ui.modal(ids!(image_viewer));
if image_viewer_modal.is_open() && image_viewer_modal.area().rect(cx).size.y > 0.0 {
let scope = &mut Scope::with_data(&mut self.app_state);
self.ui
.view(ids!(popup_list))
.handle_event(cx, event, scope);
self.ui
.modal(ids!(image_viewer))
.handle_event(cx, event, scope);
// Pass the Signal event to the underlying room screen, so as to populate the full image in the image viewer.
if let Event::Signal = event {
self.ui.handle_event(cx, event, scope);
}
if let Event::Actions(actions) = event {
for action in actions {
if self.handle_image_viewer_action(cx, action) {
continue;
}
}
}
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this one modal so uniquely different than all the other modals? I would only expect you to have to handle opening and closing the ImageViewer modal here, just like all other modals.

I don't understand why you need to explicitly pass events to the modal; this is almost certainly incorrect and will result in the event being delivered to the modal twice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to comment on the rationale. Modal widget is not behaving as expected with the events being propagated to underneath layer. This results in other image being clicked while the image viewer modal is opened.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, that's concerning. Can you open an issue on the Makepad repo with a minimal example that reproduces this, and then link to it in a code comment here? Thanks.

I'll try to dig into modal issues this week to see if we can fix it there.

Comment on lines +656 to +701
/// Handles actions for the image viewer.
/// Returns a boolean, is true continues the actions for loop.
fn handle_image_viewer_action(&mut self, cx: &mut Cx, action: &Action) -> bool {

match action.downcast_ref() {
Some(ImageViewerAction::Show(load_state)) => {
match load_state {
LoadState::Loading(texture, image_size) => {
self.ui.modal(ids!(image_viewer)).open(cx);
self.ui.image_viewer(ids!(image_viewer_inner)).reset(cx);
self.ui.room_image_viewer_footer(ids!(footer)).show_loading(cx);
self.ui.view(ids!(footer)).apply_over(cx, live!{
height: 50
});
self.ui.image_viewer(ids!(image_viewer_inner)).display_using_texture(cx, texture.as_ref().clone(), image_size);
}
LoadState::Loaded(image_bytes) => {
self.ui.modal(ids!(image_viewer)).open(cx);
self.ui.image_viewer(ids!(image_viewer_inner)).display_using_background_thread(cx, image_bytes);
}
LoadState::FinishedBackgroundDecoding => {
self.ui.room_image_viewer_footer(ids!(footer)).hide(cx);
// Collapse the footer
self.ui.view(ids!(footer)).apply_over(cx, live!{
height: 0
});
}
LoadState::Error(error) => {
if self.ui.modal(ids!(image_viewer)).is_open() {
self.ui.room_image_viewer_footer(ids!(footer)).show_error(cx, image_viewer_error_to_string(error));
self.ui.view(ids!(footer)).apply_over(cx, live!{
height: 50
});
}
}
}
true
}
Some(ImageViewerAction::Hide) => {
self.ui.modal(ids!(image_viewer)).close(cx);
self.ui.room_image_viewer_detail(ids!(image_detail)).reset_state(cx);
true
}
_ => false
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, why does this need to be here in the top-level app? The only thing that actually needs to be in the top-level app module is opening and closing the modal. There should be zero ImageViewer-specific logic here.

Comment on lines +168 to +171
/// Deletes a specific media format from the cache for the given MXC URI.
/// If `format` is None, deletes the entire cache entry for the URI.
/// Returns true if an entry was deleted, false if nothing was found.
pub fn delete_cache_entry(&mut self, mxc_uri: &OwnedMxcUri, format: Option<MediaFormat>) -> bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this function, but here are two ways you can improve it?

  1. Use "remove" instead of delete, which matches the rest of Rust.
  2. Return the actual removed cache entry instead of a boolean.

Comment on lines +803 to +821
/// Returns a human-readable string describing the given ImageViewerError.
///
/// This can be used to update the label text of an error display.
///
/// The error type is matched against a string which describes the error in a way that is visible to the user.
///
/// The strings returned by this function will be appropriate for display in a label or similar widget.
pub fn image_viewer_error_to_string(error: &ImageViewerError) -> &str {
match error {
ImageViewerError::NotFound => "Full image is not found",
ImageViewerError::BadData => "Image appears to be empty or corrupted",
ImageViewerError::UnsupportedFormat => "This image format isn't supported",
ImageViewerError::ConnectionFailed => "Check your internet connection",
ImageViewerError::Unauthorized => "You don't have permission to view this image",
ImageViewerError::ServerError => "Server temporarily unavailable",
ImageViewerError::Unknown => "Unable to load image",
ImageViewerError::Timeout => "Timed out loading this image",
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should go into the imageviewer module

Comment on lines +665 to 732
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 Some((texture, size)) = content_message.get_texture_and_size(cx) else { continue; };
let texture = std::rc::Rc::new(texture);
let screen_width = wr.area().rect(cx).size.x;
let (capped_width, capped_height) = constrain_image_dimensions(size.x, size.y, screen_width);
if let Some(tl_state) = &mut self.tl_state {
if let Some(item) = tl_state.items.get(index) {
if let Some(event_tl_item) = item.as_event() {
let sender_profile = event_tl_item.sender_profile();
let sender = event_tl_item.sender();
let event_id = event_tl_item.event_id().map(|id| id.to_owned());
let timestamp = event_tl_item.timestamp();
// Extract image name and size from the message content
let (image_name, image_size) = 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(|s| i32::try_from(s).unwrap_or_default())
.unwrap_or(0);
(name, size)
} else {
("Unknown Image".to_string(), 0)
}
} else {
("Unknown Image".to_string(), 0)
};
cx.action(ImageViewerAction::Show(LoadState::Loading(texture, DVec2 { x: capped_width, y: capped_height })));
let Some(mxc_uri_string) = mxc_uri else { continue; };
let mxc_uri = OwnedMxcUri::from(mxc_uri_string);
populate_matrix_image_modal(cx, mxc_uri, &mut tl_state.media_cache);
cx.widget_action(
room_screen_widget_uid,
&scope.path,
RoomImageViewerDetailAction::SetImageDetail {
room_id: self.room_id.clone(),
sender: Some(sender.to_owned()),
sender_profile: Some(sender_profile.clone()),
event_id,
timestamp_millis: timestamp,
image_name,
image_size
}
);
}
}
}
continue
}
let link_preview_content = wr.link_preview(ids!(content.link_preview_view)).get_children();
for text_or_image in link_preview_content.iter() {
let text_or_image = text_or_image.text_or_image(ids!(image_view.image));
if let TextOrImageAction::Clicked(mxc_uri) = actions.find_widget_action(text_or_image.widget_uid()).cast() {
let Some((texture, size)) = text_or_image.get_texture_and_size(cx) else {
continue;
};
let screen_width = wr.area().rect(cx).size.x;
let (capped_width, capped_height) = constrain_image_dimensions(size.x, size.y, screen_width);
let texture = std::rc::Rc::new(texture);
let Some(mxc_uri_string) = mxc_uri else { continue; };
let mxc_uri = OwnedMxcUri::from(mxc_uri_string);
cx.action(ImageViewerAction::Show(LoadState::Loading(texture, DVec2 { x: capped_width, y: capped_height })));
let Some(tl_state) = self.tl_state.as_mut() else { continue; };
populate_matrix_image_modal(cx, mxc_uri, &mut tl_state.media_cache);
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

phew, this is a lot of complex code.... can you clean it up (simplify all the nested indentation) and move it to a separate function?

Also, the latter half that handles link previews can be removed, along with the get_children() function. Although some clients do allow clicking a link preview image, it's better UX to open the link upon clicking any part of a link preview instead of zooming in on a preview image thumbnail.

@kevinaboos kevinaboos added waiting-on-author This issue is waiting on the original author for a response and removed waiting-on-review This issue is waiting to be reviewed labels Nov 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-on-author This issue is waiting on the original author for a response

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When the user clicks a thumbnail image, show the full-size image in an image viewer widget

2 participants