From 0a3eb01b1278059e7f7dffeff2225e1806eda1e0 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Sat, 30 May 2026 01:24:45 -0700 Subject: [PATCH 1/2] Media upload/downloads on mobile via `robius-file-picker` Support picking files and media (photos/videos) on iOS & Android via the new robius-file-picker crate, which offers a single abstraction for doing so on all platforms, not just desktop. See that crate for more info. While that crate does offer more functionality, like the ability to specifically pick only photos or videos (or both), currently Robrix only allows picking a general file and saving a general file. We'll add that, as well as the ability to download a file directly to downloads (instead of picking a file location to export to) in a future version, once we've settled on the best UI for that. --- Cargo.lock | 519 ++++-------------------------- Cargo.toml | 8 +- src/home/room_image_viewer.rs | 11 +- src/home/room_screen.rs | 5 +- src/room/room_input_bar.rs | 230 ++++--------- src/shared/attachment_download.rs | 222 ++++++++----- src/shared/file_upload_modal.rs | 100 +++++- src/shared/image_viewer.rs | 4 +- src/sliding_sync.rs | 137 ++++---- 9 files changed, 435 insertions(+), 801 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f31783d8..5d395591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,28 +239,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend 0.3.15", - "wayland-client 0.31.14", - "wayland-protocols 0.32.12", - "zbus", -] - [[package]] name = "askar-crypto" version = "0.3.7" @@ -356,18 +334,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002" -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -393,49 +359,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.1", -] - [[package]] name = "async-lock" version = "3.4.1" @@ -447,52 +370,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - [[package]] name = "async-once-cell" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "async-rx" version = "0.1.3" @@ -503,24 +386,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.1", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -543,12 +408,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -832,19 +691,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bls12_381" version = "0.8.0" @@ -1651,7 +1497,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1677,15 +1523,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - [[package]] name = "dotenvy" version = "0.15.7" @@ -1795,33 +1632,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1835,7 +1645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3390,9 +3200,9 @@ dependencies = [ "napi-ohos", "ohos-sys", "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", - "wayland-client 0.31.12", + "wayland-client", "wayland-egl", - "wayland-protocols 0.32.10", + "wayland-protocols", "windows 0.62.2", "windows-core 0.62.2", "windows-targets 0.52.6", @@ -3914,15 +3724,6 @@ name = "memchr" version = "2.7.6" source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa010e9a80a093521688c88b" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -4285,10 +4086,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "block2", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-photos-ui" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e9b818a2fae66994d23ad6bf845e9204d5503434f4c2711dc35701dac8e296" +dependencies = [ + "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.1" @@ -4299,6 +4112,17 @@ dependencies = [ "block2", "objc2", "objc2-foundation", + "objc2-uniform-type-identifiers", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d964a85778b5899a7963c5d4a5c62cec6792cf41dfdfc5dadae2d427f2681c85" +dependencies = [ + "objc2", + "objc2-foundation", ] [[package]] @@ -4381,16 +4205,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "p256" version = "0.13.2" @@ -4542,17 +4356,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs1" version = "0.7.5" @@ -4593,20 +4396,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.1", -] - [[package]] name = "pollster" version = "0.4.0" @@ -4781,15 +4570,6 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr 2.7.6 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "quinn" version = "0.11.9" @@ -5146,26 +4926,26 @@ dependencies = [ [[package]] name = "rfd" -version = "0.15.4" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd", "block2", "dispatch2", "js-sys", + "libc", "log", "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "percent-encoding", "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -5218,17 +4998,38 @@ dependencies = [ [[package]] name = "robius-directories" version = "6.0.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "dirs-sys", "jni", "robius-android-env", ] +[[package]] +name = "robius-file-picker" +version = "0.2.0" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" +dependencies = [ + "android-build", + "block2", + "cfg-if", + "dispatch2", + "jni", + "mime_guess", + "objc2", + "objc2-foundation", + "objc2-photos-ui", + "objc2-ui-kit", + "objc2-uniform-type-identifiers", + "rfd", + "robius-android-env", + "robius-directories", +] + [[package]] name = "robius-location" version = "0.2.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "android-build", "cfg-if", @@ -5243,7 +5044,7 @@ dependencies = [ [[package]] name = "robius-open" version = "0.2.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "block2", "cfg-if", @@ -5269,7 +5070,7 @@ dependencies = [ [[package]] name = "robius-web-auth-session" version = "0.2.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "block2", "cfg-if", @@ -5317,8 +5118,8 @@ dependencies = [ "rand 0.8.5", "rangemap", "reqwest 0.12.28", - "rfd", "robius-directories", + "robius-file-picker", "robius-location", "robius-open", "robius-use-makepad", @@ -5530,7 +5331,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5598,7 +5399,7 @@ dependencies = [ "security-framework 3.5.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5904,17 +5705,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -6421,7 +6211,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -6884,17 +6674,6 @@ version = "1.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.1", -] - [[package]] name = "ulid" version = "1.2.1" @@ -7320,21 +7099,7 @@ dependencies = [ "libc", "scoped-tls", "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-sys 0.31.8", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-sys 0.31.11", + "wayland-sys", ] [[package]] @@ -7344,19 +7109,7 @@ source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa01 dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc", - "wayland-backend 0.3.12", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "rustix", - "wayland-backend 0.3.15", - "wayland-scanner", + "wayland-backend", ] [[package]] @@ -7364,8 +7117,8 @@ name = "wayland-egl" version = "0.32.9" source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa010e9a80a093521688c88b" dependencies = [ - "wayland-backend 0.3.12", - "wayland-sys 0.31.8", + "wayland-backend", + "wayland-sys", ] [[package]] @@ -7374,31 +7127,8 @@ version = "0.32.10" source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa010e9a80a093521688c88b" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-backend 0.3.12", - "wayland-client 0.31.12", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-backend 0.3.15", - "wayland-client 0.31.14", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", + "wayland-backend", + "wayland-client", ] [[package]] @@ -7410,17 +7140,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "pkg-config", -] - [[package]] name = "web-sys" version = "0.3.94" @@ -7499,7 +7218,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] @@ -8204,67 +7923,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.1", - "winnow 0.7.13", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.13", - "zvariant", -] - [[package]] name = "zerocopy" version = "0.8.27" @@ -8379,44 +8037,3 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] - -[[package]] -name = "zvariant" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" -dependencies = [ - "endi", - "enumflags2", - "serde", - "url", - "winnow 0.7.13", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.106", - "winnow 0.7.13", -] diff --git a/Cargo.toml b/Cargo.toml index e742dcbd..1c7b7462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,10 @@ makepad-code-editor = { git = "https://github.com/makepad/makepad", branch = "de ## Including this crate automatically configures all `robius-*` crates to work with Makepad. robius-use-makepad = "0.1.1" -robius-open = { git = "https://github.com/project-robius/robius" } 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" } anyhow = "1.0" @@ -106,11 +107,6 @@ reqwest = { version = "0.12", default-features = false, optional = true, feature "macos-system-configuration", ] } -# Desktop-only file dialog (doesn't work on iOS/Android) -[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] -rfd = "0.15" - - ## For OAuth/SSO login on iOS, via `ASWebAuthenticationSession`. Ugh.... [target.'cfg(target_os = "ios")'.dependencies] robius-web-auth-session = { git = "https://github.com/project-robius/robius" } diff --git a/src/home/room_image_viewer.rs b/src/home/room_image_viewer.rs index 67a4833d..aa75ee57 100644 --- a/src/home/room_image_viewer.rs +++ b/src/home/room_image_viewer.rs @@ -42,17 +42,14 @@ pub fn populate_matrix_image_modal( } } -/// Gets image name and file size in bytes from an event timeline item. +/// Gets the image's file name and size in bytes from an event timeline item. pub fn get_image_name_and_filesize(event_tl_item: &EventTimelineItem) -> (String, u64) { if let Some(message) = event_tl_item.content().as_message() { if let MessageType::Image(image_content) = message.msgtype() { - let name = message.body().to_string(); - let size = image_content - .info - .as_ref() + let name = image_content.filename().to_string(); + let size = image_content.info.as_ref() .and_then(|info| info.size) - .map(u64::from) - .unwrap_or(0); + .map_or(0, u64::from); return (name, size); } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index d8c29fb7..803487f4 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::{ - 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 + 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, 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} }; @@ -2251,6 +2251,7 @@ impl RoomScreen { 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 { @@ -2259,7 +2260,7 @@ impl RoomScreen { }); portal_list.redraw(cx); let update_sender = tl.media_cache.timeline_update_sender().cloned(); - start_attachment_download(cx, info.clone(), update_sender); + start_attachment_download(info.clone(), update_sender); } MessageAction::CancelDownload(mxc) => { submit_async_request(MatrixRequest::CancelDownload(mxc.clone())); diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 02184b48..6af64aed 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -21,23 +21,7 @@ use matrix_sdk::room::reply::{EnforceThread, Reply}; use ruma::events::room::message::AddMentions; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedEventId, OwnedRoomId}; -use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}, upload_progress::UploadProgressViewWidgetRefExt}, location::init_location_subscriber, settings::app_preferences::{AppPreferencesGlobal, AppPreferencesAction}, shared::{avatar::AvatarWidgetRefExt, file_upload_modal::{AttachmentUpload, FilePreviewerAction, FileUploadAttemptId}, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; - -/// Result of the native file picker plus background file-loading work. -#[cfg_attr(any(target_os = "ios", target_os = "android"), allow(dead_code))] -enum PendingFileSelection { - /// A file was selected and read successfully. - Selected { - upload: AttachmentUpload, - }, - /// The picker was dismissed without selecting a file. - Cancelled, - /// The file could not be selected, inspected, or read. - Error(String), -} - -/// Receives the pending file-selection result back on the UI thread. -type PendingFileSelectionReceiver = std::sync::mpsc::Receiver; +use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}, upload_progress::UploadProgressViewWidgetRefExt}, location::init_location_subscriber, settings::app_preferences::{AppPreferencesAction, AppPreferencesGlobal}, shared::{avatar::AvatarWidgetRefExt, file_upload_modal::{AttachmentUpload, FilePreviewerAction, FileUploadAttemptId, load_selected_file}, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -210,8 +194,6 @@ pub struct RoomInputBar { /// Cached natural Fit height of the input_bar, used as the animation /// target when the editing pane is being hidden. #[rust] input_bar_natural_height: f64, - /// The pending file picker / background load operation, if any. - #[rust] pending_file_selection: Option, } impl ScriptHook for RoomInputBar { @@ -254,48 +236,9 @@ impl Widget for RoomInputBar { } if let Event::Actions(actions) = event { - // Handle changes to the `send_on_enter` preference. - for action in actions { - if let Some(AppPreferencesAction::SendOnEnterChanged(v)) = action.downcast_ref() { - self.mentionable_text_input(cx, ids!(mentionable_text_input)) - .text_input_ref() - .set_submit_on_enter(*v); - } - } - self.handle_actions(cx, actions, room_screen_props); } - // Handle signal events for pending file loads from background threads - if let Event::Signal = event { - if let Some(receiver) = &self.pending_file_selection { - let mut remove_receiver = false; - match receiver.try_recv() { - Ok(PendingFileSelection::Selected { upload }) => { - Cx::post_action(FilePreviewerAction::Show { upload }); - remove_receiver = true; - } - Ok(PendingFileSelection::Cancelled) => { - remove_receiver = true; - } - Ok(PendingFileSelection::Error(error)) => { - enqueue_popup_notification(error, PopupKind::Error, None); - remove_receiver = true; - } - Err(std::sync::mpsc::TryRecvError::Empty) => { - // Still waiting for file picker / loader. - } - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - remove_receiver = true; - } - } - if remove_receiver { - self.pending_file_selection = None; - self.redraw(cx); - } - } - } - self.view.handle_event(cx, event, scope); } @@ -342,6 +285,14 @@ impl RoomInputBar { let mentionable_text_input = self.mentionable_text_input(cx, ids!(mentionable_text_input)); let text_input = mentionable_text_input.text_input_ref(); + for action in actions { + // Handle changes to the `send_on_enter` preference. + if let Some(AppPreferencesAction::SendOnEnterChanged(v)) = action.downcast_ref() { + text_input.set_submit_on_enter(*v); + continue; + } + } + // Clear the replying-to preview pane if the "cancel reply" button was clicked // or if the `Escape` key was pressed within the message input box. if self.button(cx, ids!(cancel_reply_button)).clicked(actions) @@ -419,9 +370,7 @@ impl RoomInputBar { } // Handle the send message button being clicked, or a `Returned` action - // from the message text input. The text input only emits `Returned` - // for the key combination chosen by the user in App Settings (plus - // Cmd/Ctrl+Enter, which always submits). + // from the message text input, which already respects the user's app setting. if self.button(cx, ids!(send_message_button)).clicked(actions) || text_input.returned(actions).is_some() { @@ -664,30 +613,17 @@ impl RoomInputBar { self.view.check_box(cx, ids!(tsp_sign_checkbox)).active(cx) } - /// Opens the native file picker dialog to select a file for upload. - /// - /// The timeline target is captured at this moment to ensure the file is uploaded - /// to the correct room/thread, even if the user switches rooms while the modal is open. - #[cfg(not(any(target_os = "ios", target_os = "android")))] + /// Shows the native file picker dialog to select a file to be uploaded. fn open_file_picker( &mut self, cx: &mut Cx, timeline_kind: TimelineKind, ) { - if self.pending_file_selection.is_some() { - enqueue_popup_notification( - "A file selection is already in progress.", - PopupKind::Error, - None, - ); - return; - } - if self.view.view(cx, ids!(upload_progress_view)).visible() { enqueue_popup_notification( - "Finish the current upload before starting another one.", - PopupKind::Error, - None, + "Finish or cancel the current upload before starting another one.", + PopupKind::Warning, + Some(7.0), ); return; } @@ -698,54 +634,57 @@ impl RoomInputBar { #[cfg(feature = "tsp")] let sign_with_tsp = self.is_tsp_signing_enabled(cx); - let (sender, receiver) = std::sync::mpsc::channel(); - self.pending_file_selection = Some(receiver); - 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) => { - #[cfg(feature = "tsp")] - { - load_selected_file( - selected_file.path().to_path_buf(), + // `robius-file-picker` ensures that this `on_picked` callback runs on a bg thread. + let on_picked = move |result: robius_file_picker::Result>| { + match result { + Ok(Some(picked)) => match picked.into_local_file() { + Ok(local_file) => { + let loaded = load_selected_file( + local_file, timeline_kind, in_reply_to, + #[cfg(feature = "tsp")] sign_with_tsp, - ) - } - #[cfg(not(feature = "tsp"))] - { - load_selected_file( - selected_file.path().to_path_buf(), - timeline_kind, - in_reply_to, - ) + ); + match loaded { + Ok(upload) => Cx::post_action(FilePreviewerAction::Show { upload }), + Err(e) => enqueue_popup_notification(e, PopupKind::Error, None), + } } - } - None => PendingFileSelection::Cancelled, - }; - if sender.send(result).is_err() { - makepad_widgets::error!("Failed to send file picker result to UI: receiver dropped"); + Err(e) => enqueue_popup_notification( + format!("Failed to read selected file: {e}"), + PopupKind::Error, + None, + ), + }, + // User dismissed the picker, do nothing. + Ok(None) => {} + Err(err) => enqueue_popup_notification( + format!("Error selecting a file: {err}"), + PopupKind::Error, + None, + ), } - SignalToUI::set_ui_signal(); - }); - } + }; - /// Shows a "not supported" message on mobile platforms. - #[cfg(any(target_os = "ios", target_os = "android"))] - fn open_file_picker( - &mut self, - _cx: &mut Cx, - _timeline_kind: TimelineKind, - ) { - enqueue_popup_notification( - "File uploads are not yet supported on mobile.", - PopupKind::Error, - Some(5.0), - ); + match robius_file_picker::FileDialog::new().pick_file(on_picked) { + Ok(()) => {} + Err(robius_file_picker::Error::AlreadyOpen) => { + enqueue_popup_notification( + "A file picker is already open.", + PopupKind::Error, + Some(4.0), + ); + } + Err(err) => { + makepad_widgets::error!("Failed to launch file picker: {err}"); + enqueue_popup_notification( + format!("Failed to open file picker: {err}"), + PopupKind::Error, + None, + ); + } + } } } @@ -964,54 +903,3 @@ enum ShowEditingPaneBehavior { editing_pane_state: EditingPaneState, }, } - -#[cfg(not(any(target_os = "ios", target_os = "android")))] -fn load_selected_file( - selected_file_path: std::path::PathBuf, - timeline_kind: TimelineKind, - in_reply_to: Option, - #[cfg(feature = "tsp")] - sign_with_tsp: bool, -) -> PendingFileSelection { - 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}")), - } - } 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 index 61fe27fa..b60c912a 100644 --- a/src/shared/attachment_download.rs +++ b/src/shared/attachment_download.rs @@ -1,12 +1,27 @@ //! 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 std::{sync::Arc, time::Duration}; +use makepad_widgets::SignalToUI; use matrix_sdk::ruma::{OwnedMxcUri, events::room::MediaSource}; -use crate::home::room_screen::TimelineUpdate; -use crate::shared::popup_list::{PopupKind, enqueue_popup_notification}; +use crate::{ + home::room_screen::TimelineUpdate, + shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::{MatrixRequest, submit_async_request}, +}; + +type TimelineUpdateSenderOption = Option>; + +/// The result of a download request. +pub enum MediaDownloadResult { + Downloaded(Vec), + Failed(String), + Cancelled, +} + +pub fn enqueue_already_downloading_notification() { + const ALREADY_DOWNLOADING_MESSAGE: &str = "This media is already being downloaded."; + enqueue_popup_notification(ALREADY_DOWNLOADING_MESSAGE, PopupKind::Warning, Some(4.0)); +} /// The mxc URI inside any media source, whether plain or encrypted. pub fn media_source_mxc(source: &MediaSource) -> &OwnedMxcUri { @@ -85,98 +100,135 @@ impl DownloadKind { } } -/// 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. +/// Fetches the attachment bytes via the matrix worker, then shows the native +/// save picker dialog with those bytes. /// -/// If `update_sender` is provided, it will be used to send progress updates to a timeline. +/// 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 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")))] +/// 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( - cx: &mut Cx, info: DownloadableAttachment, - update_sender: Option>, + update_sender: TimelineUpdateSenderOption, ) { - 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, - }); + let mxc = media_source_mxc(&info.media_source).clone(); + let filename = info.filename.clone(); + submit_async_request(MatrixRequest::DownloadMedia { + 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) } - // 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(); - } + MediaDownloadResult::Failed(e) => { + enqueue_popup_notification( + format!("Failed to download \"{filename}\": {e}"), + PopupKind::Error, + None, + ); + finish_download_indicator(&update_sender, Some(&mxc), DownloadOutcome::Failed); } - } + MediaDownloadResult::Cancelled => { + finish_download_indicator(&update_sender, Some(&mxc), DownloadOutcome::Cancelled); + } + }), }); } -/// 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, - ), - } - }); +/// 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); } -#[cfg(any(target_os = "ios", target_os = "android"))] -pub fn start_attachment_download( - _cx: &mut Cx, - _info: DownloadableAttachment, - _update_sender: Option>, +enum DownloadOutcome { + Succeeded, + Failed, + /// The user dismissed the save dialog; return the indicator to idle. + Cancelled, +} + +/// Shows the native save-file dialog and writes `data` to the user-chosen location. +fn show_save_dialog + Send + 'static>( + filename: String, + data: D, + mxc: Option, + update_sender: TimelineUpdateSenderOption, ) { - enqueue_popup_notification( - "Saving attachments is not yet supported on mobile.", - PopupKind::Error, - Some(5.0), - ); + use robius_file_picker::{PickedFile, FileDialog, StartLocation}; + let filename2 = filename.clone(); + let mxc2 = mxc.clone(); + let sender2 = update_sender.clone(); + let on_done = move |result: robius_file_picker::Result>| { + match result { + Ok(Some(_)) => { + enqueue_popup_notification( + format!("Downloaded \"{filename2}\"."), + PopupKind::Success, + Some(DOWNLOAD_RESULT_DURATION_SECS), + ); + finish_download_indicator(&update_sender, mxc.as_ref(), DownloadOutcome::Succeeded); + } + // User dismissed the save dialog. The bytes are discarded, but the + // matrix media cache makes a re-download effectively instant, so we + // just return the indicator to idle without a popup. + Ok(None) => { + finish_download_indicator(&update_sender, mxc.as_ref(), DownloadOutcome::Cancelled); + } + Err(e) => { + enqueue_popup_notification( + format!("Failed to save \"{filename2}\": {e}"), + PopupKind::Error, + None, + ); + finish_download_indicator(&update_sender, mxc.as_ref(), DownloadOutcome::Failed); + } + } + }; + + let res = FileDialog::new() + .set_file_name(&filename) + .set_start_location(StartLocation::Downloads) + .save_data(data, on_done); + if let Err(e) = res { + enqueue_popup_notification( + format!("Failed to open save dialog: {e}"), + PopupKind::Error, + None, + ); + finish_download_indicator(&sender2, mxc2.as_ref(), DownloadOutcome::Failed); + } } -#[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), - ); +/// Handles the completion of a download, whether success, failure, or cancelled. +fn finish_download_indicator( + update_sender: &TimelineUpdateSenderOption, + mxc: Option<&OwnedMxcUri>, + outcome: DownloadOutcome, +) { + let Some(sender) = update_sender.as_ref() else { return }; + let Some(mxc) = mxc else { return }; + match outcome { + DownloadOutcome::Cancelled => { + let _ = sender.send(TimelineUpdate::AttachmentDownloadReset(mxc.clone())); + SignalToUI::set_ui_signal(); + } + DownloadOutcome::Succeeded | DownloadOutcome::Failed => { + let result = match outcome { + DownloadOutcome::Succeeded => Ok(()), + _ => Err(String::new()), + }; + let _ = sender.send(TimelineUpdate::AttachmentDownloadFinished(mxc.clone(), result)); + SignalToUI::set_ui_signal(); + // Clear the success/failure display after a short delay. + let sender = sender.clone(); + let mxc = mxc.clone(); + crate::sliding_sync::spawn_async_task(async move { + tokio::time::sleep(Duration::from_secs_f64(DOWNLOAD_RESULT_DURATION_SECS)).await; + let _ = sender.send(TimelineUpdate::AttachmentDownloadReset(mxc)); + SignalToUI::set_ui_signal(); + }); + } + } } diff --git a/src/shared/file_upload_modal.rs b/src/shared/file_upload_modal.rs index 20945f08..6aba7c12 100644 --- a/src/shared/file_upload_modal.rs +++ b/src/shared/file_upload_modal.rs @@ -2,11 +2,12 @@ //! //! This modal shows a preview of the file (image preview or file icon) //! along with file metadata and upload/cancel buttons. +//! +//! Also includes various helper functions to uploading/previewing attachments. use makepad_widgets::*; use ruma::OwnedEventId; use std::sync::{Arc, atomic::{AtomicU64, Ordering}}; -use std::path::PathBuf; use crate::{ sliding_sync::{MatrixRequest, TimelineKind, submit_async_request}, @@ -135,6 +136,7 @@ script_mod! { width: Fill, height: Fit, is_multiline: true, + submit_on_enter: true, empty_text: "Caption" padding: 10, draw_text +: { @@ -148,7 +150,7 @@ script_mod! { padding: 0, margin: Inset{ left: 4.5} draw_text +: { - text_style: REGULAR_TEXT { font_size: 10 }, + text_style: REGULAR_TEXT { font_size: 11 }, color: (SMALL_STATE_TEXT_COLOR) } text: "" @@ -161,11 +163,24 @@ script_mod! { margin: Inset{ left: 4.5} flow: Flow.Right { wrap: true } draw_text +: { - text_style: REGULAR_TEXT { font_size: 10 }, + text_style: REGULAR_TEXT { font_size: 11 }, color: (COLOR_TEXT_WARNING_NOT_FOUND) } text: "This file is large (over 10 MB). Are you sure you want to upload it to the homeserver?" } + + empty_attachment_warning_label := Label { + visible: false, + width: Fill, + padding: 0, + margin: Inset{ left: 4.5} + flow: Flow.Right { wrap: true } + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11 }, + color: (COLOR_TEXT_WARNING_NOT_FOUND) + } + text: "This file is empty (0 bytes). Are you sure you want to upload it?" + } } // Buttons @@ -194,8 +209,9 @@ script_mod! { /// Metadata describing a file to be uploaded. #[derive(Clone, Debug)] pub struct FileUploadMetadata { - /// The file path on the local filesystem. - pub path: PathBuf, + /// The local source file. For Android content selections this is a temp + /// copy, auto-deleted once this metadata and all its clones drop. + pub source: robius_file_picker::LocalFile, /// The optional user-editable caption to send with the attachment. pub caption: Option, /// The MIME type of the file. @@ -207,9 +223,14 @@ pub struct FileUploadMetadata { } impl FileUploadMetadata { + /// The local filesystem path of the file to upload. + pub fn path(&self) -> &std::path::Path { + self.source.path() + } + /// Returns the file name portion of the local path, or a fallback for invalid paths. pub fn file_name(&self) -> String { - self.path + self.path() .file_name() .and_then(|name| name.to_str()) .unwrap_or("Unknown file") @@ -264,9 +285,12 @@ impl Widget for FileUploadModal { Cx::post_action(FilePreviewerAction::Hide); } - // Handle upload button - if self.button(cx, ids!(upload_button)).clicked(actions) { - let caption = match self.text_input(cx, ids!(caption_input)).text().trim() { + // Start upload if upload button is clicked or Enter/Return is pressed in the caption text input. + let caption_input = self.text_input(cx, ids!(caption_input)); + if self.button(cx, ids!(upload_button)).clicked(actions) + || caption_input.returned(actions).is_some() + { + let caption = match caption_input.text().trim() { "" => None, caption => Some(caption.to_string()), }; @@ -321,6 +345,8 @@ impl FileUploadModal { .set_text(cx, &format_decimal_file_size(file_data.size)); self.label(cx, ids!(large_attachment_warning_label)) .set_visible(cx, file_data.size > LARGE_ATTACHMENT_WARNING_THRESHOLD_BYTES); + self.label(cx, ids!(empty_attachment_warning_label)) + .set_visible(cx, file_data.size == 0); // Show image preview if this is a displayable image let is_image = crate::image_utils::is_displayable_image(&file_data.mime_type); @@ -432,3 +458,59 @@ impl FileUploadModalRef { } } } + + +/// Builds an attachment and preview of the given local file that the user picked to upload. +/// +/// `source` is stored in the returned upload so any temp copy persists +/// until the upload completes, after which it's auto-cleans up. +/// +/// This function might block doing filesystem I/O, so it should run on a bg thread. +pub fn load_selected_file( + source: robius_file_picker::LocalFile, + timeline_kind: TimelineKind, + in_reply_to: Option, + #[cfg(feature = "tsp")] + sign_with_tsp: bool, +) -> Result { + let path = source.path(); + let metadata = std::fs::metadata(path).map_err( + |e| format!("Unable to access file: {e}") + )?; + if !metadata.is_file() { + return Err("Cannot upload directories or special files".to_string()); + } + let file_size = metadata.len(); + let mime_type = source + .mime_type() + .filter(|m| !m.is_empty() && *m != "application/octet-stream") + .map(ToOwned::to_owned) + .unwrap_or_else(|| mime_guess::from_path(path).first_or_octet_stream().to_string()); + let preview_data = if crate::image_utils::is_displayable_image(&mime_type) { + match std::fs::read(path) { + Ok(data) => Some(std::sync::Arc::new(data)), + Err(e) => return Err(format!("Unable to read image preview: {e}")), + } + } else { + None + }; + let name = source.display_name() + .filter(|n| !n.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| path.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(AttachmentUpload { + timeline_kind, + file_data: FileUploadMetadata { + source, + caption: Some(name), + mime_type, + preview_data, + size: file_size, + }, + in_reply_to, + #[cfg(feature = "tsp")] + sign_with_tsp, + }) +} diff --git a/src/shared/image_viewer.rs b/src/shared/image_viewer.rs index 0b8ab139..7916112f 100644 --- a/src/shared/image_viewer.rs +++ b/src/shared/image_viewer.rs @@ -839,9 +839,9 @@ impl MatchEvent for ImageViewer { { was_overlay_button_clicked = true; if let Some(bytes) = self.loaded_bytes.clone() { - save_loaded_attachment(cx, info, bytes); + save_loaded_attachment(info, bytes); } else { - start_attachment_download(cx, info, None); + start_attachment_download(info, None); } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 52b9bcb1..89a32a0d 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -4,7 +4,7 @@ use mime::Mime; use clap::Parser; use eyeball::Subscriber; use eyeball_im::VectorDiff; -use futures_util::{future::join_all, pin_mut, StreamExt}; +use futures_util::{future::{Abortable, join_all}, pin_mut, StreamExt}; use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; @@ -37,11 +37,11 @@ use hashbrown::{HashMap, HashSet}; use crate::{ app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails - }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, room_preview_cache::{enqueue_room_preview_update, RoomPreviewUpdate}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ + }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, - }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ - avatar::AvatarState, file_upload_modal::{AttachmentUpload, FileUploadAttemptId}, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} + }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, room_preview_cache::{RoomPreviewUpdate, enqueue_room_preview_update}, shared::{ + attachment_download::{MediaDownloadResult, media_source_mxc}, avatar::AvatarState, file_upload_modal::{AttachmentUpload, FileUploadAttemptId}, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client }; @@ -711,14 +711,16 @@ pub enum MatrixRequest { destination: Arc>, update_sender: Option>, }, - /// Request to fetch a media attachment/file and save it to the given path. + /// Request to download a media attachment/file. /// - /// Sends a [`TimelineUpdate::AttachmentDownloadFinished`] upon success or failure. - DownloadMediaToFile { + /// The given callback `on_download_result` is called from the backend + /// matrix worker tokio task. + /// If the given McxUri was already downloading, the request is rejected + /// and `on_download_result` is called with `Cancelled`. + DownloadMedia { media_source: MediaSource, - save_path: PathBuf, filename: String, - update_sender: Option>, + on_download_result: Box, }, /// Request to cancel an in-progress download. CancelDownload(OwnedMxcUri), @@ -732,6 +734,16 @@ pub fn submit_async_request(req: MatrixRequest) { } } +/// A media download in flight, tracked by MXC so duplicates are rejected and +/// the fetch can be cancelled. +struct ActiveDownload { + /// Aborts the underlying fetch when [`MatrixRequest::CancelDownload`] arrives. + abort_handle: futures_util::future::AbortHandle, + /// Delivers the result to the requester. Whichever of the completion or + /// cancellation path runs removes it from the map and calls it. + on_download_result: Box, +} + /// Spawns a one-off async task on the backend Tokio runtime. pub fn spawn_async_task(future: F) where @@ -769,8 +781,9 @@ async fn matrix_worker_task( let mut subscribers_own_user_read_receipts: HashMap> = HashMap::new(); // The async tasks that are spawned to subscribe to changes in the pinned events for each room. let mut subscribers_pinned_events: HashMap> = HashMap::new(); - // Abort handles for in-progress download tasks, keyed by MxcUri. - let download_tasks: Arc>> + // The async tasks spawned to handle media downloads, keyed by MxcUri. + // Here we intentionally use a `std` Mutex, not async, since it's cheaper under no contention. + let download_tasks: Arc>> = Arc::new(Mutex::new(HashMap::new())); while let Some(request) = request_receiver.recv().await { @@ -1871,7 +1884,7 @@ async fn matrix_worker_task( .unwrap_or(mime::APPLICATION_OCTET_STREAM); let image_dimensions = if content_type.type_() == mime::IMAGE { - image::ImageReader::open(&file_data.path) + image::ImageReader::open(file_data.path()) .ok() .and_then(|reader| reader.with_guessed_format().ok()) .and_then(|reader| reader.into_dimensions().ok()) @@ -1909,7 +1922,7 @@ async fn matrix_worker_task( }; let send_request = timeline.send_attachment( - file_data.path.clone(), + file_data.path().to_path_buf(), content_type, TimelineAttachmentConfig { info: Some(info), @@ -1962,7 +1975,7 @@ async fn matrix_worker_task( SignalToUI::set_ui_signal(); }; - match futures_util::future::Abortable::new(upload_future, abort_registration).await { + match Abortable::new(upload_future, abort_registration).await { Ok(()) => {} Err(_) => { log!("Attachment upload task {upload_id:?} for {monitor_timeline_kind} was aborted."); @@ -2185,78 +2198,66 @@ async fn matrix_worker_task( }); } - MatrixRequest::DownloadMediaToFile { media_source, save_path, filename, update_sender } => { - let Some(client) = get_client() else { continue }; - let mxc_uri = match &media_source { - MediaSource::Plain(uri) => uri.clone(), - MediaSource::Encrypted(file) => file.url.clone(), + MatrixRequest::DownloadMedia { media_source, filename, on_download_result } => { + use crate::shared::attachment_download::{enqueue_already_downloading_notification, MediaDownloadResult}; + + // Note: in this code block, we always want to call `on_download_result` with any error. + + let Some(client) = get_client() else { + on_download_result(MediaDownloadResult::Failed("Matrix client is not available".to_string())); + continue; }; + let mxc_uri = media_source_mxc(&media_source).clone(); + // Only allow a given MxcUri to be downloaded once at a time. + if download_tasks.lock().unwrap().contains_key(&mxc_uri) { + enqueue_already_downloading_notification(); + on_download_result(MediaDownloadResult::Cancelled); + continue; + } let (abort_handle, abort_registration) = futures_util::future::AbortHandle::new_pair(); - let mxc_for_task = mxc_uri.clone(); - let mxc_for_finished = mxc_uri.clone(); - let tasks_for_cleanup = download_tasks.clone(); + let download_tasks2 = download_tasks.clone(); + let mxc_uri2 = mxc_uri.clone(); let download_future = async move { let media_request = MediaRequestParameters { source: media_source, format: matrix_sdk::media::MediaFormat::File, }; - let result: Result<(), String> = match client.media().get_media_content(&media_request, true).await { - Ok(bytes) => match tokio::fs::write(&save_path, &bytes).await { - Ok(()) => { - log!("Saved downloaded attachment {filename:?} to {}", save_path.display()); - enqueue_popup_notification( - format!("Downloaded \"{filename}\"."), - PopupKind::Success, - Some(crate::shared::attachment_download::DOWNLOAD_RESULT_DURATION_SECS), - ); - Ok(()) + let res = match client.media().get_media_content(&media_request, true).await { + Ok(bytes) => { + log!("Downloaded attachment {filename:?} ({} bytes) to memory", bytes.len()); + Ok(bytes) } Err(e) => { - error!("Failed to write downloaded attachment {filename:?} to {}: {e}", save_path.display()); - enqueue_popup_notification( - format!("Failed to save \"{filename}\": {e}"), - PopupKind::Error, - None, - ); + error!("Failed to fetch media content for attachment {filename:?}: {e}"); Err(e.to_string()) } - } - Err(e) => { - error!("Failed to fetch media content for attachment {filename:?}: {e}"); - enqueue_popup_notification( - format!("Failed to download \"{filename}\": {e}"), - PopupKind::Error, - None, - ); - Err(e.to_string()) - } - }; - if let Some(sender) = update_sender { - let _ = sender.send(TimelineUpdate::AttachmentDownloadFinished(mxc_for_finished, result)); - SignalToUI::set_ui_signal(); - // Drop the success/failure indicator after a short delay. - let reset_sender = sender.clone(); - Handle::current().spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs_f64( - crate::shared::attachment_download::DOWNLOAD_RESULT_DURATION_SECS, - )).await; - let _ = reset_sender.send(TimelineUpdate::AttachmentDownloadReset(mxc_for_task)); - SignalToUI::set_ui_signal(); + }; + if let Some(active) = download_tasks2.lock().unwrap().remove(&mxc_uri2) { + (active.on_download_result)(match res { + Ok(bytes) => MediaDownloadResult::Downloaded(bytes), + Err(e) => MediaDownloadResult::Failed(e), }); } }; - - let mxc_for_cleanup = mxc_uri.clone(); - download_tasks.lock().unwrap().insert(mxc_uri, abort_handle); + + let download_tasks3 = download_tasks.clone(); + let mxc_uri3 = mxc_uri.clone(); + download_tasks.lock().unwrap().insert( + mxc_uri, + ActiveDownload { abort_handle, on_download_result }, + ); Handle::current().spawn(async move { - let _ = futures_util::future::Abortable::new(download_future, abort_registration).await; - tasks_for_cleanup.lock().unwrap().remove(&mxc_for_cleanup); + if Abortable::new(download_future, abort_registration).await.is_err() { + if let Some(active) = download_tasks3.lock().unwrap().remove(&mxc_uri3) { + (active.on_download_result)(MediaDownloadResult::Cancelled); + } + } }); } MatrixRequest::CancelDownload(mxc) => { - if let Some(abort) = download_tasks.lock().unwrap().remove(&mxc) { - abort.abort(); + if let Some(active) = download_tasks.lock().unwrap().get(&mxc) { + active.abort_handle.abort(); } } @@ -2907,7 +2908,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. loop { tokio::select! { - // If we were notified but it got canceled, check the TOKEN_EXPIRED bool. + // If we were notified but it got cancelled, check the TOKEN_EXPIRED bool. _ = TOKEN_EXPIRED_NOTIFY.notified() => { if !TOKEN_EXPIRED.load(Ordering::Acquire) { continue; From 4f4e762a8073c26b3b4312699ae41df752f188e0 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Sat, 30 May 2026 14:28:29 -0700 Subject: [PATCH 2/2] clippy --- src/home/upload_progress.rs | 1 + src/shared/file_upload_modal.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/home/upload_progress.rs b/src/home/upload_progress.rs index cb0e7ad2..9dc61a07 100644 --- a/src/home/upload_progress.rs +++ b/src/home/upload_progress.rs @@ -89,6 +89,7 @@ script_mod! { /// The current state of the upload view. #[derive(Clone, Debug, Default)] +#[allow(clippy::large_enum_variant)] pub enum UploadViewState { /// Normal state - upload in progress or ready. #[default] diff --git a/src/shared/file_upload_modal.rs b/src/shared/file_upload_modal.rs index 6aba7c12..ac8663b8 100644 --- a/src/shared/file_upload_modal.rs +++ b/src/shared/file_upload_modal.rs @@ -254,6 +254,7 @@ pub struct AttachmentUpload { /// Actions used to show/hide the FileUploadModal. #[derive(Clone, Debug, Default)] +#[allow(clippy::large_enum_variant)] pub enum FilePreviewerAction { /// No action. #[default]