diff --git a/Cargo.lock b/Cargo.lock
index 89c2a4693..a353b4e61 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.14",
+ "wayland-client 0.31.13",
+ "wayland-protocols 0.32.11",
+ "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.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
+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"
@@ -741,6 +895,12 @@ name = "byteorder"
version = "1.5.0"
source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60"
+[[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"
@@ -1459,7 +1619,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.1",
]
[[package]]
@@ -1469,6 +1629,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",
]
@@ -1483,6 +1645,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"
@@ -1592,6 +1763,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"
@@ -1605,7 +1803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.1",
]
[[package]]
@@ -1710,6 +1908,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.8 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "ff"
version = "0.13.1"
@@ -2532,6 +2739,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"
@@ -3158,7 +3380,7 @@ dependencies = [
"smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)",
"wayland-client",
"wayland-egl",
- "wayland-protocols",
+ "wayland-protocols 0.32.10",
"windows 0.62.2",
"windows-core 0.62.2",
"windows-targets 0.52.6",
@@ -3268,7 +3490,7 @@ name = "makepad-zune-inflate"
version = "0.2.0"
source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60"
dependencies = [
- "simd-adler32",
+ "simd-adler32 0.3.8 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)",
]
[[package]]
@@ -3680,6 +3902,15 @@ name = "memchr"
version = "2.7.6"
source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60"
+[[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"
@@ -3692,6 +3923,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"
@@ -3705,6 +3946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
+ "simd-adler32 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@@ -3718,6 +3960,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"
@@ -3949,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 +4330,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"
@@ -4246,6 +4509,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"
@@ -4273,6 +4547,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"
@@ -4435,6 +4742,21 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+[[package]]
+name = "pxfm"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
+
+[[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"
@@ -4580,6 +4902,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"
@@ -4722,6 +5050,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"
@@ -4838,6 +5190,7 @@ dependencies = [
"futures-util",
"hashbrown 0.16.1",
"htmlize",
+ "image",
"imbl",
"imghdr",
"indexmap 2.13.0",
@@ -4847,11 +5200,14 @@ dependencies = [
"matrix-sdk",
"matrix-sdk-base",
"matrix-sdk-ui",
+ "mime",
+ "mime_guess",
"percent-encoding",
"quinn",
"rand 0.8.5",
"rangemap",
"reqwest",
+ "rfd",
"robius-directories",
"robius-location",
"robius-open",
@@ -5101,7 +5457,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.1",
]
[[package]]
@@ -5448,6 +5804,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.0.3"
@@ -5949,7 +6316,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.1",
]
[[package]]
@@ -6405,6 +6772,17 @@ version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6"
+[[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"
@@ -6778,7 +7156,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.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "scoped-tls",
+ "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "wayland-sys 0.31.10",
]
[[package]]
@@ -6788,7 +7180,19 @@ source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb
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.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3"
+dependencies = [
+ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rustix",
+ "wayland-backend 0.3.14",
+ "wayland-scanner",
]
[[package]]
@@ -6796,8 +7200,8 @@ name = "wayland-egl"
version = "0.32.9"
source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60"
dependencies = [
- "wayland-backend",
- "wayland-sys",
+ "wayland-backend 0.3.12",
+ "wayland-sys 0.31.8",
]
[[package]]
@@ -6806,8 +7210,31 @@ version = "0.32.10"
source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60"
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.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7"
+dependencies = [
+ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "wayland-backend 0.3.14",
+ "wayland-client 0.31.13",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
]
[[package]]
@@ -6819,6 +7246,17 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "wayland-sys"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17"
+dependencies = [
+ "dlib",
+ "log",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.84"
@@ -6883,7 +7321,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.61.1",
]
[[package]]
@@ -7484,6 +7922,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",
+ "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",
+ "zvariant",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.27"
@@ -7583,3 +8082,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.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
+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",
+ "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",
+]
diff --git a/Cargo.toml b/Cargo.toml
index daf7ba9e2..0bf9cfe41 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,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/matrix-org/matrix-rust-sdk", branch = "main" }
matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", branch = "main", default-features = false, features = [
@@ -100,6 +103,10 @@ reqwest = { version = "0.12", default-features = false, features = [
"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 b5df23ff8..39b97e421 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4,14 +4,15 @@
use std::{cell::RefCell, collections::HashMap};
use makepad_widgets::*;
+use makepad_widgets::SignalToUI;
use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, RoomId}};
use serde::{Deserialize, Serialize};
use crate::{
avatar_cache::clear_avatar_cache, home::{
- event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}
+ event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, TimelineUpdate, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}
}, join_leave_room_modal::{
JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt
- }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{
+ }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, file_upload_modal::{FilePreviewerAction, FileUploadModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, get_timeline_update_sender, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{
VerificationModalAction,
VerificationModalWidgetRefExt,
}
@@ -148,6 +149,15 @@ script_mod! {
}
}
+ // A modal to preview and confirm file uploads.
+ file_upload_modal := Modal {
+ content +: {
+ width: Fit, height: Fit,
+ 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,
@@ -308,6 +318,36 @@ impl MatchEvent for App {
continue;
}
+ // Handle file upload modal actions
+ match action.downcast_ref() {
+ Some(FilePreviewerAction::Show(file_data)) => {
+ self.ui.file_upload_modal(cx, ids!(file_upload_modal_inner))
+ .set_file_data(cx, file_data.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;
+ }
+ Some(FilePreviewerAction::UploadConfirmed(file_data)) => {
+ // Send the file upload request to the currently selected room
+ if let Some(selected_room) = &self.app_state.selected_room {
+ if let Some(timeline_kind) = selected_room.timeline_kind() {
+ if let Some(sender) = get_timeline_update_sender(&timeline_kind) {
+ let _ = sender.send(TimelineUpdate::FileUploadConfirmed(file_data.clone()));
+ SignalToUI::set_ui_signal();
+ }
+ }
+ }
+ continue;
+ }
+ Some(FilePreviewerAction::Cancelled) => {
+ 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);
@@ -630,6 +670,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);
@@ -925,6 +966,26 @@ impl SelectedRoom {
SelectedRoom::Thread { room_name_id, .. } => format!("[Thread] {room_name_id}"),
}
}
+
+ /// Returns the `TimelineKind` for this selected room.
+ ///
+ /// Returns `None` for `InvitedRoom` and `Space` variants, as they don't have timelines.
+ pub fn timeline_kind(&self) -> Option {
+ match self {
+ SelectedRoom::JoinedRoom { room_name_id } => {
+ Some(crate::sliding_sync::TimelineKind::MainRoom {
+ room_id: room_name_id.room_id().clone(),
+ })
+ }
+ SelectedRoom::Thread { room_name_id, thread_root_event_id } => {
+ Some(crate::sliding_sync::TimelineKind::Thread {
+ room_id: room_name_id.room_id().clone(),
+ thread_root_event_id: thread_root_event_id.clone(),
+ })
+ }
+ SelectedRoom::InvitedRoom { .. } | SelectedRoom::Space { .. } => None,
+ }
+ }
}
impl PartialEq for SelectedRoom {
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 61f20ced9..c9c77771a 100644
--- a/src/home/room_screen.rs
+++ b/src/home/room_screen.rs
@@ -1586,6 +1586,34 @@ 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));
+ if let Some(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);
+ }
}
}
@@ -2738,6 +2766,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! {
diff --git a/src/home/upload_progress.rs b/src/home/upload_progress.rs
new file mode 100644
index 000000000..d59fba8ae
--- /dev/null
+++ b/src/home/upload_progress.rs
@@ -0,0 +1,277 @@
+//! A widget that displays upload progress with a progress bar, status label,
+//! and cancel/retry buttons.
+
+use makepad_widgets::*;
+use tokio::task::AbortHandle;
+
+use crate::shared::file_upload_modal::FileData;
+use crate::shared::progress_bar::ProgressBarWidgetRefExt;
+
+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(FileData),
+}
+
+/// 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 {
+ let file_data = file_data.clone();
+ cx.widget_action(self.widget_uid(), UploadProgressViewAction::Retry(file_data));
+ self.hide(cx);
+ }
+ }
+ }
+
+ 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) {
+ 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,
+ crate::utils::format_file_size(current),
+ crate::utils::format_file_size(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..3b866dcb0
--- /dev/null
+++ b/src/image_utils.rs
@@ -0,0 +1,104 @@
+//! Image processing utilities for thumbnail generation and image manipulation.
+
+use std::io::Cursor;
+
+/// The maximum dimension (width or height) for generated thumbnails.
+pub const THUMBNAIL_MAX_DIMENSION: u32 = 800;
+
+/// Generates a thumbnail from the given image data.
+///
+/// The thumbnail is scaled to fit within `THUMBNAIL_MAX_DIMENSION` while preserving aspect ratio.
+/// Returns the thumbnail as JPEG-encoded bytes, along with the thumbnail's dimensions.
+///
+/// # Arguments
+/// * `image_data` - The raw bytes of the source image (PNG, JPEG, etc.)
+///
+/// # Returns
+/// * `Ok((jpeg_bytes, width, height))` - The thumbnail data and dimensions
+/// * `Err(String)` - Error message if thumbnail generation fails
+pub fn generate_thumbnail(image_data: &[u8]) -> Result<(Vec, u32, u32), String> {
+ use image::{ImageFormat, ImageReader};
+
+ // Load the image from bytes
+ let img = ImageReader::new(Cursor::new(image_data))
+ .with_guessed_format()
+ .map_err(|e| format!("Failed to guess image format: {e}"))?
+ .decode()
+ .map_err(|e| format!("Failed to decode image: {e}"))?;
+
+ let (orig_width, orig_height) = (img.width(), img.height());
+
+ // Calculate thumbnail dimensions while preserving aspect ratio
+ let (thumb_width, thumb_height) = if orig_width > THUMBNAIL_MAX_DIMENSION
+ || orig_height > THUMBNAIL_MAX_DIMENSION
+ {
+ let ratio = f64::from(orig_width) / f64::from(orig_height);
+ if orig_width > orig_height {
+ let new_width = THUMBNAIL_MAX_DIMENSION;
+ let new_height = (f64::from(new_width) / ratio) as u32;
+ (new_width, new_height)
+ } else {
+ let new_height = THUMBNAIL_MAX_DIMENSION;
+ let new_width = (f64::from(new_height) * ratio) as u32;
+ (new_width, new_height)
+ }
+ } else {
+ (orig_width, orig_height)
+ };
+
+ // Resize the image using a high-quality filter
+ let thumbnail = img.resize(
+ thumb_width,
+ thumb_height,
+ image::imageops::FilterType::Lanczos3,
+ );
+
+ // Encode as JPEG
+ let mut jpeg_bytes = Vec::new();
+ thumbnail
+ .write_to(&mut Cursor::new(&mut jpeg_bytes), ImageFormat::Jpeg)
+ .map_err(|e| format!("Failed to encode thumbnail as JPEG: {e}"))?;
+
+ Ok((jpeg_bytes, thumb_width, thumb_height))
+}
+
+/// Returns the MIME type string for the given image data by inspecting its header bytes.
+///
+/// Returns `None` if the image format cannot be determined.
+pub fn detect_mime_type(data: &[u8]) -> Option<&'static str> {
+ match imghdr::from_bytes(data) {
+ Some(imghdr::Type::Png) => Some("image/png"),
+ Some(imghdr::Type::Jpeg) => Some("image/jpeg"),
+ Some(imghdr::Type::Gif) => Some("image/gif"),
+ Some(imghdr::Type::Webp) => Some("image/webp"),
+ Some(imghdr::Type::Bmp) => Some("image/bmp"),
+ Some(imghdr::Type::Tiff) => Some("image/tiff"),
+ _ => None,
+ }
+}
+
+/// Returns true if the given MIME type represents an image format that can be displayed.
+pub fn is_displayable_image(mime_type: &str) -> bool {
+ matches!(
+ mime_type,
+ "image/png"
+ | "image/jpeg"
+ | "image/jpg"
+ | "image/gif"
+ | "image/webp"
+ | "image/bmp"
+ )
+}
+
+/// Gets the dimensions of an image from its raw bytes.
+///
+/// Returns `None` if the image cannot be decoded.
+pub fn get_image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
+ use image::ImageReader;
+
+ ImageReader::new(Cursor::new(data))
+ .with_guessed_format()
+ .ok()?
+ .into_dimensions()
+ .ok()
+}
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 93b8d4a9d..f1c2844c1 100644
--- a/src/room/room_input_bar.rs
+++ b/src/room/room_input_bar.rs
@@ -20,7 +20,7 @@ use makepad_widgets::*;
use matrix_sdk::room::reply::{EnforceThread, Reply};
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, 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};
+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, shared::{avatar::AvatarWidgetRefExt, file_upload_modal::{FileData, FileLoadedData, FilePreviewerAction, FilePreviewerMetaData, ThumbnailData}, 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.*
@@ -60,6 +60,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 +83,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,
@@ -169,6 +189,9 @@ pub struct RoomInputBar {
#[rust] was_replying_preview_visible: bool,
/// Info about the message event that the user is currently replying to, if any.
#[rust] replying_to: Option<(EventTimelineItem, EmbeddedEvent)>,
+ /// 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,
}
impl Widget for RoomInputBar {
@@ -203,6 +226,36 @@ 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);
+ Cx::post_action(FilePreviewerAction::Show(file_data));
+ remove_receiver = true;
+ }
+ Ok(None) => {
+ // File loading failed, hide modal if shown
+ remove_receiver = true;
+ }
+ Err(std::sync::mpsc::TryRecvError::Empty) => {
+ // Still waiting for data
+ }
+ Err(std::sync::mpsc::TryRecvError::Disconnected) => {
+ // Channel disconnected
+ remove_receiver = true;
+ }
+ }
+ if remove_receiver {
+ self.pending_file_load = None;
+ self.redraw(cx);
+ }
+ }
+ }
+
self.view.handle_event(cx, event, scope);
}
@@ -230,6 +283,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);
+ }
+
// Handle the add location button being clicked.
if self.button(cx, ids!(location_button)).clicked(actions) {
log!("Add location button clicked; requesting current location...");
@@ -532,6 +591,101 @@ 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.
+ #[cfg(not(any(target_os = "ios", target_os = "android")))]
+ fn open_file_picker(&mut self, cx: &mut Cx) {
+ // 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() {
+ // 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;
+ }
+
+ // 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 generate thumbnail (for images)
+ let path_clone = selected_file_path.clone();
+ let mime_clone = mime.clone();
+ cx.spawn_thread(move || {
+ // Generate thumbnail for images
+ let (thumbnail, dimensions) = if crate::image_utils::is_displayable_image(mime_clone.as_ref()) {
+ match std::fs::read(&path_clone) {
+ Ok(data) => {
+ match crate::image_utils::generate_thumbnail(&data) {
+ Ok((thumb_data, width, height)) => (
+ Some(ThumbnailData { data: thumb_data, width, height }),
+ Some((width, height))
+ ),
+ Err(e) => {
+ makepad_widgets::error!("Failed to generate thumbnail: {e}");
+ (None, None)
+ }
+ }
+ }
+ Err(e) => {
+ makepad_widgets::error!("Failed to read file for thumbnail: {e}");
+ (None, None)
+ }
+ }
+ } else {
+ (None, None)
+ };
+
+ let loaded_data = FileLoadedData {
+ metadata: FilePreviewerMetaData {
+ mime: mime_clone,
+ file_size,
+ file_path: path_clone,
+ },
+ thumbnail,
+ dimensions,
+ };
+
+ 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) {
+ enqueue_popup_notification(
+ "File uploads are not yet supported on this platform.",
+ PopupKind::Error,
+ None,
+ );
+ }
}
impl RoomInputBarRef {
@@ -664,6 +818,84 @@ 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 needed to submit the upload request
+ pub fn handle_file_upload_confirmed(&self, cx: &mut Cx, file_name: &str) -> Option