diff --git a/Cargo.lock b/Cargo.lock
index bf3c59dc6..c76e9a89f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -239,6 +239,28 @@ 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"
@@ -334,6 +356,18 @@ 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"
@@ -359,6 +393,49 @@ 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"
@@ -370,12 +447,52 @@ 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"
@@ -386,6 +503,24 @@ 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"
@@ -408,6 +543,12 @@ 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"
@@ -691,6 +832,19 @@ 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"
@@ -725,6 +879,12 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -741,6 +901,12 @@ name = "byteorder"
version = "1.5.0"
source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant#4c9c85d94ae650d4ab2eafd615a88fe62ccf753f"
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1484,6 +1650,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "block2",
+ "libc",
"objc2",
]
@@ -1498,6 +1666,15 @@ 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"
@@ -1607,6 +1784,33 @@ 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"
@@ -1725,6 +1929,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32 0.3.9",
+]
+
[[package]]
name = "ff"
version = "0.13.1"
@@ -2524,6 +2737,21 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck 1.25.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+ "zune-core",
+ "zune-jpeg",
+]
+
[[package]]
name = "imbl"
version = "6.1.0"
@@ -3141,9 +3369,9 @@ dependencies = [
"napi-ohos",
"ohos-sys",
"smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant)",
- "wayland-client",
+ "wayland-client 0.31.12",
"wayland-egl",
- "wayland-protocols",
+ "wayland-protocols 0.32.10",
"windows 0.62.2",
"windows-core 0.62.2",
"windows-targets 0.52.6",
@@ -3262,7 +3490,7 @@ name = "makepad-zune-inflate"
version = "0.2.0"
source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant#4c9c85d94ae650d4ab2eafd615a88fe62ccf753f"
dependencies = [
- "simd-adler32",
+ "simd-adler32 0.3.8",
]
[[package]]
@@ -3665,6 +3893,15 @@ name = "memchr"
version = "2.7.6"
source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant#4c9c85d94ae650d4ab2eafd615a88fe62ccf753f"
+[[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"
@@ -3677,6 +3914,16 @@ version = "0.1.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase 2.8.1",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3690,6 +3937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
+ "simd-adler32 0.3.9",
]
[[package]]
@@ -3703,6 +3951,16 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
[[package]]
name = "multihash"
version = "0.19.3"
@@ -3943,6 +4201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
dependencies = [
"bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "block2",
"objc2",
"objc2-foundation",
]
@@ -4077,6 +4336,16 @@ 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"
@@ -4228,6 +4497,17 @@ 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"
@@ -4255,6 +4535,39 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
[[package]]
name = "poly1305"
version = "0.8.0"
@@ -4417,6 +4730,21 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+[[package]]
+name = "pxfm"
+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"
@@ -4585,6 +4913,12 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223"
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
[[package]]
name = "readlock"
version = "0.1.9"
@@ -4765,6 +5099,30 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "rfd"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
+dependencies = [
+ "ashpd",
+ "block2",
+ "dispatch2",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "pollster",
+ "raw-window-handle",
+ "urlencoding",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -4881,6 +5239,7 @@ dependencies = [
"futures-util",
"hashbrown 0.16.1",
"htmlize",
+ "image",
"imbl",
"imghdr",
"indexmap 2.13.0",
@@ -4890,11 +5249,14 @@ dependencies = [
"matrix-sdk",
"matrix-sdk-base",
"matrix-sdk-ui",
+ "mime",
+ "mime_guess",
"percent-encoding",
"quinn",
"rand 0.8.5",
"rangemap",
"reqwest 0.12.28",
+ "rfd",
"robius-directories",
"robius-location",
"robius-open",
@@ -5206,7 +5568,7 @@ version = "0.18.0"
source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant#4c9c85d94ae650d4ab2eafd615a88fe62ccf753f"
dependencies = [
"bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant)",
- "bytemuck",
+ "bytemuck 1.25.0 (git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant)",
"makepad-error-log",
"smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant)",
"ttf-parser",
@@ -5479,6 +5841,17 @@ 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"
@@ -5604,6 +5977,12 @@ name = "simd-adler32"
version = "0.3.8"
source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant#4c9c85d94ae650d4ab2eafd615a88fe62ccf753f"
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
[[package]]
name = "siphasher"
version = "1.0.1"
@@ -6442,6 +6821,17 @@ 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"
@@ -6867,7 +7257,21 @@ dependencies = [
"libc",
"scoped-tls",
"smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)",
- "wayland-sys",
+ "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",
]
[[package]]
@@ -6877,7 +7281,19 @@ source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_varian
dependencies = [
"bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
"libc",
- "wayland-backend",
+ "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",
]
[[package]]
@@ -6885,8 +7301,8 @@ name = "wayland-egl"
version = "0.32.9"
source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant#4c9c85d94ae650d4ab2eafd615a88fe62ccf753f"
dependencies = [
- "wayland-backend",
- "wayland-sys",
+ "wayland-backend 0.3.12",
+ "wayland-sys 0.31.8",
]
[[package]]
@@ -6895,8 +7311,31 @@ version = "0.32.10"
source = "git+https://github.com/kevinaboos/makepad?branch=script_reapply_variant#4c9c85d94ae650d4ab2eafd615a88fe62ccf753f"
dependencies = [
"bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "wayland-backend",
- "wayland-client",
+ "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",
]
[[package]]
@@ -6908,6 +7347,17 @@ 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"
@@ -7686,6 +8136,67 @@ 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"
@@ -7785,3 +8296,59 @@ name = "zmij"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea"
+
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+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 aecfbc8fa..e022e4224 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,6 +44,9 @@ hashbrown = { version = "0.16", features = ["raw-entry"] }
htmlize = "1.0.5"
indexmap = "2.6.0"
imghdr = "0.7.0"
+image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
+mime = "0.3"
+mime_guess = "2.0"
linkify = "0.10.0"
matrix-sdk-base = { git = "https://github.com/project-robius/matrix-rust-sdk", branch = "space_room_suggested" }
matrix-sdk = { git = "https://github.com/project-robius/matrix-rust-sdk", branch = "space_room_suggested", default-features = false, features = [
@@ -104,6 +107,10 @@ 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"
+
[features]
default = []
diff --git a/resources/icons/add_attachment.svg b/resources/icons/add_attachment.svg
new file mode 100644
index 000000000..523461c6a
--- /dev/null
+++ b/resources/icons/add_attachment.svg
@@ -0,0 +1,3 @@
+
diff --git a/resources/icons/file.svg b/resources/icons/file.svg
new file mode 100644
index 000000000..2b0852bf9
--- /dev/null
+++ b/resources/icons/file.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/app.rs b/src/app.rs
index 7ce812fb9..ed5cd99ca 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -16,6 +16,7 @@ use crate::{
VerificationModalWidgetRefExt,
}
};
+use crate::shared::file_upload_modal::{FileUploadModalWidgetRefExt, FilePreviewerAction};
script_mod! {
use mod.prelude.widgets.*
@@ -143,6 +144,16 @@ script_mod! {
}
}
+ // A modal to preview and confirm file uploads.
+ file_upload_modal := Modal {
+ content +: {
+ height: Fill,
+ width: Fill,
+ align: Align{x: 0.5, y: 0.5},
+ file_upload_modal_inner := FileUploadModal {}
+ }
+ }
+
PopupList {}
// Tooltips must be shown in front of all other UI elements,
@@ -284,6 +295,21 @@ impl MatchEvent for App {
// which will open the login_status_modal to show the failure message.
}
+ // Handle file upload modal actions
+ match action.downcast_ref() {
+ Some(FilePreviewerAction::Show { file_data, timeline_update_sender }) => {
+ self.ui.file_upload_modal(cx, ids!(file_upload_modal_inner))
+ .set_file_data(cx, file_data.clone(), timeline_update_sender.clone());
+ self.ui.modal(cx, ids!(file_upload_modal)).open(cx);
+ continue;
+ }
+ Some(FilePreviewerAction::Hide) => {
+ self.ui.modal(cx, ids!(file_upload_modal)).close(cx);
+ continue;
+ }
+ _ => {}
+ }
+
// Handle an action requesting to open the new message context menu.
if let MessageAction::OpenMessageContextMenu { details, abs_pos } = action.as_widget_action().cast() {
self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx);
@@ -589,6 +615,7 @@ impl AppMain for App {
crate::home::location_preview::script_mod(vm);
crate::home::tombstone_footer::script_mod(vm);
crate::home::editing_pane::script_mod(vm);
+ crate::home::upload_progress::script_mod(vm);
crate::room::script_mod(vm);
crate::join_leave_room_modal::script_mod(vm);
crate::verification_modal::script_mod(vm);
diff --git a/src/home/mod.rs b/src/home/mod.rs
index 23a1de96d..b96ee0992 100644
--- a/src/home/mod.rs
+++ b/src/home/mod.rs
@@ -29,6 +29,7 @@ pub mod new_message_context_menu;
pub mod room_context_menu;
pub mod link_preview;
pub mod room_image_viewer;
+pub mod upload_progress;
pub fn script_mod(vm: &mut ScriptVm) {
search_messages::script_mod(vm);
@@ -58,6 +59,7 @@ pub fn script_mod(vm: &mut ScriptVm) {
main_desktop_ui::script_mod(vm);
spaces_bar::script_mod(vm);
navigation_tab_bar::script_mod(vm);
+ upload_progress::script_mod(vm);
// Keep HomeScreen last, it references many widgets registered above.
home_screen::script_mod(vm);
}
diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs
index 6b984ed50..b8cadfd77 100644
--- a/src/home/room_screen.rs
+++ b/src/home/room_screen.rs
@@ -44,7 +44,7 @@ use crate::shared::mentionable_text_input::MentionableTextInputAction;
use rangemap::RangeSet;
-use super::{event_reaction_list::ReactionData, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}};
+use super::{event_reaction_list::ReactionData, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}, upload_progress::UploadProgressViewAction};
/// The maximum number of timeline items to search through
/// when looking for a particular event.
@@ -860,6 +860,31 @@ impl Widget for RoomScreen {
continue;
}
+ // Handle cancel action from upload progress view.
+ // The upload progress view already hides itself and aborts the upload,
+ // so we just need to acknowledge the action here.
+ if let UploadProgressViewAction::Cancelled = action.as_widget_action().cast() {
+ // Nothing additional to do - the widget handles hiding itself
+ continue;
+ }
+
+ // Handle retry action from upload progress view.
+ if let UploadProgressViewAction::Retry { file_data, timeline_kind } = action.as_widget_action().cast() {
+ let Some(tl) = self.tl_state.as_ref() else { continue };
+ // Only handle if this action is for the current room/thread.
+ if tl.kind != timeline_kind { continue };
+ let room_input_bar = self.view.room_input_bar(cx, ids!(room_input_bar));
+ room_input_bar.show_upload_progress(cx, &file_data.name);
+ submit_async_request(MatrixRequest::SendAttachment {
+ timeline_kind,
+ file_data,
+ replied_to: None,
+ #[cfg(feature = "tsp")]
+ sign_with_tsp: room_input_bar.is_tsp_signing_enabled(cx),
+ });
+ continue;
+ }
+
// Handle the highlight animation for a message.
let Some(tl) = self.tl_state.as_mut() else { continue };
if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state {
@@ -984,6 +1009,7 @@ impl Widget for RoomScreen {
timeline_kind: tl.kind.clone(),
room_members,
room_avatar_url,
+ timeline_update_sender: Some(tl.update_sender.clone()),
}
} else if let Some(room_name) = &self.room_name_id {
// Fallback case: we have a room_name but no tl_state yet
@@ -994,6 +1020,7 @@ impl Widget for RoomScreen {
.expect("BUG: room_name_id was set but timeline_kind was missing"),
room_members: None,
room_avatar_url: None,
+ timeline_update_sender: None,
}
} else {
// No room selected yet, skip event handling that requires room context
@@ -1009,6 +1036,7 @@ impl Widget for RoomScreen {
timeline_kind: TimelineKind::MainRoom { room_id },
room_members: None,
room_avatar_url: None,
+ timeline_update_sender: None,
}
};
let mut room_scope = Scope::with_props(&room_props);
@@ -1646,6 +1674,33 @@ impl RoomScreen {
tl.tombstone_info = Some(successor_room_details);
}
TimelineUpdate::LinkPreviewFetched => {}
+ TimelineUpdate::FileUploadConfirmed(file_data) => {
+ let room_input_bar = self.view.room_input_bar(cx, ids!(room_input_bar));
+ let replied_to = room_input_bar.handle_file_upload_confirmed(cx, &file_data.name);
+ submit_async_request(MatrixRequest::SendAttachment {
+ timeline_kind: tl.kind.clone(),
+ file_data,
+ replied_to,
+ #[cfg(feature = "tsp")]
+ sign_with_tsp: room_input_bar.is_tsp_signing_enabled(cx),
+ });
+ }
+ TimelineUpdate::FileUploadUpdate { current, total } => {
+ self.view.room_input_bar(cx, ids!(room_input_bar))
+ .set_upload_progress(cx, current, total);
+ }
+ TimelineUpdate::FileUploadAbortHandle(handle) => {
+ self.view.room_input_bar(cx, ids!(room_input_bar))
+ .set_upload_abort_handle(handle);
+ }
+ TimelineUpdate::FileUploadError { error, file_data } => {
+ self.view.room_input_bar(cx, ids!(room_input_bar))
+ .show_upload_error(cx, &error, file_data);
+ }
+ TimelineUpdate::FileUploadComplete => {
+ self.view.room_input_bar(cx, ids!(room_input_bar))
+ .hide_upload_progress(cx);
+ }
}
}
@@ -2298,6 +2353,7 @@ impl RoomScreen {
content_drawn_since_last_update: RangeSet::new(),
profile_drawn_since_last_update: RangeSet::new(),
update_receiver,
+ update_sender: update_sender.clone(),
request_sender,
media_cache: MediaCache::new(Some(update_sender.clone())),
link_preview_cache: LinkPreviewCache::new(Some(update_sender)),
@@ -2665,6 +2721,9 @@ pub struct RoomScreenProps {
pub timeline_kind: TimelineKind,
pub room_members: Option>>,
pub room_avatar_url: Option,
+ /// The sender for timeline updates, used for file uploads and other UI-initiated updates.
+ /// This is `None` when the timeline hasn't been fully loaded yet.
+ pub timeline_update_sender: Option>,
}
@@ -2794,6 +2853,22 @@ pub enum TimelineUpdate {
Tombstoned(SuccessorRoomDetails),
/// A notice that link preview data for a URL has been fetched and is now available.
LinkPreviewFetched,
+ /// User confirmed a file upload via the file upload modal.
+ FileUploadConfirmed(crate::shared::file_upload_modal::FileData),
+ /// Progress update for an ongoing file upload.
+ FileUploadUpdate {
+ current: u64,
+ total: u64,
+ },
+ /// The abort handle for an in-progress file upload.
+ FileUploadAbortHandle(tokio::task::AbortHandle),
+ /// An error occurred during file upload.
+ FileUploadError {
+ error: String,
+ file_data: crate::shared::file_upload_modal::FileData,
+ },
+ /// File upload completed successfully.
+ FileUploadComplete,
}
thread_local! {
@@ -2855,6 +2930,10 @@ struct TimelineUiState {
/// which is okay because a sender on an unbounded channel never needs to block.
update_receiver: crossbeam_channel::Receiver,
+ /// The channel sender for timeline updates for this room.
+ /// This is used to send upload confirmations and other UI-initiated updates.
+ update_sender: crossbeam_channel::Sender,
+
/// The sender for timeline requests from a RoomScreen showing this room
/// to the background async task that handles this room's timeline updates.
request_sender: TimelineRequestSender,
diff --git a/src/home/upload_progress.rs b/src/home/upload_progress.rs
new file mode 100644
index 000000000..29c12cd67
--- /dev/null
+++ b/src/home/upload_progress.rs
@@ -0,0 +1,289 @@
+//! A widget that displays upload progress with a progress bar, status label,
+//! and cancel/retry buttons.
+
+use bytesize::ByteSize;
+use makepad_widgets::*;
+use tokio::task::AbortHandle;
+
+use crate::home::room_screen::RoomScreenProps;
+use crate::shared::file_upload_modal::FileData;
+use crate::shared::progress_bar::ProgressBarWidgetRefExt;
+use crate::sliding_sync::TimelineKind;
+
+script_mod! {
+ use mod.prelude.widgets.*
+ use mod.widgets.*
+
+ mod.widgets.UploadProgressView = set_type_default() do #(UploadProgressView::register_widget(vm)) {
+ visible: false,
+ width: Fill,
+ height: Fit,
+ flow: Down,
+ padding: 10,
+ spacing: 8,
+
+ show_bg: true,
+ draw_bg +: {
+ color: (COLOR_BG_PREVIEW)
+ border_radius: 4.0
+ }
+
+ // Header with file name and cancel button
+ header := View {
+ width: Fill,
+ height: Fit,
+ flow: Right,
+ align: Align{x: 0.0, y: 0.5},
+ spacing: 10,
+
+ uploading_label := Label {
+ width: Fit,
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 10 },
+ color: (COLOR_TEXT)
+ }
+ text: "Uploading: "
+ }
+
+ file_name_label := Label {
+ width: Fill,
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 10 },
+ color: (COLOR_TEXT)
+ }
+ text: ""
+ }
+
+ cancel_button := RobrixNeutralIconButton {
+ width: 24, height: 24,
+ padding: 4,
+ draw_icon +: { svg: (ICON_CLOSE) }
+ icon_walk: Walk{width: 14, height: 14}
+ text: ""
+ }
+ }
+
+ // Progress bar
+ progress_bar := ProgressBar {
+ width: Fill,
+ height: 6,
+ }
+
+ // Status/error area
+ status_view := View {
+ width: Fill,
+ height: Fit,
+ flow: Right,
+ align: Align{x: 0.0, y: 0.5},
+ spacing: 10,
+
+ status_label := Label {
+ width: Fill,
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 9 },
+ color: (SMALL_STATE_TEXT_COLOR)
+ }
+ text: ""
+ }
+
+ retry_button := RobrixPositiveIconButton {
+ enabled: false,
+ padding: Inset{top: 4, bottom: 4, left: 8, right: 8}
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 9 },
+ }
+ text: "Retry"
+ }
+ }
+ }
+}
+
+/// The current state of the upload view.
+#[derive(Clone, Debug, Default)]
+pub enum UploadViewState {
+ /// Normal state - upload in progress or ready.
+ #[default]
+ Normal,
+ /// Error state - upload failed.
+ Error {
+ message: String,
+ file_data: FileData,
+ },
+}
+
+/// Actions emitted by the UploadProgressView.
+#[derive(Clone, Debug, Default)]
+pub enum UploadProgressViewAction {
+ /// No action.
+ #[default]
+ None,
+ /// User cancelled the upload.
+ Cancelled,
+ /// User requested retry of a failed upload.
+ Retry {
+ file_data: FileData,
+ timeline_kind: TimelineKind,
+ },
+}
+
+/// A widget showing upload progress with cancel/retry functionality.
+#[derive(Script, ScriptHook, Widget)]
+pub struct UploadProgressView {
+ #[source] source: ScriptObjectRef,
+ #[deref] view: View,
+
+ /// Handle to abort the current upload task.
+ #[rust] abort_handle: Option,
+ /// Current progress value (0.0 to 1.0).
+ #[rust] progress: f32,
+ /// Current state of the upload view.
+ #[rust] state: UploadViewState,
+}
+
+impl Widget for UploadProgressView {
+ fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
+ if let Event::Actions(actions) = event {
+ // Handle cancel button
+ if self.button(cx, ids!(cancel_button)).clicked(actions) {
+ if let Some(handle) = self.abort_handle.take() {
+ handle.abort();
+ }
+ cx.widget_action(self.widget_uid(), UploadProgressViewAction::Cancelled);
+ self.hide(cx);
+ }
+
+ // Handle retry button
+ if self.button(cx, ids!(retry_button)).clicked(actions) {
+ if let UploadViewState::Error { file_data, .. } = &self.state {
+ if let Some(room_screen_props) = scope.props.get::() {
+ cx.widget_action(self.widget_uid(), UploadProgressViewAction::Retry {
+ file_data: file_data.clone(),
+ timeline_kind: room_screen_props.timeline_kind.clone(),
+ });
+ }
+ }
+ }
+ }
+
+ self.view.handle_event(cx, event, scope);
+ }
+
+ fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
+ self.view.draw_walk(cx, scope, walk)
+ }
+}
+
+impl UploadProgressView {
+ /// Shows the upload progress view with the given file name.
+ pub fn show(&mut self, cx: &mut Cx, file_name: &str) {
+ self.set_visible(cx, true);
+ self.state = UploadViewState::Normal;
+ self.progress = 0.0;
+
+ self.label(cx, ids!(file_name_label)).set_text(cx, file_name);
+ self.label(cx, ids!(status_label)).set_text(cx, "Starting upload...");
+ self.button(cx, ids!(retry_button)).set_enabled(cx, false);
+ self.button(cx, ids!(cancel_button)).set_enabled(cx, true);
+
+ // Reset progress bar
+ self.child_by_path(ids!(progress_bar)).as_progress_bar().set_progress(cx, 0.0);
+
+ self.redraw(cx);
+ }
+
+ /// Hides the upload progress view.
+ pub fn hide(&mut self, cx: &mut Cx) {
+ self.set_visible(cx, false);
+ self.abort_handle = None;
+ self.state = UploadViewState::Normal;
+ self.redraw(cx);
+ }
+
+ /// Updates the progress value.
+ pub fn set_progress(&mut self, cx: &mut Cx, current: u64, total: u64) {
+ if let UploadViewState::Error{ message: _, file_data: _ } = self.state {
+ return
+ }
+ self.progress = if total > 0 {
+ (current as f32 / total as f32).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+
+ self.child_by_path(ids!(progress_bar)).as_progress_bar()
+ .set_progress(cx, self.progress);
+
+ // Update status label
+ let percent = (self.progress * 100.0) as u32;
+ let status = format!(
+ "Uploading... {}% ({} / {})",
+ percent,
+ ByteSize::b(current),
+ ByteSize::b(total)
+ );
+ self.label(cx, ids!(status_label)).set_text(cx, &status);
+
+ self.redraw(cx);
+ }
+
+ /// Sets the abort handle for the current upload task.
+ pub fn set_abort_handle(&mut self, handle: AbortHandle) {
+ self.abort_handle = Some(handle);
+ }
+
+ /// Shows an error state with the given message.
+ pub fn show_error(&mut self, cx: &mut Cx, error: &str, file_data: FileData) {
+ self.state = UploadViewState::Error {
+ message: error.to_string(),
+ file_data,
+ };
+
+ // Update UI for error state
+ self.label(cx, ids!(status_label))
+ .set_text(cx, &format!("Error: {}", error));
+ self.button(cx, ids!(retry_button)).set_enabled(cx, true);
+ self.button(cx, ids!(cancel_button)).set_enabled(cx, true);
+
+ // Set progress bar to error color - no longer apply color change via script_apply_eval
+ // The progress bar will use the default color for now
+
+ self.redraw(cx);
+ }
+}
+
+impl UploadProgressViewRef {
+ /// Shows the upload progress view with the given file name.
+ pub fn show(&self, cx: &mut Cx, file_name: &str) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.show(cx, file_name);
+ }
+ }
+
+ /// Hides the upload progress view.
+ pub fn hide(&self, cx: &mut Cx) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.hide(cx);
+ }
+ }
+
+ /// Updates the progress value.
+ pub fn set_progress(&self, cx: &mut Cx, current: u64, total: u64) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.set_progress(cx, current, total);
+ }
+ }
+
+ /// Sets the abort handle for the current upload task.
+ pub fn set_abort_handle(&self, handle: AbortHandle) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.set_abort_handle(handle);
+ }
+ }
+
+ /// Shows an error state with the given message.
+ pub fn show_error(&self, cx: &mut Cx, error: &str, file_data: FileData) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.show_error(cx, error, file_data);
+ }
+ }
+}
diff --git a/src/image_utils.rs b/src/image_utils.rs
new file mode 100644
index 000000000..e4d1eadea
--- /dev/null
+++ b/src/image_utils.rs
@@ -0,0 +1,13 @@
+//! Image processing utilities for thumbnail generation and image manipulation.
+
+/// Returns true if the given MIME type represents an image format that can be displayed.
+///
+/// Note: Only JPEG and PNG are supported because those are the only formats
+/// enabled in the `image` crate features. Other formats (gif, webp, bmp) would
+/// fail to decode.
+pub fn is_displayable_image(mime_type: &str) -> bool {
+ matches!(
+ mime_type,
+ "image/png" | "image/jpeg" | "image/jpg"
+ )
+}
diff --git a/src/lib.rs b/src/lib.rs
index 346c0314b..750bee2f1 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -80,6 +80,7 @@ pub mod verification;
pub mod utils;
pub mod temp_storage;
pub mod location;
+pub mod image_utils;
pub const APP_QUALIFIER: &str = "org";
pub const APP_ORGANIZATION: &str = "robius";
diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs
index fb5a7233e..9ec1488de 100644
--- a/src/room/room_input_bar.rs
+++ b/src/room/room_input_bar.rs
@@ -16,12 +16,21 @@
//!
+#[cfg(not(any(target_os = "ios", target_os = "android")))]
+use bytesize::ByteSize;
use makepad_widgets::*;
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}, 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}}, location::init_location_subscriber, settings::app_preferences::{AppPreferencesGlobal, AppPreferencesAction}, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils};
+#[cfg(not(any(target_os = "ios", target_os = "android")))]
+use std::sync::Arc;
+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::{FileData, FileLoadedData, FilePreviewerAction, TimelineUpdateSender}, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils};
+#[cfg(not(any(target_os = "ios", target_os = "android")))]
+use crate::shared::file_upload_modal::FilePreviewerMetaData;
+// Check file size limit (100 MB - homeservers typically cap at 50-100 MB)
+#[cfg(not(any(target_os = "ios", target_os = "android")))]
+const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
script_mod! {
use mod.prelude.widgets.*
@@ -60,6 +69,9 @@ script_mod! {
// Below that, display a preview of the current location that a user is about to send.
location_preview := LocationPreview { }
+ // Upload progress view (shown when a file upload is in progress)
+ upload_progress_view := UploadProgressView { }
+
// Below that, display one of multiple possible views:
// * the message input bar (buttons and message TextInput).
// * a notice that the user can't send messages to this room.
@@ -80,6 +92,23 @@ script_mod! {
align: Align{y: 1.0},
padding: 6,
+ // Attachment button for uploading files/images
+ send_attachment_button := RobrixIconButton {
+ margin: 4
+ spacing: 0,
+ draw_icon +: {
+ svg: (ICON_ADD_ATTACHMENT)
+ color: (COLOR_ACTIVE_PRIMARY_DARKER)
+ },
+ draw_bg +: {
+ color: (COLOR_BG_PREVIEW)
+ color_hover: #E0E8F0
+ color_down: #D0D8E8
+ }
+ icon_walk: Walk{width: 21, height: 21}
+ text: "",
+ }
+
location_button := RobrixIconButton {
margin: 4
spacing: 0,
@@ -174,6 +203,12 @@ 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 load operation, if any. Contains the receiver channel
+ /// for receiving the loaded file data from a background thread.
+ #[rust] pending_file_load: Option,
+ /// The timeline update sender captured when a file picker is opened, to ensure the file
+ /// is uploaded to the correct room/thread even if the user switches rooms.
+ #[rust] pending_file_update_sender: Option,
}
impl ScriptHook for RoomInputBar {
@@ -228,6 +263,41 @@ impl Widget for RoomInputBar {
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_load {
+ let mut remove_receiver = false;
+ match receiver.try_recv() {
+ Ok(Some(loaded_data)) => {
+ // Convert FileLoadedData to FileData for the modal
+ let file_data = convert_loaded_data_to_file_data(loaded_data);
+ // Use the captured sender from when the file picker was opened
+ if let Some(timeline_update_sender) = self.pending_file_update_sender.take() {
+ Cx::post_action(FilePreviewerAction::Show { file_data, timeline_update_sender });
+ }
+ remove_receiver = true;
+ }
+ Ok(None) => {
+ // File loading failed
+ self.pending_file_update_sender = None;
+ remove_receiver = true;
+ }
+ Err(std::sync::mpsc::TryRecvError::Empty) => {
+ // Still waiting for data
+ }
+ Err(std::sync::mpsc::TryRecvError::Disconnected) => {
+ // Channel disconnected
+ self.pending_file_update_sender = None;
+ remove_receiver = true;
+ }
+ }
+ if remove_receiver {
+ self.pending_file_load = None;
+ self.redraw(cx);
+ }
+ }
+ }
+
self.view.handle_event(cx, event, scope);
}
@@ -283,6 +353,12 @@ impl RoomInputBar {
self.redraw(cx);
}
+ // Handle the add attachment button being clicked.
+ if self.button(cx, ids!(send_attachment_button)).clicked(actions) {
+ log!("Add attachment button clicked; opening file picker...");
+ self.open_file_picker(cx, room_screen_props.timeline_update_sender.clone());
+ }
+
// Handle the add location button being clicked.
if self.button(cx, ids!(location_button)).clicked(actions) {
log!("Add location button clicked; requesting current location...");
@@ -589,6 +665,119 @@ impl RoomInputBar {
fn is_tsp_signing_enabled(&self, cx: &mut Cx) -> bool {
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 update sender 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")))]
+ fn open_file_picker(&mut self, cx: &mut Cx, timeline_update_sender: Option) {
+ // Get the timeline update sender - it's passed from RoomScreenProps
+ let Some(timeline_update_sender) = timeline_update_sender else {
+ enqueue_popup_notification(
+ "Cannot upload file: timeline not available.",
+ PopupKind::Error,
+ None,
+ );
+ return;
+ };
+
+ // Run file dialog on main thread (required for non-windowed environments)
+ let dialog = rfd::FileDialog::new()
+ .set_title("Select file to upload")
+ .add_filter("All files", &["*"])
+ .add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
+ .add_filter("Documents", &["pdf", "doc", "docx", "txt", "rtf"]);
+
+ if let Some(selected_file_path) = dialog.pick_file() {
+ // Store the sender for when the file finishes loading
+ self.pending_file_update_sender = Some(timeline_update_sender);
+ // Get file metadata
+ let file_size = match std::fs::metadata(&selected_file_path) {
+ Ok(metadata) => metadata.len(),
+ Err(e) => {
+ makepad_widgets::error!("Failed to read file metadata: {e}");
+ enqueue_popup_notification(
+ format!("Unable to access file: {e}"),
+ PopupKind::Error,
+ None,
+ );
+ return;
+ }
+ };
+
+ // Check for empty files
+ if file_size == 0 {
+ enqueue_popup_notification("Cannot upload empty file", PopupKind::Error, None);
+ return;
+ }
+
+ if file_size > MAX_FILE_SIZE {
+ enqueue_popup_notification(
+ format!(
+ "File too large ({}). Maximum upload size is 100 MB.",
+ ByteSize::b(file_size)
+ ),
+ PopupKind::Error,
+ None,
+ );
+ return;
+ }
+
+ // Detect the MIME type from the file extension
+ let mime = mime_guess::from_path(&selected_file_path)
+ .first_or_octet_stream();
+
+ // Create channel for receiving loaded file data
+ let (sender, receiver) = std::sync::mpsc::channel();
+ self.pending_file_load = Some(receiver);
+
+ // Spawn background thread to read file and generate thumbnail (for images)
+ let path_clone = selected_file_path.clone();
+ let mime_clone = mime.clone();
+ cx.spawn_thread(move || {
+ // Read the file data in the background thread (not on UI thread)
+ let file_data = match std::fs::read(&path_clone) {
+ Ok(data) => data,
+ Err(e) => {
+ makepad_widgets::error!("Failed to read file: {e}");
+ if sender.send(None).is_err() {
+ makepad_widgets::error!("Failed to send error to UI: receiver dropped");
+ }
+ SignalToUI::set_ui_signal();
+ return;
+ }
+ };
+
+ // Wrap file data in Arc to avoid copying when passed through channels
+ let file_data = Arc::new(file_data);
+
+ let loaded_data = FileLoadedData {
+ metadata: FilePreviewerMetaData {
+ mime: mime_clone,
+ file_size,
+ file_path: path_clone,
+ },
+ data: file_data,
+ };
+
+ if sender.send(Some(loaded_data)).is_err() {
+ makepad_widgets::error!("Failed to send file data to UI: receiver dropped");
+ }
+ 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_update_sender: Option) {
+ enqueue_popup_notification(
+ "File uploads are not yet supported on this platform.",
+ PopupKind::Error,
+ None,
+ );
+ }
}
impl RoomInputBarRef {
@@ -721,6 +910,85 @@ impl RoomInputBarRef {
// This depends on the `EditingPane` state, so it must be done after Step 3.
inner.update_tombstone_footer(cx, timeline_kind.room_id(), tombstone_info);
}
+
+ /// Shows the upload progress view for a file upload.
+ pub fn show_upload_progress(&self, cx: &mut Cx, file_name: &str) {
+ let Some(inner) = self.borrow() else { return };
+ inner.child_by_path(ids!(upload_progress_view))
+ .as_upload_progress_view()
+ .show(cx, file_name);
+ }
+
+ /// Hides the upload progress view.
+ pub fn hide_upload_progress(&self, cx: &mut Cx) {
+ let Some(inner) = self.borrow() else { return };
+ inner.child_by_path(ids!(upload_progress_view))
+ .as_upload_progress_view()
+ .hide(cx);
+ }
+
+ /// Updates the upload progress.
+ pub fn set_upload_progress(&self, cx: &mut Cx, current: u64, total: u64) {
+ let Some(inner) = self.borrow() else { return };
+ inner.child_by_path(ids!(upload_progress_view))
+ .as_upload_progress_view()
+ .set_progress(cx, current, total);
+ }
+
+ /// Sets the abort handle for the current upload.
+ pub fn set_upload_abort_handle(&self, handle: tokio::task::AbortHandle) {
+ let Some(inner) = self.borrow_mut() else { return };
+ inner.child_by_path(ids!(upload_progress_view))
+ .as_upload_progress_view()
+ .set_abort_handle(handle);
+ }
+
+ /// Shows an upload error with retry option.
+ pub fn show_upload_error(&self, cx: &mut Cx, error: &str, file_data: FileData) {
+ let Some(inner) = self.borrow() else { return };
+ inner.child_by_path(ids!(upload_progress_view))
+ .as_upload_progress_view()
+ .show_error(cx, error, file_data);
+ }
+
+ /// Handles a confirmed file upload from the file upload modal.
+ ///
+ /// This method:
+ /// - Shows the upload progress view
+ /// - Gets and clears any "replying to" state
+ /// - Returns the reply metadata (None if not replying or widget unavailable)
+ pub fn handle_file_upload_confirmed(&self, cx: &mut Cx, file_name: &str) -> Option {
+ let mut inner = self.borrow_mut()?;
+
+ // Get the reply metadata if replying to a message
+ let replied_to = inner
+ .replying_to
+ .take()
+ .and_then(|(event_tl_item, _embedded_event)| {
+ event_tl_item.event_id().map(|event_id| Reply {
+ event_id: event_id.to_owned(),
+ enforce_thread: EnforceThread::MaybeThreaded,
+ add_mentions: AddMentions::Yes
+ })
+ });
+
+ // Show the upload progress view
+ inner.child_by_path(ids!(upload_progress_view))
+ .as_upload_progress_view()
+ .show(cx, file_name);
+
+ // Clear the replying-to state
+ inner.clear_replying_to(cx);
+
+ replied_to
+ }
+
+ /// Returns whether TSP signing is enabled.
+ #[cfg(feature = "tsp")]
+ pub fn is_tsp_signing_enabled(&self, cx: &mut Cx) -> bool {
+ let Some(inner) = self.borrow() else { return false };
+ inner.is_tsp_signing_enabled(cx)
+ }
}
/// The saved UI state of a `RoomInputBar` widget.
@@ -748,3 +1016,23 @@ enum ShowEditingPaneBehavior {
editing_pane_state: EditingPaneState,
},
}
+
+/// Converts `FileLoadedData` from background thread to `FileData` for the modal.
+///
+/// The file data has already been read in the background thread,
+/// so this is a cheap conversion that doesn't block the UI thread.
+fn convert_loaded_data_to_file_data(loaded: FileLoadedData) -> FileData {
+ let name = loaded.metadata.file_path
+ .file_name()
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_else(|| "unknown".to_string());
+
+ FileData {
+ path: loaded.metadata.file_path,
+ name,
+ mime_type: loaded.metadata.mime.to_string(),
+ data: loaded.data,
+ size: loaded.metadata.file_size,
+ thumbnail: None, // Thumbnail generation is not currently implemented
+ }
+}
diff --git a/src/shared/file_upload_modal.rs b/src/shared/file_upload_modal.rs
new file mode 100644
index 000000000..464f2767c
--- /dev/null
+++ b/src/shared/file_upload_modal.rs
@@ -0,0 +1,343 @@
+//! A modal dialog for previewing and confirming file uploads.
+//!
+//! This modal shows a preview of the file (image thumbnail or file icon)
+//! along with file metadata and upload/cancel buttons.
+
+use bytesize::ByteSize;
+use makepad_widgets::*;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::home::room_screen::TimelineUpdate;
+
+/// Type alias for the sender used to send timeline updates.
+pub type TimelineUpdateSender = crossbeam_channel::Sender;
+
+script_mod! {
+ use mod.prelude.widgets.*
+ use mod.widgets.*
+
+ mod.widgets.FileUploadModal = set_type_default() do #(FileUploadModal::register_widget(vm)) {
+ ..mod.widgets.RoundedView
+
+ width: Fill { max: 1000 }
+ // TODO: i'd like for this height to be Fit with a max of Rel { base: Full, factor: 0.90 },
+ // but Makepad doesn't allow Fit views with a max to be scrolled.
+ height: Fill // { max: 1400 }
+ margin: 40,
+ align: Align{x: 0.5, y: 0}
+ flow: Down
+ padding: Inset{top: 20, right: 25, bottom: 20, left: 25}
+ show_bg: true,
+ draw_bg +: {
+ color: (COLOR_PRIMARY)
+ border_radius: 8.0
+ border_size: 0.0
+ }
+
+ // Header
+ header := View {
+ width: Fill,
+ height: Fit,
+ flow: Right,
+ align: Align{x: 0.0, y: 0.5},
+ spacing: 10,
+
+ title := Label {
+ width: Fill,
+ draw_text +: {
+ text_style: TITLE_TEXT { font_size: 14 },
+ color: (COLOR_TEXT)
+ }
+ text: "Upload File"
+ }
+
+ close_button := RobrixNeutralIconButton {
+ width: Fit,
+ height: Fit,
+ align: Align{x: 1.0, y: 0.0},
+ spacing: 0,
+ margin: Inset{top: 4.5} // vertically align with the title
+ padding: 15,
+ draw_icon.svg: (ICON_CLOSE)
+ icon_walk: Walk{width: 14, height: 14}
+ }
+ }
+
+ // Preview area - fills available space with image/icon centered
+ preview_container := View {
+ width: Fill,
+ height: Fill,
+ flow: Overlay,
+ align: Align{x: 0.5, y: 0.5},
+
+ show_bg: true,
+ draw_bg.color: (COLOR_SECONDARY)
+
+ // Image preview container (visible when file is an image)
+ image_preview_container := View {
+ visible: false,
+ width: Fill,
+ height: Fill,
+ image_preview := Image {
+ width: Fill,
+ height: Fill,
+ fit: ImageFit.Smallest,
+ }
+ }
+
+ // File icon (visible when file is not an image)
+ file_icon_container := View {
+ visible: false,
+ width: Fill,
+ height: Fill,
+ align: Align{x: 0.5, y: 0.5},
+ flow: Down,
+ spacing: 10,
+
+ Icon {
+ width: Fit, height: Fit,
+ draw_icon +: {
+ svg: (ICON_FILE)
+ color: (COLOR_TEXT)
+ }
+ icon_walk: Walk{width: 64, height: 64}
+ }
+
+ file_type_label := Label {
+ width: Fit,
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 10 },
+ color: (SMALL_STATE_TEXT_COLOR)
+ }
+ text: ""
+ }
+ }
+ }
+
+ // File info
+ file_info := View {
+ width: Fill,
+ height: Fit,
+ flow: Down,
+ spacing: 5,
+
+ file_name_label := Label {
+ width: Fill,
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 11 },
+ color: (COLOR_TEXT),
+ wrap: Word,
+ }
+ text: ""
+ }
+
+ file_size_label := Label {
+ width: Fill,
+ draw_text +: {
+ text_style: REGULAR_TEXT { font_size: 10 },
+ color: (SMALL_STATE_TEXT_COLOR)
+ }
+ text: ""
+ }
+ }
+
+ // Buttons
+ buttons := View {
+ width: Fill,
+ height: Fit,
+ flow: Right,
+ align: Align{x: 1.0, y: 0.5},
+ spacing: 10,
+
+ cancel_button := RobrixNeutralIconButton {
+ padding: Inset{top: 8, bottom: 8, left: 16, right: 16}
+ text: "Cancel"
+ }
+
+ upload_button := RobrixPositiveIconButton {
+ padding: Inset{top: 8, bottom: 8, left: 16, right: 16}
+ draw_icon +: { svg: (ICON_UPLOAD) }
+ icon_walk: Walk{width: 16, height: Fit, margin: Inset{right: 4}}
+ text: "Upload"
+ }
+ }
+ }
+}
+
+/// Data describing a file to be uploaded.
+#[derive(Clone, Debug)]
+pub struct FileData {
+ /// The file path on the local filesystem.
+ pub path: PathBuf,
+ /// The file name (without directory path).
+ pub name: String,
+ /// The MIME type of the file.
+ pub mime_type: String,
+ /// The raw file data (wrapped in Arc to avoid copying large files).
+ pub data: Arc>,
+ /// The file size in bytes.
+ pub size: u64,
+ /// Optional thumbnail data for images (JPEG bytes).
+ pub thumbnail: Option,
+}
+
+/// Thumbnail data for image files.
+#[derive(Clone, Debug)]
+pub struct ThumbnailData {
+ /// The thumbnail image data (JPEG).
+ pub data: Vec,
+ /// Width of the thumbnail.
+ pub width: u32,
+ /// Height of the thumbnail.
+ pub height: u32,
+}
+
+/// Metadata for the file previewer (used in background loading).
+#[derive(Debug, Clone)]
+pub struct FilePreviewerMetaData {
+ /// MIME type of the file.
+ pub mime: mime_guess::Mime,
+ /// File size in bytes.
+ pub file_size: u64,
+ /// Path to the original file.
+ pub file_path: PathBuf,
+}
+
+/// Data loaded from a file by a background thread.
+/// This is sent through a channel and combined with additional data to create `FileData`.
+#[derive(Debug, Clone)]
+pub struct FileLoadedData {
+ /// Metadata about the file (path, size, MIME type).
+ pub metadata: FilePreviewerMetaData,
+ /// The raw file data read from disk (wrapped in Arc to avoid copying large files).
+ pub data: Arc>,
+}
+
+/// Type alias for the receiver that gets loaded file data from a background thread.
+pub type FileLoadReceiver = std::sync::mpsc::Receiver