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);
+ }
+ }
+}