diff --git a/Cargo.lock b/Cargo.lock index 107c8adf0..36998bcbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1484,9 +1484,9 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "block2", @@ -3983,30 +3983,31 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] [[package]] name = "objc2-app-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "block2", "objc2", + "objc2-core-foundation", "objc2-foundation", ] [[package]] name = "objc2-authentication-services" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f920a0a975e281f298fab2312374a288e742f7375e8797397fd5c274b486b58" +checksum = "ee6d6f7dab884a28adaec1012eb3889257a49cc145724e35f93ece2d209f8b25" dependencies = [ "block2", "objc2", @@ -4016,9 +4017,9 @@ dependencies = [ [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "dispatch2", @@ -4027,9 +4028,9 @@ dependencies = [ [[package]] name = "objc2-core-location" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ "objc2", "objc2-foundation", @@ -4043,9 +4044,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "objc2-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "block2", @@ -4055,9 +4056,9 @@ dependencies = [ [[package]] name = "objc2-photos-ui" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e9b818a2fae66994d23ad6bf845e9204d5503434f4c2711dc35701dac8e296" +checksum = "fdddde9dfb986546746e3115021a85c4a83e1c5e9037b3c865a688fe9bf94ebb" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "objc2", @@ -4066,22 +4067,23 @@ dependencies = [ [[package]] name = "objc2-ui-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "block2", "objc2", + "objc2-core-foundation", "objc2-foundation", "objc2-uniform-type-identifiers", ] [[package]] name = "objc2-uniform-type-identifiers" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d964a85778b5899a7963c5d4a5c62cec6792cf41dfdfc5dadae2d427f2681c85" +checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4" dependencies = [ "objc2", "objc2-foundation", @@ -4978,13 +4980,13 @@ dependencies = [ [[package]] name = "robius-common" -version = "0.2.0" -source = "git+https://github.com/project-robius/robius#6cf7d57917c7d325de4f28d42ee5ae1510a255b0" +version = "0.3.0" +source = "git+https://github.com/project-robius/robius#f697e566310a201528afbac665359d53914ffc0c" [[package]] name = "robius-directories" version = "6.0.0" -source = "git+https://github.com/project-robius/robius#6cf7d57917c7d325de4f28d42ee5ae1510a255b0" +source = "git+https://github.com/project-robius/robius#f697e566310a201528afbac665359d53914ffc0c" dependencies = [ "dirs-sys", "jni", @@ -4993,8 +4995,8 @@ dependencies = [ [[package]] name = "robius-file-picker" -version = "0.2.0" -source = "git+https://github.com/project-robius/robius#6cf7d57917c7d325de4f28d42ee5ae1510a255b0" +version = "0.3.0" +source = "git+https://github.com/project-robius/robius#f697e566310a201528afbac665359d53914ffc0c" dependencies = [ "android-build", "block2", @@ -5015,8 +5017,8 @@ dependencies = [ [[package]] name = "robius-location" -version = "0.2.0" -source = "git+https://github.com/project-robius/robius#6cf7d57917c7d325de4f28d42ee5ae1510a255b0" +version = "0.3.0" +source = "git+https://github.com/project-robius/robius#f697e566310a201528afbac665359d53914ffc0c" dependencies = [ "android-build", "cfg-if", @@ -5030,8 +5032,8 @@ dependencies = [ [[package]] name = "robius-open" -version = "0.2.0" -source = "git+https://github.com/project-robius/robius#6cf7d57917c7d325de4f28d42ee5ae1510a255b0" +version = "0.3.0" +source = "git+https://github.com/project-robius/robius#f697e566310a201528afbac665359d53914ffc0c" dependencies = [ "block2", "cfg-if", @@ -5045,6 +5047,25 @@ dependencies = [ "windows 0.56.0", ] +[[package]] +name = "robius-share" +version = "0.3.0" +source = "git+https://github.com/project-robius/robius#f697e566310a201528afbac665359d53914ffc0c" +dependencies = [ + "android-build", + "cfg-if", + "dispatch2", + "jni", + "libc", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "robius-android-env", + "robius-common", + "windows 0.56.0", +] + [[package]] name = "robius-use-makepad" version = "0.1.1" @@ -5056,8 +5077,8 @@ dependencies = [ [[package]] name = "robius-web-auth-session" -version = "0.2.0" -source = "git+https://github.com/project-robius/robius#6cf7d57917c7d325de4f28d42ee5ae1510a255b0" +version = "0.3.0" +source = "git+https://github.com/project-robius/robius#f697e566310a201528afbac665359d53914ffc0c" dependencies = [ "block2", "cfg-if", @@ -5108,6 +5129,7 @@ dependencies = [ "robius-file-picker", "robius-location", "robius-open", + "robius-share", "robius-use-makepad", "robius-web-auth-session", "ruma", diff --git a/Cargo.toml b/Cargo.toml index 1ad69c844..4822aa6c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ robius-directories = { git = "https://github.com/project-robius/robius" } robius-file-picker = { git = "https://github.com/project-robius/robius" } robius-location = { git = "https://github.com/project-robius/robius" } robius-open = { git = "https://github.com/project-robius/robius" } +robius-share = { git = "https://github.com/project-robius/robius" } anyhow = "1.0" diff --git a/resources/icons/share.svg b/resources/icons/share.svg new file mode 100644 index 000000000..8f0de62c7 --- /dev/null +++ b/resources/icons/share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app.rs b/src/app.rs index b8fb1b1cb..87bb73db7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -227,6 +227,8 @@ impl MatchEvent for App { log!("App::Startup: initializing TSP (Trust Spanning Protocol) module."); crate::tsp::tsp_init(_tokio_rt_handle).unwrap(); } + + crate::temp_storage::schedule_temp_dir_cleanup(); } fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 8a35fca8a..603df2bd2 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -32,7 +32,7 @@ use crate::{ }, room::{BasicRoomDetails, reply_preview::CollapsiblePreviewWidgetRefExt, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, typing_notice::TypingNoticeWidgetExt}, shared::{ - attachment_download::{enqueue_already_downloading_notification, DownloadDisplayState, DownloadKind, DownloadableAttachment, PendingDownload, PendingDownloadState, media_source_mxc, start_attachment_download}, avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, file_upload_modal::FileUploadAttemptId, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, room_input_popup_menu::{RoomInputPopupMenuAction, RoomInputPopupMenuWidgetExt}, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageStatus, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt + attachment_download::{enqueue_already_downloading_notification, DownloadDisplayState, DownloadKind, DownloadableAttachment, PendingDownload, PendingDownloadState, TimelineUpdateSenderOption, TransferKind, media_source_mxc, start_attachment_download, start_attachment_share}, avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, file_upload_modal::FileUploadAttemptId, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, room_input_popup_menu::{RoomInputPopupMenuAction, RoomInputPopupMenuWidgetExt}, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageStatus, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, submit_async_request, take_timeline_endpoints}, utils::{self, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} }; @@ -102,6 +102,15 @@ script_mod! { text: "Download" } + share_button := RobrixIconButton { + height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + padding: Inset{left: 12, right: 12} + margin: Inset{left: 8} + draw_icon.svg: (ICON_SHARE) + icon_walk: Walk{width: 16, height: 16} + text: "Share" + } + downloading_view := View { visible: false, width: Fit, height: mod.widgets.SETTINGS_BUTTON_HEIGHT @@ -2055,6 +2064,34 @@ impl RoomScreen { .find(|ev| ev.event_id().is_some_and(|id| id == target_event_id)) } + /// Registers a pending download for a media transfer (e.g., download, share) + /// and shows the loading spinner, then calls `start` to kick off the transfer. + /// + /// Does nothing if the transfer was already in progress. + fn begin_media_transfer( + &mut self, + cx: &mut Cx, + portal_list: &PortalListRef, + info: &DownloadableAttachment, + kind: TransferKind, + start: fn(DownloadableAttachment, TimelineUpdateSenderOption), + ) { + let Some(tl) = self.tl_state.as_mut() else { return }; + let mxc = media_source_mxc(&info.media_source); + if tl.pending_downloads.iter().any(|p| &p.mxc == mxc) { + enqueue_already_downloading_notification(); + return; + } + tl.pending_downloads.push(PendingDownload { + mxc: mxc.clone(), + state: PendingDownloadState::InProgress, + kind, + }); + portal_list.redraw(cx); + let update_sender = tl.media_cache.timeline_update_sender().cloned(); + start(info.clone(), update_sender); + } + /// Handles any [`MessageAction`]s received by this RoomScreen. fn handle_message_actions( &mut self, @@ -2384,20 +2421,10 @@ impl RoomScreen { // } MessageAction::DownloadAttachment(info) => { - let Some(tl) = self.tl_state.as_mut() else { continue }; - let mxc = media_source_mxc(&info.media_source); - // Prevent the same attachment from being downloaded more than once at a time. - if tl.pending_downloads.iter().any(|p| &p.mxc == mxc) { - enqueue_already_downloading_notification(); - continue; - } - tl.pending_downloads.push(PendingDownload { - mxc: mxc.clone(), - state: PendingDownloadState::InProgress, - }); - portal_list.redraw(cx); - let update_sender = tl.media_cache.timeline_update_sender().cloned(); - start_attachment_download(info.clone(), update_sender); + self.begin_media_transfer(cx, portal_list, info, TransferKind::Download, start_attachment_download); + } + MessageAction::ShareAttachment(info) => { + self.begin_media_transfer(cx, portal_list, info, TransferKind::Share, start_attachment_share); } MessageAction::CancelDownload(mxc) => { submit_async_request(MatrixRequest::CancelDownload(mxc.clone())); @@ -3386,8 +3413,7 @@ struct TimelineUiState { /// are contained within. If `None`, the room has not been tombstoned. tombstone_info: Option, - /// Media/file attachments in this timeline currently being downloaded. - /// the inline button's spinner state. + /// Media/file attachments in this timeline that are currently being downloaded. pending_downloads: SmallVec<[PendingDownload; 1]>, /// Reply previews the user has eaxpanded that should be shown in full. @@ -4106,7 +4132,7 @@ fn populate_message_view( let mxc = media_source_mxc(&info.media_source); pending_downloads.iter() .find(|p| &p.mxc == mxc) - .map(|p| p.state.display()) + .map(|p| p.state.display(p.kind)) }) .unwrap_or_default(); let is_reply_expanded = expanded_reply_previews.contains(&message_details.timeline_event_id); @@ -5289,6 +5315,8 @@ pub enum MessageAction { /// The user clicked the "Download" button on a media/file message. DownloadAttachment(DownloadableAttachment), + /// The user clicked the "Share" button on a media/file message. + ShareAttachment(DownloadableAttachment), /// User clicked the cancel × next to the in-progress spinner. CancelDownload(OwnedMxcUri), /// The message at the given item index in the timeline should be highlighted. @@ -5508,23 +5536,26 @@ impl Widget for Message { reply_collapse_button.reset_hover(cx); } - // Handle clicks on the "Download" button shown beneath media messages. - if let Some(info) = self.download_info.as_ref() - && self.view.button(cx, ids!(content.download_section.download_button)).clicked(actions) - { - cx.widget_action( - details.room_screen_widget_uid, - MessageAction::DownloadAttachment(info.clone()), - ); - } - // Cancel × shown next to the in-progress spinner. - if let Some(info) = self.download_info.as_ref() - && self.view.button(cx, ids!(content.download_section.downloading_view.cancel_button)).clicked(actions) - { - cx.widget_action( - details.room_screen_widget_uid, - MessageAction::CancelDownload(media_source_mxc(&info.media_source).clone()), - ); + // Handle clicks on the media-related buttons (download, share, cancel) beneath media messages. + if let Some(info) = self.download_info.as_ref() { + if self.view.button(cx, ids!(content.download_section.download_button)).clicked(actions) { + cx.widget_action( + details.room_screen_widget_uid, + MessageAction::DownloadAttachment(info.clone()), + ); + } + if self.view.button(cx, ids!(content.download_section.share_button)).clicked(actions) { + cx.widget_action( + details.room_screen_widget_uid, + MessageAction::ShareAttachment(info.clone()), + ); + } + if self.view.button(cx, ids!(content.download_section.downloading_view.cancel_button)).clicked(actions) { + cx.widget_action( + details.room_screen_widget_uid, + MessageAction::CancelDownload(media_source_mxc(&info.media_source).clone()), + ); + } } } } @@ -5565,24 +5596,35 @@ impl Message { let section_visible = self.download_info.is_some(); self.view.view(cx, ids!(content.download_section)).set_visible(cx, section_visible); - if let Some(info) = self.download_info.as_ref() { - let download_button = self.view.button(cx, ids!(content.download_section.download_button)); + if section_visible { + let download_button = self.view.button(cx, ids!(content.download_section.download_button)); + let share_button = self.view.button(cx, ids!(content.download_section.share_button)); let downloading_view = self.view.view(cx, ids!(content.download_section.downloading_view)); - let cancel_button = self.view.button(cx, ids!(content.download_section.downloading_view.cancel_button)); - let success_button = self.view.button(cx, ids!(content.download_section.success_button)); - let failure_button = self.view.button(cx, ids!(content.download_section.failure_button)); - download_button.set_text(cx, info.kind.button_text()); - download_button.set_visible(cx, matches!(download_state, DownloadDisplayState::Idle)); + let cancel_button = self.view.button(cx, ids!(content.download_section.downloading_view.cancel_button)); + let success_button = self.view.button(cx, ids!(content.download_section.success_button)); + let failure_button = self.view.button(cx, ids!(content.download_section.failure_button)); + let is_idle = matches!(download_state, DownloadDisplayState::Idle); + download_button.set_visible(cx, is_idle); + share_button.set_visible(cx, is_idle); downloading_view.set_visible(cx, matches!(download_state, DownloadDisplayState::InProgress)); - success_button.set_visible(cx, matches!(download_state, DownloadDisplayState::Succeeded)); + success_button.set_visible(cx, matches!(download_state, DownloadDisplayState::Succeeded(_))); failure_button.set_visible(cx, matches!(download_state, DownloadDisplayState::Failed)); - // Only reset hover for the button that is just now becoming visible. + if let DownloadDisplayState::Succeeded(kind) = download_state { + success_button.set_text(cx, match kind { + TransferKind::Download => "Downloaded", + TransferKind::Share => "Shared", + }); + } + // Only reset hover for the button(s) just now becoming visible. let newly_visible = !prev_section_visible || prev_state != download_state; if newly_visible { match download_state { - DownloadDisplayState::Idle => download_button.reset_hover(cx), + DownloadDisplayState::Idle => { + download_button.reset_hover(cx); + share_button.reset_hover(cx); + } DownloadDisplayState::InProgress => cancel_button.reset_hover(cx), - DownloadDisplayState::Succeeded => success_button.reset_hover(cx), + DownloadDisplayState::Succeeded(_) => success_button.reset_hover(cx), DownloadDisplayState::Failed => failure_button.reset_hover(cx), } } diff --git a/src/shared/attachment_download.rs b/src/shared/attachment_download.rs index b60c912a8..7f8609744 100644 --- a/src/shared/attachment_download.rs +++ b/src/shared/attachment_download.rs @@ -3,13 +3,15 @@ use std::{sync::Arc, time::Duration}; use makepad_widgets::SignalToUI; use matrix_sdk::ruma::{OwnedMxcUri, events::room::MediaSource}; +use robius_share::ShareSheet; use crate::{ home::room_screen::TimelineUpdate, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, submit_async_request}, + temp_storage::get_temp_dir_path, }; -type TimelineUpdateSenderOption = Option>; +pub type TimelineUpdateSenderOption = Option>; /// The result of a download request. pub enum MediaDownloadResult { @@ -31,26 +33,33 @@ pub fn media_source_mxc(source: &MediaSource) -> &OwnedMxcUri { } } -/// Info about a download that has begun or recently completed. +/// Whether a pending transfer is a plain download or a share. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum TransferKind { + Download, + Share, +} + +/// Info about a download or share that has begun or recently completed. pub struct PendingDownload { pub mxc: OwnedMxcUri, pub state: PendingDownloadState, + pub kind: TransferKind, } pub enum PendingDownloadState { - /// The download request has been submitted to and is being handled by - /// the backend worker task. + /// The 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. + /// It succeeded, and will show a success indicator for a few seconds. JustSucceeded, - /// The download failed, and will show an error indicator for a few seconds. + /// It failed, and will show an error indicator for a few seconds. JustFailed, } impl PendingDownloadState { - pub fn display(&self) -> DownloadDisplayState { + pub fn display(&self, kind: TransferKind) -> DownloadDisplayState { match self { Self::InProgress => DownloadDisplayState::InProgress, - Self::JustSucceeded => DownloadDisplayState::Succeeded, + Self::JustSucceeded => DownloadDisplayState::Succeeded(kind), Self::JustFailed => DownloadDisplayState::Failed, } } @@ -59,13 +68,13 @@ impl PendingDownloadState { /// 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: show the download and share buttons. #[default] Idle, /// Show a loading spinner and cancel button. InProgress, - /// Briefly show a green success button. - Succeeded, + /// Briefly show a green success button ("Downloaded" or "Shared"). + Succeeded(TransferKind), /// Briefly show a red failed button. Failed, } @@ -90,28 +99,22 @@ pub enum DownloadKind { Image, } impl DownloadKind { - pub fn button_text(&self) -> &'static str { + /// Returns the top-level MIME type for this kind of download. + fn basic_mime_type(&self) -> Option<&'static str> { match self { - Self::File => "Download File", - Self::Audio => "Download Audio", - Self::Video => "Download Video", - Self::Image => "Download Image", + Self::Image => Some("image/*"), + Self::Audio => Some("audio/*"), + Self::Video => Some("video/*"), + Self::File => None, } } } -/// Fetches the attachment bytes via the matrix worker, then shows the native -/// save picker dialog with those bytes. -/// -/// This *does* use the matrix SDK's media cache, so there's a good chance -/// that an attachment, especially small ones, will be instantly served from the cache. -/// -/// The download indicator stays in the "in-progress" state until everything is done. -/// We transition to "success" only if the file is saved, "idle" if the user cancels, -/// and "failure" if the download fails or write to storage fails. -pub fn start_attachment_download( +/// Fetches the attachment via the media cache and then calls `on_downloaded`. +fn download_media( info: DownloadableAttachment, update_sender: TimelineUpdateSenderOption, + on_downloaded: impl FnOnce(String, OwnedMxcUri, Vec, TimelineUpdateSenderOption) + Send + 'static, ) { let mxc = media_source_mxc(&info.media_source).clone(); let filename = info.filename.clone(); @@ -119,9 +122,7 @@ pub fn start_attachment_download( media_source: info.media_source, filename: info.filename, on_download_result: Box::new(move |result| match result { - MediaDownloadResult::Downloaded(bytes) => { - show_save_dialog(filename, bytes, Some(mxc), update_sender) - } + MediaDownloadResult::Downloaded(bytes) => on_downloaded(filename, mxc, bytes, update_sender), MediaDownloadResult::Failed(e) => { enqueue_popup_notification( format!("Failed to download \"{filename}\": {e}"), @@ -131,15 +132,89 @@ pub fn start_attachment_download( finish_download_indicator(&update_sender, Some(&mxc), DownloadOutcome::Failed); } MediaDownloadResult::Cancelled => { - finish_download_indicator(&update_sender, Some(&mxc), DownloadOutcome::Cancelled); + finish_download_indicator(&update_sender, Some(&mxc), DownloadOutcome::Cancelled) } }), }); } +/// Downloads the attachment and shows the native save dialog for it. +pub fn start_attachment_download( + info: DownloadableAttachment, + update_sender: TimelineUpdateSenderOption, +) { + download_media(info, update_sender, |filename, mxc, bytes, sender| { + show_save_dialog(filename, bytes, Some(mxc), sender); + }); +} + /// Saves an attachment already in memory directly to storage, without showing any dialog. -pub fn save_loaded_attachment(info: DownloadableAttachment, bytes: Arc<[u8]>) { - show_save_dialog(info.filename, bytes, None, None); +pub fn save_loaded_attachment(filename: String, bytes: Arc<[u8]>) { + show_save_dialog(filename, bytes, None, None); +} + +/// Downloads the attachment and opens the native share sheet for it (via a temp file, +/// since sharing needs a real path). +pub fn start_attachment_share( + info: DownloadableAttachment, + update_sender: TimelineUpdateSenderOption, +) { + let mime = info.kind.basic_mime_type(); + download_media(info, update_sender, move |filename, mxc, bytes, sender| { + // Success shows a "Shared" indicator; a share-step failure (already shown as + // a popup) just resets, since the download itself did succeed. + let outcome = if share_bytes(&filename, &mxc, &bytes, mime) { + DownloadOutcome::Succeeded + } else { + DownloadOutcome::Cancelled + }; + finish_download_indicator(&sender, Some(&mxc), outcome); + }); +} + +/// Opens the native share sheet for an attachment already in memory. +pub fn share_loaded_attachment(info: &DownloadableAttachment, bytes: Arc<[u8]>) { + let mxc = media_source_mxc(&info.media_source).clone(); + let mime = info.kind.basic_mime_type(); + let filename = info.filename.clone(); + // Don't write a large attachment file to disk on the main UI thread. + std::thread::spawn(move || share_bytes(&filename, &mxc, &bytes, mime)); +} + +/// Writes the given `bytes` to a temp file (based on `filename`) and presents the native share sheet for it. +/// +/// This is required because sharing a file requires a real path. +/// +/// Returns true if the share sheet was shown. Enqueues a popup error upon any failure. +fn share_bytes(filename: &str, mxc: &OwnedMxcUri, bytes: &[u8], mime: Option<&str>) -> bool { + let mut safe_name = sanitize_filename::sanitize(filename); + if safe_name.is_empty() { + safe_name = "shared_file".to_owned(); + } + let dir = get_temp_dir_path().join(sanitize_filename::sanitize(mxc.as_str())); + let path = dir.join(safe_name); + if let Err(e) = std::fs::create_dir_all(&dir).and_then(|()| std::fs::write(&path, bytes)) { + enqueue_popup_notification( + format!("Failed to prepare \"{filename}\" for sharing: {e}"), + PopupKind::Error, + None, + ); + return false; + } + let sheet = ShareSheet::new().set_title(format!("Share {filename}")); + let sheet = match mime { + Some(mime) => sheet.add_file_with_mime_type(&path, mime), + None => sheet.add_file(&path), + }; + if let Err(e) = sheet.share() { + enqueue_popup_notification( + format!("Couldn't open the share sheet for \"{filename}\": {e}"), + PopupKind::Error, + None, + ); + return false; + } + true } enum DownloadOutcome { @@ -207,8 +282,8 @@ fn finish_download_indicator( mxc: Option<&OwnedMxcUri>, outcome: DownloadOutcome, ) { - let Some(sender) = update_sender.as_ref() else { return }; let Some(mxc) = mxc else { return }; + let Some(sender) = update_sender.as_ref() else { return }; match outcome { DownloadOutcome::Cancelled => { let _ = sender.send(TimelineUpdate::AttachmentDownloadReset(mxc.clone())); diff --git a/src/shared/image_viewer.rs b/src/shared/image_viewer.rs index f25291c01..faf38bd82 100644 --- a/src/shared/image_viewer.rs +++ b/src/shared/image_viewer.rs @@ -15,7 +15,7 @@ use matrix_sdk_ui::timeline::EventTimelineItem; use crate::utils::format_decimal_file_size; use thiserror::Error; use crate::{ - shared::{attachment_download::{DownloadableAttachment, save_loaded_attachment, start_attachment_download}, avatar::AvatarWidgetExt, timestamp::TimestampWidgetRefExt}, + shared::{attachment_download::{DownloadableAttachment, save_loaded_attachment, share_loaded_attachment, start_attachment_download, start_attachment_share}, avatar::AvatarWidgetExt, timestamp::TimestampWidgetRefExt}, sliding_sync::TimelineKind, }; @@ -320,6 +320,11 @@ script_mod! { icon_walk: Walk{width: 24, height: 24} } + share_button := mod.widgets.ImageViewerButton { + draw_icon +: { svg: (ICON_SHARE) } + icon_walk: Walk{width: 22, height: 22} + } + close_button := mod.widgets.ImageViewerButton { draw_icon +: { svg: (ICON_CLOSE) } icon_walk: Walk{width: 21, height: 21 } @@ -734,14 +739,22 @@ impl MatchEvent for ImageViewer { self.start_rotation(cx, -90.0); } - 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(info, bytes); - } else { - start_attachment_download(info, None); + if let Some(info) = self.downloadable.as_ref() { + if self.view.button(cx, ids!(download_button)).clicked(actions) { + was_overlay_button_clicked = true; + if let Some(bytes) = self.loaded_bytes.clone() { + save_loaded_attachment(info.filename.clone(), bytes); + } else { + start_attachment_download(info.clone(), None); + } + } + if self.view.button(cx, ids!(share_button)).clicked(actions) { + was_overlay_button_clicked = true; + if let Some(bytes) = self.loaded_bytes.clone() { + share_loaded_attachment(info, bytes); + } else { + start_attachment_share(info.clone(), None); + } } } diff --git a/src/shared/styles.rs b/src/shared/styles.rs index ce5e3ef32..454db2d6e 100644 --- a/src/shared/styles.rs +++ b/src/shared/styles.rs @@ -40,6 +40,7 @@ script_mod! { mod.widgets.ICON_SEND_ENCRYPTED = crate_resource("self://resources/icons/send_encrypted.svg") mod.widgets.ICON_SEND_UNENCRYPTED = crate_resource("self://resources/icons/send_unencrypted.svg") mod.widgets.ICON_SETTINGS = crate_resource("self://resources/icons/settings.svg") + mod.widgets.ICON_SHARE = crate_resource("self://resources/icons/share.svg") mod.widgets.ICON_SQUARES = crate_resource("self://resources/icons/squares_filled.svg") mod.widgets.ICON_TOMBSTONE = crate_resource("self://resources/icons/tombstone.svg") mod.widgets.ICON_TRASH = crate_resource("self://resources/icons/trash.svg") diff --git a/src/temp_storage.rs b/src/temp_storage.rs index 9142020c6..7276426b1 100644 --- a/src/temp_storage.rs +++ b/src/temp_storage.rs @@ -1,19 +1,51 @@ -use std::{sync::OnceLock, path::PathBuf}; +use std::{path::{Path, PathBuf}, sync::OnceLock, time::{Duration, SystemTime}}; +use makepad_widgets::error; +use crate::cache_dir; +/// Subdirectory of the app cache dir used for short-lived scratch files. +const TEMP_SUBDIR: &str = "temp"; -/// Creates and returns the path to a temp directory for storage. +/// How long to wait after startup before running the temp dir cleanup task. +const CLEANUP_DELAY: Duration = Duration::from_secs(60); + +/// Creates and returns the path to an app-local temp directory, +/// a subdirectory within the platform-designated cache dir for this app. /// -/// This is very efficient to call multiple times because the result is cached -/// after the first call creates the temp directory. +/// This is cheap to repeatedly call, it only does the directory work once. pub fn get_temp_dir_path() -> &'static PathBuf { - const TEMP_DIR_NAME: &str = "robrix_temp"; static TEMP_DIR_PATH: OnceLock = OnceLock::new(); - TEMP_DIR_PATH.get_or_init(|| { - let mut path = std::env::temp_dir(); - path.push(TEMP_DIR_NAME); - std::fs::create_dir_all(&path).expect("Failed to create temp dir: {path}"); + let path = cache_dir().join(TEMP_SUBDIR); + if let Err(e) = std::fs::create_dir_all(&path) { + error!("Failed to create temp dir {}: {e}", path.display()); + } path }) } +/// Schedules a task to clear leftover temp files from previous app runs. +/// +/// We do it later to avoid interfering with important operations at startup, like sync. +pub fn schedule_temp_dir_cleanup() { + let path = cache_dir().join(TEMP_SUBDIR); + let cutoff = SystemTime::now(); + std::thread::spawn(move || { + std::thread::sleep(CLEANUP_DELAY); + remove_files_older_than(&path, cutoff); + }); +} + +/// Recursively deletes files last modified before `cutoff`, then any empty dirs. +fn remove_files_older_than(dir: &Path, cutoff: SystemTime) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + remove_files_older_than(&path, cutoff); + let _ = std::fs::remove_dir(&path); // only succeeds once it's empty + } else if meta.modified().is_ok_and(|m| m < cutoff) { + let _ = std::fs::remove_file(&path); + } + } +}