diff --git a/resources/icons/download.svg b/resources/icons/download.svg
new file mode 100644
index 00000000..46f9e602
--- /dev/null
+++ b/resources/icons/download.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs
index 66307ce3..55086ba0 100644
--- a/src/home/room_screen.rs
+++ b/src/home/room_screen.rs
@@ -32,7 +32,7 @@ use crate::{
},
room::{BasicRoomDetails, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, typing_notice::TypingNoticeWidgetExt},
shared::{
- 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, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt
+ attachment_download::{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, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageStatus, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt
},
sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime}
};
@@ -87,6 +87,75 @@ script_mod! {
// An empty view that takes up no space in the portal list.
mod.widgets.Empty = View { }
+ // A download button or loading spinner shown beneath a message.
+ mod.widgets.MessageDownloadSection = View {
+ visible: false,
+ width: Fit, height: Fit,
+ flow: Right,
+ margin: Inset{top: 8, bottom: 2}
+
+ download_button := RobrixIconButton {
+ height: mod.widgets.SETTINGS_BUTTON_HEIGHT,
+ padding: Inset{left: 12, right: 12}
+ margin: 0
+ draw_icon.svg: (ICON_DOWNLOAD)
+ icon_walk: Walk{width: 16, height: 16}
+ text: "Download"
+ }
+
+ downloading_view := View {
+ visible: false,
+ width: Fit, height: mod.widgets.SETTINGS_BUTTON_HEIGHT
+ flow: Right,
+ align: Align{y: 0.5}
+ spacing: 8,
+ padding: Inset{left: 12, right: 6}
+
+ spinner := LoadingSpinner {
+ width: 16, height: 16
+ draw_bg.color: (COLOR_ACTIVE_PRIMARY)
+ }
+ status_label := Label {
+ width: Fit, height: Fit,
+ padding: 0
+ margin: 0
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 11 },
+ color: (COLOR_ACTIVE_PRIMARY)
+ }
+ text: "Downloading…"
+ }
+ cancel_button := RobrixNegativeIconButton {
+ height: mod.widgets.SETTINGS_BUTTON_HEIGHT,
+ padding: Inset{left: 12, right: 12}
+ margin: 0
+ draw_icon.svg: (ICON_CLOSE)
+ icon_walk: Walk{width: 16, height: 16}
+ text: "Cancel"
+ }
+ }
+
+ success_button := RobrixPositiveIconButton {
+ visible: false,
+ height: mod.widgets.SETTINGS_BUTTON_HEIGHT,
+ padding: Inset{left: 12, right: 12}
+ margin: 0
+ draw_icon.svg: (ICON_CHECKMARK)
+ icon_walk: Walk{width: 16, height: 16}
+ text: "Downloaded"
+ }
+
+ failure_button := RobrixNegativeIconButton {
+ visible: false,
+ height: mod.widgets.SETTINGS_BUTTON_HEIGHT,
+ padding: Inset{left: 12, right: 12}
+ margin: 0
+ draw_icon.svg: (ICON_CLOSE)
+ icon_walk: Walk{width: 16, height: 16}
+ text: "Download Failed"
+ }
+ }
+
// A summary at the bottom of a message that is the root of a thread.
mod.widgets.ThreadRootSummary = RoundedView {
visible: false
@@ -270,6 +339,7 @@ script_mod! {
message := HtmlOrPlaintext { }
link_preview_view := mod.widgets.LinkPreview {}
+ download_section := mod.widgets.MessageDownloadSection {}
View {
width: Fill,
height: Fit
@@ -315,6 +385,7 @@ script_mod! {
message := HtmlOrPlaintext { }
link_preview_view := mod.widgets.LinkPreview {}
+ download_section := mod.widgets.MessageDownloadSection {}
View {
width: Fill,
height: Fit
@@ -358,6 +429,7 @@ script_mod! {
height: (mod.widgets.IMG_MSG_FIT)
} }
}
+ download_section := mod.widgets.MessageDownloadSection {}
View {
width: Fill,
height: Fit,
@@ -385,6 +457,7 @@ script_mod! {
height: (mod.widgets.IMG_MSG_FIT)
} }
}
+ download_section := mod.widgets.MessageDownloadSection {}
View {
width: Fill,
height: Fit,
@@ -843,14 +916,21 @@ impl Widget for RoomScreen {
}
}
- // When transitioning from offline to online, clear stale `Requested`/`Failed`
- // entries from per-room caches so they can be re-fetched.
+ // When transitioning from offline to online, abort all pending downloads
+ // and clear stale `Requested`/`Failed` entries from per-room caches so they can be re-fetched.
if let Some(RoomsListHeaderAction::StateUpdate(new_state)) = action.downcast_ref() {
- if !matches!(new_state, State::Offline) {
+ if matches!(new_state, State::Offline) {
if let Some(tl) = self.tl_state.as_mut() {
- tl.media_cache.clear_all_pending_and_failed_requests();
- tl.link_preview_cache.clear_all_pending_and_failed_requests();
+ // Tell the worker to abort every in-flight download
+ // for this room before we drop them from local state.
+ for entry in tl.pending_downloads.drain(..) {
+ submit_async_request(MatrixRequest::CancelDownload(entry.mxc));
+ }
+ self.view.portal_list(cx, ids!(timeline.list)).redraw(cx);
}
+ } else if let Some(tl) = self.tl_state.as_mut() {
+ tl.media_cache.clear_all_pending_and_failed_requests();
+ tl.link_preview_cache.clear_all_pending_and_failed_requests();
}
continue;
}
@@ -1166,6 +1246,7 @@ impl Widget for RoomScreen {
&mut tl_state.pending_thread_summary_fetches,
&tl_state.user_power,
&self.pinned_events,
+ &tl_state.pending_downloads,
item_drawn_status,
room_screen_widget_uid,
)
@@ -1657,6 +1738,19 @@ impl RoomScreen {
self.view.room_input_bar(cx, ids!(room_input_bar))
.hide_upload_progress(cx, upload_id);
}
+ TimelineUpdate::AttachmentDownloadFinished(mxc, result) => {
+ if let Some(entry) = tl.pending_downloads.iter_mut().find(|p| p.mxc == mxc) {
+ entry.state = match result {
+ Ok(()) => PendingDownloadState::JustSucceeded,
+ Err(_) => PendingDownloadState::JustFailed,
+ };
+ }
+ portal_list.redraw(cx);
+ }
+ TimelineUpdate::AttachmentDownloadReset(mxc) => {
+ tl.pending_downloads.retain(|p| p.mxc != mxc);
+ portal_list.redraw(cx);
+ }
}
}
@@ -1824,6 +1918,12 @@ impl RoomScreen {
let timestamp_millis = event_tl_item.timestamp();
let (image_name, image_file_size) = get_image_name_and_filesize(event_tl_item);
+ let downloadable = Some(DownloadableAttachment {
+ media_source: media_source.clone(),
+ filename: image_name.clone(),
+ size: (image_file_size > 0).then_some(image_file_size),
+ kind: DownloadKind::Image,
+ });
cx.action(ImageViewerAction::Show(LoadState::Loading(
texture.clone(),
Some(ImageViewerMetaData {
@@ -1834,6 +1934,7 @@ impl RoomScreen {
tl_state.kind.clone(),
event_tl_item.clone(),
)),
+ downloadable,
}),
)));
@@ -2145,6 +2246,30 @@ impl RoomScreen {
// // TODO
// }
+ 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) {
+ 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(cx, info.clone(), update_sender);
+ }
+ MessageAction::CancelDownload(mxc) => {
+ submit_async_request(MatrixRequest::CancelDownload(mxc.clone()));
+ if let Some(tl) = self.tl_state.as_mut()
+ && let Some(i) = tl.pending_downloads.iter().position(|p| &p.mxc == mxc)
+ {
+ tl.pending_downloads.swap_remove(i);
+ portal_list.redraw(cx);
+ }
+ }
// This is handled within the Message widget itself.
MessageAction::HighlightMessage(..) => { }
// This is handled by the top-level App itself.
@@ -2325,6 +2450,7 @@ impl RoomScreen {
scrolled_past_read_marker: false,
latest_own_user_receipt: None,
tombstone_info,
+ pending_downloads: SmallVec::new(),
};
timeline_state_store::mark_taken(&tl_state.kind, owner);
(tl_state, true)
@@ -2856,6 +2982,12 @@ pub enum TimelineUpdate {
FileUploadComplete {
upload_id: FileUploadAttemptId,
},
+ /// Download finished. `Ok` if bytes hit disk, `Err(msg)` otherwise.
+ /// The inline button briefly shows a success/failure indicator, then
+ /// `AttachmentDownloadReset` clears the entry from `pending_downloads`.
+ AttachmentDownloadFinished(OwnedMxcUri, Result<(), String>),
+ /// Drop the entry so the inline button goes back to its default "Download …" label.
+ AttachmentDownloadReset(OwnedMxcUri),
}
/// Stores timeline UI state that is not currently owned by a `RoomScreen`.
@@ -3065,6 +3197,10 @@ struct TimelineUiState {
/// If `Some`, this room has been tombstoned and the details of its successor room
/// 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.
+ pending_downloads: SmallVec<[PendingDownload; 1]>,
}
#[derive(Default, Debug)]
@@ -3195,6 +3331,7 @@ fn populate_message_view(
pending_thread_summary_fetches: &mut HashSet,
user_power_levels: &UserPowerLevels,
pinned_events: &[OwnedEventId],
+ pending_downloads: &[PendingDownload],
item_drawn_status: ItemDrawnStatus,
room_screen_widget_uid: WidgetUid,
) -> (WidgetRef, ItemDrawnStatus) {
@@ -3222,10 +3359,12 @@ fn populate_message_view(
let has_html_body: bool;
- // Sometimes we need to call this up-front, so we save the result in this variable
- // to avoid having to call it twice.
+ // Sometimes we need to get the username/avatar up-front,
+ // so we save that here to avoid calling the function twice.
let mut set_username_and_get_avatar_retval = None;
let mut has_room_mention = false;
+ let mut download_info: Option = None;
+
let (item, used_cached_item) = match &msg_like_content.kind {
MsgLikeKind::Message(msg) => {
let room_mention_room_id = if msg.mentions().is_some_and(|m| m.room) {
@@ -3420,22 +3559,34 @@ fn populate_message_view(
id!(ImageMessage)
};
let (item, existed) = list.item_with_existed(cx, item_id, template);
- if existed && item_drawn_status.content_drawn {
- (item, true)
+ let was_cached = existed && item_drawn_status.content_drawn;
+ let text_or_image_ref = item.text_or_image(cx, ids!(content.message));
+ let fallback = if was_cached {
+ // Cached path re-reads the status the widget already has.
+ matches!(text_or_image_ref.status(), TextOrImageStatus::Text)
+ .then(|| DownloadableAttachment {
+ media_source: image.source.clone(),
+ filename: image.filename().to_owned(),
+ size: image.info.as_ref().and_then(|i| i.size).map(u64::from),
+ kind: DownloadKind::Image,
+ })
} else {
- let image_info = image.info.clone();
- let text_or_image_ref = item.text_or_image(cx, ids!(content.message));
- let is_image_fully_drawn = populate_image_message_content(
+ let (is_image_fully_drawn, fallback) = populate_image_message_content_with_fallback(
cx,
&text_or_image_ref,
- image_info,
+ image.info.clone(),
image.source.clone(),
msg.body(),
media_cache,
+ image.filename().to_owned(),
+ image.info.as_ref().and_then(|i| i.size).map(u64::from),
+ DownloadKind::Image,
);
new_drawn_status.content_drawn = is_image_fully_drawn;
- (item, false)
- }
+ fallback
+ };
+ download_info = fallback;
+ (item, was_cached)
}
MessageType::Location(location) => {
has_html_body = false;
@@ -3461,6 +3612,12 @@ fn populate_message_view(
}
MessageType::File(file_content) => {
has_html_body = file_content.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html);
+ download_info = Some(DownloadableAttachment {
+ media_source: file_content.source.clone(),
+ filename: file_content.filename().to_owned(),
+ size: file_content.info.as_ref().and_then(|i| i.size).map(u64::from),
+ kind: DownloadKind::File,
+ });
let template = if use_compact_view {
id!(CondensedMessage)
} else {
@@ -3482,6 +3639,12 @@ fn populate_message_view(
}
MessageType::Audio(audio) => {
has_html_body = audio.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html);
+ download_info = Some(DownloadableAttachment {
+ media_source: audio.source.clone(),
+ filename: audio.filename().to_owned(),
+ size: audio.info.as_ref().and_then(|i| i.size).map(u64::from),
+ kind: DownloadKind::Audio,
+ });
let template = if use_compact_view {
id!(CondensedMessage)
} else {
@@ -3503,6 +3666,12 @@ fn populate_message_view(
}
MessageType::Video(video) => {
has_html_body = video.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html);
+ download_info = Some(DownloadableAttachment {
+ media_source: video.source.clone(),
+ filename: video.filename().to_owned(),
+ size: video.info.as_ref().and_then(|i| i.size).map(u64::from),
+ kind: DownloadKind::Video,
+ });
let template = if use_compact_view {
id!(CondensedMessage)
} else {
@@ -3580,35 +3749,59 @@ fn populate_message_view(
MsgLikeKind::Sticker(sticker) => {
has_html_body = false;
let StickerEventContent { body, info, source, .. } = sticker.content();
-
let template = if use_compact_view {
id!(CondensedImageMessage)
} else {
id!(ImageMessage)
};
let (item, existed) = list.item_with_existed(cx, item_id, template);
-
- if existed && item_drawn_status.content_drawn {
- (item, true)
- } else {
- if let StickerMediaSource::Plain(owned_mxc_url) = source {
- let image_info = info;
- let text_or_image_ref = item.text_or_image(cx, ids!(content.message));
- let is_image_fully_drawn = populate_image_message_content(
- cx,
- &text_or_image_ref,
- Some(Box::new(image_info.clone())),
- MediaSource::Plain(owned_mxc_url.clone()),
- body,
- media_cache,
- );
- new_drawn_status.content_drawn = is_image_fully_drawn;
- (item, false)
- } else {
- (item, true)
+ let was_cached = existed && item_drawn_status.content_drawn;
+
+ let text_or_image_ref = item.text_or_image(cx, ids!(content.message));
+ match source {
+ StickerMediaSource::Plain(owned_mxc_url) => {
+ let filename = if body.is_empty() { "sticker".to_owned() } else { body.clone() };
+ let size = info.size.map(u64::from);
+ download_info = if was_cached {
+ matches!(text_or_image_ref.status(), TextOrImageStatus::Text)
+ .then(|| DownloadableAttachment {
+ media_source: MediaSource::Plain(owned_mxc_url.clone()),
+ filename,
+ size,
+ kind: DownloadKind::Image,
+ })
+ } else {
+ let (is_image_fully_drawn, fallback) = populate_image_message_content_with_fallback(
+ cx,
+ &text_or_image_ref,
+ Some(Box::new(info.clone())),
+ MediaSource::Plain(owned_mxc_url.clone()),
+ body,
+ media_cache,
+ filename,
+ size,
+ DownloadKind::Image,
+ );
+ new_drawn_status.content_drawn = is_image_fully_drawn;
+ fallback
+ };
+ }
+ // Encrypted sticker decryption isn't wired up yet; show a
+ // placeholder so the message doesn't render blank.
+ _ => {
+ if !was_cached {
+ let label = if body.is_empty() {
+ "[Encrypted sticker]".to_owned()
+ } else {
+ format!("[Encrypted sticker: {body}]")
+ };
+ text_or_image_ref.show_text(cx, label);
+ new_drawn_status.content_drawn = true;
+ }
}
}
- }
+ (item, was_cached)
+ }
// Handle messages that have been redacted (deleted).
MsgLikeKind::Redacted => {
has_html_body = false;
@@ -3696,8 +3889,8 @@ fn populate_message_view(
}
- // We must always re-set the message details, even when re-using a cached portallist item,
- // because the item type might be the same but for a different message entirely.
+ // Re-set even for cached items: the portal list recycles widgets, so
+ // the same template might now be showing a totally different message.
let message_details = MessageDetails {
thread_root_event_id: msg_like_content.thread_root.clone().or_else(|| {
msg_like_content.thread_summary.as_ref()
@@ -3716,7 +3909,15 @@ fn populate_message_view(
),
should_be_highlighted: event_tl_item.is_highlighted() || has_room_mention,
};
- item.as_message().set_data(message_details);
+ let download_state = download_info.as_ref()
+ .and_then(|info| {
+ let mxc = media_source_mxc(&info.media_source);
+ pending_downloads.iter()
+ .find(|p| &p.mxc == mxc)
+ .map(|p| p.state.display())
+ })
+ .unwrap_or_default();
+ item.as_message().set_data(cx, message_details, download_info, download_state);
// If `used_cached_item` is false, we should always redraw the profile, even if profile_drawn is true.
@@ -3887,9 +4088,40 @@ fn populate_text_message_content(
}
}
-/// Draws the given image message's content into the `message_content_widget`.
+/// Like `populate_image_message_content`, but also returns metadata
+/// about how to download the image if we were unable to show a preview of it.
+fn populate_image_message_content_with_fallback(
+ cx: &mut Cx,
+ text_or_image_ref: &TextOrImageRef,
+ image_info_source: Option>,
+ original_source: MediaSource,
+ body: &str,
+ media_cache: &mut MediaCache,
+ filename: String,
+ size: Option,
+ kind: DownloadKind,
+) -> (bool, Option) {
+ let fully_drawn = populate_image_message_content(
+ cx,
+ text_or_image_ref,
+ image_info_source,
+ original_source.clone(),
+ body,
+ media_cache,
+ );
+ let fallback = matches!(text_or_image_ref.status(), TextOrImageStatus::Text)
+ .then(|| DownloadableAttachment {
+ media_source: original_source,
+ filename,
+ size,
+ kind,
+ });
+ (fully_drawn, fallback)
+}
+
+/// Draws an image into the given `text_or_image_ref`.
///
-/// Returns whether the image message content was fully drawn.
+/// Returns whether it was fully drawn (meaning its content was fully loaded/available).
fn populate_image_message_content(
cx: &mut Cx,
text_or_image_ref: &TextOrImageRef,
@@ -3898,8 +4130,6 @@ fn populate_image_message_content(
body: &str,
media_cache: &mut MediaCache,
) -> bool {
- // We don't use thumbnails, as their resolution is too low to be visually useful.
- // We also don't trust the provided mimetype, as it can be incorrect.
let (mimetype, _width, _height) = image_info_source.as_ref()
.map(|info| (info.mimetype.as_deref(), info.width, info.height))
.unwrap_or_default();
@@ -3910,7 +4140,7 @@ fn populate_image_message_content(
if ImageFormat::from_mimetype(mime).is_none() {
text_or_image_ref.show_text(
cx,
- format!("{body}\n\nUnsupported type {mime:?}"),
+ format!("{body}\nUnsupported type {mime:?}"),
);
return true; // consider this as fully drawn
}
@@ -4037,7 +4267,6 @@ fn populate_file_message_content(
message_content_widget: &HtmlOrPlaintextRef,
file_content: &FileMessageEventContent,
) -> bool {
- // Display the file name, human-readable size, caption, and a button to download it.
let filename = htmlize::escape_text(file_content.filename());
let size = file_content
.info
@@ -4047,15 +4276,13 @@ fn populate_file_message_content(
.unwrap_or_default();
let caption = file_content.formatted_caption()
.filter(|fb| fb.format == MessageFormat::Html)
- .map(|fb| format!("
{}", fb.body))
- .or_else(|| file_content.caption().map(|c| format!("
{}", htmlize::escape_text(c))))
+ .map(|fb| format!("{}
", fb.body))
+ .or_else(|| file_content.caption().map(|c| format!("{}
", htmlize::escape_text(c))))
.unwrap_or_default();
- // TODO: add a button to download the file
-
message_content_widget.show_html(
cx,
- format!("{filename}{size}{caption}
→ File download not yet supported."),
+ format!("File: {caption}{filename}{size}"),
);
true
}
@@ -4068,35 +4295,34 @@ fn populate_audio_message_content(
message_content_widget: &HtmlOrPlaintextRef,
audio: &AudioMessageEventContent,
) -> bool {
- // Display the file name, human-readable size, caption, and a button to download it.
let filename = htmlize::escape_text(audio.filename());
let (duration, mime, size) = audio
.info
.as_ref()
.map(|info| (
info.duration
- .map(|d| format!(" {:.2} sec,", d.as_secs_f64()))
+ .map(|d| format!(", {:.2} sec", d.as_secs_f64()))
.unwrap_or_default(),
info.mimetype
.as_ref()
.map(|m| format!(" {},", htmlize::escape_text(m)))
.unwrap_or_default(),
info.size
- .map(|bytes| format!(" ({}),", utils::format_decimal_file_size(bytes.into())))
+ .map(|bytes| format!(" ({})", utils::format_decimal_file_size(bytes.into())))
.unwrap_or_default(),
))
.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!("
{}", htmlize::escape_text(c))))
+ .map(|fb| format!("{}
", fb.body))
+ .or_else(|| audio.caption().map(|c| format!("{}
", htmlize::escape_text(c))))
.unwrap_or_default();
// TODO: add an audio to play the audio file
message_content_widget.show_html(
cx,
- format!("Audio: {filename}{mime}{duration}{size}{caption}
→ Audio playback not yet supported."),
+ format!("Audio: {caption}File: {filename}{size}{mime}{duration}
→ Video playback not yet supported."),
);
true
}
@@ -4110,38 +4336,37 @@ fn populate_video_message_content(
message_content_widget: &HtmlOrPlaintextRef,
video: &VideoMessageEventContent,
) -> bool {
- // Display the file name, human-readable size, caption, and a button to download it.
let filename = htmlize::escape_text(video.filename());
let (duration, mime, size, dimensions) = video
.info
.as_ref()
.map(|info| (
info.duration
- .map(|d| format!(" {:.2} sec,", d.as_secs_f64()))
+ .map(|d| format!(", {:.2} sec", d.as_secs_f64()))
.unwrap_or_default(),
info.mimetype
.as_ref()
- .map(|m| format!(" {},", htmlize::escape_text(m)))
+ .map(|m| format!(", {}", htmlize::escape_text(m)))
.unwrap_or_default(),
info.size
- .map(|bytes| format!(" ({}),", utils::format_decimal_file_size(bytes.into())))
+ .map(|bytes| format!(" ({})", utils::format_decimal_file_size(bytes.into())))
.unwrap_or_default(),
info.width.and_then(|width|
- info.height.map(|height| format!(" {width}x{height},"))
+ info.height.map(|height| format!(", {width}x{height}"))
).unwrap_or_default(),
))
.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!("
{}", htmlize::escape_text(c))))
+ .map(|fb| format!("{}
", fb.body))
+ .or_else(|| video.caption().map(|c| format!("{}
", htmlize::escape_text(c))))
.unwrap_or_default();
- // TODO: add an video to play the video file
+ // TODO: populate a video widget here, once makepad supports that
message_content_widget.show_html(
cx,
- format!("Video: {filename}{mime}{duration}{size}{dimensions}{caption}
→ Video playback not yet supported."),
+ format!("Video: {caption}File: {filename}{size}{mime}{duration}{dimensions}
→ Video playback not yet supported."),
);
true
}
@@ -4838,6 +5063,10 @@ pub enum MessageAction {
// /// The user clicked the "report" button on a message.
// Report(MessageDetails),
+ /// The user clicked the "Download" button on a media/file message.
+ DownloadAttachment(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.
HighlightMessage(usize),
/// The user requested that we show a context menu with actions
@@ -4876,6 +5105,13 @@ pub struct Message {
#[apply_default] animator: Animator,
#[rust] details: Option,
+ /// Set on file/image/audio/video messages so the download button knows
+ /// what to save when the user clicks it. `None` for plain text messages,
+ /// which hide the download button entirely.
+ #[rust] download_info: Option,
+ /// Cached so `set_data` can reset_hover only on the button that just
+ /// transitioned into visibility, not on every redraw.
+ #[rust] download_state: DownloadDisplayState,
}
impl Widget for Message {
@@ -5027,6 +5263,25 @@ impl Widget for Message {
_ => {}
}
}
+
+ // 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()),
+ );
+ }
}
}
@@ -5045,15 +5300,59 @@ impl Widget for Message {
}
impl Message {
- fn set_data(&mut self, details: MessageDetails) {
+ /// Called every time `populate_message_view` runs, including on cached
+ /// items, so all state must be re-set unconditionally.
+ fn set_data(
+ &mut self,
+ cx: &mut Cx,
+ details: MessageDetails,
+ download_info: Option,
+ download_state: DownloadDisplayState,
+ ) {
+ let prev_section_visible = self.download_info.is_some();
+ let prev_state = self.download_state;
+
self.details = Some(details);
+ self.download_info = download_info;
+
+ 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));
+ 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));
+ downloading_view.set_visible(cx, matches!(download_state, DownloadDisplayState::InProgress));
+ 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.
+ let newly_visible = !prev_section_visible || prev_state != download_state;
+ if newly_visible {
+ match download_state {
+ DownloadDisplayState::Idle => download_button.reset_hover(cx),
+ DownloadDisplayState::InProgress => cancel_button.reset_hover(cx),
+ DownloadDisplayState::Succeeded => success_button.reset_hover(cx),
+ DownloadDisplayState::Failed => failure_button.reset_hover(cx),
+ }
+ }
+ }
+ self.download_state = download_state;
}
}
impl MessageRef {
- fn set_data(&self, details: MessageDetails) {
+ fn set_data(
+ &self,
+ cx: &mut Cx,
+ details: MessageDetails,
+ download_info: Option,
+ download_state: DownloadDisplayState,
+ ) {
let Some(mut inner) = self.borrow_mut() else { return };
- inner.set_data(details);
+ inner.set_data(cx, details, download_info, download_state);
}
}
diff --git a/src/media_cache.rs b/src/media_cache.rs
index 672a16cf..99aac707 100644
--- a/src/media_cache.rs
+++ b/src/media_cache.rs
@@ -66,6 +66,10 @@ impl MediaCache {
}
}
+ pub fn timeline_update_sender(&self) -> Option<&crossbeam_channel::Sender> {
+ 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,
diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs
index 41cb6f94..02184b48 100644
--- a/src/room/room_input_bar.rs
+++ b/src/room/room_input_bar.rs
@@ -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) => {
@@ -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,
+ },
}
}
diff --git a/src/shared/attachment_download.rs b/src/shared/attachment_download.rs
new file mode 100644
index 00000000..61fe27fa
--- /dev/null
+++ b/src/shared/attachment_download.rs
@@ -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,
+ 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>,
+) {
+ 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>,
+) {
+ 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),
+ );
+}
diff --git a/src/shared/image_viewer.rs b/src/shared/image_viewer.rs
index fa70bfa9..0b8ab139 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::{avatar::AvatarWidgetExt, timestamp::TimestampWidgetRefExt},
+ shared::{attachment_download::{DownloadableAttachment, save_loaded_attachment, start_attachment_download}, avatar::AvatarWidgetExt, timestamp::TimestampWidgetRefExt},
sliding_sync::TimelineKind,
};
@@ -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 }
@@ -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,
+ /// A reference to the image being shown so we can easily save it to storage.
+ #[rust] loaded_bytes: Option>,
}
impl Widget for ImageViewer {
@@ -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);
@@ -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 => {
@@ -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;
@@ -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,
@@ -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,
}
diff --git a/src/shared/mod.rs b/src/shared/mod.rs
index 4f31b80d..20be2eb9 100644
--- a/src/shared/mod.rs
+++ b/src/shared/mod.rs
@@ -1,5 +1,6 @@
use makepad_widgets::ScriptVm;
+pub mod attachment_download;
pub mod avatar;
pub mod collapsible_header;
pub mod expand_arrow;
diff --git a/src/shared/styles.rs b/src/shared/styles.rs
index d861ad60..ee731d81 100644
--- a/src/shared/styles.rs
+++ b/src/shared/styles.rs
@@ -17,6 +17,7 @@ script_mod! {
mod.widgets.ICON_ROTATE_CW = crate_resource("self://resources/icons/rotate_right_fa.svg")
mod.widgets.ICON_ROTATE_CCW = crate_resource("self://resources/icons/rotate_left_fa.svg")
mod.widgets.ICON_COPY = crate_resource("self://resources/icons/copy.svg")
+ mod.widgets.ICON_DOWNLOAD = crate_resource("self://resources/icons/download.svg")
mod.widgets.ICON_EDIT = crate_resource("self://resources/icons/edit.svg")
mod.widgets.ICON_EXTERNAL_LINK = crate_resource("self://resources/icons/external_link.svg")
mod.widgets.ICON_IMPORT = crate_resource("self://resources/icons/import.svg")
diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs
index 3c1cec83..52b9bcb1 100644
--- a/src/sliding_sync.rs
+++ b/src/sliding_sync.rs
@@ -711,6 +711,17 @@ pub enum MatrixRequest {
destination: Arc>,
update_sender: Option>,
},
+ /// Request to fetch a media attachment/file and save it to the given path.
+ ///
+ /// Sends a [`TimelineUpdate::AttachmentDownloadFinished`] upon success or failure.
+ DownloadMediaToFile {
+ media_source: MediaSource,
+ save_path: PathBuf,
+ filename: String,
+ update_sender: Option>,
+ },
+ /// Request to cancel an in-progress download.
+ CancelDownload(OwnedMxcUri),
}
/// Submits a request to the worker thread to be executed asynchronously.
@@ -721,6 +732,14 @@ pub fn submit_async_request(req: MatrixRequest) {
}
}
+/// Spawns a one-off async task on the backend Tokio runtime.
+pub fn spawn_async_task(future: F)
+where
+ F: Future