Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions resources/icons/download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
437 changes: 368 additions & 69 deletions src/home/room_screen.rs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/media_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ impl MediaCache {
}
}

pub fn timeline_update_sender(&self) -> Option<&crossbeam_channel::Sender<TimelineUpdate>> {
self.timeline_update_sender.as_ref()
}

/// Tries to get the media from the cache, or submits an async request to fetch it.
///
/// This method *does not* block or wait for the media to be fetched,
Expand Down
85 changes: 42 additions & 43 deletions src/room/room_input_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,12 +698,12 @@ impl RoomInputBar {
#[cfg(feature = "tsp")]
let sign_with_tsp = self.is_tsp_signing_enabled(cx);

let dialog = rfd::AsyncFileDialog::new()
.set_title("Select file to upload");
let (sender, receiver) = std::sync::mpsc::channel();
self.pending_file_selection = Some(receiver);
let dialog_task = dialog.pick_file();
let dialog_task = rfd::AsyncFileDialog::new().pick_file();

// Native thread, not a tokio task: rfd's macOS dialog panics if it
// runs on a tokio worker thread.
cx.spawn_thread(move || {
let result = match futures::executor::block_on(dialog_task) {
Some(selected_file) => {
Expand Down Expand Up @@ -973,46 +973,45 @@ fn load_selected_file(
#[cfg(feature = "tsp")]
sign_with_tsp: bool,
) -> PendingFileSelection {
match std::fs::metadata(&selected_file_path) {
Ok(metadata) => {
if !metadata.is_file() {
return PendingFileSelection::Error("Cannot upload directories or special files".to_string());
}
let file_size = metadata.len();
if file_size == 0 {
return PendingFileSelection::Error("Cannot upload empty file".to_string());
}
let mime = mime_guess::from_path(&selected_file_path)
.first_or_octet_stream();
let preview_data = if crate::image_utils::is_displayable_image(mime.essence_str()) {
match std::fs::read(&selected_file_path) {
Ok(data) => Some(std::sync::Arc::new(data)),
Err(e) => return PendingFileSelection::Error(format!("Unable to read image preview: {e}")),
}
} else {
None
};
let name = selected_file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());

PendingFileSelection::Selected {
upload: AttachmentUpload {
timeline_kind,
file_data: crate::shared::file_upload_modal::FileUploadMetadata {
path: selected_file_path,
caption: Some(name),
mime_type: mime.to_string(),
preview_data,
size: file_size,
},
in_reply_to,
#[cfg(feature = "tsp")]
sign_with_tsp,
},
}
let metadata = match std::fs::metadata(&selected_file_path) {
Ok(m) => m,
Err(e) => return PendingFileSelection::Error(format!("Unable to access file: {e}")),
};
if !metadata.is_file() {
return PendingFileSelection::Error("Cannot upload directories or special files".to_string());
}
let file_size = metadata.len();
if file_size == 0 {
return PendingFileSelection::Error("Cannot upload empty file".to_string());
}
let mime = mime_guess::from_path(&selected_file_path)
.first_or_octet_stream();
let preview_data = if crate::image_utils::is_displayable_image(mime.essence_str()) {
match std::fs::read(&selected_file_path) {
Ok(data) => Some(std::sync::Arc::new(data)),
Err(e) => return PendingFileSelection::Error(format!("Unable to read image preview: {e}")),
}
Err(e) => PendingFileSelection::Error(format!("Unable to access file: {e}")),
} else {
None
};
let name = selected_file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());

PendingFileSelection::Selected {
upload: AttachmentUpload {
timeline_kind,
file_data: crate::shared::file_upload_modal::FileUploadMetadata {
path: selected_file_path,
caption: Some(name),
mime_type: mime.to_string(),
preview_data,
size: file_size,
},
in_reply_to,
#[cfg(feature = "tsp")]
sign_with_tsp,
},
}
}
182 changes: 182 additions & 0 deletions src/shared/attachment_download.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! Download a Matrix media attachment and save it to storage.

use std::sync::Arc;
use makepad_widgets::Cx;
#[cfg(not(any(target_os = "ios", target_os = "android")))]
use makepad_widgets::CxOsApi;
use matrix_sdk::ruma::{OwnedMxcUri, events::room::MediaSource};
use crate::home::room_screen::TimelineUpdate;
use crate::shared::popup_list::{PopupKind, enqueue_popup_notification};

/// The mxc URI inside any media source, whether plain or encrypted.
pub fn media_source_mxc(source: &MediaSource) -> &OwnedMxcUri {
match source {
MediaSource::Plain(uri) => uri,
MediaSource::Encrypted(file) => &file.url,
}
}

/// Info about a download that has begun or recently completed.
pub struct PendingDownload {
pub mxc: OwnedMxcUri,
pub state: PendingDownloadState,
}

pub enum PendingDownloadState {
/// The download request has been submitted to and is being handled by
/// the backend worker task.
InProgress,
/// The download was successful, and will show a success indicator for a few seconds.
JustSucceeded,
/// The download failed, and will show an error indicator for a few seconds.
JustFailed,
}
impl PendingDownloadState {
pub fn display(&self) -> DownloadDisplayState {
match self {
Self::InProgress => DownloadDisplayState::InProgress,
Self::JustSucceeded => DownloadDisplayState::Succeeded,
Self::JustFailed => DownloadDisplayState::Failed,
}
}
}

/// What the download section below a message should show.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum DownloadDisplayState {
/// Default: show the download button.
#[default]
Idle,
/// Show a loading spinner and cancel button.
InProgress,
/// Briefly show a green success button.
Succeeded,
/// Briefly show a red failed button.
Failed,
}

/// How long (in seconds) the success/failure state stays visible before resetting the button.
pub const DOWNLOAD_RESULT_DURATION_SECS: f64 = 5.0;

/// Metadata describing an attachment/media file to be downloaded.
#[derive(Clone, Debug)]
pub struct DownloadableAttachment {
pub media_source: MediaSource,
pub filename: String,
pub size: Option<u64>,
pub kind: DownloadKind,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DownloadKind {
File,
Audio,
Video,
Image,
}
impl DownloadKind {
pub fn button_text(&self) -> &'static str {
match self {
Self::File => "Download File",
Self::Audio => "Download Audio",
Self::Video => "Download Video",
Self::Image => "Download Image",
}
}
}

/// Opens the rfd save dialog with sensible defaults for `info`.
#[cfg(not(any(target_os = "ios", target_os = "android")))]
fn build_save_dialog(info: &DownloadableAttachment) -> rfd::AsyncFileDialog {
let dialog = rfd::AsyncFileDialog::new().set_file_name(&info.filename);
if let Some(user_dirs) = robius_directories::UserDirs::new() {
let dir = user_dirs.download_dir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| user_dirs.home_dir().to_path_buf());
dialog.set_directory(dir)
} else {
dialog
}
}

/// Opens the save dialog, then submits a request to fetch and write the file.
///
/// If `update_sender` is provided, it will be used to send progress updates to a timeline.
///
/// The save dialog runs on a newly-spawned OS-native thread (not a tokio task)
/// because `rfd` requires it, at least on macOS.
#[cfg(not(any(target_os = "ios", target_os = "android")))]
pub fn start_attachment_download(
cx: &mut Cx,
info: DownloadableAttachment,
update_sender: Option<crossbeam_channel::Sender<TimelineUpdate>>,
) {
use crate::sliding_sync::{MatrixRequest, submit_async_request};

let dialog_task = build_save_dialog(&info).save_file();
cx.spawn_thread(move || {
match futures::executor::block_on(dialog_task) {
// If Some, the user chose a valid location from the file dialog.
Some(handle) => {
submit_async_request(MatrixRequest::DownloadMediaToFile {
media_source: info.media_source,
save_path: handle.path().to_path_buf(),
filename: info.filename,
update_sender,
});
}
// If None, the user cancelled the file dialog.
None => {
if let Some(sender) = update_sender {
let mxc = media_source_mxc(&info.media_source).clone();
let _ = sender.send(TimelineUpdate::AttachmentDownloadReset(mxc));
makepad_widgets::SignalToUI::set_ui_signal();
}
}
}
});
}

/// Saves an attachment already in memory directly to storage.
#[cfg(not(any(target_os = "ios", target_os = "android")))]
pub fn save_loaded_attachment(cx: &mut Cx, info: DownloadableAttachment, bytes: Arc<[u8]>) {
let dialog_task = build_save_dialog(&info).save_file();
cx.spawn_thread(move || {
let Some(handle) = futures::executor::block_on(dialog_task) else { return };
let save_path = handle.path().to_path_buf();
match std::fs::write(&save_path, &bytes[..]) {
Ok(()) => enqueue_popup_notification(
format!("Downloaded \"{}\".", info.filename),
PopupKind::Success,
Some(5.0),
),
Err(e) => enqueue_popup_notification(
format!("Failed to save \"{}\": {e}", info.filename),
PopupKind::Error,
None,
),
}
});
}

#[cfg(any(target_os = "ios", target_os = "android"))]
pub fn start_attachment_download(
_cx: &mut Cx,
_info: DownloadableAttachment,
_update_sender: Option<crossbeam_channel::Sender<TimelineUpdate>>,
) {
enqueue_popup_notification(
"Saving attachments is not yet supported on mobile.",
PopupKind::Error,
Some(5.0),
);
}

#[cfg(any(target_os = "ios", target_os = "android"))]
pub fn save_loaded_attachment(_cx: &mut Cx, _info: DownloadableAttachment, _bytes: Arc<[u8]>) {
enqueue_popup_notification(
"Saving attachments is not yet supported on mobile.",
PopupKind::Error,
Some(5.0),
);
}
35 changes: 31 additions & 4 deletions src/shared/image_viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use matrix_sdk_ui::timeline::EventTimelineItem;
use crate::utils::format_decimal_file_size;
use thiserror::Error;
use crate::{
shared::{avatar::AvatarWidgetExt, timestamp::TimestampWidgetRefExt},
shared::{attachment_download::{DownloadableAttachment, save_loaded_attachment, start_attachment_download}, avatar::AvatarWidgetExt, timestamp::TimestampWidgetRefExt},
sliding_sync::TimelineKind,
};

Expand Down Expand Up @@ -332,6 +332,11 @@ script_mod! {
icon_walk: Walk{width: 25, height: 25, margin: Inset{bottom: 2}}
}

download_button := mod.widgets.ImageViewerButton {
draw_icon +: { svg: (ICON_DOWNLOAD) }
icon_walk: Walk{width: 24, height: 24}
}

close_button := mod.widgets.ImageViewerButton {
draw_icon +: { svg: (ICON_CLOSE) }
icon_walk: Walk{width: 21, height: 21 }
Expand Down Expand Up @@ -488,6 +493,10 @@ struct ImageViewer {
/// from the continuous `FingerHoverOver` events that fire every frame.
#[rust] last_mouse_pos: DVec2,
#[rust] capped_dimension: DVec2,
/// Info about how to download the image being shown.
#[rust] downloadable: Option<DownloadableAttachment>,
/// A reference to the image being shown so we can easily save it to storage.
#[rust] loaded_bytes: Option<Arc<[u8]>>,
}

impl Widget for ImageViewer {
Expand Down Expand Up @@ -825,9 +834,18 @@ 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 self.view.button(cx, ids!(download_button)).clicked(actions)
&& let Some(info) = self.downloadable.clone()
{
was_overlay_button_clicked = true;
if let Some(bytes) = self.loaded_bytes.clone() {
save_loaded_attachment(cx, info, bytes);
} else {
start_attachment_download(cx, info, None);
}
}

// Restart the auto-hide timer if any overlay button was clicked.
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);
Expand All @@ -845,6 +863,7 @@ impl MatchEvent for ImageViewer {
self.show_loading(cx);
}
LoadState::Loaded(image_bytes) => {
self.loaded_bytes = Some(image_bytes.clone());
self.show_loaded(cx, image_bytes);
}
LoadState::FinishedBackgroundDecoding => {
Expand Down Expand Up @@ -897,6 +916,7 @@ impl ImageViewer {
self.last_mouse_pos = DVec2::default();
self.receiver = None;
self.is_loaded = false;
self.loaded_bytes = None;
self.image_container_size = DVec2::new();
self.ui_overlay_visible = true;
self.mouse_over_overlay_ui = false;
Expand Down Expand Up @@ -1132,6 +1152,11 @@ impl ImageViewer {
.set_date_time(cx, timestamp);
}

self.loaded_bytes = None;
self.downloadable = metadata.downloadable.clone();
self.view.button(cx, ids!(download_button))
.set_visible(cx, self.downloadable.is_some());

if let Some((timeline_kind, event_timeline_item)) = &metadata.avatar_parameter {
let (sender, _) = self.view.avatar(cx, ids!(avatar_timestamp_view.avatar)).set_avatar_and_get_username(
cx,
Expand Down Expand Up @@ -1221,4 +1246,6 @@ pub struct ImageViewerMetaData {
pub image_name: String,
// Image size in bytes
pub image_file_size: u64,
/// When `Some`, the overlay's download button is shown.
pub downloadable: Option<DownloadableAttachment>,
}
1 change: 1 addition & 0 deletions src/shared/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use makepad_widgets::ScriptVm;

pub mod attachment_download;
pub mod avatar;
pub mod collapsible_header;
pub mod expand_arrow;
Expand Down
Loading
Loading