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> { + 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, + }) + }); + + // 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); + + Some(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. @@ -691,3 +923,22 @@ enum ShowEditingPaneBehavior { editing_pane_state: EditingPaneState, }, } + +/// Converts `FileLoadedData` from background thread to `FileData` for the modal. +fn convert_loaded_data_to_file_data(loaded: FileLoadedData) -> FileData { + // Read the file data from the path + let data = std::fs::read(&loaded.metadata.file_path).unwrap_or_default(); + 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, + size: loaded.metadata.file_size, + thumbnail: loaded.thumbnail, + } +} diff --git a/src/shared/file_upload_modal.rs b/src/shared/file_upload_modal.rs new file mode 100644 index 000000000..851b6c7b4 --- /dev/null +++ b/src/shared/file_upload_modal.rs @@ -0,0 +1,338 @@ +//! 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 makepad_widgets::*; +use std::path::PathBuf; + +use crate::utils::format_file_size; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.FileUploadModal = set_type_default() do #(FileUploadModal::register_widget(vm)) { + ..mod.widgets.RoundedView + + width: 400, + height: Fit, + flow: Down, + padding: 20, + spacing: 15, + + show_bg: true, + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 8.0 + shadow_color: #00000044 + shadow_radius: 10.0 + shadow_offset: vec2(0.0, 2.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 + preview_container := View { + width: Fill, + height: 200, + 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, + align: Align{x: 0.5, y: 0.5}, + // cannot center align for tall images + 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, + flow: Flow.Right{wrap: true}, + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11 }, + color: (COLOR_TEXT) + } + 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. + pub data: Vec, + /// 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, + /// Optional thumbnail for image files. + pub thumbnail: Option, + /// Optional dimensions for image/video files, width and height in pixels. + pub dimensions: Option<(u32, u32)>, +} + +/// Type alias for the receiver that gets loaded file data from a background thread. +pub type FileLoadReceiver = std::sync::mpsc::Receiver>; + +/// Actions emitted by the FileUploadModal. +#[derive(Clone, Debug, Default)] +pub enum FilePreviewerAction { + /// No action. + #[default] + None, + /// Show the file upload modal with the given file data. + Show(FileData), + /// Hide the file upload modal. + Hide, + /// User confirmed the upload. + UploadConfirmed(FileData), + /// User cancelled the upload. + Cancelled, +} + +/// A modal for previewing and confirming file uploads. +#[derive(Script, ScriptHook, Widget)] +pub struct FileUploadModal { + #[source] source: ScriptObjectRef, + #[deref] view: View, + + /// The current file data being previewed. + #[rust] file_data: Option, +} + +impl Widget for FileUploadModal { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + if let Event::Actions(actions) = event { + // Handle close button + if self.button(cx, ids!(close_button)).clicked(actions) + || self.button(cx, ids!(cancel_button)).clicked(actions) + { + Cx::post_action(FilePreviewerAction::Cancelled); + Cx::post_action(FilePreviewerAction::Hide); + } + + // Handle upload button + if self.button(cx, ids!(upload_button)).clicked(actions) { + if let Some(file_data) = self.file_data.take() { + Cx::post_action(FilePreviewerAction::UploadConfirmed(file_data)); + Cx::post_action(FilePreviewerAction::Hide); + } + } + } + + 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 FileUploadModal { + /// Sets the file data and updates the preview UI. + pub fn set_file_data(&mut self, cx: &mut Cx, file_data: FileData) { + // Update file name label + self.label(cx, ids!(file_name_label)) + .set_text(cx, &file_data.name); + + // Update file size label + self.label(cx, ids!(file_size_label)) + .set_text(cx, &format_file_size(file_data.size)); + + // Determine if this is an image + let is_image = crate::image_utils::is_displayable_image(&file_data.mime_type); + + // Show/hide appropriate preview widgets + let image_preview = self.view.image(cx, ids!(image_preview_container.image_preview)); + let image_preview_container = self.view.view(cx, ids!(image_preview_container)); + let file_icon_container = self.view.view(cx, ids!(file_icon_container)); + + if is_image { + makepad_widgets::log!("FileUploadModal: Loading image preview, data size: {} bytes, mime: {}", file_data.data.len(), file_data.mime_type); + // Hide file icon first + file_icon_container.set_visible(cx, false); + + // Load image data into the preview + if let Err(e) = crate::utils::load_png_or_jpg(&image_preview, cx, &file_data.data) { + makepad_widgets::error!("Failed to load image preview: {:?}", e); + // Fall back to file icon + image_preview_container.set_visible(cx, false); + file_icon_container.set_visible(cx, true); + self.update_file_type_label(cx, &file_data.mime_type); + } else { + makepad_widgets::log!("FileUploadModal: Image loaded successfully"); + // Set container visible after loading + image_preview_container.set_visible(cx, true); + } + } else { + image_preview_container.set_visible(cx, false); + file_icon_container.set_visible(cx, true); + self.update_file_type_label(cx, &file_data.mime_type); + } + + self.file_data = Some(file_data); + self.redraw(cx); + } + + /// Updates the file type label based on MIME type. + fn update_file_type_label(&mut self, cx: &mut Cx, mime_type: &str) { + let type_text = mime_type + .split('/') + .next_back() + .unwrap_or("Unknown") + .to_uppercase(); + self.label(cx, ids!(file_type_label)) + .set_text(cx, &format!("{} File", type_text)); + } +} + +impl FileUploadModalRef { + /// Sets the file data and updates the preview UI. + pub fn set_file_data(&self, cx: &mut Cx, file_data: FileData) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_file_data(cx, file_data); + } + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index a92a81fd9..e9a04b020 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -4,12 +4,14 @@ pub mod avatar; pub mod collapsible_header; pub mod expand_arrow; pub mod confirmation_modal; +pub mod file_upload_modal; pub mod helpers; pub mod html_or_plaintext; pub mod icon_button; pub mod jump_to_bottom_button; pub mod mentionable_text_input; pub mod popup_list; +pub mod progress_bar; pub mod room_filter_input_bar; pub mod styles; pub mod text_or_image; @@ -44,4 +46,6 @@ pub fn script_mod(vm: &mut ScriptVm) { restore_status_view::script_mod(vm); confirmation_modal::script_mod(vm); image_viewer::script_mod(vm); + progress_bar::script_mod(vm); + file_upload_modal::script_mod(vm); } diff --git a/src/shared/progress_bar.rs b/src/shared/progress_bar.rs new file mode 100644 index 000000000..36f2aaab7 --- /dev/null +++ b/src/shared/progress_bar.rs @@ -0,0 +1,106 @@ +//! A progress bar widget with capsule-shaped design for showing upload/download progress. + +use makepad_widgets::*; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.ProgressBar = set_type_default() do #(ProgressBar::register_widget(vm)) { + width: Fill, + height: 8, + show_bg: true, + + draw_bg +: { + progress: instance(0.0) + + // Background color (track) + color: (COLOR_SECONDARY) + // Filled portion color + progress_color: instance((COLOR_ACTIVE_PRIMARY)) + + border_radius: 4.0 + + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size); + + // Draw background track (full width, rounded) + sdf.box( + 0.0, + 0.0, + self.rect_size.x, + self.rect_size.y, + self.border_radius + ); + sdf.fill(self.color); + + // Draw progress fill (partial width based on progress, rounded) + let progress_width = self.rect_size.x * self.progress; + if progress_width > 0.0 { + sdf.box( + 0.0, + 0.0, + progress_width, + self.rect_size.y, + self.border_radius + ); + sdf.fill(self.progress_color); + } + + return sdf.result; + } + } + } +} + +/// A capsule-shaped progress bar widget. +#[derive(Script, ScriptHook, Widget)] +pub struct ProgressBar { + #[source] source: ScriptObjectRef, + #[deref] view: View, + + /// Current progress value between 0.0 and 1.0 + #[rust] progress: f32, +} + +impl Widget for ProgressBar { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + // Update the progress uniform before drawing + let progress = self.progress.clamp(0.0, 1.0); + script_apply_eval!(cx, self.view, { + draw_bg.progress: #(progress as f64), + }); + self.view.draw_walk(cx, scope, walk) + } +} + +impl ProgressBar { + /// Sets the progress value (0.0 to 1.0). + pub fn set_progress(&mut self, cx: &mut Cx, value: f32) { + self.progress = value.clamp(0.0, 1.0); + self.redraw(cx); + } + + /// Gets the current progress value. + pub fn progress(&self) -> f32 { + self.progress + } +} + +impl ProgressBarRef { + /// Sets the progress value (0.0 to 1.0). + pub fn set_progress(&self, cx: &mut Cx, value: f32) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_progress(cx, value); + } + } + + /// Gets the current progress value. + pub fn progress(&self) -> f32 { + self.borrow().map(|inner| inner.progress()).unwrap_or(0.0) + } +} diff --git a/src/shared/styles.rs b/src/shared/styles.rs index feb778dff..1f0425918 100644 --- a/src/shared/styles.rs +++ b/src/shared/styles.rs @@ -44,6 +44,8 @@ script_mod! { mod.widgets.ICON_WARNING = crate_resource("self://resources/icons/warning.svg") mod.widgets.ICON_ZOOM_IN = crate_resource("self://resources/icons/zoom_in.svg") mod.widgets.ICON_ZOOM_OUT = crate_resource("self://resources/icons/zoom_out.svg") + mod.widgets.ICON_ADD_ATTACHMENT = crate_resource("self://resources/icons/add_attachment.svg") + mod.widgets.ICON_FILE = crate_resource("self://resources/icons/file.svg") mod.widgets.TITLE_TEXT = theme.font_regular { font_size: (13), diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 1744639a8..590a48143 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, bail, Result}; use bitflags::bitflags; +use mime::Mime; use clap::Parser; use eyeball::Subscriber; use eyeball_im::VectorDiff; @@ -573,6 +574,14 @@ pub enum MatrixRequest { #[cfg(feature = "tsp")] sign_with_tsp: bool, }, + /// Request to send a file attachment to the given room. + SendAttachment { + timeline_kind: TimelineKind, + file_data: crate::shared::file_upload_modal::FileData, + replied_to: Option, + #[cfg(feature = "tsp")] + sign_with_tsp: bool, + }, /// Sends a notice to the given room that the current user is or is not typing. /// /// This request does not return a response or notify the UI thread, and @@ -1689,6 +1698,94 @@ async fn matrix_worker_task( }); } + MatrixRequest::SendAttachment { + timeline_kind, + file_data, + replied_to, + #[cfg(feature = "tsp")] + sign_with_tsp: _sign_with_tsp, + } => { + let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { + log!("BUG: {timeline_kind} not found for send attachment request"); + continue; + }; + + // Spawn a new async task to send the attachment. + let _send_attachment_task = Handle::current().spawn(async move { + use matrix_sdk::attachment::AttachmentConfig; + use eyeball::SharedObservable; + + log!("Sending attachment to {timeline_kind}: {} ({} bytes)...", + file_data.name, file_data.size); + + // For now, we'll just send the attachment without reply support + // TODO: Add proper reply support for attachments + let _ = replied_to; // Suppress unused warning for now + + // Parse MIME type + let content_type: Mime = file_data.mime_type.parse() + .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()); + + // Create a progress observable to track upload progress + let send_progress: SharedObservable = Default::default(); + let progress_subscriber = send_progress.subscribe(); + + // Spawn a task to handle progress updates + let sender_clone = sender.clone(); + Handle::current().spawn(async move { + let mut subscriber = progress_subscriber; + loop { + let progress = subscriber.get(); + let current: u64 = progress.current as u64; + let total: u64 = progress.total as u64; + if sender_clone.send(TimelineUpdate::FileUploadUpdate { + current, + total, + }).is_err() { + break; + } + SignalToUI::set_ui_signal(); + // Wait for next update + if subscriber.next().await.is_none() { + break; + } + } + }); + + // Use the Room's send_attachment method directly + let room = timeline.room(); + let config = AttachmentConfig::new(); + + let send_future = room.send_attachment( + &file_data.name, + &content_type, + file_data.data.clone(), + config, + ).with_send_progress_observable(send_progress); + + match send_future.await { + Ok(_response) => { + log!("Successfully sent attachment to {timeline_kind}."); + let _ = sender.send(TimelineUpdate::FileUploadComplete); + } + Err(e) => { + error!("Failed to send attachment to {timeline_kind}: {e:?}"); + let _ = sender.send(TimelineUpdate::FileUploadError { + error: format!("{e}"), + file_data: file_data.clone(), + }); + enqueue_popup_notification( + format!("Failed to upload file: {e}"), + PopupKind::Error, + None, + ); + } + } + + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); @@ -2175,6 +2272,19 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option }) } +/// Returns a clone of the timeline update sender for the given timeline. +/// +/// This can be called multiple times, as it only clones the sender. +pub fn get_timeline_update_sender(kind: &TimelineKind) -> Option> { + let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); + let jrd = all_joined_rooms.get(kind.room_id())?; + let details = match kind { + TimelineKind::MainRoom { .. } => &jrd.main_timeline, + TimelineKind::Thread { thread_root_event_id, .. } => jrd.thread_timelines.get(thread_root_event_id)?, + }; + Some(details.timeline_update_sender.clone()) +} + const DEFAULT_HOMESERVER: &str = "matrix.org"; fn username_to_full_user_id( diff --git a/src/utils.rs b/src/utils.rs index aa3ac8143..636053171 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1007,6 +1007,25 @@ impl From<(Option, OwnedRoomId)> for RoomNameId { } } +/// Formats a file size in bytes to a human-readable string. +/// +/// Examples: "1.5 KB", "2.3 MB", "4.0 GB" +pub fn format_file_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + /// Returns a text avatar string containing the first character of the room name. /// /// Skips the first character if it is a `#` or `!`, the sigils used for Room aliases and Room IDs.