diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000..f9156ad4 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1 @@ +## 2026-04-15 - Prevent HTML Injection in Media Captions\n**Vulnerability:** XSS / HTML Injection in media captions. The `formatted_caption()` was used indiscriminately, injecting `fb.body` into `show_html` regardless of the `fb.format` type. If a malicious client sent a custom format or plaintext with HTML tags, it would be executed by the UI.\n**Learning:** The `FormattedBody` structure from Matrix (via Ruma) must have its `.format` field explicitly checked (e.g., `MessageFormat::Html`) before treating its `.body` as safe HTML, as native UI renders rely on this explicit contract.\n**Prevention:** Always use `.filter(|fb| fb.format == MessageFormat::Html)` when extracting HTML from a `FormattedBody`, and strictly fallback to `htmlize::escape_text` for plain text representations. diff --git a/Cargo.toml b/Cargo.toml index c881ca96..0a39a738 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -221,7 +221,14 @@ and the Project Robius app dev framework and platform abstractions Robrix runs on all major desktop and mobile platforms: macOS, Windows, Linux, Android, and iOS. """ -icons = ["./packaging/robrix_logo_alpha.png"] +icons = [ + "./packaging/robrix_logo_alpha_32x32.png", + "./packaging/robrix_logo_alpha_48x48.png", + "./packaging/robrix_logo_alpha_64x64.png", + "./packaging/robrix_logo_alpha_128x128.png", + "./packaging/robrix_logo_alpha_256x256.png", + "./packaging/robrix_logo_alpha_512x512.png", +] out_dir = "./dist" ## Here, we define the list of resource directories for both Makepad and Robrix. diff --git a/README.md b/README.md index 1eb6d5e8..c651a7c9 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ The following table shows which host systems can currently be used to build Robr cargo run --release ``` +> [!TIP] +> If you get a build error from `aws-lc-sys` about a **"COMPILER BUG DETECTED"** related to `memcmp` +> ([GCC #95189](https://gcc.gnu.org/bugzilla/show_bug.cgi?id=95189)), +> your GCC version is too old (GCC 9 and earlier are affected). +> The easiest fix is to build using `clang` instead: +> ```sh +> CC=clang CXX=clang++ cargo run --release +> ``` +> Alternatively, upgrade GCC to version 10 or newer. + + ## Building & Running Robrix on Mobile: Android, iOS, iPadOS 1. Install the `cargo-makepad` build tool: @@ -122,6 +133,12 @@ The following table shows which host systems can currently be used to build Robr --app=robrix \ run-sim -p robrix --release ``` +> [!TIP] +> If you get errors from the simulator, update your simulator tooling: +> ```sh +> xcodebuild -downloadPlatform iOS +> ``` + #### Running on a real iOS device 4. Run the following command to show all provisioning profiles, signing identities, and device identifiers on your Mac. diff --git a/packaging/robrix_logo_alpha_128x128.png b/packaging/robrix_logo_alpha_128x128.png new file mode 100644 index 00000000..12ba8adb Binary files /dev/null and b/packaging/robrix_logo_alpha_128x128.png differ diff --git a/packaging/robrix_logo_alpha_256x256.png b/packaging/robrix_logo_alpha_256x256.png new file mode 100644 index 00000000..9d4bab7d Binary files /dev/null and b/packaging/robrix_logo_alpha_256x256.png differ diff --git a/packaging/robrix_logo_alpha_32x32.png b/packaging/robrix_logo_alpha_32x32.png new file mode 100644 index 00000000..ce5f6595 Binary files /dev/null and b/packaging/robrix_logo_alpha_32x32.png differ diff --git a/packaging/robrix_logo_alpha_48x48.png b/packaging/robrix_logo_alpha_48x48.png new file mode 100644 index 00000000..ec597597 Binary files /dev/null and b/packaging/robrix_logo_alpha_48x48.png differ diff --git a/packaging/robrix_logo_alpha_512x512.png b/packaging/robrix_logo_alpha_512x512.png new file mode 100644 index 00000000..d11efc7b Binary files /dev/null and b/packaging/robrix_logo_alpha_512x512.png differ diff --git a/packaging/robrix_logo_alpha_64x64.png b/packaging/robrix_logo_alpha_64x64.png new file mode 100644 index 00000000..ff87e944 Binary files /dev/null and b/packaging/robrix_logo_alpha_64x64.png differ diff --git a/resources/android/res/mipmap-hdpi/ic_launcher.png b/resources/android/res/mipmap-hdpi/ic_launcher.png index c34528de..3b61fc6f 100644 Binary files a/resources/android/res/mipmap-hdpi/ic_launcher.png and b/resources/android/res/mipmap-hdpi/ic_launcher.png differ diff --git a/resources/android/res/mipmap-mdpi/ic_launcher.png b/resources/android/res/mipmap-mdpi/ic_launcher.png index b7df3c7d..26b1061c 100644 Binary files a/resources/android/res/mipmap-mdpi/ic_launcher.png and b/resources/android/res/mipmap-mdpi/ic_launcher.png differ diff --git a/resources/android/res/mipmap-xhdpi/ic_launcher.png b/resources/android/res/mipmap-xhdpi/ic_launcher.png index ecf7ea9a..0d5a264a 100644 Binary files a/resources/android/res/mipmap-xhdpi/ic_launcher.png and b/resources/android/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/resources/android/res/mipmap-xxhdpi/ic_launcher.png b/resources/android/res/mipmap-xxhdpi/ic_launcher.png index 003b84c0..4b9268b4 100644 Binary files a/resources/android/res/mipmap-xxhdpi/ic_launcher.png and b/resources/android/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/resources/android/res/mipmap-xxxhdpi/ic_launcher.png b/resources/android/res/mipmap-xxxhdpi/ic_launcher.png index 679d0f46..1c55d90b 100644 Binary files a/resources/android/res/mipmap-xxxhdpi/ic_launcher.png and b/resources/android/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/resources/icon_1024.png b/resources/icon_1024.png index 598abaa0..d7d2a86c 100644 Binary files a/resources/icon_1024.png and b/resources/icon_1024.png differ diff --git a/src/app.rs b/src/app.rs index ac682da0..0555dad1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1710,37 +1710,6 @@ impl AppMain for App { let scope = &mut Scope::with_data(&mut self.app_state); self.ui.handle_event(cx, event, scope); - /* - * TODO: I'd like for this to work, but it doesn't behave as expected. - * The context menu fails to draw properly when a draw event is passed to it. - * Also, once we do get this to work, we should remove the - * Hit::FingerScroll event handler in the new_message_context_menu widget. - * - // We only forward "interactive hit" events to the underlying UI view - // if none of the various overlay views are visible. - // Currently, the only overlay view that captures interactive events is - // the new message context menu. - // We always forward "non-interactive hit" events to the inner UI view. - // We check which overlay views are visible in the order of those views' z-ordering, - // such that the top-most views get a chance to handle the event first. - - let new_message_context_menu = self.ui.new_message_context_menu(cx, ids!(new_message_context_menu)); - let is_interactive_hit = utils::is_interactive_hit_event(event); - let is_pane_shown: bool; - if new_message_context_menu.is_currently_shown(cx) { - is_pane_shown = true; - new_message_context_menu.handle_event(cx, event, scope); - } - else { - is_pane_shown = false; - } - - if !is_pane_shown || !is_interactive_hit { - // Forward the event to the inner UI view. - self.ui.handle_event(cx, event, scope); - } - * - */ } } diff --git a/src/home/new_message_context_menu.rs b/src/home/new_message_context_menu.rs index 6fab28a7..4b6752f3 100644 --- a/src/home/new_message_context_menu.rs +++ b/src/home/new_message_context_menu.rs @@ -310,7 +310,12 @@ impl Widget for NewMessageContextMenu { self.visible = false; }; - self.view.draw_walk(cx, scope, walk) + let step = self.view.draw_walk(cx, scope, walk); + if self.visible { + let main_content_area = self.view(cx, ids!(main_content)).area(); + cx.block_scrolling_except_within(main_content_area); + } + step } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { @@ -323,7 +328,6 @@ impl Widget for NewMessageContextMenu { // 1. The back navigational gesture/action occurs (e.g., Back on Android), // 2. The escape key is pressed if this menu has key focus, // 3. The user clicks/touches outside the main_content view area. - // 4. The user scrolls anywhere. let close_menu = { event.back_pressed() || match event.hits_with_capture_overload(cx, area, true) { @@ -344,9 +348,6 @@ impl Widget for NewMessageContextMenu { !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) } } - // Ignore zero-scroll events: macOS trackpad generates FingerScroll(0,0) - // on two-finger press (right-click), which would incorrectly dismiss the menu. - Hit::FingerScroll(fse) => fse.scroll.x != 0.0 || fse.scroll.y != 0.0, _ => false, } }; @@ -651,6 +652,7 @@ impl NewMessageContextMenu { self.details = None; self.pending_open_gesture = None; cx.revert_key_focus(); + cx.unblock_scrolling(); self.redraw(cx); } } diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index cc176491..2829cbda 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -158,7 +158,12 @@ impl Widget for RoomContextMenu { if self.details.is_none() { self.visible = false; }; - self.view.draw_walk(cx, scope, walk) + let step = self.view.draw_walk(cx, scope, walk); + if self.visible { + let main_content_area = self.view(cx, ids!(main_content)).area(); + cx.block_scrolling_except_within(main_content_area); + } + step } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { @@ -186,9 +191,6 @@ impl Widget for RoomContextMenu { !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) } } - // Ignore zero-scroll events: macOS trackpad generates FingerScroll(0,0) - // on two-finger press (right-click), which would incorrectly dismiss the menu. - Hit::FingerScroll(fse) => fse.scroll.x != 0.0 || fse.scroll.y != 0.0, _ => false, } }; @@ -355,6 +357,7 @@ impl RoomContextMenu { self.details = None; self.pending_open_gesture = None; cx.revert_key_focus(); + cx.unblock_scrolling(); self.redraw(cx); } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 733bd7fa..6ea0221e 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -9736,7 +9736,8 @@ fn populate_file_message_content( .unwrap_or_default(); // Escape caption to prevent HTML injection from untrusted message content let caption = file_content.formatted_caption() - .map(|fb| format!("
{}", htmlize::escape_text(&fb.body))) + .filter(|fb| fb.format == MessageFormat::Html) + .map(|fb| format!("
{}", fb.body)) .or_else(|| file_content.caption().map(|c| format!("
{}", htmlize::escape_text(c)))) .unwrap_or_default(); @@ -9790,6 +9791,7 @@ fn populate_audio_message_content( )) .unwrap_or_default(); let caption = audio.formatted_caption() + .filter(|fb| fb.format == MessageFormat::Html) .map(|fb| format!("
{}", fb.body)) .or_else(|| audio.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -9841,6 +9843,7 @@ fn populate_video_message_content( )) .unwrap_or_default(); let caption = video.formatted_caption() + .filter(|fb| fb.format == MessageFormat::Html) .map(|fb| format!("
{}", fb.body)) .or_else(|| video.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); diff --git a/src/shared/image_viewer.rs b/src/shared/image_viewer.rs index e08d1d15..2e4d8da9 100644 --- a/src/shared/image_viewer.rs +++ b/src/shared/image_viewer.rs @@ -4,6 +4,7 @@ //! ImageViewerRef has 4 public methods, `configure_zoom`, `show_loading`, `show_loaded` and `reset`. use std::sync::{mpsc::Receiver, Arc}; +use bytesize::ByteSize; use chrono::{DateTime, Local}; use makepad_widgets::{ event::TouchUpdateEvent, @@ -108,7 +109,7 @@ script_mod! { use mod.prelude.widgets.* use mod.widgets.* - mod.widgets.UI_ANIMATION_DURATION_SECS = 0.5 + mod.widgets.UI_ANIMATION_DURATION_SECS = 0.4 mod.widgets.ROTATION_ANIMATION_DURATION_SECS = 0.2 @@ -118,7 +119,11 @@ script_mod! { align: Align{x: 0.5, y: 0.5}, spacing: 0, padding: 0, - draw_bg.color: (COLOR_SECONDARY * 0.925) + draw_bg +: { + color: (COLOR_SECONDARY * 0.925) + color_hover: (COLOR_SECONDARY * 0.825) + color_down: (COLOR_SECONDARY * 0.7) + } draw_icon +: { svg: (ICON_ZOOM_OUT), color: #000 @@ -195,13 +200,15 @@ script_mod! { metadata_view := View { width: Fill, height: Fill, - margin: 20, + // Placeholder. Real values are set in Rust via `script_apply_eval!` + // during `draw_walk`, since the slide animation writes here too. + margin: Inset{top: 20, left: 20, right: 20, bottom: 20} align: Align{x: 0.0, y: 1.0}, metadata_rounded_view := RoundedView { width: Fill, height: Fit flow: Right align: Align{y: 0.5, x: 0.0} - padding: 13 + padding: Inset{top: 13, bottom: 8, left: 13, right: 13} spacing: 8, show_bg: true @@ -210,14 +217,13 @@ script_mod! { color: (COLOR_IMAGE_VIEWER_META_BACKGROUND) } - // Display user profile view below the button group when the width is not enough. - user_profile_view := View { - width: Fit, - height: Fit, - flow: Right, - spacing: 13, - align: Align{ y: 0.5 } - + avatar_timestamp_view := View { + width: Fit + height: Fit + flow: Down + spacing: 2 + align: Align{x: 0.5, y: 0.0} + avatar := Avatar { width: 45, height: 45, text_view +: { @@ -228,55 +234,54 @@ script_mod! { } } } - - content := View { - width: Fit + timestamp := Timestamp { + width: Fit, height: Fit, - align: Align{ y: 0.5 } - spacing: 3 - flow: Down, - - username := Label { - width: Fit - height: Fit, - padding: 0 - margin: 0 - flow: Right + ts_label := Label { draw_text +: { - text_style: REGULAR_TEXT {font_size: 12}, + text_style: theme.font_regular {font_size: 9.5}, color: (COLOR_TEXT) } } + } + } - timestamp_view := View { - width: Fit - height: Fit - - timestamp := Timestamp { - width: Fit, - height: Fit, - ts_label := Label { - draw_text +: { - text_style: theme.font_regular {font_size: 9.5}, - color: (COLOR_TEXT) - } - } - } + username_label_view := View { + width: Fill{weight: 0.35}, + // width: Fill, + height: Fit, + flow: Right, + align: Align{ y: 0.5 } + + username := Label { + width: Fill, + height: Fit, + padding: 0 + margin: 0 + flow: Flow.Right{wrap: true} + max_lines: 2 + text_overflow: Ellipsis + draw_text +: { + text_style: REGULAR_TEXT {font_size: 12}, + color: (COLOR_TEXT) } } } - // Display image name and size below the user_profile_view if the width is not enough. + // Display image name and size below the username when the width is not enough. image_name_and_size_view := View { - width: Fill + width: Fill{weight: 0.65}, + // width: Fill height: Fit, - align: Align{x: 0.5, y: 0.5} + align: Align{x: 0, y: 0.5} flow: Right image_name_and_size := Label { width: Fill, height: Fit, - align: Align{x: 0.5, y: 0.5} + align: Align{x: 0, y: 0.5} flow: Flow.Right{wrap: true} + max_lines: 2 + text_overflow: Ellipsis draw_text +: { text_style: REGULAR_TEXT {font_size: 13}, color: (COLOR_TEXT), @@ -289,6 +294,7 @@ script_mod! { button_group_view := View { width: Fill, height: Fit flow: Right + // Placeholder, see `metadata_view` above. margin: Inset{top: 20, right: 20} align: Align{x: 1.0, y: 0.5}, @@ -402,27 +408,17 @@ script_mod! { ui_animator: { default: @hide show: AnimatorState{ - redraw: false, + redraw: true, from: { all: Forward { duration: (mod.widgets.UI_ANIMATION_DURATION_SECS) } } apply: { - button_group_view: { - margin: Inset{ top: 20 } - } - metadata_view: { - margin: Inset{ bottom: 20 } - } + ui_overlay_slide: 0.0 } } hide: AnimatorState{ - redraw: false, + redraw: true, from: { all: Forward { duration: (mod.widgets.UI_ANIMATION_DURATION_SECS) } } apply: { - button_group_view: { - margin: Inset{ top: -200 } - } - metadata_view: { - margin: Inset{ bottom: -300 } - } + ui_overlay_slide: 1.0 } } } @@ -474,10 +470,22 @@ struct ImageViewer { #[rust] texture: Option, /// The event to trigger displaying with the loaded image after peek_walk_turtle of the widget. #[rust] next_frame: NextFrame, - /// Whether to display the UI overlay, including buttons and metadata. - #[rust] ui_visible_toggle: bool, - /// Timer used to animate-out (hide) the UI view after the latest user input. + /// Whether the UI overlay (buttons + metadata) is currently visible or animating to visible. + #[rust] ui_overlay_visible: bool, + /// Whether the mouse is hovering over the overlay UI (buttons or metadata). + /// When true, the auto-hide timer should not run. + #[rust] mouse_over_overlay_ui: bool, + /// Whether the hide animation is currently playing. When it finishes, + /// the overlay views are set to invisible. + #[rust] is_hiding_overlay: bool, + /// Animated slide value for the UI overlay: 0.0 = fully visible, 1.0 = fully hidden. + /// The animator interpolates this value; `draw_walk` uses it to position the views. + #[live] ui_overlay_slide: f32, + /// Timer used to animate-out (hide) the UI overlay after no user mouse/tap activity. #[rust] hide_ui_timer: Timer, + /// Last known mouse position, used to distinguish actual mouse movement + /// from the continuous `FingerHoverOver` events that fire every frame. + #[rust] last_mouse_pos: DVec2, #[rust] capped_dimension: DVec2, } @@ -523,67 +531,86 @@ impl Widget for ImageViewer { } } - // Handle hover events for UI elements without consuming the main image events - // We'll track hover state in the FingerMove event within the image handling + // Handle hover events for UI overlay elements. + // Only hit-test these when the overlay is visible; when hidden, their areas + // persist from the last draw and would consume events before rotated_image. let rotated_image = self.view.image(cx, ids!(rotated_image)); let button_group_rounded_view = self.view.view(cx, ids!(button_group_rounded_view)); - match event.hits(cx, button_group_rounded_view.area()) { - Hit::FingerHoverIn(_) if !self.ui_visible_toggle => { - cx.stop_timer(self.hide_ui_timer); - self.animator_cut(cx, ids!(ui_animator.show)); - } - Hit::FingerHoverOut(fe) - if !self.ui_visible_toggle - && !button_group_rounded_view.area().rect(cx).contains(fe.abs) - => { - // FingerHoverOut is triggered when the cursor enters into the button. - // Hence we need to check if the cursor is actually inside the button group. - self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); + // All hit events (hover + finger) must use self.view.area() because the inner + // View's handle_event captures events on its own area first (due to its animator), + // preventing rotated_image.area() from receiving them. + // Position checks distinguish image vs. background interactions. + match event.hits(cx, self.view.area()) { + Hit::FingerHoverIn(he) if rotated_image.area().rect(cx).contains(he.abs) => { + self.mouse_cursor_hover_over_image = true; + cx.set_cursor(MouseCursor::Hand); } - Hit::FingerHoverOut(_) => {} - _ => {} - } - match event.hits(cx, self.view.view(cx, ids!(metadata_rounded_view)).area()) { - Hit::FingerHoverIn(_) if !self.ui_visible_toggle => { - cx.stop_timer(self.hide_ui_timer); - self.animator_cut(cx, ids!(ui_animator.show)); + Hit::FingerHoverOut(_) => { + self.mouse_cursor_hover_over_image = false; + cx.set_cursor(MouseCursor::Default); } - Hit::FingerHoverOut(_) if !self.ui_visible_toggle => { - self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); + Hit::FingerHoverOver(he) => { + // Update cursor based on position over image. + let on_image = rotated_image.area().rect(cx).contains(he.abs); + if on_image != self.mouse_cursor_hover_over_image { + self.mouse_cursor_hover_over_image = on_image; + cx.set_cursor(if on_image { MouseCursor::Hand } else { MouseCursor::Default }); + } + // Track whether cursor is over the overlay UI elements. + let on_overlay = button_group_rounded_view.area().rect(cx).contains(he.abs) + || self.view.view(cx, ids!(metadata_rounded_view)).area().rect(cx).contains(he.abs); + if on_overlay != self.mouse_over_overlay_ui { + self.mouse_over_overlay_ui = on_overlay; + if on_overlay { + cx.stop_timer(self.hide_ui_timer); + } else { + self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); + } + } + // FingerHoverOver fires every frame the cursor is over the area, + // even without actual movement. Only react to real mouse movement. + let dist = (he.abs - self.last_mouse_pos).length(); + let mouse_moved = dist > 0.5; + self.last_mouse_pos = he.abs; + if mouse_moved { + self.show_overlay_ui(cx, true); + } } - _ => {} - } - // Handle cursor changes on mouse hover - match event.hits(cx, rotated_image.area()) { Hit::FingerDown(fe) if fe.is_primary_hit() => { - self.drag_state.drag_start = fe.abs; - // Initialize pan_offset with current position if not set - if self.drag_state.pan_offset.is_none() { - self.drag_state.pan_offset = Some(DVec2::default()); + let click_pos = fe.abs; + let on_image = rotated_image.area().rect(cx).contains(click_pos); + let on_buttons = button_group_rounded_view.area().rect(cx).contains(click_pos); + let on_metadata = self.view.view(cx, ids!(metadata_rounded_view)) + .area().rect(cx).contains(click_pos); + if on_image { + self.drag_state.drag_start = fe.abs; + if self.drag_state.pan_offset.is_none() { + self.drag_state.pan_offset = Some(DVec2::default()); + } + } else if !on_buttons && !on_metadata { + self.reset(cx); + cx.action(ImageViewerAction::Hide); } } - Hit::FingerDown(_) => {} Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() => { - // Only reset pan_offset on double-tap, not single tap - if fe.tap_count == 2 { - self.drag_state.pan_offset = Some(DVec2::default()); - let mut rotated_image_container = self.view.image(cx, ids!(rotated_image)); - script_apply_eval!(cx, rotated_image_container, { - margin +: { top: 0.0, left: 0.0 }, - }); - rotated_image_container.redraw(cx); - } - self.ui_visible_toggle = !self.ui_visible_toggle; - if self.ui_visible_toggle { - self.animator_play(cx, ids!(ui_animator.show)); - } else { - self.animator_play(cx, ids!(ui_animator.hide)); + let on_image = rotated_image.area().rect(cx).contains(fe.abs); + if on_image { + // Only reset pan_offset on double-tap, not single tap + if fe.tap_count == 2 { + self.drag_state.pan_offset = Some(DVec2::default()); + let mut rotated_image_container = self.view.image(cx, ids!(rotated_image)); + script_apply_eval!(cx, rotated_image_container, { + margin +: { top: 0.0, left: 0.0 }, + }); + rotated_image_container.redraw(cx); + } + // Tap toggles the overlay UI visibility. + if self.ui_overlay_visible { + self.hide_overlay_ui(cx); + } else { + self.show_overlay_ui(cx, true); + } } - cx.stop_timer(self.hide_ui_timer); - } - Hit::FingerHoverIn(_) => { - self.mouse_cursor_hover_over_image = true; - cx.set_cursor(MouseCursor::Hand); } Hit::FingerMove(fe) => { if let Some(current_offset) = self.drag_state.pan_offset { @@ -596,37 +623,22 @@ impl Widget for ImageViewer { width: #(size.x), height: #(size.y) }); - - // Update pan_offset with new position self.drag_state.pan_offset = Some(new_offset); } self.drag_state.drag_start = fe.abs; } - Hit::FingerHoverOut(_) => { - self.mouse_cursor_hover_over_image = false; - cx.set_cursor(MouseCursor::Default); - } - Hit::FingerHoverOver(_) - if !self.ui_visible_toggle - && !self.animator.in_state(cx, ids!(ui_animator.show)) - => { - self.animator_cut(cx, ids!(ui_animator.hide)); - self.animator_play(cx, ids!(ui_animator.show)); - cx.stop_timer(self.hide_ui_timer); - self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); - } - Hit::FingerHoverOver(_) => {} _ => {} } if let Event::Scroll(scroll_event) = event { if self.mouse_cursor_hover_over_image { let scroll_delta = scroll_event.scroll.y; + // Scale the zoom factor proportionally to the scroll magnitude, + // clamped so each scroll tick produces a gentle zoom step. + let normalized = (scroll_delta.abs() / 200.0).clamp(0.005, 0.06); if scroll_delta > 0.0 { - // Scroll up = Zoom in - self.adjust_zoom(cx, self.config.zoom_scale_factor); + self.adjust_zoom(cx, 1.0 + normalized); } else if scroll_delta < 0.0 { - // Scroll down = Zoom out - self.adjust_zoom(cx, 1.0 / self.config.zoom_scale_factor); + self.adjust_zoom(cx, 1.0 / (1.0 + normalized)); } } } @@ -682,6 +694,19 @@ impl Widget for ImageViewer { } let animator_action = self.animator_handle_event(cx, event); + if animator_action.must_redraw() { + self.view.redraw(cx); + } + + // When the hide animation finishes, make the overlay views invisible + // so their stale areas don't consume events. + if self.is_hiding_overlay && !self.animator.is_track_animating(id!(ui_animator)) { + self.is_hiding_overlay = false; + self.view.view(cx, ids!(button_group_view)).set_visible(cx, false); + self.view.view(cx, ids!(metadata_view)).set_visible(cx, false); + self.view.redraw(cx); + } + if self.next_frame.is_event(event).is_some() { self.display_using_texture(cx); } @@ -706,7 +731,7 @@ impl Widget for ImageViewer { } if self.hide_ui_timer.is_event(event).is_some() { - self.animator_play(cx, ids!(ui_animator.hide)); + self.hide_overlay_ui(cx); } } @@ -716,6 +741,38 @@ impl Widget for ImageViewer { self.image_container_size = rect.size; self.next_frame = cx.new_next_frame(); } + + // Position the overlays from the animated `ui_overlay_slide`. + // 0.0 = fully visible, 1.0 = fully off-screen. + // + // Visible-state margin is `max(20.0, safe_inset)` so the overlays + // clear cutouts. Has to be done in Rust cuz Modal bypasses the + // window body's padding, and `script_apply_eval!` overrides the + // DSL values every draw anyway. + let slide = self.ui_overlay_slide as f64; + let insets = cx.display_context.safe_area_insets; + let button_top_visible = 20.0_f64.max(insets.top); + let button_right = 20.0_f64.max(insets.right); + let meta_top = 20.0_f64.max(insets.top); + let meta_left = 20.0_f64.max(insets.left); + let meta_right = 20.0_f64.max(insets.right); + let meta_bottom_visible = 20.0_f64.max(insets.bottom); + let button_top = button_top_visible - (slide * 220.0); // visible → -200 + let meta_bottom = meta_bottom_visible - (slide * 320.0); // visible → -300 + let mut bg = self.view(cx, ids!(button_group_view)); + script_apply_eval!(cx, bg, { + margin +: { top: #(button_top), right: #(button_right) } + }); + let mut mv = self.view(cx, ids!(metadata_view)); + script_apply_eval!(cx, mv, { + margin +: { + top: #(meta_top), + left: #(meta_left), + right: #(meta_right), + bottom: #(meta_bottom), + } + }); + self.view.draw_walk(cx, scope, walk) } } @@ -726,30 +783,24 @@ impl MatchEvent for ImageViewer { self.reset(cx); cx.action(ImageViewerAction::Hide); } + + let mut was_overlay_button_clicked = false; if self.view.button(cx, ids!(reset_button)).clicked(actions) { + was_overlay_button_clicked = true; self.reset(cx); } - if self - .view - .button(cx, ids!(zoom_out_button)) - .clicked(actions) - { + if self.view.button(cx, ids!(zoom_out_button)).clicked(actions) { + was_overlay_button_clicked = true; self.adjust_zoom(cx, 1.0 / self.config.zoom_scale_factor); } - if self - .view - .button(cx, ids!(zoom_in_button)) - .clicked(actions) - { + if self.view.button(cx, ids!(zoom_in_button)).clicked(actions) { + was_overlay_button_clicked = true; self.adjust_zoom(cx, self.config.zoom_scale_factor); } - if self - .view - .button(cx, ids!(rotate_cw_button)) - .clicked(actions) - { + if self.view.button(cx, ids!(rotate_cw_button)).clicked(actions) { + was_overlay_button_clicked = true; if !self.is_animating_rotation { self.is_animating_rotation = true; if self.rotation_step == 3 { @@ -760,11 +811,8 @@ impl MatchEvent for ImageViewer { } } - if self - .view - .button(cx, ids!(rotate_ccw_button)) - .clicked(actions) - { + if self.view.button(cx, ids!(rotate_ccw_button)).clicked(actions) { + was_overlay_button_clicked = true; if !self.is_animating_rotation { self.is_animating_rotation = true; if self.rotation_step == 0 { @@ -776,6 +824,14 @@ impl MatchEvent for ImageViewer { } } + // Restart the auto-hide timer if any overlay button was clicked. If the + // mouse is still over the overlay the hover handler keeps the timer + // stopped anyway. + if was_overlay_button_clicked && !self.mouse_over_overlay_ui { + cx.stop_timer(self.hide_ui_timer); + self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); + } + for action in actions.iter() { if let Some(ImageViewerAction::Show(state)) = action.downcast_ref() { match state { @@ -804,19 +860,52 @@ impl MatchEvent for ImageViewer { } impl ImageViewer { + /// Shows the UI overlay (buttons + metadata) and optionally starts the auto-hide timer. + fn show_overlay_ui(&mut self, cx: &mut Cx, start_auto_hide_timer: bool) { + if !self.ui_overlay_visible { + self.ui_overlay_visible = true; + self.is_hiding_overlay = false; + self.view.view(cx, ids!(button_group_view)).set_visible(cx, true); + self.view.view(cx, ids!(metadata_view)).set_visible(cx, true); + self.animator_play(cx, ids!(ui_animator.show)); + self.view.redraw(cx); + } + cx.stop_timer(self.hide_ui_timer); + if start_auto_hide_timer && !self.mouse_over_overlay_ui { + self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); + } + } + + /// Hides the UI overlay (buttons + metadata) with an animated slide-out. + /// The views are kept visible during animation; `handle_event` sets them + /// invisible once the animation finishes. + fn hide_overlay_ui(&mut self, cx: &mut Cx) { + self.ui_overlay_visible = false; + self.is_hiding_overlay = true; + cx.stop_timer(self.hide_ui_timer); + self.animator_play(cx, ids!(ui_animator.hide)); + self.view.redraw(cx); + } + /// Reset state. pub fn reset(&mut self, cx: &mut Cx) { self.rotation_step = 0; // Reset to upright (0°) self.is_animating_rotation = false; // Reset animation state self.previous_pinch_distance = None; // Reset pinch tracking self.mouse_cursor_hover_over_image = false; // Reset hover state + self.last_mouse_pos = DVec2::default(); self.receiver = None; self.is_loaded = false; self.image_container_size = DVec2::new(); - self.ui_visible_toggle = false; + self.ui_overlay_visible = true; + self.mouse_over_overlay_ui = false; + self.is_hiding_overlay = false; cx.stop_timer(self.hide_ui_timer); - self.animator_cut(cx, ids!(ui_animator.show)); self.hide_ui_timer = Timer::empty(); + self.view.view(cx, ids!(button_group_view)).set_visible(cx, true); + self.view.view(cx, ids!(metadata_view)).set_visible(cx, true); + // Snap to fully visible (no animation on reset). + self.animator_cut(cx, ids!(ui_animator.show)); self.reset_drag_state(cx); self.animator_cut(cx, ids!(mode.upright)); let rotated_image_ref = self @@ -886,8 +975,7 @@ impl ImageViewer { let _ = sender.send(get_png_or_jpg_image_buffer(image_bytes_clone)); SignalToUI::set_ui_signal(); }); - cx.stop_timer(self.hide_ui_timer); - self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); + self.show_overlay_ui(cx, true); } /// Displays an image in the image viewer widget using the provided texture. @@ -992,7 +1080,12 @@ impl ImageViewer { footer.view(cx, ids!(image_viewer_forbidden_view)) .set_visible(cx, false); footer.set_visible(cx, true); - self.ui_visible_toggle = true; + // Snap the overlay to visible immediately on initial open (no animation). + self.ui_overlay_visible = true; + self.is_hiding_overlay = false; + self.view.view(cx, ids!(button_group_view)).set_visible(cx, true); + self.view.view(cx, ids!(metadata_view)).set_visible(cx, true); + self.animator_cut(cx, ids!(ui_animator.show)); cx.stop_timer(self.hide_ui_timer); self.hide_ui_timer = cx.start_timeout(SHOW_UI_DURATION); } @@ -1023,29 +1116,22 @@ impl ImageViewer { /// Sets the metadata view in the image viewer with the provided metadata. /// - /// The metadata view is updated with the truncated image name and the human-readable size of the image. - /// - /// The image name is truncated to 24 characters and appended with "..." if it exceeds the limit. - /// The human-readable size is calculated based on the image size in bytes. + /// The image_name_and_size and username labels handle their own overflow + /// via `max_lines: 2` + `text_overflow: Ellipsis` in the layout. pub fn set_metadata(&mut self, cx: &mut Cx, metadata: &ImageViewerMetaData) { let meta_view = self.view.view(cx, ids!(metadata_view)); - let truncated_name = truncate_image_name(&metadata.image_name); - let human_readable_size = format_file_size(metadata.image_file_size); - let display_text = format!("{} ({})", truncated_name, human_readable_size); + let display_text = format!("{} ({})", metadata.image_name, ByteSize::b(metadata.image_file_size)); meta_view .label(cx, ids!(image_name_and_size)) .set_text(cx, &display_text); if let Some(timestamp) = metadata.timestamp { meta_view - .view(cx, ids!(user_profile_view.content.timestamp_view)) - .set_visible(cx, true); - meta_view - .timestamp(cx, ids!(user_profile_view.content.timestamp_view.timestamp)) + .timestamp(cx, ids!(avatar_timestamp_view.timestamp)) .set_date_time(cx, timestamp); } if let Some((timeline_kind, event_timeline_item)) = &metadata.avatar_parameter { - let (sender, _) = self.view.avatar(cx, ids!(user_profile_view.avatar)).set_avatar_and_get_username( + let (sender, _) = self.view.avatar(cx, ids!(avatar_timestamp_view.avatar)).set_avatar_and_get_username( cx, timeline_kind, event_timeline_item.sender(), @@ -1053,15 +1139,9 @@ impl ImageViewer { event_timeline_item.event_id(), false, ); - if sender.len() > MAX_USERNAME_LENGTH { - meta_view - .label(cx, ids!(user_profile_view.content.username)) - .set_text(cx, &format!("{}...", &sender[..MAX_USERNAME_LENGTH - 3])); - } else { - meta_view - .label(cx, ids!(user_profile_view.content.username)) - .set_text(cx, &sender); - }; + meta_view + .label(cx, ids!(username_label_view.username)) + .set_text(cx, &sender); } } } @@ -1141,57 +1221,3 @@ pub struct ImageViewerMetaData { pub image_file_size: u64, } -/// Maximum image name length to be displayed -const MAX_IMAGE_NAME_LENGTH: usize = 50; -/// Maximum username length to be displayed -const MAX_USERNAME_LENGTH: usize = 50; - -/// Truncate image name while preserving file extension -fn truncate_image_name(image_name: &str) -> String { - let max_length = MAX_IMAGE_NAME_LENGTH; - - if image_name.len() <= max_length { - return image_name.to_string(); - } - - // Find the last dot to separate name and extension - if let Some(dot_pos) = image_name.rfind('.') { - let name_part = &image_name[..dot_pos]; - let extension = &image_name[dot_pos..]; - - // Reserve space for "..." and the extension - let available_length = max_length.saturating_sub(3 + extension.len()); - - if available_length > 0 && name_part.len() > available_length { - format!("{}...{}", &name_part[..available_length], extension) - } else { - image_name.to_string() - } - } else { - // No extension found, just truncate the name - format!("{}...", &image_name[..max_length.saturating_sub(3)]) - } -} - -/// Convert bytes to human-readable file size format -fn format_file_size(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; - - if bytes == 0 { - return "0 B".to_string(); - } - - let mut size = bytes as f64; - let mut unit_index = 0; - - while size >= 1024.0 && unit_index < UNITS.len() - 1 { - size /= 1024.0; - unit_index += 1; - } - - if unit_index == 0 { - format!("{} {}", bytes, UNITS[unit_index]) - } else { - format!("{:.1} {}", size, UNITS[unit_index]) - } -}