diff --git a/Cargo.lock b/Cargo.lock index 5192d4d..c69a776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,19 +45,6 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0453232ace82dee0dd0b4c87a59bd90f7b53b314f3e0f61fe2ee7c8a16482289" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "const-random", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -95,18 +82,68 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.100" +name = "anstream" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "ar_archive_writer" -version = "0.2.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object 0.32.2", + "object", ] [[package]] @@ -123,7 +160,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -138,12 +175,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - [[package]] name = "as-slice" version = "0.2.1" @@ -153,31 +184,11 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "ash" -version = "0.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading", -] - -[[package]] -name = "ash-window" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" -dependencies = [ - "ash", - "raw-window-handle", - "raw-window-metal", -] - [[package]] name = "ashpd" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618a409b91d5265798a99e3d1d0b226911605e581c4e7255e83c1e397b172bce" +checksum = "522dc9bec59923af17c43c5911cdabbacdb32ed4f955e83ecf592855618b20b5" dependencies = [ "async-fs", "async-net", @@ -188,9 +199,6 @@ dependencies = [ "serde", "serde_repr", "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", "zbus", ] @@ -241,9 +249,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.37" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" dependencies = [ "compression-codecs", "compression-core", @@ -253,9 +261,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -357,7 +365,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -433,7 +441,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -451,10 +459,8 @@ dependencies = [ [[package]] name = "async-wsocket" version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" +source = "git+https://github.com/shadowylab/async-wsocket?rev=0fed6c9c6aec7393ee0e9cf3933d76914ab427d3#0fed6c9c6aec7393ee0e9cf3933d76914ab427d3" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", @@ -464,6 +470,7 @@ dependencies = [ "tokio-tungstenite", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -477,7 +484,7 @@ dependencies = [ "crc32fast", "futures-lite 2.6.1", "pin-project", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -486,12 +493,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -504,15 +505,16 @@ version = "0.3.0" dependencies = [ "anyhow", "common", + "futures", "gpui", "gpui_tokio", "log", - "nostr-sdk", "reqwest", "semver", + "serde", + "serde_json", "smallvec", "smol", - "state", "tempfile", ] @@ -537,7 +539,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.17", + "thiserror 2.0.18", "v_frame", "y4m", ] @@ -558,18 +560,18 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" dependencies = [ "arrayvec", ] [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -577,9 +579,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ "cc", "cmake", @@ -597,7 +599,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object 0.37.3", + "object", "rustc-demangle", "windows-link 0.2.1", ] @@ -626,7 +628,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -637,7 +639,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.114", + "syn", ] [[package]] @@ -697,9 +699,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -713,61 +715,6 @@ dependencies = [ "core2", ] -[[package]] -name = "blade-graphics" -version = "0.7.0" -source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" -dependencies = [ - "ash", - "ash-window", - "bitflags 2.10.0", - "bytemuck", - "codespan-reporting", - "glow", - "gpu-alloc", - "gpu-alloc-ash", - "hidden-trait", - "js-sys", - "khronos-egl", - "libloading", - "log", - "mint", - "naga", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", - "objc2-ui-kit", - "once_cell", - "raw-window-handle", - "slab", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "blade-macros" -version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "blade-util" -version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" -dependencies = [ - "blade-graphics", - "bytemuck", - "log", - "profiling", -] - [[package]] name = "block" version = "0.1.6" @@ -792,15 +739,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "blocking" version = "1.6.2" @@ -838,15 +776,15 @@ checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ "bytemuck_derive", ] @@ -859,7 +797,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -876,35 +814,23 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "calloop" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "polling", "rustix 1.1.3", "slab", "tracing", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop", - "rustix 1.1.3", - "wayland-backend", - "wayland-client", -] - [[package]] name = "cbc" version = "0.1.2" @@ -927,16 +853,16 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.114", + "syn", "tempfile", "toml 0.8.23", ] [[package]] name = "cc" -version = "1.2.52" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -1034,10 +960,9 @@ dependencies = [ "anyhow", "chat", "common", - "emojis", + "flume", "gpui", "gpui_tokio", - "indexset", "itertools 0.13.0", "log", "nostr-sdk", @@ -1056,9 +981,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -1096,6 +1021,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + [[package]] name = "cmake" version = "0.1.57" @@ -1105,31 +1070,15 @@ dependencies = [ "cc", ] -[[package]] -name = "cocoa" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation 0.1.2", - "core-foundation 0.9.4", - "core-graphics 0.23.2", - "foreign-types 0.5.0", - "libc", - "objc", -] - [[package]] name = "cocoa" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block", - "cocoa-foundation 0.2.0", + "cocoa-foundation", "core-foundation 0.10.0", "core-graphics 0.24.0", "foreign-types 0.5.0", @@ -1137,27 +1086,13 @@ dependencies = [ "objc", ] -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", -] - [[package]] name = "cocoa-foundation" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "core-foundation 0.10.0", "core-graphics-types 0.2.0", @@ -1171,15 +1106,13 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ - "serde", - "termcolor", "unicode-width", ] [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1191,6 +1124,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -1208,7 +1147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd" dependencies = [ "nix 0.30.1", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -1228,14 +1167,13 @@ dependencies = [ "reqwest", "smallvec", "smol", - "whoami", ] [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "compression-core", "deflate64", @@ -1258,26 +1196,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - [[package]] name = "convert_case" version = "0.4.0" @@ -1297,6 +1215,7 @@ dependencies = [ "device", "futures", "gpui", + "gpui_platform", "gpui_tokio", "indexset", "itertools 0.13.0", @@ -1347,26 +1266,13 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.5.0", - "libc", -] - [[package]] name = "core-graphics" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.0", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -1374,14 +1280,14 @@ dependencies = [ ] [[package]] -name = "core-graphics-helmer-fork" -version = "0.24.0" +name = "core-graphics" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", + "bitflags 2.11.0", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "libc", ] @@ -1403,7 +1309,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.0", "libc", ] @@ -1414,7 +1320,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "cfg-if", "core-foundation 0.10.0", @@ -1423,14 +1329,13 @@ dependencies = [ [[package]] name = "core-text" -version = "21.0.0" +version = "21.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +checksum = "fce32d657e17d6e4a8e70fe2ae6875218015f320620a78e5949d228bc76622bd" dependencies = [ "core-foundation 0.10.0", - "core-graphics 0.24.0", + "core-graphics 0.25.0", "foreign-types 0.5.0", - "libc", ] [[package]] @@ -1465,29 +1370,6 @@ dependencies = [ "libm", ] -[[package]] -name = "cosmic-text" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" -dependencies = [ - "bitflags 2.10.0", - "fontdb 0.16.2", - "log", - "rangemap", - "rustc-hash 1.1.0", - "rustybuzz 0.14.1", - "self_cell", - "smol_str", - "swash", - "sys-locale", - "ttf-parser 0.21.1", - "unicode-bidi", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1601,17 +1483,17 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn", ] [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1684,22 +1566,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.10.0", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -1708,7 +1574,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1729,12 +1595,6 @@ dependencies = [ "rand 0.8.5", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "doxygen-rs" version = "0.4.2" @@ -1798,20 +1658,11 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "vswhom", "winreg", ] -[[package]] -name = "emojis" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" -dependencies = [ - "phf", -] - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1845,7 +1696,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1865,7 +1716,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1907,9 +1758,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.11" +version = "0.22.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" dependencies = [ "num-traits", ] @@ -1988,7 +1839,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -2009,34 +1860,22 @@ dependencies = [ "nix 0.29.0", ] -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flatbuffers" @@ -2044,15 +1883,15 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "rustc_version", ] [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -2109,6 +1948,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-types" version = "0.10.1" @@ -2127,20 +1972,6 @@ dependencies = [ "roxmltree", ] -[[package]] -name = "fontdb" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2 0.9.9", - "slotmap", - "tinyvec", - "ttf-parser 0.20.0", -] - [[package]] name = "fontdb" version = "0.23.0" @@ -2149,10 +1980,10 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2 0.9.9", + "memmap2 0.9.10", "slotmap", "tinyvec", - "ttf-parser 0.25.1", + "ttf-parser", ] [[package]] @@ -2182,7 +2013,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -2254,9 +2085,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2269,9 +2100,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2279,15 +2110,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2296,9 +2127,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2330,32 +2161,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2365,7 +2196,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2388,16 +2218,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.3", - "windows-link 0.2.1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2425,6 +2245,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -2472,83 +2305,31 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "glow" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.10.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-ash" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbda7a18a29bc98c2e0de0435c347df935bf59489935d0cbd0b73f1679b6f79a" -dependencies = [ - "ash", - "gpu-alloc-types", - "tinyvec", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "anyhow", - "as-raw-xcb-connection", - "ashpd", "async-task", "bindgen", - "bitflags 2.10.0", - "blade-graphics", - "blade-macros", - "blade-util", + "bitflags 2.11.0", "block", - "bytemuck", - "calloop", - "calloop-wayland-source", "cbindgen", "chrono", "circular-buffer", - "cocoa 0.26.0", - "cocoa-foundation 0.2.0", + "cocoa", + "cocoa-foundation", "collections", "core-foundation 0.10.0", "core-foundation-sys", "core-graphics 0.24.0", "core-text", "core-video", - "cosmic-text", "ctor", "derive_more", "embed-resource", "etagere", - "filedescriptor", "foreign-types 0.5.0", "futures", "gpui_macros", @@ -2556,7 +2337,6 @@ dependencies = [ "image", "inventory", "itertools 0.14.0", - "libc", "log", "lyon", "mach2", @@ -2565,8 +2345,6 @@ dependencies = [ "naga", "num_cpus", "objc", - "oo7", - "open", "parking", "parking_lot", "pathfinder_geometry", @@ -2580,7 +2358,6 @@ dependencies = [ "scheduler", "schemars", "seahash", - "semver", "serde", "serde_json", "slotmap", @@ -2588,49 +2365,114 @@ dependencies = [ "smol", "spin 0.10.0", "stacksafe", - "strum 0.27.2", + "strum", "sum_tree", - "swash", "taffy", - "thiserror 2.0.17", + "thiserror 2.0.18", "usvg", "util", "util_macros", "uuid", "waker-fn", - "wayland-backend", - "wayland-client", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-plasma", - "wayland-protocols-wlr", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics", - "windows-registry 0.5.3", - "x11-clipboard", - "x11rb", - "xkbcommon", + "windows", "zed-font-kit", - "zed-scap", - "zed-xim", +] + +[[package]] +name = "gpui_linux" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" +dependencies = [ + "anyhow", + "bytemuck", + "calloop", + "collections", + "futures", + "gpui", + "http_client", + "itertools 0.14.0", + "libc", + "log", + "oo7", + "parking_lot", + "pathfinder_geometry", + "profiling", + "raw-window-handle", + "smallvec", + "smol", + "strum", + "swash", + "util", + "uuid", +] + +[[package]] +name = "gpui_macos" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" +dependencies = [ + "anyhow", + "async-task", + "bindgen", + "block", + "cbindgen", + "cocoa", + "collections", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "ctor", + "derive_more", + "etagere", + "foreign-types 0.5.0", + "futures", + "gpui", + "image", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "media", + "metal", + "objc", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum", + "util", + "uuid", ] [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn", +] + +[[package]] +name = "gpui_platform" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" +dependencies = [ + "gpui", + "gpui_linux", + "gpui_macos", + "gpui_windows", ] [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "anyhow", "gpui", @@ -2638,6 +2480,31 @@ dependencies = [ "util", ] +[[package]] +name = "gpui_windows" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" +dependencies = [ + "anyhow", + "collections", + "etagere", + "futures", + "gpui", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "rand 0.9.2", + "raw-window-handle", + "smallvec", + "util", + "uuid", + "windows", + "windows-core 0.61.2", + "windows-numerics", + "windows-registry 0.5.3", +] + [[package]] name = "grid" version = "0.18.0" @@ -2681,22 +2548,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" dependencies = [ - "ahash 0.4.8", + "ahash", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2704,6 +2565,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -2723,7 +2587,7 @@ version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "heed-traits", "heed-types", @@ -2778,17 +2642,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "hidden-trait" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -2852,7 +2705,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "anyhow", "async-compression", @@ -2877,7 +2730,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2946,14 +2799,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2963,7 +2815,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", @@ -2972,9 +2824,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -3075,6 +2927,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -3110,14 +2968,14 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png 0.18.0", + "png 0.18.1", "qoi", "ravif", "rayon", "rgb", "tiff", - "zune-core 0.5.0", - "zune-jpeg 0.5.8", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", ] [[package]] @@ -3190,14 +3048,14 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" dependencies = [ "rustversion", ] @@ -3231,23 +3089,10 @@ dependencies = [ ] [[package]] -name = "is-docker" -version = "0.2.0" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -3307,9 +3152,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -3331,16 +3176,6 @@ dependencies = [ "smol", ] -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", -] - [[package]] name = "kurbo" version = "0.11.3" @@ -3385,6 +3220,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -3393,15 +3234,15 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libfuzzer-sys" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" dependencies = [ "arbitrary", "cc", @@ -3419,9 +3260,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -3429,9 +3270,9 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall 0.7.0", + "redox_syscall 0.7.1", ] [[package]] @@ -3638,7 +3479,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "anyhow", "bindgen", @@ -3652,9 +3493,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" @@ -3667,9 +3508,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ "libc", ] @@ -3689,7 +3530,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -3730,12 +3571,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mint" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" - [[package]] name = "mio" version = "1.1.1" @@ -3759,26 +3594,26 @@ dependencies = [ [[package]] name = "naga" -version = "25.0.1" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.10.0", + "bitflags 2.11.0", + "cfg-if", "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hexf-parse", "indexmap", + "libm", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", - "spirv", - "strum 0.26.3", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-ident", ] @@ -3793,17 +3628,17 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -3832,7 +3667,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -3844,7 +3679,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -3878,7 +3713,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#78e3b86e736c470ca7241e7383d3886721fc64c2" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "aes", "base64", @@ -3903,11 +3738,12 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#78e3b86e736c470ca7241e7383d3886721fc64c2" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "async-utility", + "futures-core", "nostr", - "nostr-relay-pool", + "nostr-sdk", "tokio", "tracing", ] @@ -3915,7 +3751,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#78e3b86e736c470ca7241e7383d3886721fc64c2" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "btreecap", "flatbuffers", @@ -3927,7 +3763,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#78e3b86e736c470ca7241e7383d3886721fc64c2" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "nostr", ] @@ -3935,7 +3771,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#78e3b86e736c470ca7241e7383d3886721fc64c2" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "async-utility", "flume", @@ -3947,45 +3783,24 @@ dependencies = [ ] [[package]] -name = "nostr-relay-pool" -version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#78e3b86e736c470ca7241e7383d3886721fc64c2" +name = "nostr-sdk" +version = "0.44.1" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "async-utility", "async-wsocket", - "atomic-destructor", + "futures", "hex", "lru", "negentropy", "nostr", "nostr-database", - "tokio", - "tracing", -] - -[[package]] -name = "nostr-sdk" -version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#78e3b86e736c470ca7241e7383d3886721fc64c2" -dependencies = [ - "async-utility", - "nostr", - "nostr-database", "nostr-gossip", - "nostr-relay-pool", "tokio", + "tokio-stream", "tracing", ] -[[package]] -name = "ntapi" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" -dependencies = [ - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4053,7 +3868,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4114,18 +3929,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", - "objc_exception", -] - -[[package]] -name = "objc-foundation" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" -dependencies = [ - "block", - "objc", - "objc_id", ] [[package]] @@ -4137,30 +3940,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.10.0", - "dispatch2", - "objc2", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -4173,74 +3952,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.10.0", - "block2", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" -dependencies = [ - "cc", -] - -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", ] [[package]] @@ -4259,10 +3972,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "oneshot" -version = "0.1.11" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" [[package]] name = "oo7" @@ -4305,24 +4024,13 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - [[package]] name = "openssl" version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4339,20 +4047,14 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-probe" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" @@ -4454,12 +4156,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -4498,7 +4194,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "collections", "serde", @@ -4511,6 +4207,7 @@ version = "0.3.0" dependencies = [ "anyhow", "common", + "device", "flume", "gpui", "log", @@ -4520,6 +4217,20 @@ dependencies = [ "state", ] +[[package]] +name = "petname" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd31dcfdbbd7431a807ef4df6edd6473228e94d5c805e8cf671227a21bad068" +dependencies = [ + "anyhow", + "clap", + "itertools 0.14.0", + "proc-macro2", + "quote", + "rand 0.8.5", +] + [[package]] name = "phf" version = "0.11.3" @@ -4550,7 +4261,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4585,7 +4296,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4632,11 +4343,11 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -4716,7 +4427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn", ] [[package]] @@ -4747,14 +4458,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -4775,14 +4486,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "psm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" dependencies = [ "ar_archive_writer", "cc", @@ -4821,24 +4532,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.9" @@ -4853,7 +4546,7 @@ dependencies = [ "rustc-hash 2.1.1", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -4874,7 +4567,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4896,9 +4589,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -4968,12 +4661,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rangemap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" - [[package]] name = "rav1e" version = "0.8.1" @@ -5004,7 +4691,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "simd_helpers", - "thiserror 2.0.17", + "thiserror 2.0.18", "v_frame", "wasm-bindgen", ] @@ -5030,18 +4717,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "raw-window-metal" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" -dependencies = [ - "cocoa 0.25.0", - "core-graphics 0.23.2", - "objc", - "raw-window-handle", -] - [[package]] name = "rayon" version = "1.11.0" @@ -5087,16 +4762,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -5127,22 +4802,22 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "derive_refineable", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -5152,9 +4827,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -5163,9 +4838,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "relay_auth" @@ -5232,7 +4907,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "anyhow", "bytes", @@ -5243,6 +4918,7 @@ dependencies = [ "regex", "serde", "tokio", + "util", "zed-reqwest", ] @@ -5286,7 +4962,7 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "arrayvec", "log", @@ -5306,9 +4982,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rust-embed" -version = "8.10.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f783a9e226b5319beefe29d45941f559ace8b56801bb8355be17eea277fc8272" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -5317,22 +4993,22 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.10.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "303d4e979140595f1d824b3dd53a32684835fa32425542056826521ac279f538" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.114", + "syn", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.10.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6b4ab509cae251bd524d2425d746b0af0018f5a81fc1eaecdd4e661c8ab3a0" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", "sha2", @@ -5351,9 +5027,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" @@ -5382,7 +5058,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5395,7 +5071,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -5424,10 +5100,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.0", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -5441,9 +5117,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -5464,7 +5140,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.5.1", + "security-framework", "security-framework-sys", "webpki-root-certs 0.26.11", "windows-sys 0.59.0", @@ -5478,9 +5154,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -5494,46 +5170,29 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rustybuzz" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" -dependencies = [ - "bitflags 2.10.0", - "bytemuck", - "libm", - "smallvec", - "ttf-parser 0.21.1", - "unicode-bidi-mirroring 0.2.0", - "unicode-ccc 0.2.0", - "unicode-properties", - "unicode-script", -] - [[package]] name = "rustybuzz" version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytemuck", "core_maths", "log", "smallvec", - "ttf-parser 0.25.1", - "unicode-bidi-mirroring 0.4.0", - "unicode-ccc 0.4.0", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", "unicode-properties", "unicode-script", ] [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "salsa20" @@ -5565,7 +5224,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "async-task", "backtrace", @@ -5578,9 +5237,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "indexmap", @@ -5592,51 +5251,22 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "screencapturekit" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" -dependencies = [ - "screencapturekit-sys", -] - -[[package]] -name = "screencapturekit-sys" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" -dependencies = [ - "block", - "dispatch", - "objc", - "objc-foundation", - "objc_id", - "once_cell", -] - [[package]] name = "scrypt" version = "0.11.0" @@ -5676,24 +5306,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.0", "core-foundation-sys", "libc", @@ -5702,20 +5319,14 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - [[package]] name = "semver" version = "1.0.27" @@ -5753,7 +5364,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5764,7 +5375,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5811,7 +5422,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5849,6 +5460,7 @@ name = "settings" version = "0.3.0" dependencies = [ "anyhow", + "common", "gpui", "log", "nostr-sdk", @@ -5856,7 +5468,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "state", + "smol", ] [[package]] @@ -5938,9 +5550,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skrifa" @@ -5954,9 +5566,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slotmap" @@ -5990,17 +5602,11 @@ dependencies = [ "futures-lite 2.6.1", ] -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" - [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -6024,15 +5630,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6041,9 +5638,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" dependencies = [ "cc", "cfg-if", @@ -6070,7 +5667,7 @@ checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" dependencies = [ "proc-macro-error2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6081,11 +5678,19 @@ dependencies = [ "common", "flume", "gpui", + "gpui_tokio", "log", + "nostr-connect", "nostr-lmdb", "nostr-sdk", + "petname", + "reqwest", "rustls", + "serde", + "serde_json", "smol", + "webbrowser", + "whoami", ] [[package]] @@ -6104,13 +5709,10 @@ dependencies = [ ] [[package]] -name = "strum" -version = "0.26.3" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" @@ -6118,20 +5720,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", + "strum_macros", ] [[package]] @@ -6143,7 +5732,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6155,7 +5744,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "arrayvec", "log", @@ -6166,15 +5755,15 @@ dependencies = [ [[package]] name = "sval" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502b8906c4736190684646827fbab1e954357dfe541013bbd7994d033d53a1ca" +checksum = "c1aaf178a50bbdd86043fce9bf0a5867007d9b382db89d1c96ccae4601ff1ff9" [[package]] name = "sval_buffer" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4b854348b15b6c441bdd27ce9053569b016a0723eab2d015b1fd8e6abe4f708" +checksum = "f89273e48f03807ebf51c4d81c52f28d35ffa18a593edf97e041b52de143df89" dependencies = [ "sval", "sval_ref", @@ -6182,18 +5771,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bd9e8b74410ddad37c6962587c5f9801a2caadba9e11f3f916ee3f31ae4a1f" +checksum = "0430f4e18e7eba21a49d10d25a8dec3ce0e044af40b162347e99a8e3c3ced864" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe17b8deb33a9441280b4266c2d257e166bafbaea6e66b4b34ca139c91766d9" +checksum = "835f51b9d7331b9d7fc48fc716c02306fa88c4a076b1573531910c91a525882d" dependencies = [ "itoa", "ryu", @@ -6202,9 +5791,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854addb048a5bafb1f496c98e0ab5b9b581c3843f03ca07c034ae110d3b7c623" +checksum = "13cbfe3ef406ee2366e7e8ab3678426362085fa9eaedf28cb878a967159dced3" dependencies = [ "itoa", "ryu", @@ -6213,9 +5802,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96cf068f482108ff44ae8013477cb047a1665d5f1a635ad7cf79582c1845dce9" +checksum = "8b20358af4af787c34321a86618c3cae12eabdd0e9df22cd9dd2c6834214c518" dependencies = [ "sval", "sval_buffer", @@ -6224,18 +5813,18 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed02126365ffe5ab8faa0abd9be54fbe68d03d607cd623725b0a71541f8aaa6f" +checksum = "fb5e500f8eb2efa84f75e7090f7fc43f621b9f8b6cde571c635b3855f97b332a" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a263383c6aa2076c4ef6011d3bae1b356edf6ea2613e3d8e8ebaa7b57dd707d5" +checksum = "ca2032ae39b11dcc6c18d5fbc50a661ea191cac96484c59ccf49b002261ca2c1" dependencies = [ "serde_core", "sval", @@ -6271,20 +5860,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.109" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -6317,30 +5895,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - -[[package]] -name = "sysinfo" -version = "0.31.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" -dependencies = [ - "core-foundation-sys", - "libc", - "memchr", - "ntapi", - "rayon", - "windows 0.57.0", + "syn", ] [[package]] @@ -6349,7 +5904,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6382,26 +5948,14 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" -[[package]] -name = "tao-core-video-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "objc", -] - [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", @@ -6418,15 +5972,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "theme" version = "0.3.0" @@ -6452,11 +5997,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -6467,18 +6012,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6504,15 +6049,6 @@ dependencies = [ "zune-jpeg 0.4.21", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tiny-skia" version = "0.11.4" @@ -6578,7 +6114,7 @@ dependencies = [ "smol", "theme", "ui", - "windows 0.61.3", + "windows", ] [[package]] @@ -6604,7 +6140,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6640,10 +6176,22 @@ dependencies = [ ] [[package]] -name = "tokio-tungstenite" -version = "0.26.2" +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -6682,9 +6230,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", @@ -6741,9 +6289,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -6781,7 +6329,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -6825,7 +6373,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6869,18 +6417,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" - -[[package]] -name = "ttf-parser" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" - [[package]] name = "ttf-parser" version = "0.25.1" @@ -6892,9 +6428,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -6905,7 +6441,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.18", "utf-8", ] @@ -6967,24 +6503,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" - [[package]] name = "unicode-bidi-mirroring" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" -[[package]] -name = "unicode-ccc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" - [[package]] name = "unicode-ccc" version = "0.4.0" @@ -6993,15 +6517,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -7042,6 +6560,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -7080,13 +6604,13 @@ dependencies = [ "base64", "data-url", "flate2", - "fontdb 0.23.0", + "fontdb", "imagesize", "kurbo", "log", "pico-args", "roxmltree", - "rustybuzz 0.20.1", + "rustybuzz", "simplecss", "siphasher", "strict-num", @@ -7110,10 +6634,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "anyhow", "async-fs", @@ -7130,6 +6660,7 @@ dependencies = [ "log", "mach2", "nix 0.29.0", + "percent-encoding", "regex", "rust-embed", "schemars", @@ -7142,6 +6673,7 @@ dependencies = [ "tempfile", "tendril", "unicase", + "url", "walkdir", "which", ] @@ -7149,20 +6681,20 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "perf", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "uuid" -version = "1.19.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "serde_core", "sha1_smol", @@ -7287,9 +6819,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] @@ -7302,9 +6843,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -7315,11 +6856,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -7328,9 +6870,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7338,26 +6880,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -7372,108 +6936,22 @@ dependencies = [ ] [[package]] -name = "wayland-backend" -version = "0.3.12" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.3", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" -dependencies = [ - "bitflags 2.10.0", - "rustix 1.1.3", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" -dependencies = [ - "rustix 1.1.3", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" -dependencies = [ - "proc-macro2", - "quick-xml 0.38.4", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -7491,9 +6969,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.0.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" dependencies = [ "core-foundation 0.10.0", "jni", @@ -7511,14 +6989,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "webpki-root-certs 1.0.5", + "webpki-root-certs 1.0.6", ] [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -7529,14 +7007,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -7601,16 +7079,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -7624,19 +7092,6 @@ dependencies = [ "windows-numerics", ] -[[package]] -name = "windows-capture" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" -dependencies = [ - "parking_lot", - "rayon", - "thiserror 2.0.17", - "windows 0.61.3", - "windows-future", -] - [[package]] name = "windows-collections" version = "0.2.0" @@ -7646,26 +7101,14 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -7677,8 +7120,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -7695,17 +7138,6 @@ dependencies = [ "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -7714,18 +7146,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -7736,7 +7157,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -7794,15 +7215,6 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -8190,9 +7602,91 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -8200,46 +7694,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-clipboard" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" -dependencies = [ - "libc", - "x11rb", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "rustix 1.1.3", - "x11rb-protocol", - "xcursor", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - [[package]] name = "xattr" version = "0.2.3" @@ -8249,58 +7703,6 @@ dependencies = [ "libc", ] -[[package]] -name = "xcb" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" -dependencies = [ - "bitflags 1.3.2", - "libc", - "quick-xml 0.30.0", - "x11", -] - -[[package]] -name = "xcursor" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" - -[[package]] -name = "xim-ctext" -version = "0.3.0" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" -dependencies = [ - "encoding_rs", -] - -[[package]] -name = "xim-parser" -version = "0.2.1" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "xkbcommon" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" -dependencies = [ - "as-raw-xcb-connection", - "libc", - "memmap2 0.9.9", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - [[package]] name = "xmlwriter" version = "0.1.0" @@ -8349,15 +7751,15 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", "synstructure", ] [[package]] name = "zbus" -version = "5.13.1" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f79257df967b6779afa536788657777a0001f5b42524fcaf5038d4344df40b" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -8390,14 +7792,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.1" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad23e2d2f91cae771c7af7a630a49e755f1eb74f8a46e9f6d5f7a146edf5a37" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn", "zbus_names", "zvariant", "zvariant_utils", @@ -8419,7 +7821,7 @@ name = "zed-font-kit" version = "0.14.1-zed" source = "git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5#110523127440aefb11ce0cf280ae7c5071337ec5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "core-foundation 0.10.0", "core-graphics 0.24.0", @@ -8472,7 +7874,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tokio-rustls", "tokio-socks", @@ -8487,40 +7889,6 @@ dependencies = [ "windows-registry 0.4.0", ] -[[package]] -name = "zed-scap" -version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" -dependencies = [ - "anyhow", - "cocoa 0.25.0", - "core-graphics-helmer-fork", - "log", - "objc", - "rand 0.8.5", - "screencapturekit", - "screencapturekit-sys", - "sysinfo", - "tao-core-video-sys", - "windows 0.61.3", - "windows-capture", - "x11", - "xcb", -] - -[[package]] -name = "zed-xim" -version = "0.4.0-zed" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" -dependencies = [ - "ahash 0.8.12", - "hashbrown 0.14.5", - "log", - "x11rb", - "xim-ctext", - "xim-parser", -] - [[package]] name = "zeno" version = "0.3.3" @@ -8529,22 +7897,22 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -8564,7 +7932,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", "synstructure", ] @@ -8585,7 +7953,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -8618,13 +7986,13 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "anyhow", "chrono", @@ -8634,14 +8002,14 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.14" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" dependencies = [ "tracing", "tracing-subscriber", @@ -8652,7 +8020,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#aa91fd4a964b02878e7928f50eaeac0ba2c922f3" [[package]] name = "zune-core" @@ -8662,9 +8030,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-inflate" @@ -8686,18 +8054,18 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.8" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ - "zune-core 0.5.0", + "zune-core 0.5.1", ] [[package]] name = "zvariant" -version = "5.9.1" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "326aaed414f04fe839777b4c443d4e94c74e7b3621093bd9c5e649ac8aa96543" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", @@ -8710,14 +8078,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.9.1" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba44e1f8f4da9e6e2d25d2a60b116ef8b9d0be174a7685e55bb12a99866279a7" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn", "zvariant_utils", ] @@ -8730,6 +8098,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 8ecb3fe..1fa7ada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,15 @@ publish = false # GPUI gpui = { git = "https://github.com/zed-industries/zed" } +gpui_platform = { git = "https://github.com/zed-industries/zed" } gpui_tokio = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } # Nostr nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } +nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] } # Others anyhow = "1.0.44" diff --git a/assets/fonts/Inter/Inter-Bold.ttf b/assets/fonts/Inter/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/assets/fonts/Inter/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter/Inter-BoldItalic.ttf b/assets/fonts/Inter/Inter-BoldItalic.ttf new file mode 100644 index 0000000..8d05000 Binary files /dev/null and b/assets/fonts/Inter/Inter-BoldItalic.ttf differ diff --git a/assets/fonts/Inter/Inter-Italic.ttf b/assets/fonts/Inter/Inter-Italic.ttf new file mode 100644 index 0000000..e7ed211 Binary files /dev/null and b/assets/fonts/Inter/Inter-Italic.ttf differ diff --git a/assets/fonts/Inter/Inter-Medium.ttf b/assets/fonts/Inter/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter/Inter-MediumItalic.ttf b/assets/fonts/Inter/Inter-MediumItalic.ttf new file mode 100644 index 0000000..22b898d Binary files /dev/null and b/assets/fonts/Inter/Inter-MediumItalic.ttf differ diff --git a/assets/fonts/Inter/Inter-Regular.ttf b/assets/fonts/Inter/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter/Inter-SemiBold.ttf b/assets/fonts/Inter/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter/Inter-SemiBold.ttf differ diff --git a/assets/fonts/Inter/Inter-SemiBoldItalic.ttf b/assets/fonts/Inter/Inter-SemiBoldItalic.ttf new file mode 100644 index 0000000..3f704a2 Binary files /dev/null and b/assets/fonts/Inter/Inter-SemiBoldItalic.ttf differ diff --git a/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf b/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf deleted file mode 100644 index d5f4b5e..0000000 Binary files a/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf and /dev/null differ diff --git a/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf b/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf deleted file mode 100644 index 05eaf7c..0000000 Binary files a/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf and /dev/null differ diff --git a/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf b/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf deleted file mode 100644 index 3b07821..0000000 Binary files a/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf and /dev/null differ diff --git a/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf b/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf deleted file mode 100644 index 61dbb58..0000000 Binary files a/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf and /dev/null differ diff --git a/assets/fonts/plex-mono/license.txt b/assets/fonts/plex-mono/license.txt deleted file mode 100644 index f72f765..0000000 --- a/assets/fonts/plex-mono/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf b/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf deleted file mode 100644 index f1e6639..0000000 Binary files a/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf and /dev/null differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf b/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf deleted file mode 100644 index 7612dc5..0000000 Binary files a/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf and /dev/null differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf b/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf deleted file mode 100644 index 8769c23..0000000 Binary files a/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf and /dev/null differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf b/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf deleted file mode 100644 index 3ea293d..0000000 Binary files a/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf and /dev/null differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-SemiBold.ttf b/assets/fonts/plex-sans/ZedPlexSans-SemiBold.ttf deleted file mode 100644 index aba7b0c..0000000 Binary files a/assets/fonts/plex-sans/ZedPlexSans-SemiBold.ttf and /dev/null differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-SemiBoldItalic.ttf b/assets/fonts/plex-sans/ZedPlexSans-SemiBoldItalic.ttf deleted file mode 100644 index 560de36..0000000 Binary files a/assets/fonts/plex-sans/ZedPlexSans-SemiBoldItalic.ttf and /dev/null differ diff --git a/assets/fonts/plex-sans/license.txt b/assets/fonts/plex-sans/license.txt deleted file mode 100644 index f72f765..0000000 --- a/assets/fonts/plex-sans/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/icons/arrow-left.svg b/assets/icons/arrow-left.svg index d0b5d88..ac3e659 100644 --- a/assets/icons/arrow-left.svg +++ b/assets/icons/arrow-left.svg @@ -1,3 +1,16 @@ - - + + + diff --git a/assets/icons/arrow-right.svg b/assets/icons/arrow-right.svg index 05ef2da..8aa7e0d 100644 --- a/assets/icons/arrow-right.svg +++ b/assets/icons/arrow-right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/arrows-in.svg b/assets/icons/arrows-in.svg deleted file mode 100644 index 46904e4..0000000 --- a/assets/icons/arrows-in.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/boom.svg b/assets/icons/boom.svg new file mode 100644 index 0000000..dcaf940 --- /dev/null +++ b/assets/icons/boom.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/caret-down-fill.svg b/assets/icons/caret-down-fill.svg deleted file mode 100644 index 48d34e0..0000000 --- a/assets/icons/caret-down-fill.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/caret-down.svg b/assets/icons/caret-down.svg index ca53e93..36be93e 100644 --- a/assets/icons/caret-down.svg +++ b/assets/icons/caret-down.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/caret-right.svg b/assets/icons/caret-right.svg index f86630d..1d0cc50 100644 --- a/assets/icons/caret-right.svg +++ b/assets/icons/caret-right.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/caret-up.svg b/assets/icons/caret-up.svg index 09e8ccd..6e3a8a1 100644 --- a/assets/icons/caret-up.svg +++ b/assets/icons/caret-up.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/check-circle-fill.svg b/assets/icons/check-circle-fill.svg deleted file mode 100644 index e776f4f..0000000 --- a/assets/icons/check-circle-fill.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/check-circle.svg b/assets/icons/check-circle.svg index 4889d9c..6b1b8d0 100644 --- a/assets/icons/check-circle.svg +++ b/assets/icons/check-circle.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/check.svg b/assets/icons/check.svg index 5b34ba7..299e001 100644 --- a/assets/icons/check.svg +++ b/assets/icons/check.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/chevron-down.svg b/assets/icons/chevron-down.svg new file mode 100644 index 0000000..d8788af --- /dev/null +++ b/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/close-circle-fill.svg b/assets/icons/close-circle-fill.svg index 9e7b90f..079ddbb 100644 --- a/assets/icons/close-circle-fill.svg +++ b/assets/icons/close-circle-fill.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/close-circle.svg b/assets/icons/close-circle.svg index 9e7b90f..201fc93 100644 --- a/assets/icons/close-circle.svg +++ b/assets/icons/close-circle.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/close.svg b/assets/icons/close.svg index 55af35f..484ffce 100644 --- a/assets/icons/close.svg +++ b/assets/icons/close.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg index 5af5438..76a3f55 100644 --- a/assets/icons/copy.svg +++ b/assets/icons/copy.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/door.svg b/assets/icons/door.svg new file mode 100644 index 0000000..b9cb553 --- /dev/null +++ b/assets/icons/door.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/edit.svg b/assets/icons/edit.svg deleted file mode 100644 index 0bcd4e0..0000000 --- a/assets/icons/edit.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/ellipsis.svg b/assets/icons/ellipsis.svg index f3113af..411f545 100644 --- a/assets/icons/ellipsis.svg +++ b/assets/icons/ellipsis.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/assets/icons/emoji-fill.svg b/assets/icons/emoji-fill.svg deleted file mode 100644 index 974ccf4..0000000 --- a/assets/icons/emoji-fill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/emoji.svg b/assets/icons/emoji.svg new file mode 100644 index 0000000..2df5b84 --- /dev/null +++ b/assets/icons/emoji.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/encryption.svg b/assets/icons/encryption.svg deleted file mode 100644 index 4f23a2d..0000000 --- a/assets/icons/encryption.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 0000000..44d426a --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/fistbump-fill.svg b/assets/icons/fistbump-fill.svg new file mode 100644 index 0000000..ad34489 --- /dev/null +++ b/assets/icons/fistbump-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/fistbump.svg b/assets/icons/fistbump.svg new file mode 100644 index 0000000..4a3d0dc --- /dev/null +++ b/assets/icons/fistbump.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/group.svg b/assets/icons/group.svg deleted file mode 100644 index 7f94e3c..0000000 --- a/assets/icons/group.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/inbox-fill.svg b/assets/icons/inbox-fill.svg new file mode 100644 index 0000000..719c050 --- /dev/null +++ b/assets/icons/inbox-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/inbox.svg b/assets/icons/inbox.svg new file mode 100644 index 0000000..5569c57 --- /dev/null +++ b/assets/icons/inbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/info.svg b/assets/icons/info.svg index 1b3641a..7c816dc 100644 --- a/assets/icons/info.svg +++ b/assets/icons/info.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/invite.svg b/assets/icons/invite.svg new file mode 100644 index 0000000..228373b --- /dev/null +++ b/assets/icons/invite.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/link.svg b/assets/icons/link.svg new file mode 100644 index 0000000..f5217bf --- /dev/null +++ b/assets/icons/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/logout.svg b/assets/icons/logout.svg deleted file mode 100644 index e7beade..0000000 --- a/assets/icons/logout.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg deleted file mode 100644 index 67009bf..0000000 --- a/assets/icons/minimize.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/moon.svg b/assets/icons/moon.svg index c161221..68dd15a 100644 --- a/assets/icons/moon.svg +++ b/assets/icons/moon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/open-url.svg b/assets/icons/open-url.svg deleted file mode 100644 index 837e1e6..0000000 --- a/assets/icons/open-url.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/panel-left-open.svg b/assets/icons/panel-left-open.svg index bd9af3e..a0f79d3 100644 --- a/assets/icons/panel-left-open.svg +++ b/assets/icons/panel-left-open.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg index def688f..814388d 100644 --- a/assets/icons/panel-left.svg +++ b/assets/icons/panel-left.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/panel-right-open.svg b/assets/icons/panel-right-open.svg index 0cf3c3c..2d3e722 100644 --- a/assets/icons/panel-right-open.svg +++ b/assets/icons/panel-right-open.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/panel-right.svg b/assets/icons/panel-right.svg index f7278a5..ea70e43 100644 --- a/assets/icons/panel-right.svg +++ b/assets/icons/panel-right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/paper-plane-fill.svg b/assets/icons/paper-plane-fill.svg new file mode 100644 index 0000000..8231630 --- /dev/null +++ b/assets/icons/paper-plane-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/plus-circle-fill.svg b/assets/icons/plus-circle-fill.svg deleted file mode 100644 index 848c240..0000000 --- a/assets/icons/plus-circle-fill.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/plus-circle.svg b/assets/icons/plus-circle.svg new file mode 100644 index 0000000..96ac654 --- /dev/null +++ b/assets/icons/plus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/plus-fill.svg b/assets/icons/plus-fill.svg deleted file mode 100644 index 1ba3086..0000000 --- a/assets/icons/plus-fill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg index 8cf89a0..ed27feb 100644 --- a/assets/icons/plus.svg +++ b/assets/icons/plus.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/profile.svg b/assets/icons/profile.svg new file mode 100644 index 0000000..0ed1300 --- /dev/null +++ b/assets/icons/profile.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/refresh.svg b/assets/icons/refresh.svg deleted file mode 100644 index ed200e0..0000000 --- a/assets/icons/refresh.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/relay.svg b/assets/icons/relay.svg new file mode 100644 index 0000000..ea4e7b4 --- /dev/null +++ b/assets/icons/relay.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/reply.svg b/assets/icons/reply.svg index 53da0cb..499a40a 100644 --- a/assets/icons/reply.svg +++ b/assets/icons/reply.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/report.svg b/assets/icons/report.svg deleted file mode 100644 index 07c1403..0000000 --- a/assets/icons/report.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/resize-corner.svg b/assets/icons/resize-corner.svg deleted file mode 100644 index 31065be..0000000 --- a/assets/icons/resize-corner.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/search.svg b/assets/icons/search.svg index 5207a79..01ed3cc 100644 --- a/assets/icons/search.svg +++ b/assets/icons/search.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/server.svg b/assets/icons/server.svg deleted file mode 100644 index 841911c..0000000 --- a/assets/icons/server.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg index 059f4a8..cb2a6ac 100644 --- a/assets/icons/settings.svg +++ b/assets/icons/settings.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/assets/icons/shield.svg b/assets/icons/shield.svg new file mode 100644 index 0000000..dacf3b7 --- /dev/null +++ b/assets/icons/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ship.svg b/assets/icons/ship.svg new file mode 100644 index 0000000..137c977 --- /dev/null +++ b/assets/icons/ship.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/signal.svg b/assets/icons/signal.svg deleted file mode 100644 index 5c46c01..0000000 --- a/assets/icons/signal.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/sun.svg b/assets/icons/sun.svg index de6bb8b..b9d5dbd 100644 --- a/assets/icons/sun.svg +++ b/assets/icons/sun.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/upload.svg b/assets/icons/upload.svg index 448cd21..cfb5e30 100644 --- a/assets/icons/upload.svg +++ b/assets/icons/upload.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/usb.svg b/assets/icons/usb.svg new file mode 100644 index 0000000..1d89050 --- /dev/null +++ b/assets/icons/usb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/warning.svg b/assets/icons/warning.svg index 8ac94eb..07eeb04 100644 --- a/assets/icons/warning.svg +++ b/assets/icons/warning.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/zoom.svg b/assets/icons/zoom.svg new file mode 100644 index 0000000..663232e --- /dev/null +++ b/assets/icons/zoom.svg @@ -0,0 +1,58 @@ + + + + + + + + + + diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 9227179..39a37e3 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -6,16 +6,17 @@ publish.workspace = true [dependencies] common = { path = "../common" } -state = { path = "../state" } gpui.workspace = true gpui_tokio.workspace = true reqwest.workspace = true -nostr-sdk.workspace = true anyhow.workspace = true smol.workspace = true log.workspace = true smallvec.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true semver = "1.0.27" tempfile = "3.23.0" +futures.workspace = true diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index de57b71..c81fc30 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -4,21 +4,39 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; -use common::BOOTSTRAP_RELAYS; use gpui::http_client::{AsyncBody, HttpClient}; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task, }; -use nostr_sdk::prelude::*; use semver::Version; +use serde::Deserialize; use smallvec::{smallvec, SmallVec}; use smol::fs::File; use smol::process::Command; -use state::NostrRegistry; -const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q"; +const GITHUB_API_URL: &str = "https://api.github.com"; +const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION"; + +fn get_github_repo_owner() -> String { + std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string()) +} + +fn get_github_repo_name() -> String { + std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_string()) +} + +fn is_flatpak_installation() -> bool { + // Check if app is installed via Flatpak + std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok() +} pub fn init(cx: &mut App) { + // Skip auto-update initialization if installed via Flatpak + if is_flatpak_installation() { + log::info!("Skipping auto-update initialization: App is installed via Flatpak"); + return; + } + AutoUpdater::set_global(cx.new(AutoUpdater::new), cx); } @@ -109,7 +127,7 @@ impl Drop for MacOsUnmounter<'_> { pub enum AutoUpdateStatus { Idle, Checking, - Checked { files: Vec }, + Checked { download_url: String }, Installing, Updated, Errored { msg: Box }, @@ -130,8 +148,8 @@ impl AutoUpdateStatus { matches!(self, Self::Updated) } - pub fn checked(files: Vec) -> Self { - Self::Checked { files } + pub fn checked(download_url: String) -> Self { + Self::Checked { download_url } } pub fn error(e: String) -> Self { @@ -139,6 +157,18 @@ impl AutoUpdateStatus { } } +#[derive(Debug, Deserialize)] +pub struct GitHubRelease { + pub tag_name: String, + pub assets: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct GitHubAsset { + pub name: String, + pub browser_download_url: String, +} + #[derive(Debug)] pub struct AutoUpdater { /// Current status of the auto updater @@ -173,36 +203,32 @@ impl AutoUpdater { let mut tasks = smallvec![]; tasks.push( - // Subscribe to get the new update event in the bootstrap relays - Self::subscribe_to_updates(cx), - ); - - tasks.push( - // Subscribe to get the new update event in the bootstrap relays + // Check for updates after 2 minutes cx.spawn(async move |this, cx| { - // Check for updates after 2 minutes cx.background_executor() .timer(Duration::from_secs(120)) .await; // Update the status to checking - _ = this.update(cx, |this, cx| { + this.update(cx, |this, cx| { this.set_status(AutoUpdateStatus::Checking, cx); - }); + }) + .ok(); match Self::check_for_updates(async_version, cx).await { - Ok(ids) => { - // Update the status to downloading - _ = this.update(cx, |this, cx| { - this.set_status(AutoUpdateStatus::checked(ids), cx); - }); + Ok(download_url) => { + // Update the status to checked with download URL + this.update(cx, |this, cx| { + this.set_status(AutoUpdateStatus::checked(download_url), cx); + }) + .ok(); } Err(e) => { - _ = this.update(cx, |this, cx| { + log::warn!("Failed to check for updates: {e}"); + this.update(cx, |this, cx| { this.set_status(AutoUpdateStatus::Idle, cx); - }); - - log::warn!("{e}"); + }) + .ok(); } } }), @@ -211,8 +237,8 @@ impl AutoUpdater { subscriptions.push( // Observe the status cx.observe_self(|this, cx| { - if let AutoUpdateStatus::Checked { files } = this.status.clone() { - this.get_latest_release(&files, cx); + if let AutoUpdateStatus::Checked { download_url } = this.status.clone() { + this.download_and_install(&download_url, cx); } }), ); @@ -230,118 +256,82 @@ impl AutoUpdater { cx.notify(); } - fn subscribe_to_updates(cx: &App) -> Task<()> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - + fn check_for_updates(version: Version, cx: &AsyncApp) -> Task> { cx.background_spawn(async move { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); + let client = reqwest::Client::new(); + let repo_owner = get_github_repo_owner(); + let repo_name = get_github_repo_name(); + let url = format!( + "{}/repos/{}/{}/releases/latest", + GITHUB_API_URL, repo_owner, repo_name + ); - let filter = Filter::new() - .kind(Kind::ReleaseArtifactSet) - .author(app_pubkey) - .limit(1); - - if let Err(e) = client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + let response = client + .get(&url) + .header("User-Agent", "Coop-Auto-Updater") + .send() .await - { - log::error!("Failed to subscribe to updates: {e}"); - }; - }) - } + .context("Failed to fetch GitHub releases")?; - fn check_for_updates(version: Version, cx: &AsyncApp) -> Task, Error>> { - let client = cx.update(|cx| { - let nostr = NostrRegistry::global(cx); - nostr.read(cx).client() - }); + if !response.status().is_success() { + return Err(anyhow!("GitHub API returned error: {}", response.status())); + } - cx.background_spawn(async move { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); + let release: GitHubRelease = response + .json() + .await + .context("Failed to parse GitHub release")?; - let filter = Filter::new() - .kind(Kind::ReleaseArtifactSet) - .author(app_pubkey) - .limit(1); + // Parse version from tag (remove 'v' prefix if present) + let tag_version = release.tag_name.trim_start_matches('v'); + let new_version = Version::parse(tag_version).context(format!( + "Failed to parse version from tag: {}", + release.tag_name + ))?; - if let Some(event) = client.database().query(filter).await?.first_owned() { - let new_version: Version = event - .tags - .find(TagKind::d()) - .and_then(|tag| tag.content()) - .and_then(|content| content.split("@").last()) - .and_then(|content| Version::parse(content).ok()) - .context("Failed to parse version")?; + if new_version > version { + // Find the appropriate asset for the current platform + let current_os = std::env::consts::OS; + let asset_name = match current_os { + "macos" => "Coop.dmg", + "linux" => "coop.tar.gz", + "windows" => "Coop.exe", + _ => return Err(anyhow!("Unsupported OS: {}", current_os)), + }; - if new_version > version { - // Get all file metadata event ids - let ids: Vec = event.tags.event_ids().copied().collect(); + let download_url = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .map(|asset| asset.browser_download_url.clone()) + .context(format!( + "No {} asset found in release {}", + asset_name, release.tag_name + ))?; - let filter = Filter::new() - .kind(Kind::FileMetadata) - .author(app_pubkey) - .ids(ids.clone()); - - // Get all files for this release - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(ids) - } else { - Err(anyhow!("No update available")) - } + Ok(download_url) } else { - Err(anyhow!("No update available")) + Err(anyhow!( + "No update available. Current: {}, Latest: {}", + version, + new_version + )) } }) } - fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); + fn download_and_install(&mut self, download_url: &str, cx: &mut Context) { let http_client = cx.http_client(); - let ids = ids.to_vec(); + let download_url = download_url.to_string(); let task: Task> = cx.background_spawn(async move { - let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); - let os = std::env::consts::OS; + let installer_dir = InstallerDir::new().await?; + let target_path = Self::target_path(&installer_dir).await?; - let filter = Filter::new() - .kind(Kind::FileMetadata) - .author(app_pubkey) - .ids(ids); + // Download the release + download(&download_url, &target_path, http_client).await?; - // Get all urls for this release - let events = client.database().query(filter).await?; - - for event in events.into_iter() { - // Only process events that match current platform - if event.content != os { - continue; - } - - // Parse the url - let url = event - .tags - .find(TagKind::Url) - .and_then(|tag| tag.content()) - .and_then(|content| Url::parse(content).ok()) - .context("Failed to parse url")?; - - let installer_dir = InstallerDir::new().await?; - let target_path = Self::target_path(&installer_dir).await?; - - // Download the release - download(url.as_str(), &target_path, http_client).await?; - - return Ok((installer_dir, target_path)); - } - - Err(anyhow!("Failed to get latest release")) + Ok((installer_dir, target_path)) }); self._tasks.push( @@ -374,6 +364,7 @@ impl AutoUpdater { async fn target_path(installer_dir: &InstallerDir) -> Result { let filename = match std::env::consts::OS { "macos" => anyhow::Ok("Coop.dmg"), + "linux" => Ok("coop.tar.gz"), "windows" => Ok("Coop.exe"), unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), }?; @@ -388,6 +379,7 @@ impl AutoUpdater { ) -> Result<(), Error> { match std::env::consts::OS { "macos" => install_release_macos(&installer_dir, target_path, cx).await, + "linux" => install_release_linux(&installer_dir, target_path, cx).await, "windows" => install_release_windows(target_path).await, unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"), } @@ -460,6 +452,75 @@ async fn install_release_macos( Ok(()) } +async fn install_release_linux( + temp_dir: &InstallerDir, + downloaded_tar_gz: PathBuf, + cx: &AsyncApp, +) -> Result<(), Error> { + let running_app_path = cx.update(|cx| cx.app_path())?; + + // Extract the tar.gz file + let extracted = temp_dir.path().join("coop"); + smol::fs::create_dir_all(&extracted) + .await + .context("failed to create directory to extract update")?; + + let output = Command::new("tar") + .arg("-xzf") + .arg(&downloaded_tar_gz) + .arg("-C") + .arg(&extracted) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "failed to extract {:?} to {:?}: {:?}", + downloaded_tar_gz, + extracted, + String::from_utf8_lossy(&output.stderr) + ); + + // Find the extracted app directory + let mut entries = smol::fs::read_dir(&extracted).await?; + let mut app_dir = None; + + use smol::stream::StreamExt; + + while let Some(entry) = entries.next().await { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + app_dir = Some(path); + break; + } + } + + let from = app_dir.context("No app directory found in archive")?; + + // Copy to the current installation directory + let output = Command::new("rsync") + .args(["-av", "--delete"]) + .arg(&from) + .arg( + running_app_path + .parent() + .context("No parent directory for app")?, + ) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "failed to copy app from {:?} to {:?}: {:?}", + from, + running_app_path.parent(), + String::from_utf8_lossy(&output.stderr) + ); + + Ok(()) +} + async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> { //const CREATE_NO_WINDOW: u32 = 0x08000000; diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 6080a97..9cad5e3 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -7,16 +7,14 @@ use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; use common::EventUtils; -use device::DeviceRegistry; -use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, + App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, }; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION}; +use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP}; mod message; mod room; @@ -24,8 +22,8 @@ mod room; pub use message::*; pub use room::*; -pub fn init(cx: &mut App) { - ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); +pub fn init(window: &mut Window, cx: &mut App) { + ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx); } struct GlobalChatRegistry(Entity); @@ -45,11 +43,9 @@ pub enum ChatEvent { /// Channel signal. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum NostrEvent { +enum Signal { /// Message received from relay pool Message(NewMessage), - /// Unwrapping status - Unwrapping(bool), /// Eose received from relay pool Eose, } @@ -57,23 +53,17 @@ enum NostrEvent { /// Chat Registry #[derive(Debug)] pub struct ChatRegistry { + /// Relay state for messaging relay list + messaging_relay_list: Entity, + /// Collection of all chat rooms rooms: Vec>, - /// Loading status of the registry - loading: bool, - /// Tracking the status of unwrapping gift wrap events. tracking_flag: Arc, - /// Channel's sender for communication between nostr and gpui - sender: Sender, - - /// Handle notifications asynchronous task - notifications: Option>>, - - /// Tasks for asynchronous operations - tasks: Vec>, + /// Async tasks + tasks: SmallVec<[Task>; 2]>, /// Subscriptions _subscriptions: SmallVec<[Subscription; 1]>, @@ -93,79 +83,52 @@ impl ChatRegistry { } /// Create a new chat registry instance - fn new(cx: &mut Context) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let messaging_relay_list = cx.new(|_| RelayState::default()); let nostr = NostrRegistry::global(cx); - let identity = nostr.read(cx).identity(); - let device = DeviceRegistry::global(cx); - let device_signer = device.read(cx).device_signer.clone(); - - // A flag to indicate if the registry is loading - let tracking_flag = Arc::new(AtomicBool::new(true)); - - // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::(2048); - - let mut tasks = vec![]; let mut subscriptions = smallvec![]; subscriptions.push( - // Observe the identity - cx.observe(&identity, |this, state, cx| { - if state.read(cx).has_public_key() { - // Handle nostr notifications - this.handle_notifications(cx); - // Track unwrapping progress - this.tracking(cx); + // Observe the nip65 state and load chat rooms on every state change + cx.observe(&nostr, |this, state, cx| { + match state.read(cx).relay_list_state() { + RelayState::Idle => { + this.reset(cx); + } + RelayState::Configured => { + this.ensure_messaging_relays(cx); + } + _ => {} } }), ); subscriptions.push( - // Observe the device signer state - cx.observe(&device_signer, |this, state, cx| { - if state.read(cx).is_some() { - this.handle_notifications(cx); + // Observe the nip17 state and load chat rooms on every state change + cx.observe(&messaging_relay_list, |this, state, cx| { + match state.read(cx) { + RelayState::Configured => { + this.get_messages(cx); + } + _ => { + this.get_rooms(cx); + } } }), ); - tasks.push( - // Update GPUI states - cx.spawn(async move |this, cx| { - while let Ok(message) = rx.recv_async().await { - match message { - NostrEvent::Message(message) => { - this.update(cx, |this, cx| { - this.new_message(message, cx); - }) - .ok(); - } - NostrEvent::Eose => { - this.update(cx, |this, cx| { - this.get_rooms(cx); - }) - .ok(); - } - NostrEvent::Unwrapping(status) => { - this.update(cx, |this, cx| { - this.set_loading(status, cx); - this.get_rooms(cx); - }) - .ok(); - } - }; - } - }), - ); + // Run at the end of current cycle + cx.defer_in(window, |this, _window, cx| { + this.handle_notifications(cx); + this.tracking(cx); + }); Self { + messaging_relay_list, rooms: vec![], - loading: true, - tracking_flag, - sender: tx.clone(), - notifications: None, - tasks, + tracking_flag: Arc::new(AtomicBool::new(false)), + tasks: smallvec![], _subscriptions: subscriptions, } } @@ -174,22 +137,23 @@ impl ChatRegistry { fn handle_notifications(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - - let device = DeviceRegistry::global(cx); - let device_signer = device.read(cx).signer(cx); - + let signer = nostr.read(cx).signer(); let status = self.tracking_flag.clone(); - let tx = self.sender.clone(); + + let initialized_at = Timestamp::now(); + let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP); + let sub_id2 = SubscriptionId::new(USER_GIFTWRAP); + + // Channel for communication between nostr and gpui + let (tx, rx) = flume::bounded::(1024); self.tasks.push(cx.background_spawn(async move { - let initialized_at = Timestamp::now(); - let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); - + let device_signer = signer.get_encryption_signer().await; let mut notifications = client.notifications(); let mut processed_events = HashSet::new(); - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, .. } = notification else { + while let Some(notification) = notifications.next().await { + let ClientNotification::Message { message, .. } = notification else { // Skip non-message notifications continue; }; @@ -206,99 +170,187 @@ impl ChatRegistry { continue; } + log::info!("Received gift wrap event: {:?}", event); + // Extract the rumor from the gift wrap event match Self::extract_rumor(&client, &device_signer, event.as_ref()).await { Ok(rumor) => match rumor.created_at >= initialized_at { true => { - // Check if the event is sent by coop - let sent_by_coop = { - let tracker = tracker().read().await; - tracker.is_sent_by_coop(&event.id) - }; - // No need to emit if sent by coop - // the event is already emitted - if !sent_by_coop { - let new_message = NewMessage::new(event.id, rumor); - let signal = NostrEvent::Message(new_message); + let new_message = NewMessage::new(event.id, rumor); + let signal = Signal::Message(new_message); - tx.send_async(signal).await.ok(); - } + tx.send_async(signal).await?; } false => { status.store(true, Ordering::Release); } }, Err(e) => { - log::warn!("Failed to unwrap: {e}"); + log::warn!("Failed to unwrap the gift wrap event: {e}"); } } } RelayMessage::EndOfStoredEvents(id) => { - if id.as_ref() == &subscription_id { - tx.send_async(NostrEvent::Eose).await.ok(); + if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 { + tx.send_async(Signal::Eose).await?; } } _ => {} } } + + Ok(()) + })); + + self.tasks.push(cx.spawn(async move |this, cx| { + while let Ok(message) = rx.recv_async().await { + match message { + Signal::Message(message) => { + this.update(cx, |this, cx| { + this.new_message(message, cx); + })?; + } + Signal::Eose => { + this.update(cx, |this, cx| { + this.get_rooms(cx); + })?; + } + }; + } + + Ok(()) })); } /// Tracking the status of unwrapping gift wrap events. fn tracking(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let status = self.tracking_flag.clone(); - let tx = self.sender.clone(); - self.notifications = Some(cx.background_spawn(async move { - let loop_duration = Duration::from_secs(12); - - let mut is_start_processing = false; - let mut total_loops = 0; + self.tasks.push(cx.background_spawn(async move { + let loop_duration = Duration::from_secs(10); loop { - if client.has_signer().await { - total_loops += 1; - - if status.load(Ordering::Acquire) { - is_start_processing = true; - // Reset gift wrap processing flag - _ = status.compare_exchange( - true, - false, - Ordering::Release, - Ordering::Relaxed, - ); - - tx.send_async(NostrEvent::Unwrapping(true)).await.ok(); - } else { - // Only run further if we are already processing - // Wait until after 2 loops to prevent exiting early while events are still being processed - if is_start_processing && total_loops >= 2 { - tx.send_async(NostrEvent::Unwrapping(false)).await.ok(); - - // Reset the counter - is_start_processing = false; - total_loops = 0; - } - } + if status.load(Ordering::Acquire) { + _ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed); } smol::Timer::after(loop_duration).await; } })); } - /// Get the loading status of the chat registry - pub fn loading(&self) -> bool { - self.loading + fn ensure_messaging_relays(&mut self, cx: &mut Context) { + let state = self.messaging_relay_list.downgrade(); + let task = self.verify_relays(cx); + + self.tasks.push(cx.spawn(async move |_this, cx| { + let result = task.await?; + + // Update state + state.update(cx, |this, cx| { + *this = result; + cx.notify(); + })?; + + Ok(()) + })); } - /// Set the loading status of the chat registry - pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { - self.loading = loading; - cx.notify(); + // Verify messaging relay list for current user + fn verify_relays(&mut self, cx: &mut Context) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + + let write_relays = nostr.read(cx).write_relays(&public_key, cx); + + cx.background_spawn(async move { + let urls = write_relays.await; + + // Construct filter for inbox relays + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = + urls.iter().map(|relay| (relay, filter.clone())).collect(); + + // Stream events from user's write relays + let mut stream = client + .stream_events(target) + .timeout(Duration::from_secs(TIMEOUT)) + .await?; + + while let Some((_url, res)) = stream.next().await { + match res { + Ok(event) => { + log::info!("Received relay list event: {event:?}"); + return Ok(RelayState::Configured); + } + Err(e) => { + log::error!("Failed to receive relay list event: {e}"); + } + } + } + + Ok(RelayState::NotConfigured) + }) + } + + /// Get all messages for current user + fn get_messages(&mut self, cx: &mut Context) { + let task = self.subscribe_to_giftwrap_events(cx); + + self.tasks.push(cx.spawn(async move |_this, _cx| { + task.await?; + + // Update state + + Ok(()) + })); + } + + /// Continuously get gift wrap events for the current user in their messaging relays + fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + + let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx); + + cx.background_spawn(async move { + let urls = messaging_relays.await; + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let id = SubscriptionId::new(USER_GIFTWRAP); + + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = + urls.iter().map(|relay| (relay, filter.clone())).collect(); + + let output = client.subscribe(target).with_id(id).await?; + + log::info!( + "Successfully subscribed to gift-wrap messages on: {:?}", + output.success + ); + + Ok(()) + }) + } + + /// Get the relay state + pub fn relay_state(&self, cx: &App) -> RelayState { + self.messaging_relay_list.read(cx).clone() + } + + /// Get the loading status of the chat registry + pub fn loading(&self) -> bool { + self.tracking_flag.load(Ordering::Acquire) } /// Get a weak reference to a room by its ID. @@ -309,47 +361,60 @@ impl ChatRegistry { .map(|this| this.downgrade()) } - /// Get all ongoing rooms. - pub fn ongoing_rooms(&self, cx: &App) -> Vec> { + /// Get all rooms based on the filter. + pub fn rooms(&self, filter: &RoomKind, cx: &App) -> Vec> { self.rooms .iter() - .filter(|room| room.read(cx).kind == RoomKind::Ongoing) + .filter(|room| &room.read(cx).kind == filter) .cloned() .collect() } - /// Get all request rooms. - pub fn request_rooms(&self, cx: &App) -> Vec> { + /// Count the number of rooms based on the filter. + pub fn count(&self, filter: &RoomKind, cx: &App) -> usize { self.rooms .iter() - .filter(|room| room.read(cx).kind != RoomKind::Ongoing) - .cloned() - .collect() + .filter(|room| &room.read(cx).kind == filter) + .count() } /// Add a new room to the start of list. pub fn add_room(&mut self, room: I, cx: &mut Context) where - I: Into, + I: Into + 'static, { - self.rooms.insert(0, cx.new(|_| room.into())); - cx.notify(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + cx.spawn(async move |this, cx| { + let signer = client.signer()?; + let public_key = signer.get_public_key().await.ok()?; + let room: Room = room.into().organize(&public_key); + + this.update(cx, |this, cx| { + this.rooms.insert(0, cx.new(|_| room)); + cx.emit(ChatEvent::Ping); + cx.notify(); + }) + .ok() + }) + .detach(); } /// Emit an open room event. + /// /// If the room is new, add it to the registry. - pub fn emit_room(&mut self, room: WeakEntity, cx: &mut Context) { - if let Some(room) = room.upgrade() { - let id = room.read(cx).id; + pub fn emit_room(&mut self, room: &Entity, cx: &mut Context) { + // Get the room's ID. + let id = room.read(cx).id; - // If the room is new, add it to the registry. - if !self.rooms.iter().any(|r| r.read(cx).id == id) { - self.rooms.insert(0, room); - } - - // Emit the open room event. - cx.emit(ChatEvent::OpenRoom(id)); + // If the room is new, add it to the registry. + if !self.rooms.iter().any(|r| r.read(cx).id == id) { + self.rooms.insert(0, room.to_owned()); } + + // Emit the open room event. + cx.emit(ChatEvent::OpenRoom(id)); } /// Close a room. @@ -365,28 +430,27 @@ impl ChatRegistry { cx.notify(); } - /// Search rooms by their name. - pub fn search(&self, query: &str, cx: &App) -> Vec> { + /// Finding rooms based on a query. + pub fn find(&self, query: &str, cx: &App) -> Vec> { let matcher = SkimMatcherV2::default(); - self.rooms - .iter() - .filter(|room| { - matcher - .fuzzy_match(room.read(cx).display_name(cx).as_ref(), query) - .is_some() - }) - .cloned() - .collect() - } - - /// Search rooms by public keys. - pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec> { - self.rooms - .iter() - .filter(|room| room.read(cx).members.contains(&public_key)) - .cloned() - .collect() + if let Ok(public_key) = PublicKey::parse(query) { + self.rooms + .iter() + .filter(|room| room.read(cx).members.contains(&public_key)) + .cloned() + .collect() + } else { + self.rooms + .iter() + .filter(|room| { + matcher + .fuzzy_match(room.read(cx).display_name(cx).as_ref(), query) + .is_some() + }) + .cloned() + .collect() + } } /// Reset the registry. @@ -427,23 +491,16 @@ impl ChatRegistry { pub fn get_rooms(&mut self, cx: &mut Context) { let task = self.get_rooms_from_database(cx); - self.tasks.push( - // Run and finished in the background - cx.spawn(async move |this, cx| { - match task.await { - Ok(rooms) => { - this.update(cx, move |this, cx| { - this.extend_rooms(rooms, cx); - this.sort(cx); - }) - .ok(); - } - Err(e) => { - log::error!("Failed to load rooms: {e}") - } - }; - }), - ); + cx.spawn(async move |this, cx| { + let rooms = task.await.ok()?; + + this.update(cx, move |this, cx| { + this.extend_rooms(rooms, cx); + this.sort(cx); + }) + .ok() + }) + .detach(); } /// Create a task to load rooms from the database @@ -452,10 +509,13 @@ impl ChatRegistry { let client = nostr.read(cx).client(); cx.background_spawn(async move { - let signer = client.signer().await?; + let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; + + // Get contacts let contacts = client.database().contacts_public_keys(public_key).await?; + // Construct authored filter let authored_filter = Filter::new() .kind(Kind::ApplicationSpecificData) .custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key); @@ -463,6 +523,7 @@ impl ChatRegistry { // Get all authored events let authored = client.database().query(authored_filter).await?; + // Construct addressed filter let addressed_filter = Filter::new() .kind(Kind::ApplicationSpecificData) .custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key); @@ -473,6 +534,7 @@ impl ChatRegistry { // Merge authored and addressed events let events = authored.merge(addressed); + // Collect results let mut rooms: HashSet = HashSet::new(); let mut grouped: HashMap> = HashMap::new(); @@ -488,24 +550,21 @@ impl ChatRegistry { for (_id, mut messages) in grouped.into_iter() { messages.sort_by_key(|m| Reverse(m.created_at)); + // Always use the latest message let Some(latest) = messages.first() else { continue; }; - let mut room = Room::from(latest); - - if rooms.iter().any(|r| r.id == room.id) { - continue; - } - - let mut public_keys = room.members(); - public_keys.retain(|pk| pk != &public_key); + // Construct the room from the latest message. + // + // Call `.organize` to ensure the current user is at the end of the list. + let mut room = Room::from(latest).organize(&public_key); // Check if the user has responded to the room let user_sent = messages.iter().any(|m| m.pubkey == public_key); // Check if public keys are from the user's contacts - let is_contact = public_keys.iter().any(|k| contacts.contains(k)); + let is_contact = room.members.iter().any(|k| contacts.contains(k)); // Set the room's kind based on status if user_sent || is_contact { @@ -519,6 +578,24 @@ impl ChatRegistry { }) } + /// Parse a nostr event into a message and push it to the belonging room + /// + /// If the room doesn't exist, it will be created. + /// Updates room ordering based on the most recent messages. + pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { + match self.rooms.iter().find(|e| e.read(cx).id == message.room) { + Some(room) => { + room.update(cx, |this, cx| { + this.push_message(message, cx); + }); + } + None => { + // Push the new room to the front of the list + self.add_room(message.rumor, cx); + } + } + } + /// Trigger a refresh of the opened chat rooms by their IDs pub fn refresh_rooms(&mut self, ids: Option>, cx: &mut Context) { if let Some(ids) = ids { @@ -532,54 +609,7 @@ impl ChatRegistry { } } - /// Parse a Nostr event into a Coop Message and push it to the belonging room - /// - /// If the room doesn't exist, it will be created. - /// Updates room ordering based on the most recent messages. - pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - // Get the unique id - let id = message.rumor.uniq_id(); - // Get the author - let author = message.rumor.pubkey; - - match self.rooms.iter().find(|room| room.read(cx).id == id) { - Some(room) => { - let new_message = message.rumor.created_at > room.read(cx).created_at; - let created_at = message.rumor.created_at; - - // Update room - room.update(cx, |this, cx| { - // Update the last timestamp if the new message is newer - if new_message { - this.set_created_at(created_at, cx); - } - - // Set this room is ongoing if the new message is from current user - if author == nostr.read(cx).identity().read(cx).public_key() { - this.set_ongoing(cx); - } - - // Emit the new message to the room - this.emit_message(message, cx); - }); - - // Resort all rooms in the registry by their created at (after updated) - if new_message { - self.sort(cx); - } - } - None => { - // Push the new room to the front of the list - self.add_room(&message.rumor, cx); - - // Notify the UI about the new room - cx.emit(ChatEvent::Ping); - } - } - } - - // Unwraps a gift-wrapped event and processes its contents. + /// Unwraps a gift-wrapped event and processes its contents. async fn extract_rumor( client: &Client, device_signer: &Option>, @@ -603,35 +633,50 @@ impl ChatRegistry { Ok(rumor_unsigned) } - // Helper method to try unwrapping with different signers + /// Helper method to try unwrapping with different signers async fn try_unwrap( client: &Client, device_signer: &Option>, gift_wrap: &Event, ) -> Result { - if let Some(signer) = device_signer.as_ref() { - let seal = signer - .nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content) - .await?; + // Try with the device signer first + if let Some(signer) = device_signer { + if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await { + return Ok(unwrapped); + }; + }; - let seal: Event = Event::from_json(seal)?; - seal.verify_with_ctx(&SECP256K1)?; - - let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?; - let rumor = UnsignedEvent::from_json(rumor)?; - - return Ok(UnwrappedGift { - sender: seal.pubkey, - rumor, - }); - } - - let signer = client.signer().await?; - let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?; + // Try with the user's signer + let user_signer = client.signer().context("Signer not found")?; + let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?; Ok(unwrapped) } + /// Attempts to unwrap a gift wrap event with a given signer. + async fn try_unwrap_with( + gift_wrap: &Event, + signer: &Arc, + ) -> Result { + // Get the sealed event + let seal = signer + .nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content) + .await?; + + // Verify the sealed event + let seal: Event = Event::from_json(seal)?; + seal.verify_with_ctx(&SECP256K1)?; + + // Get the rumor event + let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?; + let rumor = UnsignedEvent::from_json(rumor)?; + + Ok(UnwrappedGift { + sender: seal.pubkey, + rumor, + }) + } + /// Stores an unwrapped event in local database with reference to original async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { let rumor_id = rumor.id.context("Rumor is missing an event id")?; diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index c4cfef6..6118331 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -1,17 +1,25 @@ use std::hash::Hash; +use common::EventUtils; use nostr_sdk::prelude::*; /// New message. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct NewMessage { + pub room: u64, pub gift_wrap: EventId, pub rumor: UnsignedEvent, } impl NewMessage { pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self { - Self { gift_wrap, rumor } + let room = rumor.uniq_id(); + + Self { + room, + gift_wrap, + rumor, + } } } diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 192c0d9..029debd 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -1,81 +1,66 @@ use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::time::Duration; -use anyhow::Error; +use anyhow::{Context as AnyhowContext, Error}; use common::EventUtils; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; -use state::{tracker, NostrRegistry}; +use settings::{RoomConfig, SignerKind}; +use state::{NostrRegistry, TIMEOUT}; -use crate::NewMessage; - -const SEND_RETRY: usize = 10; +use crate::{ChatRegistry, NewMessage}; #[derive(Debug, Clone)] pub struct SendReport { pub receiver: PublicKey, - pub status: Option>, + pub gift_wrap_id: Option, pub error: Option, - pub on_hold: Option, - pub encryption: bool, - pub relays_not_found: bool, - pub device_not_found: bool, + pub output: Option>, } impl SendReport { pub fn new(receiver: PublicKey) -> Self { Self { receiver, - status: None, + gift_wrap_id: None, error: None, - on_hold: None, - encryption: false, - relays_not_found: false, - device_not_found: false, + output: None, } } - pub fn status(mut self, output: Output) -> Self { - self.status = Some(output); + /// Set the gift wrap ID. + pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self { + self.gift_wrap_id = Some(gift_wrap_id); self } - pub fn error(mut self, error: impl Into) -> Self { + /// Set the output. + pub fn output(mut self, output: Output) -> Self { + self.output = Some(output); + self + } + + /// Set the error message. + pub fn error(mut self, error: T) -> Self + where + T: Into, + { self.error = Some(error.into()); self } - pub fn on_hold(mut self, event: Event) -> Self { - self.on_hold = Some(event); - self + /// Returns true if the send is pending. + pub fn pending(&self) -> bool { + self.output.is_none() && self.error.is_none() } - pub fn encryption(mut self) -> Self { - self.encryption = true; - self - } - - pub fn relays_not_found(mut self) -> Self { - self.relays_not_found = true; - self - } - - pub fn device_not_found(mut self) -> Self { - self.device_not_found = true; - self - } - - pub fn is_relay_error(&self) -> bool { - self.error.is_some() || self.relays_not_found - } - - pub fn is_sent_success(&self) -> bool { - if let Some(output) = self.status.as_ref() { - !output.success.is_empty() + /// Returns true if the send was successful. + pub fn success(&self) -> bool { + if let Some(output) = self.output.as_ref() { + !output.failed.is_empty() } else { false } @@ -99,18 +84,25 @@ pub enum RoomKind { Ongoing, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Room { /// Conversation ID pub id: u64, + /// The timestamp of the last message in the room pub created_at: Timestamp, + /// Subject of the room pub subject: Option, + /// All members of the room - pub members: Vec, + pub(super) members: Vec, + /// Kind pub kind: RoomKind, + + /// Configuration + config: RoomConfig, } impl Ord for Room { @@ -145,11 +137,7 @@ impl From<&UnsignedEvent> for Room { fn from(val: &UnsignedEvent) -> Self { let id = val.uniq_id(); let created_at = val.created_at; - - // Get the members from the event's tags and event's pubkey let members = val.extract_public_keys(); - - // Get subject from tags let subject = val .tags .find(TagKind::Subject) @@ -161,38 +149,50 @@ impl From<&UnsignedEvent> for Room { subject, members, kind: RoomKind::default(), + config: RoomConfig::default(), } } } +impl From for Room { + fn from(val: UnsignedEvent) -> Self { + Room::from(&val) + } +} + impl Room { /// Constructs a new room with the given receiver and tags. - pub fn new(subject: Option, author: PublicKey, receivers: Vec) -> Self { - // Convert receiver's public keys into tags - let mut tags: Tags = Tags::from_list( - receivers - .iter() - .map(|pubkey| Tag::public_key(pubkey.to_owned())) - .collect(), - ); - - // Add subject if it is present - if let Some(subject) = subject { - tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( - subject, - ))); - } + pub fn new(author: PublicKey, receivers: T) -> Self + where + T: IntoIterator, + { + // Map receiver public keys to tags + let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect()); + // Construct an unsigned event for a direct message + // + // WARNING: never sign this event let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "") .tags(tags) .build(author); - // Generate event ID + // Ensure that the ID is set event.ensure_id(); Room::from(&event) } + /// Organizes the members of the room by moving the target member to the end. + /// + /// Always call this function to ensure the current user is at the end of the list. + pub fn organize(mut self, target: &PublicKey) -> Self { + if let Some(index) = self.members.iter().position(|member| member == target) { + let member = self.members.remove(index); + self.members.push(member); + } + self + } + /// Sets the kind of the room and returns the modified room pub fn kind(mut self, kind: RoomKind) -> Self { self.kind = kind; @@ -227,28 +227,6 @@ impl Room { self.members.clone() } - /// Returns the members of the room with their messaging relays - pub fn members_with_relays(&self, cx: &App) -> Task)>> { - let nostr = NostrRegistry::global(cx); - let mut tasks = vec![]; - - for member in self.members.iter() { - let task = nostr.read(cx).messaging_relays(member, cx); - tasks.push((*member, task)); - } - - cx.background_spawn(async move { - let mut results = vec![]; - - for (public_key, task) in tasks.into_iter() { - let urls = task.await; - results.push((public_key, urls)); - } - - results - }) - } - /// Checks if the room has more than two members (group) pub fn is_group(&self) -> bool { self.members.len() > 2 @@ -277,17 +255,7 @@ impl Room { /// Display member is always different from the current user. pub fn display_member(&self, cx: &App) -> Person { let persons = PersonRegistry::global(cx); - let nostr = NostrRegistry::global(cx); - let public_key = nostr.read(cx).identity().read(cx).public_key(); - - let target_member = self - .members - .iter() - .find(|&member| member != &public_key) - .or_else(|| self.members.first()) - .expect("Room should have at least one member"); - - persons.read(cx).get(target_member, cx) + persons.read(cx).get(&self.members[0], cx) } /// Merge the names of the first two members of the room. @@ -308,7 +276,7 @@ impl Room { .collect::>() .join(", "); - if profiles.len() > 2 { + if profiles.len() > 3 { name = format!("{}, +{}", name, profiles.len() - 2); } @@ -318,9 +286,21 @@ impl Room { } } - /// Emits a new message signal to the current room - pub fn emit_message(&self, message: NewMessage, cx: &mut Context) { + /// Push a new message to the current room + pub fn push_message(&mut self, message: NewMessage, cx: &mut Context) { + let created_at = message.rumor.created_at; + let new_message = created_at > self.created_at; + + // Emit the incoming message event cx.emit(RoomEvent::Incoming(message)); + + if new_message { + self.set_created_at(created_at, cx); + // Sort chats after emitting a new message + ChatRegistry::global(cx).update(cx, |this, cx| { + this.sort(cx); + }); + } } /// Emits a signal to reload the current room's messages. @@ -329,32 +309,43 @@ impl Room { } /// Get gossip relays for each member - pub fn connect(&self, cx: &App) -> Task> { + pub fn early_connect(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let members = self.members(); - let id = SubscriptionId::new(format!("room-{}", self.id)); + let subscription_id = SubscriptionId::new(format!("room-{}", self.id)); cx.background_spawn(async move { - let signer = client.signer().await?; + let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; - // Subscription options - let opts = SubscribeAutoCloseOptions::default() - .timeout(Some(Duration::from_secs(2))) - .exit_policy(ReqExitPolicy::ExitOnEOSE); - for member in members.into_iter() { if member == public_key { continue; }; - // Construct a filter for gossip relays - let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1); + // Construct a filter for messaging relays + let inbox = Filter::new() + .kind(Kind::InboxRelays) + .author(member) + .limit(1); + + // Construct a filter for announcement + let announcement = Filter::new() + .kind(Kind::Custom(10044)) + .author(member) + .limit(1); // Subscribe to get member's gossip relays client - .subscribe_with_id(id.clone(), filter, Some(opts)) + .subscribe(vec![inbox, announcement]) + .with_id(subscription_id.clone()) + .close_on( + SubscribeAutoCloseOptions::default() + .timeout(Some(Duration::from_secs(TIMEOUT))) + .exit_policy(ReqExitPolicy::ExitOnEOSE), + ) .await?; } @@ -386,68 +377,265 @@ impl Room { }) } - /// Create a new message event (unsigned) - pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent { + // Construct a rumor event for direct message + pub fn rumor(&self, content: S, replies: I, cx: &App) -> Option + where + S: Into, + I: IntoIterator, + { + let kind = Kind::PrivateDirectMessage; + let content: String = content.into(); + let replies: Vec = replies.into_iter().collect(); + + let persons = PersonRegistry::global(cx); let nostr = NostrRegistry::global(cx); - // Get current user - let public_key = nostr.read(cx).identity().read(cx).public_key(); + // Get current user's public key + let sender = nostr.read(cx).signer().public_key()?; - // Get room's subject - let subject = self.subject.clone(); + // Get all members + let members: Vec = self + .members + .iter() + .filter(|public_key| public_key != &&sender) + .map(|member| persons.read(cx).get(member, cx)) + .collect(); + // Construct event's tags let mut tags = vec![]; - // Add receivers - // - // NOTE: current user will be removed from the list of receivers - for member in self.members.iter() { - // Get relay hint if available - let relay_url = nostr.read(cx).relay_hint(member, cx); - - // Construct a public key tag with relay hint - let tag = TagStandard::PublicKey { - public_key: member.to_owned(), - relay_url, - alias: None, - uppercase: false, - }; - - tags.push(Tag::from_standardized_without_cell(tag)); - } - - // Add subject tag if it's present - if let Some(value) = subject { + // Add subject tag if present + if let Some(value) = self.subject.as_ref() { tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( value.to_string(), ))); } - // Add reply/quote tag - if replies.len() == 1 { - tags.push(Tag::event(replies[0])) - } else { - for id in replies { - let tag = TagStandard::Quote { - event_id: id.to_owned(), - relay_url: None, - public_key: None, - }; - tags.push(Tag::from_standardized_without_cell(tag)) - } + // Add all reply tags + for id in replies.into_iter() { + tags.push(Tag::event(id)) } - // Construct a direct message event - // - // WARNING: never sign and send this event to relays - let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content) - .tags(tags) - .build(public_key); + // Add all receiver tags + for member in members.into_iter() { + // Skip current user + if member.public_key() == sender { + continue; + } - // Ensure the event id has been generated + tags.push(Tag::from_standardized_without_cell( + TagStandard::PublicKey { + public_key: member.public_key(), + relay_url: member.messaging_relay_hint(), + alias: None, + uppercase: false, + }, + )); + } + + // Construct a direct message rumor event + // WARNING: never sign and send this event to relays + let mut event = EventBuilder::new(kind, content).tags(tags).build(sender); + + // Ensure that the ID is set event.ensure_id(); - event + Some(event) + } + + /// Send rumor event to all members's messaging relays + pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option>> { + let persons = PersonRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + // Get room's config + let config = self.config.clone(); + + // Get current user's public key + let sender = nostr.read(cx).signer().public_key()?; + + // Get all members (excluding sender) + let members: Vec = self + .members + .iter() + .filter(|public_key| public_key != &&sender) + .map(|member| persons.read(cx).get(member, cx)) + .collect(); + + Some(cx.background_spawn(async move { + let signer_kind = config.signer_kind(); + let user_signer = signer.get().await; + let encryption_signer = signer.get_encryption_signer().await; + + let mut reports = Vec::new(); + + for member in members { + let relays = member.messaging_relays(); + let announcement = member.announcement(); + + // Skip if member has no messaging relays + if relays.is_empty() { + reports.push(SendReport::new(member.public_key()).error("No messaging relays")); + continue; + } + + // Ensure relay connections + for url in relays.iter() { + client + .add_relay(url) + .and_connect() + .capabilities(RelayCapabilities::GOSSIP) + .await + .ok(); + } + + // When forced to use encryption signer, skip if receiver has no announcement + if signer_kind.encryption() && announcement.is_none() { + reports + .push(SendReport::new(member.public_key()).error("Encryption not found")); + continue; + } + + // Determine receiver and signer based on signer kind + let (receiver, signer_to_use) = match signer_kind { + SignerKind::Auto => { + if let Some(announcement) = announcement { + if let Some(enc_signer) = encryption_signer.as_ref() { + (announcement.public_key(), enc_signer.clone()) + } else { + (member.public_key(), user_signer.clone()) + } + } else { + (member.public_key(), user_signer.clone()) + } + } + SignerKind::Encryption => { + let Some(encryption_signer) = encryption_signer.as_ref() else { + reports.push( + SendReport::new(member.public_key()).error("Encryption not found"), + ); + continue; + }; + let Some(announcement) = announcement else { + reports.push( + SendReport::new(member.public_key()) + .error("Announcement not found"), + ); + continue; + }; + (announcement.public_key(), encryption_signer.clone()) + } + SignerKind::User => (member.public_key(), user_signer.clone()), + }; + + // Create and send gift-wrapped event + match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await { + Ok(event) => { + match client + .send_event(&event) + .to(relays) + .ack_policy(AckPolicy::none()) + .await + { + Ok(output) => { + reports.push( + SendReport::new(member.public_key()) + .gift_wrap_id(event.id) + .output(output), + ); + } + Err(e) => { + reports.push( + SendReport::new(member.public_key()).error(e.to_string()), + ); + } + } + } + Err(e) => { + reports.push(SendReport::new(member.public_key()).error(e.to_string())); + } + } + } + + reports + })) + } + + /* + * /// Create a new unsigned message event + pub fn create_message( + &self, + content: &str, + replies: Vec, + cx: &App, + ) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let subject = self.subject.clone(); + let content = content.to_string(); + + let mut member_and_relay_hints = HashMap::new(); + + // Populate the hashmap with member and relay hint tasks + for member in self.members.iter() { + let hint = nostr.read(cx).relay_hint(member, cx); + member_and_relay_hints.insert(member.to_owned(), hint); + } + + cx.background_spawn(async move { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + // List of event tags for each receiver + let mut tags = vec![]; + + for (member, task) in member_and_relay_hints.into_iter() { + // Skip current user + if member == public_key { + continue; + } + + // Get relay hint if available + let relay_url = task.await; + + // Construct a public key tag with relay hint + let tag = TagStandard::PublicKey { + public_key: member, + relay_url, + alias: None, + uppercase: false, + }; + + tags.push(Tag::from_standardized_without_cell(tag)); + } + + // Add subject tag if present + if let Some(value) = subject { + tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( + value.to_string(), + ))); + } + + // Add all reply tags + for id in replies { + tags.push(Tag::event(id)) + } + + // Construct a direct message event + // + // WARNING: never sign and send this event to relays + let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content) + .tags(tags) + .build(public_key); + + // Ensure the event ID has been generated + event.ensure_id(); + + Ok(event) + }) } /// Create a task to send a message to all room members @@ -459,46 +647,27 @@ impl Room { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - // Get current user's public key and relays - let current_user = nostr.read(cx).identity().read(cx).public_key(); - let current_user_relays = nostr.read(cx).messaging_relays(¤t_user, cx); - + let mut members = self.members(); let rumor = rumor.to_owned(); - // Get all members and their messaging relays - let task = self.members_with_relays(cx); - cx.background_spawn(async move { - let signer = client.signer().await?; - let current_user_relays = current_user_relays.await; - let mut members = task.await; + let signer = client.signer().context("Signer not found")?; + let current_user = signer.get_public_key().await?; // Remove the current user's public key from the list of receivers // the current user will be handled separately - members.retain(|(this, _)| this != ¤t_user); + members.retain(|this| this != ¤t_user); // Collect the send reports let mut reports: Vec = vec![]; - for (receiver, relays) in members.into_iter() { - // Check if there are any relays to send the message to - if relays.is_empty() { - reports.push(SendReport::new(receiver).relays_not_found()); - continue; - } - - // Ensure relay connection - for url in relays.iter() { - client.add_relay(url).await?; - client.connect_relay(url).await?; - } - + for receiver in members.into_iter() { // Construct the gift wrap event let event = - EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?; + EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?; // Send the gift wrap event to the messaging relays - match client.send_event_to(relays, &event).await { + match client.send_event(&event).to_nip17().await { Ok(output) => { let id = output.id().to_owned(); let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-")); @@ -536,24 +705,12 @@ impl Room { // Construct the gift-wrapped event let event = - EventBuilder::gift_wrap(&signer, ¤t_user, rumor.clone(), vec![]).await?; + EventBuilder::gift_wrap(signer, ¤t_user, rumor.clone(), vec![]).await?; // Only send a backup message to current user if sent successfully to others if reports.iter().all(|r| r.is_sent_success()) { - // Check if there are any relays to send the event to - if current_user_relays.is_empty() { - reports.push(SendReport::new(current_user).relays_not_found()); - return Ok(reports); - } - - // Ensure relay connection - for url in current_user_relays.iter() { - client.add_relay(url).await?; - client.connect_relay(url).await?; - } - // Send the event to the messaging relays - match client.send_event_to(current_user_relays, &event).await { + match client.send_event(&event).to_nip17().await { Ok(output) => { reports.push(SendReport::new(current_user).status(output)); } @@ -591,7 +748,7 @@ impl Room { if let Some(event) = client.database().event_by_id(id).await? { for url in urls.into_iter() { - let relay = client.pool().relay(url).await?; + let relay = client.relay(url).await?.context("Relay not found")?; let id = relay.send_event(&event).await?; let resent: Output = Output { @@ -622,4 +779,5 @@ impl Room { Ok(resend_reports) }) } + */ } diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index bff3e8d..7d4147b 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -21,11 +21,10 @@ anyhow.workspace = true itertools.workspace = true smallvec.workspace = true smol.workspace = true +flume.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true -indexset = "0.12.3" -emojis = "0.6.4" once_cell = "1.19.0" regex = "1" diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index bea282e..ab28139 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -2,6 +2,13 @@ use gpui::Action; use nostr_sdk::prelude::*; use serde::Deserialize; +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = chat, no_json)] +pub enum Command { + Insert(&'static str), + ChangeSubject(&'static str), +} + #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = chat, no_json)] pub struct SeenOn(pub EventId); diff --git a/crates/chat_ui/src/emoji.rs b/crates/chat_ui/src/emoji.rs deleted file mode 100644 index c60aeeb..0000000 --- a/crates/chat_ui/src/emoji.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::sync::OnceLock; - -use gpui::prelude::FluentBuilder; -use gpui::{ - div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, -}; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::input::InputState; -use ui::popover::{Popover, PopoverContent}; -use ui::{Icon, Sizable, Size}; - -static EMOJIS: OnceLock> = OnceLock::new(); - -fn get_emojis() -> &'static Vec { - EMOJIS.get_or_init(|| { - let mut emojis: Vec = vec![]; - - emojis.extend( - emojis::Group::SmileysAndEmotion - .emojis() - .map(|e| SharedString::from(e.as_str())) - .collect::>(), - ); - - emojis - }) -} - -#[derive(IntoElement)] -pub struct EmojiPicker { - target: Option>, - icon: Option, - anchor: Option, - size: Size, -} - -impl EmojiPicker { - pub fn new() -> Self { - Self { - size: Size::default(), - target: None, - anchor: None, - icon: None, - } - } - - pub fn target(mut self, target: WeakEntity) -> Self { - self.target = Some(target); - self - } - - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - #[allow(dead_code)] - pub fn anchor(mut self, corner: Corner) -> Self { - self.anchor = Some(corner); - self - } -} - -impl Sizable for EmojiPicker { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl RenderOnce for EmojiPicker { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - Popover::new("emojis") - .map(|this| { - if let Some(corner) = self.anchor { - this.anchor(corner) - } else { - this.anchor(gpui::Corner::BottomLeft) - } - }) - .trigger( - Button::new("emojis-trigger") - .when_some(self.icon, |this, icon| this.icon(icon)) - .ghost() - .with_size(self.size), - ) - .content(move |window, cx| { - let input = self.target.clone(); - - cx.new(|cx| { - PopoverContent::new(window, cx, move |_window, cx| { - div() - .flex() - .flex_wrap() - .items_center() - .gap_2() - .children(get_emojis().iter().map(|e| { - div() - .id(e.clone()) - .flex_auto() - .size_10() - .flex() - .items_center() - .justify_center() - .rounded(cx.theme().radius) - .child(e.clone()) - .hover(|this| this.bg(cx.theme().ghost_element_hover)) - .on_click({ - let item = e.clone(); - let input = input.clone(); - - move |_, window, cx| { - if let Some(input) = input.as_ref() { - _ = input.update(cx, |this, cx| { - let value = this.value(); - let new_text = if value.is_empty() { - format!("{item}") - } else if value.ends_with(" ") { - format!("{value}{item}") - } else { - format!("{value} {item}") - }; - this.set_value(new_text, window, cx); - }); - } - } - }) - })) - .into_any() - }) - .scrollable() - .max_h(px(300.)) - .max_w(px(300.)) - }) - }) - } -} diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 109182f..d5d6f41 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,44 +1,44 @@ -use std::collections::HashSet; -use std::time::Duration; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::sync::Arc; pub use actions::*; -use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport}; +use anyhow::{Context as AnyhowContext, Error}; +use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport}; use common::{nip96_upload, RenderedTimestamp}; use gpui::prelude::FluentBuilder; use gpui::{ - div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, + deferred, div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, - PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, - Styled, StyledImage, Subscription, Task, WeakEntity, Window, + PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage, + Subscription, Task, WeakEntity, Window, }; use gpui_tokio::Tokio; -use indexset::{BTreeMap, BTreeSet}; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::fs; +use smol::lock::RwLock; use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::context_menu::ContextMenuExt; use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::indicator::Indicator; use ui::input::{InputEvent, InputState, TextInput}; +use ui::menu::{ContextMenuExt, DropdownMenu}; use ui::notification::Notification; -use ui::popup_menu::PopupMenuExt; +use ui::scroll::Scrollbar; use ui::{ - h_flex, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, - StyledExt, + h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, + WindowExtension, }; -use crate::emoji::EmojiPicker; use crate::text::RenderedText; mod actions; -mod emoji; mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -49,7 +49,6 @@ pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity pub struct ChatPanel { id: SharedString, focus_handle: FocusHandle, - image_cache: Entity, /// Chat Room room: WeakEntity, @@ -63,12 +62,15 @@ pub struct ChatPanel { /// Mapping message ids to their rendered texts rendered_texts_by_id: BTreeMap, - /// Mapping message ids to their reports - reports_by_id: BTreeMap>, + /// Mapping message (rumor event) ids to their reports + reports_by_id: Entity>>, /// Input state input: Entity, + /// Sent message ids + sent_ids: Arc>>, + /// Replies to replies_to: Entity>, @@ -79,97 +81,63 @@ pub struct ChatPanel { uploading: bool, /// Async operations - tasks: SmallVec<[Task<()>; 2]>, + tasks: Vec>>, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, + subscriptions: SmallVec<[Subscription; 2]>, } impl ChatPanel { pub fn new(room: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { + // Define attachments and replies_to entities + let attachments = cx.new(|_| vec![]); + let replies_to = cx.new(|_| HashSet::new()); + let reports_by_id = cx.new(|_| BTreeMap::new()); + + // Define list of messages + let messages = BTreeSet::from([Message::system()]); + let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); + + // Get room id and name + let (id, name) = room + .read_with(cx, |this, _cx| { + let id = this.id.to_string().into(); + let name = this.display_name(cx); + + (id, name) + }) + .unwrap_or(("Unknown".into(), "Message...".into())); + + // Define input state let input = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Message...") + .placeholder(format!("Message {}", name)) .auto_grow(1, 20) .prevent_new_line_on_enter() .clean_on_escape() }); - let attachments = cx.new(|_| vec![]); - let replies_to = cx.new(|_| HashSet::new()); - - let messages = BTreeSet::from([Message::system()]); - let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); - - let id: SharedString = room - .read_with(cx, |this, _cx| this.id.to_string().into()) - .unwrap_or("Unknown".into()); - - let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; - - if let Ok(connect) = room.read_with(cx, |this, cx| this.connect(cx)) { - tasks.push( - // Get messaging relays and encryption keys announcement for each member - cx.background_spawn(async move { - if let Err(e) = connect.await { - log::error!("Failed to initialize room: {}", e); - } - }), - ); - }; - - if let Ok(get_messages) = room.read_with(cx, |this, cx| this.get_messages(cx)) { - tasks.push( - // Load all messages belonging to this room - cx.spawn_in(window, async move |this, cx| { - let result = get_messages.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(events) => { - this.insert_messages(&events, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }), - ); - } - - if let Some(room) = room.upgrade() { - subscriptions.push( - // Subscribe to room events - cx.subscribe_in(&room, window, move |this, _room, event, window, cx| { - match event { - RoomEvent::Incoming(message) => { - this.insert_message(message, false, cx); - } - RoomEvent::Reload => { - this.load_messages(window, cx); - } - }; - }), - ); - } - - subscriptions.push( - // Subscribe to input events - cx.subscribe_in( - &input, - window, - move |this: &mut Self, _input, event, window, cx| { + // Define subscriptions + let subscriptions = + smallvec![ + cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { - this.send_message(window, cx); + this.send_text_message(window, cx); }; - }, - ), - ); + }) + ]; + + // Define all functions that will run after the current cycle + cx.defer_in(window, |this, window, cx| { + this.connect(window, cx); + this.handle_notifications(cx); + + this.subscribe_room_events(window, cx); + this.get_messages(window, cx); + }); Self { + focus_handle: cx.focus_handle(), id, messages, room, @@ -178,38 +146,113 @@ impl ChatPanel { replies_to, attachments, rendered_texts_by_id: BTreeMap::new(), - reports_by_id: BTreeMap::new(), + reports_by_id, + sent_ids: Arc::new(RwLock::new(Vec::new())), uploading: false, - image_cache: RetainAllImageCache::new(cx), - focus_handle: cx.focus_handle(), - _subscriptions: subscriptions, - tasks, + subscriptions, + tasks: vec![], } } + /// Handle nostr notifications + fn handle_notifications(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let sent_ids = self.sent_ids.clone(); + + let (tx, rx) = flume::bounded::<(EventId, RelayUrl)>(256); + + self.tasks.push(cx.background_spawn(async move { + let mut notifications = client.notifications(); + + while let Some(notification) = notifications.next().await { + if let ClientNotification::Message { + message: RelayMessage::Ok { event_id, .. }, + relay_url, + } = notification + { + let sent_ids = sent_ids.read().await; + + if sent_ids.contains(&event_id) { + tx.send_async((event_id, relay_url)).await.ok(); + } + } + } + + Ok(()) + })); + + self.tasks.push(cx.spawn(async move |this, cx| { + while let Ok((event_id, relay_url)) = rx.recv_async().await { + this.update(cx, |this, cx| { + this.reports_by_id.update(cx, |this, cx| { + for reports in this.values_mut() { + for report in reports.iter_mut() { + if let Some(output) = report.output.as_mut() { + if output.id() == &event_id { + output.success.insert(relay_url.clone()); + cx.notify(); + } + } + } + } + }); + })?; + } + + Ok(()) + })); + } + + fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context) { + let Some(room) = self.room.upgrade() else { + return; + }; + + self.subscriptions.push( + // Subscribe to room events + cx.subscribe_in(&room, window, move |this, _room, event, window, cx| { + match event { + RoomEvent::Incoming(message) => { + this.insert_message(message, false, cx); + } + RoomEvent::Reload => { + this.get_messages(window, cx); + } + }; + }), + ); + } + + /// Get all necessary data for each member + fn connect(&mut self, _window: &mut Window, cx: &mut Context) { + let Ok(connect) = self.room.read_with(cx, |this, cx| this.early_connect(cx)) else { + return; + }; + + self.tasks.push(cx.background_spawn(connect)); + } + /// Load all messages belonging to this room - fn load_messages(&mut self, window: &mut Window, cx: &mut Context) { - if let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) { - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = get_messages.await; + fn get_messages(&mut self, _window: &mut Window, cx: &mut Context) { + let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else { + return; + }; - this.update_in(cx, |this, window, cx| { - match result { - Ok(events) => { - this.insert_messages(&events, cx); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); - })); - } + self.tasks.push(cx.spawn(async move |this, cx| { + let events = get_messages.await?; + + // Update message list + this.update(cx, |this, cx| { + this.insert_messages(&events, cx); + })?; + + Ok(()) + })); } - /// Get user input content and merged all attachments - fn input_content(&self, cx: &Context) -> String { + /// Get user input content and merged all attachments if available + fn get_input_value(&self, cx: &Context) -> String { // Get input's value let mut content = self.input.read(cx).value().trim().to_string(); @@ -233,10 +276,9 @@ impl ChatPanel { content } - /// Send a message to all members of the chat - fn send_message(&mut self, window: &mut Window, cx: &mut Context) { + fn send_text_message(&mut self, window: &mut Window, cx: &mut Context) { // Get the message which includes all attachments - let content = self.input_content(cx); + let content = self.get_input_value(cx); // Return if message is empty if content.trim().is_empty() { @@ -244,79 +286,97 @@ impl ChatPanel { return; } - // Get the current room entity - let Some(room) = self.room.upgrade().map(|this| this.read(cx)) else { + self.send_message(&content, window, cx); + } + + /// Send a message to all members of the chat + fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context) { + if value.trim().is_empty() { + window.push_notification("Cannot send an empty message", cx); + return; + } + + // Get room entity + let room = self.room.clone(); + + // Get content and replies + let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); + let content = value.to_string(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let room = room.upgrade().context("Room is not available")?; + + this.update_in(cx, |this, window, cx| { + match room.read(cx).rumor(content, replies, cx) { + Some(rumor) => { + this.insert_message(&rumor, true, cx); + this.send_and_wait(rumor, window, cx); + this.clear(window, cx); + } + None => { + window.push_notification("Failed to create message", cx); + } + } + })?; + + Ok(()) + })); + } + + /// Send message in the background and wait for the response + fn send_and_wait(&mut self, rumor: UnsignedEvent, window: &mut Window, cx: &mut Context) { + let sent_ids = self.sent_ids.clone(); + // This can't fail, because we already ensured that the ID is set + let id = rumor.id.unwrap(); + + let Some(room) = self.room.upgrade() else { return; }; - // Get replies_to if it's present - let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); - - // Create a temporary message for optimistic update - let rumor = room.create_message(&content, replies.as_ref(), cx); - let rumor_id = rumor.id.unwrap(); - - // Create a task for sending the message in the background - let send_message = room.send_message(&rumor, cx); - - // Optimistically update message list - cx.spawn_in(window, async move |this, cx| { - // Wait for the delay - cx.background_executor() - .timer(Duration::from_millis(100)) - .await; - - // Update the message list and reset the states - this.update_in(cx, |this, window, cx| { - this.remove_all_replies(cx); - this.remove_all_attachments(cx); - - // Reset the input to its default state - this.input.update(cx, |this, cx| { - this.set_loading(false, cx); - this.set_disabled(false, cx); - this.set_value("", window, cx); - }); - - // Update the message list - this.insert_message(&rumor, true, cx); - }) - .ok(); - }) - .detach(); + let Some(task) = room.read(cx).send(rumor, cx) else { + window.push_notification("Failed to send message", cx); + return; + }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = send_message.await; + let outputs = task.await; - this.update_in(cx, |this, window, cx| { - match result { - Ok(reports) => { - // Update room's status - this.room - .update(cx, |this, cx| { - if this.kind != RoomKind::Ongoing { - // Update the room kind to ongoing, - // but keep the room kind if send failed - if reports.iter().all(|r| !r.is_sent_success()) { - this.kind = RoomKind::Ongoing; - cx.notify(); - } - } - }) - .ok(); + // Add sent IDs to the list + let mut sent_ids = sent_ids.write().await; + sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id)); - // Insert the sent reports - this.reports_by_id.insert(rumor_id, reports); + // Update the state + this.update(cx, |this, cx| { + this.insert_reports(id, outputs, cx); + })?; - cx.notify(); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - } - }) - .ok(); - })); + Ok(()) + })) + } + + /// Clear the input field, attachments, and replies + /// + /// Only run after sending a message + fn clear(&mut self, window: &mut Window, cx: &mut Context) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + self.attachments.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); + self.replies_to.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }) + } + + /// Insert reports + fn insert_reports(&mut self, id: EventId, reports: Vec, cx: &mut Context) { + self.reports_by_id.update(cx, |this, cx| { + this.insert(id, reports); + cx.notify(); + }); } /// Insert a message into the chat panel @@ -349,23 +409,33 @@ impl ChatPanel { } } - /// Check if a message failed to send by its ID - fn is_sent_failed(&self, id: &EventId) -> bool { + /// Check if a message is pending + fn sent_pending(&self, id: &EventId, cx: &App) -> bool { self.reports_by_id + .read(cx) .get(id) - .is_some_and(|reports| reports.iter().all(|r| !r.is_sent_success())) + .is_some_and(|reports| reports.iter().any(|r| r.pending())) } /// Check if a message was sent successfully by its ID - fn is_sent_success(&self, id: &EventId) -> Option { + fn sent_success(&self, id: &EventId, cx: &App) -> bool { self.reports_by_id + .read(cx) .get(id) - .map(|reports| reports.iter().all(|r| r.is_sent_success())) + .is_some_and(|reports| reports.iter().any(|r| r.success())) } - /// Get the sent reports for a message by its ID - fn sent_reports(&self, id: &EventId) -> Option<&Vec> { - self.reports_by_id.get(id) + /// Check if a message failed to send by its ID + fn sent_failed(&self, id: &EventId, cx: &App) -> Option { + self.reports_by_id + .read(cx) + .get(id) + .map(|reports| reports.iter().all(|r| !r.success())) + } + + /// Get all sent reports for a message by its ID + fn sent_reports(&self, id: &EventId, cx: &App) -> Option> { + self.reports_by_id.read(cx).get(id).cloned() } /// Get a message by its ID @@ -414,13 +484,6 @@ impl ChatPanel { }); } - fn remove_all_replies(&mut self, cx: &mut Context) { - self.replies_to.update(cx, |this, cx| { - this.clear(); - cx.notify(); - }); - } - fn upload(&mut self, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -435,9 +498,9 @@ impl ChatPanel { prompt: None, }); - cx.spawn_in(window, async move |this, cx| { - let mut paths = path.await.ok()?.ok()??; - let path = paths.pop()?; + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let mut paths = path.await??.context("Not found")?; + let path = paths.pop().context("No path")?; let upload = Tokio::spawn(cx, async move { let file = fs::read(path).await.ok()?; @@ -466,9 +529,8 @@ impl ChatPanel { .ok(); } - Some(()) - }) - .detach(); + Ok(()) + })); } fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { @@ -492,28 +554,21 @@ impl ChatPanel { }); } - fn remove_all_attachments(&mut self, cx: &mut Context) { - self.attachments.update(cx, |this, cx| { - this.clear(); - cx.notify(); - }); - } - fn profile(&self, public_key: &PublicKey, cx: &Context) -> Person { let persons = PersonRegistry::global(cx); persons.read(cx).get(public_key, cx) } fn render_announcement(&self, ix: usize, cx: &Context) -> AnyElement { + const MSG: &str = + "This conversation is private. Only members can see each other's messages."; + v_flex() .id(ix) - .group("") - .h_32() + .h_40() .w_full() - .relative() .gap_3() - .px_3() - .py_2() + .p_3() .items_center() .justify_center() .text_center() @@ -523,12 +578,10 @@ impl ChatPanel { .child( svg() .path("brand/coop.svg") - .size_10() - .text_color(cx.theme().elevated_surface_background), + .size_12() + .text_color(cx.theme().ghost_element_active), ) - .child(SharedString::from( - "This conversation is private. Only members can see each other's messages.", - )) + .child(SharedString::from(MSG)) .into_any_element() } @@ -566,7 +619,7 @@ impl ChatPanel { window: &mut Window, cx: &mut Context, ) -> AnyElement { - if let Some(message) = self.messages.get_index(ix) { + if let Some(message) = self.messages.iter().nth(ix) { match message { Message::User(rendered) => { let text = self @@ -591,7 +644,7 @@ impl ChatPanel { &self, ix: usize, message: &RenderedMessage, - text: AnyElement, + rendered_text: AnyElement, cx: &Context, ) -> AnyElement { let id = message.id; @@ -602,10 +655,13 @@ impl ChatPanel { let has_replies = !replies.is_empty(); // Check if message is sent failed - let is_sent_failed = self.is_sent_failed(&id); + let sent_pending = self.sent_pending(&id, cx); // Check if message is sent successfully - let is_sent_success = self.is_sent_success(&id); + let sent_success = self.sent_success(&id, cx); + + // Check if message is sent failed + let sent_failed = self.sent_failed(&id, cx); // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); @@ -653,18 +709,21 @@ impl ChatPanel { .child(author.name()), ) .child(message.created_at.to_human_time()) - .when_some(is_sent_success, |this, status| { - this.when(status, |this| { - this.child(self.render_message_sent(&id, cx)) - }) + .when(sent_pending, |this| { + this.child(deferred(Indicator::new().small())) + }) + .when(sent_success, |this| { + this.child(deferred(self.render_sent_indicator(&id, cx))) }), ) .when(has_replies, |this| { this.children(self.render_message_replies(replies, cx)) }) - .child(text) - .when(is_sent_failed, |this| { - this.child(self.render_message_reports(&id, cx)) + .child(rendered_text) + .when_some(sent_failed, |this, failed| { + this.when(failed, |this| { + this.child(deferred(self.render_message_reports(&id, cx))) + }) }), ), ) @@ -729,11 +788,11 @@ impl ChatPanel { items } - fn render_message_sent(&self, id: &EventId, _cx: &Context) -> impl IntoElement { + fn render_sent_indicator(&self, id: &EventId, cx: &Context) -> impl IntoElement { div() .id(SharedString::from(id.to_hex())) .child(SharedString::from("• Sent")) - .when_some(self.sent_reports(id).cloned(), |this, reports| { + .when_some(self.sent_reports(id, cx), |this, reports| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); @@ -765,7 +824,7 @@ impl ChatPanel { .child(SharedString::from( "Failed to send message. Click to see details.", )) - .when_some(self.sent_reports(id).cloned(), |this, reports| { + .when_some(self.sent_reports(id, cx), |this, reports| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); @@ -808,48 +867,6 @@ impl ChatPanel { .child(name.clone()), ), ) - .when(report.relays_not_found, |this| { - this.child( - h_flex() - .flex_wrap() - .justify_center() - .p_2() - .h_20() - .w_full() - .text_sm() - .rounded(cx.theme().radius) - .bg(cx.theme().danger_background) - .text_color(cx.theme().danger_foreground) - .child( - div() - .flex_1() - .w_full() - .text_center() - .child(SharedString::from("Messaging Relays not found")), - ), - ) - }) - .when(report.device_not_found, |this| { - this.child( - h_flex() - .flex_wrap() - .justify_center() - .p_2() - .h_20() - .w_full() - .text_sm() - .rounded(cx.theme().radius) - .bg(cx.theme().danger_background) - .text_color(cx.theme().danger_foreground) - .child( - div() - .flex_1() - .w_full() - .text_center() - .child(SharedString::from("Encryption Key not found")), - ), - ) - }) .when_some(report.error.clone(), |this, error| { this.child( h_flex() @@ -865,7 +882,7 @@ impl ChatPanel { .child(div().flex_1().w_full().text_center().child(error)), ) }) - .when_some(report.status.clone(), |this, output| { + .when_some(report.output.clone(), |this, output| { this.child( v_flex() .gap_2() @@ -992,9 +1009,9 @@ impl ChatPanel { .icon(IconName::Ellipsis) .small() .ghost() - .popup_menu({ + .dropdown_menu({ let id = id.to_owned(); - move |this, _, _| this.menu("Seen on", Box::new(SeenOn(id))) + move |this, _window, _cx| this.menu("Seen on", Box::new(SeenOn(id))) }), ) .group_hover("", |this| this.visible()) @@ -1115,6 +1132,25 @@ impl ChatPanel { items } + + fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { + match command { + Command::Insert(content) => { + self.send_message(content, window, cx); + } + Command::ChangeSubject(subject) => { + if self + .room + .update(cx, |this, cx| { + this.set_subject(*subject, cx); + }) + .is_err() + { + window.push_notification(Notification::error("Failed to change subject"), cx); + } + } + } + } } impl Panel for ChatPanel { @@ -1149,61 +1185,86 @@ impl Focusable for ChatPanel { impl Render for ChatPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .image_cache(self.image_cache.clone()) + .on_action(cx.listener(Self::on_command)) .size_full() .child( - list( - self.list_state.clone(), - cx.processor(|this, ix, window, cx| { - // Get and render message by index - this.render_message(ix, window, cx) - }), - ) - .flex_1(), + div() + .flex_1() + .size_full() + .child( + list( + self.list_state.clone(), + cx.processor(move |this, ix, window, cx| { + this.render_message(ix, window, cx) + }), + ) + .size_full(), + ) + .child(Scrollbar::vertical(&self.list_state)), ) .child( - div() + v_flex() .flex_shrink_0() + .p_2() .w_full() - .relative() - .px_3() - .py_2() + .gap_1p5() + .children(self.render_attachment_list(window, cx)) + .children(self.render_reply_list(window, cx)) .child( - v_flex() - .gap_1p5() - .children(self.render_attachment_list(window, cx)) - .children(self.render_reply_list(window, cx)) + h_flex() + .items_end() .child( - div() - .w_full() - .flex() - .items_end() - .gap_2p5() + Button::new("upload") + .icon(IconName::Plus) + .tooltip("Upload media") + .loading(self.uploading) + .disabled(self.uploading) + .ghost() + .large() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.upload(window, cx); + })), + ) + .child( + TextInput::new(&self.input) + .appearance(false) + .flex_1() + .text_sm(), + ) + .child( + h_flex() + .pl_1() + .gap_1() .child( - h_flex() - .gap_1() - .text_color(cx.theme().text_muted) - .child( - Button::new("upload") - .icon(IconName::Upload) - .loading(self.uploading) - .disabled(self.uploading) - .ghost() - .large() - .on_click(cx.listener( - move |this, _, window, cx| { - this.upload(window, cx); - }, - )), - ) - .child( - EmojiPicker::new() - .target(self.input.downgrade()) - .icon(IconName::EmojiFill) - .large(), + Button::new("emoji") + .icon(IconName::Emoji) + .ghost() + .large() + .dropdown_menu_with_anchor( + gpui::Corner::BottomLeft, + move |this, _window, _cx| { + this.horizontal() + .menu("👍", Box::new(Command::Insert("👍"))) + .menu("👎", Box::new(Command::Insert("👎"))) + .menu("😄", Box::new(Command::Insert("😄"))) + .menu("🎉", Box::new(Command::Insert("🎉"))) + .menu("😕", Box::new(Command::Insert("😕"))) + .menu("❤️", Box::new(Command::Insert("❤️"))) + .menu("🚀", Box::new(Command::Insert("🚀"))) + .menu("👀", Box::new(Command::Insert("👀"))) + }, ), ) - .child(TextInput::new(&self.input)), + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .disabled(self.uploading) + .ghost() + .large() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.send_text_message(window, cx); + })), + ), ), ), ) diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 857374e..44b7514 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -6,6 +6,7 @@ publish.workspace = true [dependencies] gpui.workspace = true +nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true @@ -19,5 +20,3 @@ log.workspace = true dirs = "5.0" qrcode = "0.14.1" -whoami = "1.6.1" -nostr = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs deleted file mode 100644 index bfa6c11..0000000 --- a/crates/common/src/constants.rs +++ /dev/null @@ -1,31 +0,0 @@ -pub const CLIENT_NAME: &str = "Coop"; -pub const APP_ID: &str = "su.reya.coop"; - -/// Bootstrap Relays. -pub const BOOTSTRAP_RELAYS: [&str; 4] = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nos.social", - "wss://user.kindpag.es", -]; - -/// Search Relays. -pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"]; - -/// Default relay for Nostr Connect -pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; - -/// Default retry count for fetching NIP-17 relays -pub const RELAY_RETRY: u64 = 2; - -/// Default retry count for sending messages -pub const SEND_RETRY: u64 = 10; - -/// Default timeout (in seconds) for Nostr Connect -pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; - -/// Default timeout (in seconds) for Nostr Connect (Bunker) -pub const BUNKER_TIMEOUT: u64 = 30; - -/// Default width of the sidebar. -pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.; diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index bbfb448..75511a4 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,68 +1,11 @@ -use std::sync::OnceLock; - -pub use constants::*; pub use debounced_delay::*; pub use display::*; pub use event::*; -pub use nip05::*; pub use nip96::*; -use nostr_sdk::prelude::*; pub use paths::*; -mod constants; mod debounced_delay; mod display; mod event; -mod nip05; mod nip96; mod paths; - -static APP_NAME: OnceLock = OnceLock::new(); -static NIP65_RELAYS: OnceLock)>> = OnceLock::new(); -static NIP17_RELAYS: OnceLock> = OnceLock::new(); - -/// Get the app name -pub fn app_name() -> &'static String { - APP_NAME.get_or_init(|| { - let devicename = whoami::devicename(); - let platform = whoami::platform(); - - format!("{CLIENT_NAME} on {platform} ({devicename})") - }) -} - -/// Default NIP-65 Relays. Used for new account -pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option)> { - NIP65_RELAYS.get_or_init(|| { - vec![ - ( - RelayUrl::parse("wss://nostr.mom").unwrap(), - Some(RelayMetadata::Read), - ), - ( - RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(), - Some(RelayMetadata::Read), - ), - ( - RelayUrl::parse("wss://nos.lol").unwrap(), - Some(RelayMetadata::Write), - ), - ( - RelayUrl::parse("wss://relay.snort.social").unwrap(), - Some(RelayMetadata::Write), - ), - (RelayUrl::parse("wss://relay.primal.net").unwrap(), None), - (RelayUrl::parse("wss://relay.damus.io").unwrap(), None), - ] - }) -} - -/// Default NIP-17 Relays. Used for new account -pub fn default_nip17_relays() -> &'static Vec { - NIP17_RELAYS.get_or_init(|| { - vec![ - RelayUrl::parse("wss://nip17.com").unwrap(), - RelayUrl::parse("wss://auth.nostr1.com").unwrap(), - ] - }) -} diff --git a/crates/common/src/nip96.rs b/crates/common/src/nip96.rs index 10d4e6f..c40cc89 100644 --- a/crates/common/src/nip96.rs +++ b/crates/common/src/nip96.rs @@ -72,11 +72,10 @@ pub async fn nip96_upload( let json: Value = res.json().await?; let config = nip96::ServerConfig::from_json(json.to_string())?; - let signer = if client.has_signer().await { - client.signer().await? - } else { - Keys::generate().into_nostr_signer() - }; + let signer = client + .signer() + .cloned() + .unwrap_or(Keys::generate().into_nostr_signer()); let url = upload(&signer, &config, file, None).await?; diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index a437497..3ac2f2a 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -43,6 +43,7 @@ person = { path = "../person" } relay_auth = { path = "../relay_auth" } gpui.workspace = true +gpui_platform.workspace = true gpui_tokio.workspace = true reqwest_client.workspace = true diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs deleted file mode 100644 index 4616486..0000000 --- a/crates/coop/src/chatspace.rs +++ /dev/null @@ -1,677 +0,0 @@ -use std::sync::Arc; - -use auto_update::{AutoUpdateStatus, AutoUpdater}; -use chat::{ChatEvent, ChatRegistry}; -use chat_ui::{CopyPublicKey, OpenPublicKey}; -use common::DEFAULT_SIDEBAR_WIDTH; -use gpui::prelude::FluentBuilder; -use gpui::{ - deferred, div, px, relative, rems, App, AppContext, Axis, ClipboardItem, Context, Entity, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Subscription, Window, -}; -use key_store::{Credential, KeyItem, KeyStore}; -use nostr_connect::prelude::*; -use person::PersonRegistry; -use relay_auth::RelayAuth; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::{ActiveTheme, Theme, ThemeMode, ThemeRegistry}; -use title_bar::TitleBar; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::dock::DockPlacement; -use ui::dock_area::panel::PanelView; -use ui::dock_area::{ClosePanel, DockArea, DockItem}; -use ui::modal::ModalButtonProps; -use ui::popup_menu::PopupMenuExt; -use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt}; - -use crate::actions::{ - reset, DarkMode, KeyringPopup, Logout, Settings, Themes, ViewProfile, ViewRelays, -}; -use crate::user::viewer; -use crate::views::compose::compose_button; -use crate::views::{onboarding, preferences, setup_relay, startup, welcome}; -use crate::{login, new_identity, sidebar, user}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| ChatSpace::new(window, cx)) -} - -pub fn login(window: &mut Window, cx: &mut App) { - let panel = login::init(window, cx); - ChatSpace::set_center_panel(panel, window, cx); -} - -pub fn new_account(window: &mut Window, cx: &mut App) { - let panel = new_identity::init(window, cx); - ChatSpace::set_center_panel(panel, window, cx); -} - -#[derive(Debug)] -pub struct ChatSpace { - /// App's Title Bar - title_bar: Entity, - - /// App's Dock Area - dock: Entity, - - /// Determines if the chat space is ready to use - ready: bool, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 4]>, -} - -impl ChatSpace { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - let chat = ChatRegistry::global(cx); - let keystore = KeyStore::global(cx); - - let title_bar = cx.new(|_| TitleBar::new()); - let dock = cx.new(|cx| DockArea::new(window, cx)); - - let identity = nostr.read(cx).identity(); - - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Automatically sync theme with system appearance - window.observe_window_appearance(|window, cx| { - Theme::sync_system_appearance(Some(window), cx); - }), - ); - - subscriptions.push( - // Observe account entity changes - cx.observe_in(&identity, window, move |this, state, window, cx| { - if !this.ready && state.read(cx).has_public_key() { - this.set_default_layout(window, cx); - - // Load all chat room in the database if available - let chat = ChatRegistry::global(cx); - chat.update(cx, |this, cx| { - this.get_rooms(cx); - }); - }; - }), - ); - - subscriptions.push( - // Observe keystore entity changes - cx.observe_in(&keystore, window, move |_this, state, window, cx| { - if state.read(cx).initialized { - let backend = state.read(cx).backend(); - - cx.spawn_in(window, async move |this, cx| { - let result = backend - .read_credentials(&KeyItem::User.to_string(), cx) - .await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(Some((user, secret))) => { - let credential = Credential::new(user, secret); - this.set_startup_layout(credential, window, cx); - } - _ => { - this.set_onboarding_layout(window, cx); - } - }; - }) - .ok(); - }) - .detach(); - } - }), - ); - - subscriptions.push( - // Observe all events emitted by the chat registry - cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { - match ev { - ChatEvent::OpenRoom(id) => { - if let Some(room) = chat.read(cx).room(id, cx) { - this.dock.update(cx, |this, cx| { - this.add_panel( - Arc::new(chat_ui::init(room, window, cx)), - DockPlacement::Center, - window, - cx, - ); - }); - } - } - ChatEvent::CloseRoom(..) => { - this.dock.update(cx, |this, cx| { - // Force focus to the tab panel - this.focus_tab_panel(window, cx); - // Dispatch the close panel action - cx.defer_in(window, |_, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - window.close_all_modals(cx); - }); - }); - } - _ => {} - }; - }), - ); - - subscriptions.push( - // Observe the chat registry - cx.observe(&chat, move |this, chat, cx| { - let ids = this.get_all_panels(cx); - - chat.update(cx, |this, cx| { - this.refresh_rooms(ids, cx); - }); - }), - ); - - Self { - dock, - title_bar, - ready: false, - _subscriptions: subscriptions, - } - } - - fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context) { - let panel = Arc::new(onboarding::init(window, cx)); - let center = DockItem::panel(panel); - - self.dock.update(cx, |this, cx| { - this.reset(window, cx); - this.set_center(center, window, cx); - }); - } - - fn set_startup_layout(&mut self, cre: Credential, window: &mut Window, cx: &mut Context) { - let panel = Arc::new(startup::init(cre, window, cx)); - let center = DockItem::panel(panel); - - self.dock.update(cx, |this, cx| { - this.reset(window, cx); - this.set_center(center, window, cx); - }); - } - - fn set_default_layout(&mut self, window: &mut Window, cx: &mut Context) { - let weak_dock = self.dock.downgrade(); - - let sidebar = Arc::new(sidebar::init(window, cx)); - let center = Arc::new(welcome::init(window, cx)); - - let left = DockItem::panel(sidebar); - let center = DockItem::split_with_sizes( - Axis::Vertical, - vec![DockItem::tabs(vec![center], None, &weak_dock, window, cx)], - vec![None], - &weak_dock, - window, - cx, - ); - - self.ready = true; - self.dock.update(cx, |this, cx| { - this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx); - this.set_center(center, window, cx); - }); - } - - fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context) { - let view = preferences::init(window, cx); - - window.open_modal(cx, move |modal, _window, _cx| { - modal - .title(SharedString::from("Preferences")) - .width(px(520.)) - .child(view.clone()) - }); - } - - fn on_profile(&mut self, _ev: &ViewProfile, window: &mut Window, cx: &mut Context) { - let view = user::init(window, cx); - let entity = view.downgrade(); - - window.open_modal(cx, move |modal, _window, _cx| { - let entity = entity.clone(); - - modal - .title("Profile") - .confirm() - .child(view.clone()) - .button_props(ModalButtonProps::default().ok_text("Update")) - .on_ok(move |_, window, cx| { - entity - .update(cx, |this, cx| { - let persons = PersonRegistry::global(cx); - let set_metadata = this.set_metadata(cx); - - cx.spawn_in(window, async move |this, cx| { - let result = set_metadata.await; - - this.update_in(cx, |_, window, cx| { - match result { - Ok(person) => { - persons.update(cx, |this, cx| { - this.insert(person, cx); - // Close the edit profile modal - window.close_all_modals(cx); - }); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - }) - .ok(); - - // false to keep the modal open - false - }) - }); - } - - fn on_relays(&mut self, _ev: &ViewRelays, window: &mut Window, cx: &mut Context) { - let view = setup_relay::init(window, cx); - let entity = view.downgrade(); - - window.open_modal(cx, move |this, _window, _cx| { - let entity = entity.clone(); - - this.confirm() - .title(SharedString::from("Set Up Messaging Relays")) - .child(view.clone()) - .button_props(ModalButtonProps::default().ok_text("Update")) - .on_ok(move |_, window, cx| { - entity - .update(cx, |this, cx| { - this.set_relays(window, cx); - }) - .ok(); - - // false to keep the modal open - false - }) - }); - } - - fn on_dark_mode(&mut self, _ev: &DarkMode, window: &mut Window, cx: &mut Context) { - if cx.theme().mode.is_dark() { - Theme::change(ThemeMode::Light, Some(window), cx); - } else { - Theme::change(ThemeMode::Dark, Some(window), cx); - } - } - - fn on_themes(&mut self, _ev: &Themes, window: &mut Window, cx: &mut Context) { - window.open_modal(cx, move |this, _window, cx| { - let registry = ThemeRegistry::global(cx); - let themes = registry.read(cx).themes(); - - this.title("Select theme") - .show_close(true) - .overlay_closable(true) - .child(v_flex().gap_2().pb_4().children({ - let mut items = Vec::with_capacity(themes.len()); - - for (name, theme) in themes.iter() { - items.push( - h_flex() - .h_10() - .justify_between() - .child( - v_flex() - .child( - div() - .text_sm() - .text_color(cx.theme().text) - .line_height(relative(1.3)) - .child(theme.name.clone()), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(theme.author.clone()), - ), - ) - .child( - Button::new(format!("change-{name}")) - .label("Set") - .small() - .ghost() - .on_click({ - let theme = theme.clone(); - move |_ev, window, cx| { - Theme::apply_theme(theme.clone(), Some(window), cx); - } - }), - ), - ); - } - - items - })) - }) - } - - fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context) { - reset(cx); - } - - fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context) { - let public_key = ev.0; - let view = viewer::init(public_key, window, cx); - - window.open_modal(cx, move |this, _window, _cx| { - this.alert() - .show_close(true) - .overlay_closable(true) - .child(view.clone()) - .button_props(ModalButtonProps::default().ok_text("View on njump.me")) - .on_ok(move |_, _window, cx| { - let bech32 = public_key.to_bech32().unwrap(); - let url = format!("https://njump.me/{bech32}"); - - // Open the URL in the default browser - cx.open_url(&url); - - // false to keep the modal open - false - }) - }); - } - - fn on_copy_pubkey(&mut self, ev: &CopyPublicKey, window: &mut Window, cx: &mut Context) { - let Ok(bech32) = ev.0.to_bech32(); - cx.write_to_clipboard(ClipboardItem::new_string(bech32)); - window.push_notification("Copied", cx); - } - - fn on_keyring(&mut self, _ev: &KeyringPopup, window: &mut Window, cx: &mut Context) { - window.open_modal(cx, move |this, _window, _cx| { - this.show_close(true) - .title(SharedString::from("Keyring is disabled")) - .child( - v_flex() - .gap_2() - .pb_4() - .text_sm() - .child(SharedString::from("Coop cannot access the Keyring Service on your system. By design, Coop uses Keyring to store your credentials.")) - .child(SharedString::from("Without access to Keyring, Coop will store your credentials as plain text.")) - .child(SharedString::from("If you want to store your credentials in the Keyring, please enable Keyring and allow Coop to access it.")), - ) - }); - } - - fn get_all_panels(&self, cx: &App) -> Option> { - let ids: Vec = self - .dock - .read(cx) - .items - .panel_ids(cx) - .into_iter() - .filter_map(|panel| panel.parse::().ok()) - .collect(); - - Some(ids) - } - - fn set_center_panel

(panel: P, window: &mut Window, cx: &mut App) - where - P: PanelView, - { - if let Some(Some(root)) = window.root::() { - if let Ok(chatspace) = root.read(cx).view().clone().downcast::() { - let panel = Arc::new(panel); - let center = DockItem::panel(panel); - - chatspace.update(cx, |this, cx| { - this.dock.update(cx, |this, cx| { - this.set_center(center, window, cx); - }); - }); - } - } - } - - fn titlebar_left(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { - let nostr = NostrRegistry::global(cx); - let chat = ChatRegistry::global(cx); - let status = chat.read(cx).loading(); - - if !nostr.read(cx).identity().read(cx).has_public_key() { - return div(); - } - - h_flex() - .gap_2() - .h_6() - .w_full() - .child(compose_button()) - .when(status, |this| { - this.child(deferred( - h_flex() - .px_2() - .h_6() - .gap_1() - .text_xs() - .rounded_full() - .bg(cx.theme().surface_background) - .child(SharedString::from( - "Getting messages. This may take a while...", - )), - )) - }) - } - - fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let auto_update = AutoUpdater::global(cx); - - let relay_auth = RelayAuth::global(cx); - let pending_requests = relay_auth.read(cx).pending_requests(cx); - - let nostr = NostrRegistry::global(cx); - let identity = nostr.read(cx).identity(); - - h_flex() - .gap_2() - .map(|this| match auto_update.read(cx).status.as_ref() { - AutoUpdateStatus::Checking => this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Checking for Coop updates...")), - ), - AutoUpdateStatus::Installing => this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Installing updates...")), - ), - AutoUpdateStatus::Errored { msg } => this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(msg.as_ref())), - ), - AutoUpdateStatus::Updated => this.child( - div() - .id("restart") - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Updated. Click to restart")) - .on_click(|_ev, _window, cx| { - cx.restart(); - }), - ), - _ => this.child(div()), - }) - .when(pending_requests > 0, |this| { - this.child( - h_flex() - .id("requests") - .h_6() - .px_2() - .items_center() - .justify_center() - .text_xs() - .rounded_full() - .bg(cx.theme().warning_background) - .text_color(cx.theme().warning_foreground) - .hover(|this| this.bg(cx.theme().warning_hover)) - .active(|this| this.bg(cx.theme().warning_active)) - .child(SharedString::from(format!( - "You have {} pending authentication requests", - pending_requests - ))) - .on_click(move |_ev, window, cx| { - relay_auth.update(cx, |this, cx| { - this.re_ask(window, cx); - }); - }), - ) - }) - .when_some(identity.read(cx).public_key, |this, public_key| { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&public_key, cx); - - let keystore = KeyStore::global(cx); - let is_using_file_keystore = keystore.read(cx).is_using_file_keystore(); - - let keyring_label = if is_using_file_keystore { - SharedString::from("Disabled") - } else { - SharedString::from("Enabled") - }; - - this.child( - Button::new("user") - .small() - .reverse() - .transparent() - .icon(IconName::CaretDown) - .child(Avatar::new(profile.avatar()).size(rems(1.45))) - .popup_menu(move |this, _window, _cx| { - this.label(profile.name()) - .menu_with_icon( - "Profile", - IconName::EmojiFill, - Box::new(ViewProfile), - ) - .menu_with_icon( - "Messaging Relays", - IconName::Server, - Box::new(ViewRelays), - ) - .separator() - .label(SharedString::from("Keyring Service")) - .menu_with_icon_and_disabled( - keyring_label.clone(), - IconName::Encryption, - Box::new(KeyringPopup), - !is_using_file_keystore, - ) - .separator() - .menu_with_icon("Dark Mode", IconName::Sun, Box::new(DarkMode)) - .menu_with_icon("Themes", IconName::Moon, Box::new(Themes)) - .menu_with_icon("Settings", IconName::Settings, Box::new(Settings)) - .menu_with_icon("Sign Out", IconName::Logout, Box::new(Logout)) - }), - ) - }) - } - - fn titlebar_center(&mut self, cx: &mut Context) -> impl IntoElement { - let entity = cx.entity().downgrade(); - let panel = self.dock.read(cx).items.view(); - let title = panel.title(cx); - let id = panel.panel_id(cx); - - if id == "Onboarding" { - return div(); - }; - - h_flex() - .flex_1() - .w_full() - .justify_center() - .text_center() - .font_semibold() - .text_sm() - .child( - div().flex_1().child( - Button::new("back") - .icon(IconName::ArrowLeft) - .small() - .ghost_alt() - .rounded() - .on_click(move |_ev, window, cx| { - entity - .update(cx, |this, cx| { - this.set_onboarding_layout(window, cx); - }) - .expect("Entity has been released"); - }), - ), - ) - .child(div().flex_1().child(title)) - .child(div().flex_1()) - } -} - -impl Render for ChatSpace { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let modal_layer = Root::render_modal_layer(window, cx); - let notification_layer = Root::render_notification_layer(window, cx); - - let left = self.titlebar_left(window, cx).into_any_element(); - let right = self.titlebar_right(window, cx).into_any_element(); - let center = self.titlebar_center(cx).into_any_element(); - let single_panel = self.dock.read(cx).items.panel_ids(cx).is_empty(); - - // Update title bar children - self.title_bar.update(cx, |this, _cx| { - if single_panel { - this.set_children(vec![center]); - } else { - this.set_children(vec![left, right]); - } - }); - - div() - .id(SharedString::from("chatspace")) - .on_action(cx.listener(Self::on_settings)) - .on_action(cx.listener(Self::on_profile)) - .on_action(cx.listener(Self::on_relays)) - .on_action(cx.listener(Self::on_dark_mode)) - .on_action(cx.listener(Self::on_themes)) - .on_action(cx.listener(Self::on_sign_out)) - .on_action(cx.listener(Self::on_open_pubkey)) - .on_action(cx.listener(Self::on_copy_pubkey)) - .on_action(cx.listener(Self::on_keyring)) - .relative() - .size_full() - .child( - v_flex() - .size_full() - // Title Bar - .child(self.title_bar.clone()) - // Dock - .child(self.dock.clone()), - ) - // Notifications - .children(notification_layer) - // Modals - .children(modal_layer) - } -} diff --git a/crates/coop/src/dialogs/mod.rs b/crates/coop/src/dialogs/mod.rs new file mode 100644 index 0000000..7e8b4b2 --- /dev/null +++ b/crates/coop/src/dialogs/mod.rs @@ -0,0 +1 @@ +pub mod screening; diff --git a/crates/coop/src/views/screening.rs b/crates/coop/src/dialogs/screening.rs similarity index 68% rename from crates/coop/src/views/screening.rs rename to crates/coop/src/dialogs/screening.rs index bc0705f..5cfb8e8 100644 --- a/crates/coop/src/views/screening.rs +++ b/crates/coop/src/dialogs/screening.rs @@ -1,454 +1,511 @@ -use std::time::Duration; - -use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS}; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, -}; -use gpui_tokio::Tokio; -use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry}; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::indicator::Indicator; -use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt}; - -pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Screening::new(public_key, window, cx)) -} - -pub struct Screening { - profile: Person, - verified: bool, - followed: bool, - last_active: Option, - mutual_contacts: Vec, - _tasks: SmallVec<[Task<()>; 3]>, -} - -impl Screening { - pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&public_key, cx); - - let mut tasks = smallvec![]; - - let contact_check: Task), Error>> = cx.background_spawn({ - let client = nostr.read(cx).client(); - async move { - let signer = client.signer().await?; - let signer_pubkey = signer.get_public_key().await?; - - // Check if user is in contact list - let contacts = client.database().contacts_public_keys(signer_pubkey).await; - let followed = contacts.unwrap_or_default().contains(&public_key); - - // Check mutual contacts - let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key); - let mut mutual_contacts = vec![]; - - if let Ok(events) = client.database().query(contact_list).await { - for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) { - if let Ok(metadata) = client.database().metadata(event.pubkey).await { - let profile = Profile::new(event.pubkey, metadata.unwrap_or_default()); - mutual_contacts.push(profile); - } - } - } - - Ok((followed, mutual_contacts)) - } - }); - - let activity_check = cx.background_spawn(async move { - let filter = Filter::new().author(public_key).limit(1); - let mut activity: Option = None; - - if let Ok(mut stream) = client - .stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2)) - .await - { - while let Some((_url, event)) = stream.next().await { - if let Ok(event) = event { - activity = Some(event.created_at); - } - } - } - - activity - }); - - let addr_check = if let Some(address) = profile.metadata().nip05 { - Some(Tokio::spawn(cx, async move { - nip05_verify(public_key, &address).await.unwrap_or(false) - })) - } else { - None - }; - - tasks.push( - // Run the contact check in the background - cx.spawn_in(window, async move |this, cx| { - if let Ok((followed, mutual_contacts)) = contact_check.await { - this.update(cx, |this, cx| { - this.followed = followed; - this.mutual_contacts = mutual_contacts; - cx.notify(); - }) - .ok(); - } - }), - ); - - tasks.push( - // Run the activity check in the background - cx.spawn_in(window, async move |this, cx| { - let active = activity_check.await; - - this.update(cx, |this, cx| { - this.last_active = active; - cx.notify(); - }) - .ok(); - }), - ); - - tasks.push( - // Run the NIP-05 verification in the background - cx.spawn_in(window, async move |this, cx| { - if let Some(task) = addr_check { - if let Ok(verified) = task.await { - this.update(cx, |this, cx| { - this.verified = verified; - cx.notify(); - }) - .ok(); - } - } - }), - ); - - Self { - profile, - verified: false, - followed: false, - last_active: None, - mutual_contacts: vec![], - _tasks: tasks, - } - } - - fn address(&self, _cx: &Context) -> Option { - self.profile.metadata().nip05 - } - - fn open_njump(&mut self, _window: &mut Window, cx: &mut App) { - let Ok(bech32) = self.profile.public_key().to_bech32(); - cx.open_url(&format!("https://njump.me/{bech32}")); - } - - fn report(&mut self, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = self.profile.public_key(); - - let task: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; - let tag = Tag::public_key_report(public_key, Report::Impersonation); - let event = EventBuilder::report(vec![tag], "").sign(&signer).await?; - - // Send the report to the public relays - client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; - - Ok(()) - }); - - cx.spawn_in(window, async move |_, cx| { - if task.await.is_ok() { - cx.update(|window, cx| { - window.close_modal(cx); - window.push_notification("Report submitted successfully", cx); - }) - .ok(); - } - }) - .detach(); - } - - fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let contacts = self.mutual_contacts.clone(); - - window.open_modal(cx, move |this, _window, _cx| { - let contacts = contacts.clone(); - let total = contacts.len(); - - this.title(SharedString::from("Mutual contacts")).child( - v_flex().gap_1().pb_4().child( - uniform_list("contacts", total, move |range, _window, cx| { - let mut items = Vec::with_capacity(total); - - for ix in range { - if let Some(contact) = contacts.get(ix) { - items.push( - h_flex() - .h_11() - .w_full() - .px_2() - .gap_1p5() - .rounded(cx.theme().radius) - .text_sm() - .hover(|this| { - this.bg(cx.theme().elevated_surface_background) - }) - .child(Avatar::new(contact.avatar()).size(rems(1.75))) - .child(contact.display_name()), - ); - } - } - - items - }) - .h(px(300.)), - ), - ) - }); - } -} - -impl Render for Screening { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8); - let total_mutuals = self.mutual_contacts.len(); - let last_active = self.last_active.map(|_| true); - - v_flex() - .gap_4() - .child( - v_flex() - .gap_3() - .items_center() - .justify_center() - .text_center() - .child(Avatar::new(self.profile.avatar()).size(rems(4.))) - .child( - div() - .font_semibold() - .line_height(relative(1.25)) - .child(self.profile.name()), - ), - ) - .child( - h_flex() - .gap_3() - .child( - h_flex() - .p_1() - .flex_1() - .h_7() - .justify_center() - .rounded_full() - .bg(cx.theme().surface_background) - .text_sm() - .truncate() - .text_ellipsis() - .text_center() - .line_height(relative(1.)) - .child(shorten_pubkey), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("njump") - .label("View on njump.me") - .secondary() - .small() - .rounded() - .on_click(cx.listener(move |this, _e, window, cx| { - this.open_njump(window, cx); - })), - ) - .child( - Button::new("report") - .tooltip("Report as a scam or impostor") - .icon(IconName::Report) - .danger() - .rounded() - .on_click(cx.listener(move |this, _e, window, cx| { - this.report(window, cx); - })), - ), - ), - ) - .child( - v_flex() - .gap_3() - .child( - h_flex() - .items_start() - .gap_2() - .text_sm() - .child(status_badge(Some(self.followed), cx)) - .child( - v_flex() - .text_sm() - .child(SharedString::from("Contact")) - .child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if self.followed { - SharedString::from("This person is one of your contacts.") - } else { - SharedString::from("This person is not one of your contacts.") - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .text_sm() - .child(status_badge(last_active, cx)) - .child( - v_flex() - .text_sm() - .child( - h_flex() - .gap_0p5() - .child(SharedString::from("Activity on Public Relays")) - .child( - Button::new("active") - .icon(IconName::Info) - .xsmall() - .ghost() - .rounded() - .tooltip("This may be inaccurate if the user only publishes to their private relays."), - ), - ) - .child( - div() - .w_full() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .map(|this| { - if let Some(date) = self.last_active { - this.child(SharedString::from(format!( - "Last active: {}.", - date.to_human_time() - ))) - } else { - this.child(SharedString::from("This person hasn't had any activity.")) - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .child(status_badge(Some(self.verified), cx)) - .child( - v_flex() - .text_sm() - .child({ - if let Some(addr) = self.address(cx) { - SharedString::from(format!("{} validation", addr)) - } else { - SharedString::from("Friendly Address (NIP-05) validation") - } - }) - .child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if self.address(cx).is_some() { - if self.verified { - SharedString::from("The address matches the user's public key.") - } else { - SharedString::from("The address does not match the user's public key.") - } - } else { - SharedString::from("This person has not set up their friendly address") - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .child(status_badge(Some(total_mutuals > 0), cx)) - .child( - v_flex() - .text_sm() - .child( - h_flex() - .gap_0p5() - .child(SharedString::from("Mutual contacts")) - .child( - Button::new("mutuals") - .icon(IconName::Info) - .xsmall() - .ghost() - .rounded() - .on_click(cx.listener( - move |this, _, window, cx| { - this.mutual_contacts(window, cx); - }, - )), - ), - ) - .child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if total_mutuals > 0 { - SharedString::from(format!( - "You have {} mutual contacts with this person.", - total_mutuals - )) - } else { - SharedString::from("You don't have any mutual contacts with this person.") - } - }), - ), - ), - ), - ) - } -} - -fn status_badge(status: Option, cx: &App) -> Div { - h_flex() - .size_6() - .justify_center() - .flex_shrink_0() - .map(|this| { - if let Some(status) = status { - this.child(Icon::new(IconName::CheckCircleFill).small().text_color({ - if status { - cx.theme().icon_accent - } else { - cx.theme().icon_muted - } - })) - } else { - this.child(Indicator::new().small()) - } - }) -} +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::{Context as AnyhowContext, Error}; +use common::{shorten_pubkey, RenderedTimestamp}; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, +}; +use nostr_sdk::prelude::*; +use person::{Person, PersonRegistry}; +use smallvec::{smallvec, SmallVec}; +use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::indicator::Indicator; +use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension}; + +pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Screening::new(public_key, window, cx)) +} + +/// Screening +pub struct Screening { + /// Public Key of the person being screened. + public_key: PublicKey, + + /// Whether the person's address is verified. + verified: bool, + + /// Whether the person is followed by current user. + followed: bool, + + /// Last time the person was active. + last_active: Option, + + /// All mutual contacts of the person being screened. + mutual_contacts: Vec, + + /// Async tasks + tasks: SmallVec<[Task<()>; 3]>, +} + +impl Screening { + pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + cx.defer_in(window, move |this, _window, cx| { + this.check_contact(cx); + this.check_wot(cx); + this.check_last_activity(cx); + this.verify_identifier(cx); + }); + + Self { + public_key, + verified: false, + followed: false, + last_active: None, + mutual_contacts: vec![], + tasks: smallvec![], + } + } + + fn check_contact(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let public_key = self.public_key; + + let task: Task> = cx.background_spawn(async move { + let signer = client.signer().context("Signer not found")?; + let signer_pubkey = signer.get_public_key().await?; + + // Check if user is in contact list + let contacts = client.database().contacts_public_keys(signer_pubkey).await; + let followed = contacts.unwrap_or_default().contains(&public_key); + + Ok(followed) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await.unwrap_or(false); + + this.update(cx, |this, cx| { + this.followed = result; + cx.notify(); + }) + .ok(); + })); + } + + fn check_wot(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let public_key = self.public_key; + + let task: Task, Error>> = cx.background_spawn(async move { + let signer = client.signer().context("Signer not found")?; + let signer_pubkey = signer.get_public_key().await?; + + // Check mutual contacts + let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); + let mut mutual_contacts = vec![]; + + if let Ok(events) = client.database().query(filter).await { + for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) { + mutual_contacts.push(event.pubkey); + } + } + + Ok(mutual_contacts) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.mutual_contacts = contacts; + cx.notify(); + }) + .ok(); + } + Err(e) => { + log::error!("Failed to fetch mutual contacts: {}", e); + } + }; + })); + } + + fn check_last_activity(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let public_key = self.public_key; + + let task: Task> = cx.background_spawn(async move { + let filter = Filter::new().author(public_key).limit(1); + let mut activity: Option = None; + + // Construct target for subscription + let target = BOOTSTRAP_RELAYS + .into_iter() + .map(|relay| (relay, vec![filter.clone()])) + .collect::>(); + + if let Ok(mut stream) = client + .stream_events(target) + .timeout(Duration::from_secs(TIMEOUT)) + .await + { + while let Some((_url, event)) = stream.next().await { + if let Ok(event) = event { + activity = Some(event.created_at); + } + } + } + + activity + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await; + + this.update(cx, |this, cx| { + this.last_active = result; + cx.notify(); + }) + .ok(); + })); + } + + fn verify_identifier(&mut self, cx: &mut Context) { + let http_client = cx.http_client(); + let public_key = self.public_key; + + // Skip if the user doesn't have a NIP-05 identifier + let Some(address) = self.address(cx) else { + return; + }; + + let task: Task> = + cx.background_spawn(async move { address.verify(&http_client, &public_key).await }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await.unwrap_or(false); + + this.update(cx, |this, cx| { + this.verified = result; + cx.notify(); + }) + .ok(); + })); + } + + fn profile(&self, cx: &Context) -> Person { + let persons = PersonRegistry::global(cx); + persons.read(cx).get(&self.public_key, cx) + } + + fn address(&self, cx: &Context) -> Option { + self.profile(cx) + .metadata() + .nip05 + .and_then(|addr| Nip05Address::parse(&addr).ok()) + } + + fn open_njump(&mut self, _window: &mut Window, cx: &mut Context) { + let Ok(bech32) = self.profile(cx).public_key().to_bech32(); + cx.open_url(&format!("https://njump.me/{bech32}")); + } + + fn report(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let public_key = self.public_key; + + let task: Task> = cx.background_spawn(async move { + let tag = Tag::public_key_report(public_key, Report::Impersonation); + let builder = EventBuilder::report(vec![tag], ""); + let event = client.sign_event_builder(builder).await?; + + // Send the report to the public relays + client.send_event(&event).to(BOOTSTRAP_RELAYS).await?; + + Ok(()) + }); + + self.tasks.push(cx.spawn_in(window, async move |_, cx| { + if task.await.is_ok() { + cx.update(|window, cx| { + window.close_modal(cx); + window.push_notification("Report submitted successfully", cx); + }) + .ok(); + } + })); + } + + fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { + let contacts = self.mutual_contacts.clone(); + + window.open_modal(cx, move |this, _window, _cx| { + let contacts = contacts.clone(); + let total = contacts.len(); + + this.title(SharedString::from("Mutual contacts")).child( + v_flex().gap_1().pb_4().child( + uniform_list("contacts", total, move |range, _window, cx| { + let persons = PersonRegistry::global(cx); + let mut items = Vec::with_capacity(total); + + for ix in range { + let Some(contact) = contacts.get(ix) else { + continue; + }; + let profile = persons.read(cx).get(contact, cx); + + items.push( + h_flex() + .h_11() + .w_full() + .px_2() + .gap_1p5() + .rounded(cx.theme().radius) + .text_sm() + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .child(Avatar::new(profile.avatar()).size(rems(1.75))) + .child(profile.name()), + ); + } + + items + }) + .h(px(300.)), + ), + ) + }); + } +} + +impl Render for Screening { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let profile = self.profile(cx); + let shorten_pubkey = shorten_pubkey(self.public_key, 8); + + let total_mutuals = self.mutual_contacts.len(); + let last_active = self.last_active.map(|_| true); + + v_flex() + .gap_4() + .child( + v_flex() + .gap_3() + .items_center() + .justify_center() + .text_center() + .child(Avatar::new(profile.avatar()).size(rems(4.))) + .child( + div() + .font_semibold() + .line_height(relative(1.25)) + .child(profile.name()), + ), + ) + .child( + h_flex() + .gap_3() + .child( + h_flex() + .p_1() + .flex_1() + .h_7() + .justify_center() + .rounded_full() + .bg(cx.theme().surface_background) + .text_sm() + .truncate() + .text_ellipsis() + .text_center() + .line_height(relative(1.)) + .child(shorten_pubkey), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("njump") + .label("View on njump.me") + .secondary() + .small() + .rounded() + .on_click(cx.listener(move |this, _e, window, cx| { + this.open_njump(window, cx); + })), + ) + .child( + Button::new("report") + .tooltip("Report as a scam or impostor") + .icon(IconName::Boom) + .danger() + .rounded() + .on_click(cx.listener(move |this, _e, window, cx| { + this.report(window, cx); + })), + ), + ), + ) + .child( + v_flex() + .gap_3() + .child( + h_flex() + .items_start() + .gap_2() + .text_sm() + .child(status_badge(Some(self.followed), cx)) + .child( + v_flex() + .text_sm() + .child(SharedString::from("Contact")) + .child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if self.followed { + SharedString::from("This person is one of your contacts.") + } else { + SharedString::from("This person is not one of your contacts.") + } + }), + ), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .text_sm() + .child(status_badge(last_active, cx)) + .child( + v_flex() + .text_sm() + .child( + h_flex() + .gap_0p5() + .child(SharedString::from("Activity on Public Relays")) + .child( + Button::new("active") + .icon(IconName::Info) + .xsmall() + .ghost() + .rounded() + .tooltip("This may be inaccurate if the user only publishes to their private relays."), + ), + ) + .child( + div() + .w_full() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .map(|this| { + if let Some(date) = self.last_active { + this.child(SharedString::from(format!( + "Last active: {}.", + date.to_human_time() + ))) + } else { + this.child(SharedString::from("This person hasn't had any activity.")) + } + }), + ), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .child(status_badge(Some(self.verified), cx)) + .child( + v_flex() + .text_sm() + .child({ + if let Some(addr) = self.address(cx) { + SharedString::from(format!("{} validation", addr)) + } else { + SharedString::from("Friendly Address (NIP-05) validation") + } + }) + .child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if self.address(cx).is_some() { + if self.verified { + SharedString::from("The address matches the user's public key.") + } else { + SharedString::from("The address does not match the user's public key.") + } + } else { + SharedString::from("This person has not set up their friendly address") + } + }), + ), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .child(status_badge(Some(total_mutuals > 0), cx)) + .child( + v_flex() + .text_sm() + .child( + h_flex() + .gap_0p5() + .child(SharedString::from("Mutual contacts")) + .child( + Button::new("mutuals") + .icon(IconName::Info) + .xsmall() + .ghost() + .rounded() + .on_click(cx.listener( + move |this, _, window, cx| { + this.mutual_contacts(window, cx); + }, + )), + ), + ) + .child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if total_mutuals > 0 { + SharedString::from(format!( + "You have {} mutual contacts with this person.", + total_mutuals + )) + } else { + SharedString::from("You don't have any mutual contacts with this person.") + } + }), + ), + ), + ), + ) + } +} + +fn status_badge(status: Option, cx: &App) -> Div { + h_flex() + .size_6() + .justify_center() + .flex_shrink_0() + .map(|this| { + if let Some(status) = status { + this.child(Icon::new(IconName::CheckCircle).small().text_color({ + if status { + cx.theme().icon_accent + } else { + cx.theme().icon_muted + } + })) + } else { + this.child(Indicator::new().small()) + } + }) +} diff --git a/crates/coop/src/login/mod.rs b/crates/coop/src/login/mod.rs deleted file mode 100644 index 324b167..0000000 --- a/crates/coop/src/login/mod.rs +++ /dev/null @@ -1,427 +0,0 @@ -use std::time::Duration; - -use anyhow::anyhow; -use common::BUNKER_TIMEOUT; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, -}; -use key_store::{KeyItem, KeyStore}; -use nostr_connect::prelude::*; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::notification::Notification; -use ui::{v_flex, ContextModal, Disableable, StyledExt}; - -use crate::actions::CoopAuthUrlHandler; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Login::new(window, cx)) -} - -#[derive(Debug)] -pub struct Login { - key_input: Entity, - pass_input: Entity, - error: Entity>, - countdown: Entity>, - require_password: bool, - logging_in: bool, - - /// Panel - name: SharedString, - focus_handle: FocusHandle, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, -} - -impl Login { - fn new(window: &mut Window, cx: &mut Context) -> Self { - let key_input = cx.new(|cx| InputState::new(window, cx)); - let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); - - let error = cx.new(|_| None); - let countdown = cx.new(|_| None); - - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Subscribe to key input events and process login when the user presses enter - cx.subscribe_in(&key_input, window, |this, input, event, window, cx| { - match event { - InputEvent::PressEnter { .. } => { - this.login(window, cx); - } - InputEvent::Change => { - if input.read(cx).value().starts_with("ncryptsec1") { - this.require_password = true; - cx.notify(); - } - } - _ => {} - }; - }), - ); - - Self { - key_input, - pass_input, - error, - countdown, - name: "Welcome Back".into(), - focus_handle: cx.focus_handle(), - logging_in: false, - require_password: false, - _subscriptions: subscriptions, - } - } - - fn login(&mut self, window: &mut Window, cx: &mut Context) { - if self.logging_in { - return; - }; - - // Prevent duplicate login requests - self.set_logging_in(true, cx); - - let value = self.key_input.read(cx).value(); - let password = self.pass_input.read(cx).value(); - - if value.starts_with("bunker://") { - self.login_with_bunker(&value, window, cx); - } else if value.starts_with("ncryptsec1") { - self.login_with_password(&value, &password, cx); - } else if value.starts_with("nsec1") { - if let Ok(secret) = SecretKey::parse(&value) { - let keys = Keys::new(secret); - self.login_with_keys(keys, cx); - } else { - self.set_error("Invalid", cx); - } - } else { - self.set_error("Invalid", cx); - } - } - - fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context) { - let Ok(uri) = NostrConnectUri::parse(content) else { - self.set_error("Bunker is not valid", cx); - return; - }; - - let app_keys = Keys::generate(); - let timeout = Duration::from_secs(BUNKER_TIMEOUT); - let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap(); - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); - - // Start countdown - cx.spawn_in(window, async move |this, cx| { - for i in (0..=BUNKER_TIMEOUT).rev() { - if i == 0 { - this.update(cx, |this, cx| { - this.set_countdown(None, cx); - }) - .ok(); - } else { - this.update(cx, |this, cx| { - this.set_countdown(Some(i), cx); - }) - .ok(); - } - cx.background_executor().timer(Duration::from_secs(1)).await; - } - }) - .detach(); - - // Handle connection - cx.spawn_in(window, async move |this, cx| { - let result = signer.bunker_uri().await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(uri) => { - this.save_connection(&app_keys, &uri, window, cx); - this.connect(signer, cx); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - fn save_connection( - &mut self, - keys: &Keys, - uri: &NostrConnectUri, - window: &mut Window, - cx: &mut Context, - ) { - let keystore = KeyStore::global(cx).read(cx).backend(); - let username = keys.public_key().to_hex(); - let secret = keys.secret_key().to_secret_bytes(); - let mut clean_uri = uri.to_string(); - - // Clear the secret parameter in the URI if it exists - if let Some(s) = uri.secret() { - clean_uri = clean_uri.replace(s, ""); - } - - cx.spawn_in(window, async move |this, cx| { - let user_url = KeyItem::User.to_string(); - let bunker_url = KeyItem::Bunker.to_string(); - let user_password = clean_uri.into_bytes(); - - // Write bunker uri to keyring for further connection - if let Err(e) = keystore - .write_credentials(&user_url, "bunker", &user_password, cx) - .await - { - this.update_in(cx, |_, window, cx| { - window.push_notification(e.to_string(), cx); - }) - .ok(); - } - - // Write the app keys for further connection - if let Err(e) = keystore - .write_credentials(&bunker_url, &username, &secret, cx) - .await - { - this.update_in(cx, |_, window, cx| { - window.push_notification(e.to_string(), cx); - }) - .ok(); - } - }) - .detach(); - } - - fn connect(&mut self, signer: NostrConnect, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - - nostr.update(cx, |this, cx| { - this.set_signer(signer, cx); - }); - } - - pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context) { - if pwd.is_empty() { - self.set_error("Password is required", cx); - return; - } - - let Ok(enc) = EncryptedSecretKey::from_bech32(content) else { - self.set_error("Secret Key is invalid", cx); - return; - }; - - let password = pwd.to_owned(); - - // Decrypt in the background to ensure it doesn't block the UI - let task = cx.background_spawn(async move { - if let Ok(content) = enc.decrypt(&password) { - Ok(Keys::new(content)) - } else { - Err(anyhow!("Invalid password")) - } - }); - - cx.spawn(async move |this, cx| { - let result = task.await; - - this.update(cx, |this, cx| { - match result { - Ok(keys) => { - this.login_with_keys(keys, cx); - } - Err(e) => { - this.set_error(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context) { - let keystore = KeyStore::global(cx).read(cx).backend(); - let username = keys.public_key().to_hex(); - let secret = keys.secret_key().to_secret_hex().into_bytes(); - - cx.spawn(async move |this, cx| { - let bunker_url = KeyItem::User.to_string(); - - // Write the app keys for further connection - if let Err(e) = keystore - .write_credentials(&bunker_url, &username, &secret, cx) - .await - { - this.update(cx, |this, cx| { - this.set_error(e.to_string(), cx); - }) - .ok(); - } - - this.update(cx, |_this, cx| { - let nostr = NostrRegistry::global(cx); - - nostr.update(cx, |this, cx| { - this.set_signer(keys, cx); - }); - }) - .ok(); - }) - .detach(); - } - - fn set_error(&mut self, message: S, cx: &mut Context) - where - S: Into, - { - // Reset the log in state - self.set_logging_in(false, cx); - - // Reset the countdown - self.set_countdown(None, cx); - - // Update error message - self.error.update(cx, |this, cx| { - *this = Some(message.into()); - cx.notify(); - }); - - // Clear the error message after 3 secs - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(3)).await; - - this.update(cx, |this, cx| { - this.error.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - }) - .ok(); - }) - .detach(); - } - - fn set_logging_in(&mut self, status: bool, cx: &mut Context) { - self.logging_in = status; - cx.notify(); - } - - fn set_countdown(&mut self, i: Option, cx: &mut Context) { - self.countdown.update(cx, |this, cx| { - *this = i; - cx.notify(); - }); - } -} - -impl Panel for Login { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for Login {} - -impl Focusable for Login { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for Login { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .relative() - .size_full() - .items_center() - .justify_center() - .child( - v_flex() - .w_96() - .gap_10() - .child( - div() - .text_center() - .text_xl() - .font_semibold() - .line_height(relative(1.3)) - .child(SharedString::from("Continue with Private Key or Bunker")), - ) - .child( - v_flex() - .gap_3() - .text_sm() - .child( - v_flex() - .gap_1() - .text_sm() - .text_color(cx.theme().text_muted) - .child("nsec or bunker://") - .child(TextInput::new(&self.key_input)), - ) - .when(self.require_password, |this| { - this.child( - v_flex() - .gap_1() - .text_sm() - .text_color(cx.theme().text_muted) - .child("Password:") - .child(TextInput::new(&self.pass_input)), - ) - }) - .child( - Button::new("login") - .label("Continue") - .primary() - .loading(self.logging_in) - .disabled(self.logging_in) - .on_click(cx.listener(move |this, _, window, cx| { - this.login(window, cx); - })), - ) - .when_some(self.countdown.read(cx).as_ref(), |this, i| { - this.child( - div() - .text_xs() - .text_center() - .text_color(cx.theme().text_muted) - .child(SharedString::from(format!( - "Approve connection request from your signer in {} seconds", - i - ))), - ) - }) - .when_some(self.error.read(cx).as_ref(), |this, error| { - this.child( - div() - .text_xs() - .text_center() - .text_color(cx.theme().danger_foreground) - .child(error.clone()), - ) - }), - ), - ) - } -} diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index a1fb4de..68ac196 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -1,118 +1,143 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use assets::Assets; -use common::{APP_ID, CLIENT_NAME}; use gpui::{ - point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString, + actions, point, px, size, App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, }; +use gpui_platform::application; +use state::{APP_ID, CLIENT_NAME}; use ui::Root; -use crate::actions::{load_embedded_fonts, quit, Quit}; - -mod actions; -mod chatspace; -mod login; -mod new_identity; +mod dialogs; +mod panels; mod sidebar; -mod user; -mod views; +mod workspace; + +actions!(coop, [Quit]); fn main() { // Initialize logging tracing_subscriber::fmt::init(); - // Initialize the Application - let app = Application::new() - .with_assets(Assets) - .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())); - // Run application - app.run(move |cx| { - // Load embedded fonts in assets/fonts - load_embedded_fonts(cx); + application() + .with_assets(Assets) + .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) + .run(move |cx| { + // Load embedded fonts in assets/fonts + load_embedded_fonts(cx); - // Register the `quit` function - cx.on_action(quit); + // Register the `quit` function + cx.on_action(quit); - // Register the `quit` function with CMD+Q (macOS) - #[cfg(target_os = "macos")] - cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + // Register the `quit` function with CMD+Q (macOS) + #[cfg(target_os = "macos")] + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); - // Register the `quit` function with Super+Q (others) - #[cfg(not(target_os = "macos"))] - cx.bind_keys([KeyBinding::new("super-q", Quit, None)]); + // Register the `quit` function with Super+Q (others) + #[cfg(not(target_os = "macos"))] + cx.bind_keys([KeyBinding::new("super-q", Quit, None)]); - // Set menu items - cx.set_menus(vec![Menu { - name: "Coop".into(), - items: vec![MenuItem::action("Quit", Quit)], - }]); + // Set menu items + cx.set_menus(vec![Menu { + name: "Coop".into(), + items: vec![MenuItem::action("Quit", Quit)], + }]); - // Set up the window bounds - let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx); + // Set up the window bounds + let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx); - // Set up the window options - let opts = WindowOptions { - window_background: WindowBackgroundAppearance::Opaque, - window_decorations: Some(WindowDecorations::Client), - window_bounds: Some(WindowBounds::Windowed(bounds)), - kind: WindowKind::Normal, - app_id: Some(APP_ID.to_owned()), - titlebar: Some(TitlebarOptions { - title: Some(SharedString::new_static(CLIENT_NAME)), - traffic_light_position: Some(point(px(9.0), px(9.0))), - appears_transparent: true, - }), - ..Default::default() - }; + // Set up the window options + let opts = WindowOptions { + window_background: WindowBackgroundAppearance::Opaque, + window_decorations: Some(WindowDecorations::Client), + window_bounds: Some(WindowBounds::Windowed(bounds)), + kind: WindowKind::Normal, + app_id: Some(APP_ID.to_owned()), + titlebar: Some(TitlebarOptions { + title: Some(SharedString::new_static(CLIENT_NAME)), + traffic_light_position: Some(point(px(9.0), px(9.0))), + appears_transparent: true, + }), + ..Default::default() + }; - // Open a window with default options - cx.open_window(opts, |window, cx| { - // Bring the app to the foreground - cx.activate(true); + // Open a window with default options + cx.open_window(opts, |window, cx| { + // Bring the app to the foreground + cx.activate(true); - cx.new(|cx| { - // Initialize the tokio runtime - gpui_tokio::init(cx); + cx.new(|cx| { + // Initialize the tokio runtime + gpui_tokio::init(cx); - // Initialize components - ui::init(cx); + // Initialize components + ui::init(cx); - // Initialize theme registry - theme::init(cx); + // Initialize theme registry + theme::init(cx); - // Initialize backend for keys storage - key_store::init(cx); + // Initialize backend for keys storage + key_store::init(cx); - // Initialize the nostr client - state::init(cx); + // Initialize the nostr client + state::init(window, cx); - // Initialize device signer - // - // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - device::init(cx); + // Initialize device signer + // + // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + device::init(window, cx); - // Initialize settings - settings::init(cx); + // Initialize settings + settings::init(cx); - // Initialize relay auth registry - relay_auth::init(window, cx); + // Initialize relay auth registry + relay_auth::init(window, cx); - // Initialize app registry - chat::init(cx); + // Initialize app registry + chat::init(window, cx); - // Initialize person registry - person::init(cx); + // Initialize person registry + person::init(cx); - // Initialize auto update - auto_update::init(cx); + // Initialize auto update + auto_update::init(cx); - // Root Entity - Root::new(chatspace::init(window, cx).into(), window, cx) + // Root Entity + Root::new(workspace::init(window, cx).into(), window, cx) + }) }) - }) - .expect("Failed to open window. Please restart the application."); - }); + .expect("Failed to open window. Please restart the application."); + }); +} + +fn load_embedded_fonts(cx: &App) { + let asset_source = cx.asset_source(); + let font_paths = asset_source.list("fonts").unwrap(); + let embedded_fonts = Mutex::new(vec![]); + let executor = cx.background_executor(); + + cx.foreground_executor().block_on(executor.scoped(|scope| { + for font_path in &font_paths { + if !font_path.ends_with(".ttf") { + continue; + } + + scope.spawn(async { + let font_bytes = asset_source.load(font_path.as_str()).unwrap().unwrap(); + embedded_fonts.lock().unwrap().push(font_bytes); + }); + } + })); + + cx.text_system() + .add_fonts(embedded_fonts.into_inner().unwrap()) + .unwrap(); +} + +fn quit(_ev: &Quit, cx: &mut App) { + log::info!("Gracefully quitting the application . . ."); + cx.quit(); } diff --git a/crates/coop/src/new_identity/backup.rs b/crates/coop/src/new_identity/backup.rs deleted file mode 100644 index 36858db..0000000 --- a/crates/coop/src/new_identity/backup.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::time::Duration; - -use anyhow::{anyhow, Error}; -use common::home_dir; -use gpui::{ - div, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement, Render, - SharedString, Styled, Task, Window, -}; -use nostr_sdk::prelude::*; -use smallvec::{smallvec, SmallVec}; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::input::{InputState, TextInput}; -use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt}; - -pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Backup::new(keys, window, cx)) -} - -#[derive(Debug)] -pub struct Backup { - pubkey_input: Entity, - secret_input: Entity, - error: Option, - copied: bool, - - // Async operations - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl Backup { - pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context) -> Self { - let Ok(npub) = keys.public_key.to_bech32(); - let Ok(nsec) = keys.secret_key().to_bech32(); - - let pubkey_input = cx.new(|cx| { - InputState::new(window, cx) - .disabled(true) - .default_value(npub) - }); - - let secret_input = cx.new(|cx| { - InputState::new(window, cx) - .disabled(true) - .default_value(nsec) - }); - - Self { - pubkey_input, - secret_input, - error: None, - copied: false, - _tasks: smallvec![], - } - } - - pub fn backup(&self, window: &Window, cx: &Context) -> Task> { - let dir = home_dir(); - let path = cx.prompt_for_new_path(dir, Some("My Nostr Account")); - let nsec = self.secret_input.read(cx).value().to_string(); - - cx.spawn_in(window, async move |this, cx| { - match path.await { - Ok(Ok(Some(path))) => { - if let Err(e) = smol::fs::write(&path, nsec).await { - this.update_in(cx, |this, window, cx| { - this.set_error(e.to_string(), window, cx); - }) - .expect("Entity has been released"); - } else { - return Ok(()); - } - } - _ => { - log::error!("Failed to save backup keys"); - } - }; - - Err(anyhow!("Failed to backup keys")) - }) - } - - fn copy(&mut self, value: impl Into, window: &mut Window, cx: &mut Context) { - let item = ClipboardItem::new_string(value.into()); - cx.write_to_clipboard(item); - - self.set_copied(true, window, cx); - } - - fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - self.copied = status; - cx.notify(); - - // Reset the copied state after a delay - if status { - self._tasks.push(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - this.update_in(cx, |this, window, cx| { - this.set_copied(false, window, cx); - }) - .ok(); - })); - } - } - - fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) - where - E: Into, - { - self.error = Some(error.into()); - cx.notify(); - - // Clear the error message after a delay - self._tasks.push(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - this.update(cx, |this, cx| { - this.error = None; - cx.notify(); - }) - .ok(); - })); - } -} - -impl Render for Backup { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you."; - const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account."; - const PK: &str = "Public Key is the address that others will use to find you."; - const SK: &str = "Secret Key provides access to your account."; - - v_flex() - .gap_2() - .text_sm() - .child(SharedString::from(DESCRIPTION)) - .child( - v_flex() - .gap_1() - .child( - div() - .font_semibold() - .child(SharedString::from("Public Key:")), - ) - .child( - h_flex() - .gap_1() - .child(TextInput::new(&self.pubkey_input).small()) - .child( - Button::new("copy-pubkey") - .icon({ - if self.copied { - IconName::CheckCircleFill - } else { - IconName::Copy - } - }) - .ghost_alt() - .disabled(self.copied) - .on_click(cx.listener(move |this, _e, window, cx| { - this.copy(this.pubkey_input.read(cx).value(), window, cx); - })), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(PK)), - ), - ) - .child(divider(cx)) - .child( - v_flex() - .gap_1() - .child( - div() - .font_semibold() - .child(SharedString::from("Secret Key:")), - ) - .child( - h_flex() - .gap_1() - .child(TextInput::new(&self.secret_input).small()) - .child( - Button::new("copy-secret") - .icon({ - if self.copied { - IconName::CheckCircleFill - } else { - IconName::Copy - } - }) - .ghost_alt() - .disabled(self.copied) - .on_click(cx.listener(move |this, _e, window, cx| { - this.copy(this.secret_input.read(cx).value(), window, cx); - })), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(SK)), - ), - ) - .child(divider(cx)) - .child( - div() - .text_xs() - .text_color(cx.theme().danger_foreground) - .child(SharedString::from(WARN)), - ) - } -} diff --git a/crates/coop/src/new_identity/mod.rs b/crates/coop/src/new_identity/mod.rs deleted file mode 100644 index ab7daa8..0000000 --- a/crates/coop/src/new_identity/mod.rs +++ /dev/null @@ -1,350 +0,0 @@ -use anyhow::{anyhow, Error}; -use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS}; -use gpui::{ - rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, Window, -}; -use gpui_tokio::Tokio; -use key_store::{KeyItem, KeyStore}; -use nostr_sdk::prelude::*; -use settings::AppSettings; -use smol::fs; -use state::NostrRegistry; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::input::{InputState, TextInput}; -use ui::modal::ModalButtonProps; -use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable}; - -mod backup; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| NewAccount::new(window, cx)) -} - -#[derive(Debug)] -pub struct NewAccount { - name_input: Entity, - avatar_input: Entity, - temp_keys: Entity, - uploading: bool, - submitting: bool, - // Panel - name: SharedString, - focus_handle: FocusHandle, -} - -impl NewAccount { - fn new(window: &mut Window, cx: &mut Context) -> Self { - let temp_keys = cx.new(|_| Keys::generate()); - let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); - let avatar_input = cx.new(|cx| InputState::new(window, cx)); - - Self { - name_input, - avatar_input, - temp_keys, - uploading: false, - submitting: false, - name: "Create a new identity".into(), - focus_handle: cx.focus_handle(), - } - } - - fn create(&mut self, window: &mut Window, cx: &mut Context) { - self.submitting(true, cx); - - let keys = self.temp_keys.read(cx).clone(); - let view = backup::init(&keys, window, cx); - let weak_view = view.downgrade(); - let current_view = cx.entity().downgrade(); - - window.open_modal(cx, move |modal, _window, _cx| { - let weak_view = weak_view.clone(); - let current_view = current_view.clone(); - - modal - .alert() - .title(SharedString::from( - "Backup to avoid losing access to your account", - )) - .child(view.clone()) - .button_props(ModalButtonProps::default().ok_text("Download")) - .on_ok(move |_, window, cx| { - weak_view - .update(cx, |this, cx| { - let view = current_view.clone(); - let task = this.backup(window, cx); - - cx.spawn_in(window, async move |_this, cx| { - let result = task.await; - - match result { - Ok(_) => { - view.update_in(cx, |this, window, cx| { - this.set_signer(window, cx); - }) - .expect("Entity has been released"); - } - Err(e) => { - log::error!("Failed to backup: {e}"); - } - } - }) - .detach(); - }) - .ok(); - // true to close the modal - false - }) - }) - } - - pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context) { - let keystore = KeyStore::global(cx).read(cx).backend(); - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let keys = self.temp_keys.read(cx).clone(); - let username = keys.public_key().to_hex(); - let secret = keys.secret_key().to_secret_hex().into_bytes(); - - let avatar = self.avatar_input.read(cx).value().to_string(); - let name = self.name_input.read(cx).value().to_string(); - let mut metadata = Metadata::new().display_name(name.clone()).name(name); - - if let Ok(url) = Url::parse(&avatar) { - metadata = metadata.picture(url); - }; - - // Close all modals if available - window.close_all_modals(cx); - - // Set the client's signer with the current keys - let task: Task> = cx.background_spawn(async move { - let signer = keys.clone(); - let nip65_relays = default_nip65_relays(); - let nip17_relays = default_nip17_relays(); - - // Construct a NIP-65 event - let event = EventBuilder::new(Kind::RelayList, "") - .tags( - nip65_relays - .iter() - .cloned() - .map(|(url, metadata)| Tag::relay_metadata(url, metadata)), - ) - .sign(&signer) - .await?; - - // Set NIP-65 relays - client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; - - // Extract only write relays - let write_relays: Vec = nip65_relays - .iter() - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url.to_owned()) - } else { - None - } - }) - .collect(); - - // Ensure relays are connected - for url in write_relays.iter() { - client.add_relay(url).await?; - client.connect_relay(url).await?; - } - - // Construct a NIP-17 event - let event = EventBuilder::new(Kind::InboxRelays, "") - .tags(nip17_relays.iter().cloned().map(Tag::relay)) - .sign(&signer) - .await?; - - // Set NIP-17 relays - client.send_event_to(&write_relays, &event).await?; - - // Construct a metadata event - let event = EventBuilder::metadata(&metadata).sign(&signer).await?; - - // Send metadata event to both write relays and bootstrap relays - client.send_event_to(&write_relays, &event).await?; - client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; - - // Update the client's signer with the current keys - client.set_signer(keys).await; - - Ok(()) - }); - - cx.spawn_in(window, async move |this, cx| { - let url = KeyItem::User.to_string(); - - // Write the app keys for further connection - keystore - .write_credentials(&url, &username, &secret, cx) - .await - .ok(); - - if let Err(e) = task.await { - this.update_in(cx, |this, window, cx| { - this.submitting(false, cx); - window.push_notification(e.to_string(), cx); - }) - .expect("Entity has been released"); - } - }) - .detach(); - } - - fn upload(&mut self, window: &mut Window, cx: &mut Context) { - self.uploading(true, cx); - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - // Get the user's configured NIP96 server - let nip96_server = AppSettings::get_file_server(cx); - - // Open native file dialog - let paths = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); - - let task = Tokio::spawn(cx, async move { - match paths.await { - Ok(Ok(Some(mut paths))) => { - if let Some(path) = paths.pop() { - let file = fs::read(path).await?; - let url = nip96_upload(&client, &nip96_server, file).await?; - - Ok(url) - } else { - Err(anyhow!("Path not found")) - } - } - _ => Err(anyhow!("Error")), - } - }); - - cx.spawn_in(window, async move |this, cx| { - let result = task.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(Ok(url)) => { - this.avatar_input.update(cx, |this, cx| { - this.set_value(url.to_string(), window, cx); - }); - } - Ok(Err(e)) => { - window.push_notification(e.to_string(), cx); - } - Err(e) => { - log::warn!("Failed to upload avatar: {e}"); - } - }; - this.uploading(false, cx); - }) - .expect("Entity has been released"); - }) - .detach(); - } - - fn submitting(&mut self, status: bool, cx: &mut Context) { - self.submitting = status; - cx.notify(); - } - - fn uploading(&mut self, status: bool, cx: &mut Context) { - self.uploading = status; - cx.notify(); - } -} - -impl Panel for NewAccount { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for NewAccount {} - -impl Focusable for NewAccount { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for NewAccount { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = self.avatar_input.read(cx).value(); - - v_flex() - .size_full() - .relative() - .items_center() - .justify_center() - .child( - v_flex() - .w_96() - .gap_2() - .child( - v_flex() - .h_40() - .w_full() - .items_center() - .justify_center() - .gap_4() - .child(Avatar::new(avatar).size(rems(4.25))) - .child( - Button::new("upload") - .icon(IconName::PlusCircleFill) - .label("Add an avatar") - .xsmall() - .ghost() - .rounded() - .disabled(self.uploading) - //.loading(self.uploading) - .on_click(cx.listener(move |this, _, window, cx| { - this.upload(window, cx); - })), - ), - ) - .child( - v_flex() - .gap_1() - .text_sm() - .child(SharedString::from("What should people call you?")) - .child( - TextInput::new(&self.name_input) - .disabled(self.submitting) - .small(), - ), - ) - .child(divider(cx)) - .child( - Button::new("submit") - .label("Continue") - .primary() - .loading(self.submitting) - .disabled(self.submitting || self.uploading) - .on_click(cx.listener(move |this, _, window, cx| { - this.create(window, cx); - })), - ), - ) - } -} diff --git a/crates/coop/src/panels/connect.rs b/crates/coop/src/panels/connect.rs new file mode 100644 index 0000000..398eb1b --- /dev/null +++ b/crates/coop/src/panels/connect.rs @@ -0,0 +1,127 @@ +use std::sync::Arc; + +use common::TextUtils; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, + FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task, + Window, +}; +use smallvec::{smallvec, SmallVec}; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::dock_area::ClosePanel; +use ui::notification::Notification; +use ui::{v_flex, StyledExt, WindowExtension}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| ConnectPanel::new(window, cx)) +} + +pub struct ConnectPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// QR Code + qr_code: Option>, + + /// Background tasks + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl ConnectPanel { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let weak_state = nostr.downgrade(); + let (signer, uri) = nostr.read(cx).client_connect(None); + + // Generate a QR code for quick connection + let qr_code = uri.to_string().to_qr(); + + let mut tasks = smallvec![]; + + tasks.push( + // Wait for nostr connect + cx.spawn_in(window, async move |_this, cx| { + let result = signer.bunker_uri().await; + + weak_state + .update_in(cx, |this, window, cx| { + match result { + Ok(uri) => { + this.persist_bunker(uri, cx); + this.set_signer(signer, true, cx); + // Close the current panel after setting the signer + window.dispatch_action(Box::new(ClosePanel), cx); + } + Err(e) => { + window.push_notification(Notification::error(e.to_string()), cx); + } + }; + }) + .ok(); + }), + ); + + Self { + name: "Nostr Connect".into(), + focus_handle: cx.focus_handle(), + qr_code, + _tasks: tasks, + } + } +} + +impl Panel for ConnectPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for ConnectPanel {} + +impl Focusable for ConnectPanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ConnectPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_2() + .gap_10() + .child( + v_flex() + .justify_center() + .items_center() + .text_center() + .child( + div() + .font_semibold() + .line_height(relative(1.25)) + .child(SharedString::from("Continue with Nostr Connect")), + ) + .child(div().text_sm().text_color(cx.theme().text_muted).child( + SharedString::from("Use Nostr Connect apps to scan the code"), + )), + ) + .when_some(self.qr_code.as_ref(), |this, qr| { + this.child( + img(qr.clone()) + .size(px(256.)) + .rounded(cx.theme().radius_lg) + .border_1() + .border_color(cx.theme().border), + ) + }) + } +} diff --git a/crates/coop/src/panels/greeter.rs b/crates/coop/src/panels/greeter.rs new file mode 100644 index 0000000..45c90e8 --- /dev/null +++ b/crates/coop/src/panels/greeter.rs @@ -0,0 +1,297 @@ +use chat::ChatRegistry; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use state::{NostrRegistry, RelayState}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock_area::dock::DockPlacement; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt}; + +use crate::panels::{connect, import, messaging_relays, profile, relay_list}; +use crate::workspace::Workspace; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| GreeterPanel::new(window, cx)) +} + +pub struct GreeterPanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl GreeterPanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Onboarding".into(), + focus_handle: cx.focus_handle(), + } + } + + fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + + if let Some(public_key) = signer.public_key() { + cx.spawn_in(window, async move |_this, cx| { + cx.update(|window, cx| { + Workspace::add_panel( + profile::init(public_key, window, cx), + DockPlacement::Center, + window, + cx, + ); + }) + .ok(); + }) + .detach(); + } + } +} + +impl Panel for GreeterPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + div() + .child( + svg() + .path("brand/coop.svg") + .size_4() + .text_color(cx.theme().text_muted), + ) + .into_any_element() + } +} + +impl EventEmitter for GreeterPanel {} + +impl Focusable for GreeterPanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for GreeterPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Welcome to Coop!"; + const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr."; + + let chat = ChatRegistry::global(cx); + let nip17_state = chat.read(cx).relay_state(cx); + + let nostr = NostrRegistry::global(cx); + let nip65_state = nostr.read(cx).relay_list_state(); + let signer = nostr.read(cx).signer(); + let owned = signer.owned(); + + let required_actions = + nip65_state == RelayState::NotConfigured || nip17_state == RelayState::NotConfigured; + + h_flex() + .size_full() + .items_center() + .justify_center() + .p_2() + .child( + v_flex() + .h_full() + .w_112() + .gap_6() + .items_center() + .justify_center() + .child( + h_flex() + .mb_4() + .gap_2() + .w_full() + .child( + svg() + .path("brand/coop.svg") + .size_12() + .text_color(cx.theme().icon_muted), + ) + .child( + v_flex() + .child( + div() + .font_semibold() + .line_height(relative(1.25)) + .child(SharedString::from(TITLE)), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().text_muted) + .line_height(relative(1.25)) + .child(SharedString::from(DESCRIPTION)), + ), + ), + ) + .when(required_actions, |this| { + this.child( + v_flex() + .gap_2() + .w_full() + .child( + h_flex() + .gap_1() + .w_full() + .text_sm() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Required Actions")) + .child(div().flex_1().h_px().bg(cx.theme().border)), + ) + .child( + v_flex() + .gap_2() + .w_full() + .when(nip65_state.not_configured(), |this| { + this.child( + Button::new("relaylist") + .icon(Icon::new(IconName::Relay)) + .label("Set up relay list") + .ghost() + .small() + .justify_start() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + relay_list::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + }), + ) + }) + .when(nip17_state.not_configured(), |this| { + this.child( + Button::new("import") + .icon(Icon::new(IconName::Relay)) + .label("Set up messaging relays") + .ghost() + .small() + .justify_start() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + messaging_relays::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + }), + ) + }), + ), + ) + }) + .when(!owned, |this| { + this.child( + v_flex() + .gap_2() + .w_full() + .child( + h_flex() + .gap_1() + .w_full() + .text_sm() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Use your own identity")) + .child(div().flex_1().h_px().bg(cx.theme().border)), + ) + .child( + v_flex() + .gap_2() + .w_full() + .child( + Button::new("connect") + .icon(Icon::new(IconName::Door)) + .label("Connect account via Nostr Connect") + .ghost() + .small() + .justify_start() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + connect::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + }), + ) + .child( + Button::new("import") + .icon(Icon::new(IconName::Usb)) + .label("Import a secret key or bunker") + .ghost() + .small() + .justify_start() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + import::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + }), + ), + ), + ) + }) + .child( + v_flex() + .gap_2() + .w_full() + .child( + h_flex() + .gap_1() + .w_full() + .text_sm() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Get Started")) + .child(div().flex_1().h_px().bg(cx.theme().border)), + ) + .child( + v_flex() + .gap_2() + .w_full() + .child( + Button::new("backup") + .icon(Icon::new(IconName::Shield)) + .label("Backup account") + .ghost() + .small() + .justify_start(), + ) + .child( + Button::new("profile") + .icon(Icon::new(IconName::Profile)) + .label("Update profile") + .ghost() + .small() + .justify_start() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.add_profile_panel(window, cx) + })), + ) + .child( + Button::new("invite") + .icon(Icon::new(IconName::Invite)) + .label("Invite friends") + .ghost() + .small() + .justify_start(), + ), + ), + ), + ) + } +} diff --git a/crates/coop/src/panels/import.rs b/crates/coop/src/panels/import.rs new file mode 100644 index 0000000..3d22161 --- /dev/null +++ b/crates/coop/src/panels/import.rs @@ -0,0 +1,371 @@ +use std::time::Duration; + +use anyhow::anyhow; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, +}; +use nostr_connect::prelude::*; +use smallvec::{smallvec, SmallVec}; +use state::{CoopAuthUrlHandler, NostrRegistry}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::dock_area::ClosePanel; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::notification::Notification; +use ui::{v_flex, Disableable, StyledExt, WindowExtension}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| ImportPanel::new(window, cx)) +} + +#[derive(Debug)] +pub struct ImportPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Secret key input + key_input: Entity, + + /// Password input (if required) + pass_input: Entity, + + /// Error message + error: Entity>, + + /// Countdown timer for nostr connect + countdown: Entity>, + + /// Whether the user is currently logging in + logging_in: bool, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl ImportPanel { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let key_input = cx.new(|cx| InputState::new(window, cx).masked(true)); + let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); + + let error = cx.new(|_| None); + let countdown = cx.new(|_| None); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Subscribe to key input events and process login when the user presses enter + cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.login(window, cx); + }; + }), + ); + + Self { + key_input, + pass_input, + error, + countdown, + name: "Import".into(), + focus_handle: cx.focus_handle(), + logging_in: false, + _subscriptions: subscriptions, + } + } + + fn login(&mut self, window: &mut Window, cx: &mut Context) { + if self.logging_in { + return; + }; + // Prevent duplicate login requests + self.set_logging_in(true, cx); + + let value = self.key_input.read(cx).value(); + let password = self.pass_input.read(cx).value(); + + if value.starts_with("bunker://") { + self.login_with_bunker(&value, window, cx); + return; + } + + if value.starts_with("ncryptsec1") { + self.login_with_password(&value, &password, window, cx); + return; + } + + if let Ok(secret) = SecretKey::parse(&value) { + let keys = Keys::new(secret); + let nostr = NostrRegistry::global(cx); + // Update the signer + nostr.update(cx, |this, cx| { + this.set_signer(keys, true, cx); + }); + // Close the current panel after setting the signer + window.dispatch_action(Box::new(ClosePanel), cx); + } else { + self.set_error("Invalid", cx); + } + } + + fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context) { + let Ok(uri) = NostrConnectUri::parse(content) else { + self.set_error("Bunker is not valid", cx); + return; + }; + + let nostr = NostrRegistry::global(cx); + let weak_state = nostr.downgrade(); + + let app_keys = nostr.read(cx).app_keys(); + let timeout = Duration::from_secs(30); + let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap(); + + // Handle auth url with the default browser + signer.auth_url_handler(CoopAuthUrlHandler); + + // Start countdown + cx.spawn_in(window, async move |this, cx| { + for i in (0..=30).rev() { + if i == 0 { + this.update(cx, |this, cx| { + this.set_countdown(None, cx); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + this.set_countdown(Some(i), cx); + }) + .ok(); + } + cx.background_executor().timer(Duration::from_secs(1)).await; + } + }) + .detach(); + + // Handle connection + cx.spawn_in(window, async move |_this, cx| { + let result = signer.bunker_uri().await; + + weak_state + .update_in(cx, |this, window, cx| { + match result { + Ok(uri) => { + this.persist_bunker(uri, cx); + this.set_signer(signer, true, cx); + // Close the current panel after setting the signer + window.dispatch_action(Box::new(ClosePanel), cx); + } + Err(e) => { + window.push_notification(Notification::error(e.to_string()), cx); + } + }; + }) + .ok(); + }) + .detach(); + } + + pub fn login_with_password( + &mut self, + content: &str, + pwd: &str, + window: &mut Window, + cx: &mut Context, + ) { + if pwd.is_empty() { + self.set_error("Password is required", cx); + return; + } + + let Ok(enc) = EncryptedSecretKey::from_bech32(content) else { + self.set_error("Secret Key is invalid", cx); + return; + }; + + let password = pwd.to_owned(); + + // Decrypt in the background to ensure it doesn't block the UI + let task = cx.background_spawn(async move { + if let Ok(content) = enc.decrypt(&password) { + Ok(Keys::new(content)) + } else { + Err(anyhow!("Invalid password")) + } + }); + + cx.spawn_in(window, async move |this, cx| { + let result = task.await; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(keys) => { + let nostr = NostrRegistry::global(cx); + // Update the signer + nostr.update(cx, |this, cx| { + this.set_signer(keys, true, cx); + }); + // Close the current panel after setting the signer + window.dispatch_action(Box::new(ClosePanel), cx); + } + Err(e) => { + this.set_error(e.to_string(), cx); + } + }; + }) + .ok(); + }) + .detach(); + } + + fn set_error(&mut self, message: S, cx: &mut Context) + where + S: Into, + { + // Reset the log in state + self.set_logging_in(false, cx); + + // Reset the countdown + self.set_countdown(None, cx); + + // Update error message + self.error.update(cx, |this, cx| { + *this = Some(message.into()); + cx.notify(); + }); + + // Clear the error message after 3 secs + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(3)).await; + + this.update(cx, |this, cx| { + this.error.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); + }) + .ok(); + }) + .detach(); + } + + fn set_logging_in(&mut self, status: bool, cx: &mut Context) { + self.logging_in = status; + cx.notify(); + } + + fn set_countdown(&mut self, i: Option, cx: &mut Context) { + self.countdown.update(cx, |this, cx| { + *this = i; + cx.notify(); + }); + } +} + +impl Panel for ImportPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for ImportPanel {} + +impl Focusable for ImportPanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ImportPanel { + fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + const SECRET_WARN: &str = "* Coop doesn't store your secret key. \ + It will be cleared when you close the app. \ + To persist your identity, please connect via Nostr Connect."; + + v_flex() + .size_full() + .items_center() + .justify_center() + .p_2() + .gap_10() + .child( + div() + .text_center() + .font_semibold() + .line_height(relative(1.25)) + .child(SharedString::from("Import a Secret Key or Bunker")), + ) + .child( + v_flex() + .w_112() + .gap_2() + .text_sm() + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child("nsec or bunker://") + .child(TextInput::new(&self.key_input)), + ) + .when( + self.key_input.read(cx).value().starts_with("ncryptsec1"), + |this| { + this.child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child("Password:") + .child(TextInput::new(&self.pass_input)), + ) + }, + ) + .child( + Button::new("login") + .label("Continue") + .primary() + .loading(self.logging_in) + .disabled(self.logging_in) + .on_click(cx.listener(move |this, _, window, cx| { + this.login(window, cx); + })), + ) + .when_some(self.countdown.read(cx).as_ref(), |this, i| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().text_muted) + .child(SharedString::from(format!( + "Approve connection request from your signer in {} seconds", + i + ))), + ) + }) + .when_some(self.error.read(cx).as_ref(), |this, error| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().danger_foreground) + .child(error.clone()), + ) + }) + .child( + div() + .mt_2() + .italic() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(SECRET_WARN)), + ), + ) + } +} diff --git a/crates/coop/src/panels/messaging_relays.rs b/crates/coop/src/panels/messaging_relays.rs new file mode 100644 index 0000000..b976b6c --- /dev/null +++ b/crates/coop/src/panels/messaging_relays.rs @@ -0,0 +1,348 @@ +use std::collections::HashSet; +use std::time::Duration; + +use anyhow::{anyhow, Context as AnyhowContext, Error}; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, + FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + Styled, Subscription, Task, TextAlign, UniformList, Window, +}; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| MessagingRelayPanel::new(window, cx)) +} + +#[derive(Debug)] +pub struct MessagingRelayPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Relay URL input + input: Entity, + + /// Error message + error: Option, + + // All relays + relays: HashSet, + + // Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, + + // Background tasks + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl MessagingRelayPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let mut subscriptions = smallvec![]; + let mut tasks = smallvec![]; + + tasks.push( + // Load user's relays in the local database + cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { Self::load(&client).await }) + .await; + + if let Ok(relays) = result { + this.update(cx, |this, cx| { + this.relays.extend(relays); + cx.notify(); + }) + .ok(); + } + }), + ); + + subscriptions.push( + // Subscribe to user's input events + cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.add(window, cx); + } + }), + ); + + Self { + name: "Update Messaging Relays".into(), + focus_handle: cx.focus_handle(), + input, + relays: HashSet::new(), + error: None, + _subscriptions: subscriptions, + _tasks: tasks, + } + } + + async fn load(client: &Client) -> Result, Error> { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + Ok(nip17::extract_owned_relay_list(event).collect()) + } else { + Err(anyhow!("Not found.")) + } + } + + fn add(&mut self, window: &mut Window, cx: &mut Context) { + let value = self.input.read(cx).value().to_string(); + + if !value.starts_with("ws") { + self.set_error("Relay URl is invalid", window, cx); + return; + } + + if let Ok(url) = RelayUrl::parse(&value) { + if !self.relays.insert(url) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + cx.notify(); + } + } else { + self.set_error("Relay URl is invalid", window, cx); + } + } + + fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { + self.relays.remove(url); + cx.notify(); + } + + fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) + where + E: Into, + { + self.error = Some(error.into()); + cx.notify(); + + cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + // Clear the error message after a delay + this.update(cx, |this, cx| { + this.error = None; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context) { + if self.relays.is_empty() { + self.set_error("You need to add at least 1 relay", window, cx); + return; + }; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let tags: Vec = self + .relays + .iter() + .map(|relay| Tag::relay(relay.clone())) + .collect(); + + let task: Task> = cx.background_spawn(async move { + // Construct nip17 event builder + let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags); + let event = client.sign_event_builder(builder).await?; + + // Set messaging relays + client.send_event(&event).to_nip65().await?; + + Ok(()) + }); + + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + // TODO + } + Err(e) => { + this.update_in(cx, |this, window, cx| { + this.set_error(e.to_string(), window, cx); + }) + .ok(); + } + }; + }) + .detach(); + } + + fn render_list(&mut self, _window: &mut Window, cx: &mut Context) -> UniformList { + let relays = self.relays.clone(); + let total = relays.len(); + + uniform_list( + "relays", + total, + cx.processor(move |_v, range, _window, cx| { + let mut items = Vec::new(); + + for ix in range { + let Some(url) = relays.iter().nth(ix) else { + continue; + }; + + items.push( + div() + .id(SharedString::from(url.to_string())) + .group("") + .w_full() + .h_9() + .py_0p5() + .child( + h_flex() + .px_2() + .flex() + .justify_between() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child( + div().text_sm().child(SharedString::from(url.to_string())), + ) + .child( + Button::new("remove_{ix}") + .icon(IconName::Close) + .xsmall() + .ghost() + .invisible() + .group_hover("", |this| this.visible()) + .on_click({ + let url = url.to_owned(); + cx.listener(move |this, _ev, _window, cx| { + this.remove(&url, cx); + }) + }), + ), + ), + ) + } + + items + }), + ) + .h_full() + } + + fn render_empty(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .mt_2() + .h_20() + .justify_center() + .border_2() + .border_dashed() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .text_sm() + .text_align(TextAlign::Center) + .child(SharedString::from("Please add some relays.")) + } +} + +impl Panel for MessagingRelayPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for MessagingRelayPanel {} + +impl Focusable for MessagingRelayPanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for MessagingRelayPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_2() + .gap_10() + .child( + div() + .text_center() + .font_semibold() + .line_height(relative(1.25)) + .child(SharedString::from("Update Messaging Relays")), + ) + .child( + v_flex() + .w_112() + .gap_2() + .text_sm() + .child( + v_flex() + .gap_1p5() + .child( + h_flex() + .gap_1() + .w_full() + .child(TextInput::new(&self.input).small()) + .child( + Button::new("add") + .icon(IconName::Plus) + .label("Add") + .ghost() + .on_click(cx.listener(move |this, _, window, cx| { + this.add(window, cx); + })), + ), + ) + .when_some(self.error.as_ref(), |this, error| { + this.child( + div() + .italic() + .text_xs() + .text_color(cx.theme().danger_foreground) + .child(error.clone()), + ) + }), + ) + .map(|this| { + if !self.relays.is_empty() { + this.child(self.render_list(window, cx)) + } else { + this.child(self.render_empty(window, cx)) + } + }) + .child(divider(cx)) + .child( + Button::new("submit") + .label("Update") + .primary() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.set_relays(window, cx); + })), + ), + ) + } +} diff --git a/crates/coop/src/panels/mod.rs b/crates/coop/src/panels/mod.rs new file mode 100644 index 0000000..7e6e20a --- /dev/null +++ b/crates/coop/src/panels/mod.rs @@ -0,0 +1,6 @@ +pub mod connect; +pub mod greeter; +pub mod import; +pub mod messaging_relays; +pub mod profile; +pub mod relay_list; diff --git a/crates/coop/src/user/mod.rs b/crates/coop/src/panels/profile.rs similarity index 51% rename from crates/coop/src/user/mod.rs rename to crates/coop/src/panels/profile.rs index cfee59a..7152698 100644 --- a/crates/coop/src/user/mod.rs +++ b/crates/coop/src/panels/profile.rs @@ -3,33 +3,36 @@ use std::time::Duration; use anyhow::{anyhow, Error}; use common::{nip96_upload, shorten_pubkey}; -use gpui::prelude::FluentBuilder; use gpui::{ - div, img, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement, - PathPromptOptions, Render, SharedString, Styled, Task, Window, + div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, + FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, + Styled, Task, Window, }; use gpui_tokio::Tokio; use nostr_sdk::prelude::*; -use person::Person; +use person::{Person, PersonRegistry}; use settings::AppSettings; -use smallvec::{smallvec, SmallVec}; use smol::fs; use state::NostrRegistry; use theme::ActiveTheme; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; +use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputState, TextInput}; -use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt}; +use ui::notification::Notification; +use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension}; -pub mod viewer; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| UserProfile::new(window, cx)) +pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| ProfilePanel::new(public_key, window, cx)) } #[derive(Debug)] -pub struct UserProfile { - /// User profile - profile: Option, +pub struct ProfilePanel { + name: SharedString, + focus_handle: FocusHandle, + + /// User's public key + public_key: PublicKey, /// User's name text input name_input: Entity, @@ -48,17 +51,13 @@ pub struct UserProfile { /// Copied states copied: bool, - - /// Async operations - _tasks: SmallVec<[Task<()>; 1]>, } -impl UserProfile { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { +impl ProfilePanel { + fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); - let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg")); let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me")); - + let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg")); // Use multi-line input for bio let bio_input = cx.new(|cx| { InputState::new(window, cx) @@ -67,53 +66,29 @@ impl UserProfile { .placeholder("A short introduce about you.") }); - let get_profile = Self::get_profile(cx); - let mut tasks = smallvec![]; - - tasks.push( - // Get metadata in the background - cx.spawn_in(window, async move |this, cx| { - if let Ok(profile) = get_profile.await { - this.update_in(cx, |this, window, cx| { - this.set_profile(profile, window, cx); - }) - .ok(); - } - }), - ); + // Get user's profile and update inputs + cx.defer_in(window, move |this, window, cx| { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(&public_key, cx); + // Set all input's values with current profile + this.set_profile(profile, window, cx); + }); Self { - profile: None, + name: "Update Profile".into(), + focus_handle: cx.focus_handle(), + public_key, name_input, avatar_input, bio_input, website_input, uploading: false, copied: false, - _tasks: tasks, } } - fn get_profile(cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let metadata = client - .database() - .metadata(public_key) - .await? - .unwrap_or_default(); - - Ok(Profile::new(public_key, metadata)) - }) - } - - fn set_profile(&mut self, profile: Profile, window: &mut Window, cx: &mut Context) { - let metadata = profile.metadata(); + fn set_profile(&mut self, person: Person, window: &mut Window, cx: &mut Context) { + let metadata = person.metadata(); self.avatar_input.update(cx, |this, cx| { if let Some(avatar) = metadata.picture.as_ref() { @@ -138,9 +113,6 @@ impl UserProfile { this.set_value(website, window, cx); } }); - - self.profile = Some(profile); - cx.notify(); } fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context) { @@ -155,19 +127,19 @@ impl UserProfile { cx.notify(); if status { - self._tasks.push( + cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + // Reset the copied state after a delay - cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - cx.update(|window, cx| { - this.update(cx, |this, cx| { - this.set_copied(false, window, cx); - }) - .ok(); + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.set_copied(false, window, cx); }) .ok(); - }), - ); + }) + .ok(); + }) + .detach(); } } @@ -233,149 +205,197 @@ impl UserProfile { .detach(); } - pub fn set_metadata(&mut self, cx: &mut Context) -> Task> { - let avatar = self.avatar_input.read(cx).value().to_string(); - let name = self.name_input.read(cx).value().to_string(); - let bio = self.bio_input.read(cx).value().to_string(); - let website = self.website_input.read(cx).value().to_string(); + /// Set the metadata for the current user + fn publish(&self, metadata: &Metadata, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let metadata = metadata.clone(); - // Get the current profile metadata - let old_metadata = self - .profile - .as_ref() - .map(|profile| profile.metadata()) - .unwrap_or_default(); + cx.background_spawn(async move { + // Build and sign the metadata event + let builder = EventBuilder::metadata(&metadata); + let event = client.sign_event_builder(builder).await?; + + // Send event to user's relays + client.send_event(&event).await?; + + Ok(()) + }) + } + + fn update(&mut self, window: &mut Window, cx: &mut Context) { + let persons = PersonRegistry::global(cx); + let public_key = self.public_key; + let old_metadata = persons.read(cx).get(&public_key, cx).metadata(); + + // Extract all new metadata fields + let avatar = self.avatar_input.read(cx).value(); + let name = self.name_input.read(cx).value(); + let bio = self.bio_input.read(cx).value(); + let website = self.website_input.read(cx).value(); // Construct the new metadata - let mut new_metadata = old_metadata.display_name(name).about(bio); + let mut new_metadata = old_metadata + .display_name(name.as_ref()) + .name(name.as_ref()) + .about(bio.as_ref()); + // Verify the avatar URL before adding it if let Ok(url) = Url::from_str(&avatar) { new_metadata = new_metadata.picture(url); - }; + } + // Verify the website URL before adding it if let Ok(url) = Url::from_str(&website) { new_metadata = new_metadata.website(url); } - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); - let write_relays = nostr.read(cx).write_relays(&public_key, cx); + // Set the metadata + let task = self.publish(&new_metadata, cx); - cx.background_spawn(async move { - let urls = write_relays.await; - let signer = client.signer().await?; - - // Sign the new metadata event - let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?; - - // Send event to user's write relayss - client.send_event_to(urls, &event).await?; - - // Return the updated profile - let metadata = Metadata::from_json(&event.content).unwrap_or_default(); - let profile = Person::new(event.pubkey, metadata); - - Ok(profile) + cx.spawn_in(window, async move |_this, cx| { + match task.await { + Ok(_) => { + cx.update(|window, cx| { + persons.update(cx, |this, cx| { + this.insert(Person::new(public_key, new_metadata), cx); + }); + window.push_notification("Profile updated successfully", cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } + }; }) + .detach(); } } -impl Render for UserProfile { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .gap_3() - .child( - v_flex() - .relative() - .w_full() - .h_32() - .items_center() - .justify_center() - .gap_2() - .bg(cx.theme().surface_background) - .rounded(cx.theme().radius) - .map(|this| { - let picture = self.avatar_input.read(cx).value(); - let source = if picture.is_empty() { - "brand/avatar.png" - } else { - picture.as_str() - }; - this.child(img(source).rounded_full().size_10().flex_shrink_0()) - }) - .child( - Button::new("upload") - .icon(IconName::Upload) - .label("Change") - .ghost() - .small() - .disabled(self.uploading) - .on_click(cx.listener(move |this, _, window, cx| { - this.upload(window, cx); - })), - ), - ) - .child( - v_flex() - .gap_1() - .text_sm() - .child(SharedString::from("Name:")) - .child(TextInput::new(&self.name_input).small()), - ) - .child( - v_flex() - .gap_1() - .text_sm() - .child(SharedString::from("Bio:")) - .child(TextInput::new(&self.bio_input).small()), - ) - .child( - v_flex() - .gap_1() - .text_sm() - .child(SharedString::from("Website:")) - .child(TextInput::new(&self.website_input).small()), - ) - .when_some(self.profile.as_ref(), |this, profile| { - let public_key = profile.public_key(); - let display = SharedString::from(shorten_pubkey(profile.public_key(), 8)); +impl Panel for ProfilePanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } - this.child(div().my_1().h_px().w_full().bg(cx.theme().border)) + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for ProfilePanel {} + +impl Focusable for ProfilePanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ProfilePanel { + fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); + + // Get the avatar + let avatar_input = self.avatar_input.read(cx).value(); + let avatar = if avatar_input.is_empty() { + "brand/avatar.png" + } else { + avatar_input.as_str() + }; + + v_flex() + .size_full() + .items_center() + .justify_center() + .p_2() + .child( + v_flex() + .gap_2() + .w_112() + .child( + v_flex() + .h_40() + .w_full() + .items_center() + .justify_center() + .gap_4() + .child(Avatar::new(avatar).size(rems(4.25))) + .child( + Button::new("upload") + .icon(IconName::PlusCircle) + .label("Add an avatar") + .xsmall() + .ghost() + .rounded() + .disabled(self.uploading) + .loading(self.uploading) + .on_click(cx.listener(move |this, _, window, cx| { + this.upload(window, cx); + })), + ), + ) + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child(SharedString::from("What should people call you?")) + .child(TextInput::new(&self.name_input).small()), + ) + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child(SharedString::from("A short introduction about you:")) + .child(TextInput::new(&self.bio_input).small()), + ) + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Website:")) + .child(TextInput::new(&self.website_input).small()), + ) + .child(divider(cx)) .child( v_flex() .gap_1() .child( div() + .font_semibold() .text_xs() .text_color(cx.theme().text_placeholder) - .font_semibold() .child(SharedString::from("Public Key:")), ) .child( h_flex() - .gap_2() + .h_8() .w_full() - .h_12() .justify_center() + .gap_2() .bg(cx.theme().surface_background) .rounded(cx.theme().radius) .text_sm() - .child(display) + .child(shorten_pkey) .child( Button::new("copy") .icon({ if self.copied { - IconName::CheckCircleFill + IconName::CheckCircle } else { IconName::Copy } }) .xsmall() .ghost() - .on_click(cx.listener(move |this, _e, window, cx| { + .on_click(cx.listener(move |this, _ev, window, cx| { this.copy( - public_key.to_bech32().unwrap(), + this.public_key.to_bech32().unwrap(), window, cx, ); @@ -383,6 +403,16 @@ impl Render for UserProfile { ), ), ) - }) + .child(divider(cx)) + .child( + Button::new("submit") + .label("Update") + .primary() + .disabled(self.uploading) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.update(window, cx); + })), + ), + ) } } diff --git a/crates/coop/src/panels/relay_list.rs b/crates/coop/src/panels/relay_list.rs new file mode 100644 index 0000000..3ee0790 --- /dev/null +++ b/crates/coop/src/panels/relay_list.rs @@ -0,0 +1,365 @@ +use std::collections::HashSet; +use std::time::Duration; + +use anyhow::{anyhow, Context as AnyhowContext, Error}; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, + FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + Styled, Subscription, Task, TextAlign, UniformList, Window, +}; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; +use state::{NostrRegistry, BOOTSTRAP_RELAYS}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| RelayListPanel::new(window, cx)) +} + +#[derive(Debug)] +pub struct RelayListPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Relay URL input + input: Entity, + + /// Relay metadata input + metadata: Entity>, + + /// Error message + error: Option, + + // All relays + relays: HashSet<(RelayUrl, Option)>, + + // Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, + + // Background tasks + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl RelayListPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); + let metadata = cx.new(|_| None); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let mut subscriptions = smallvec![]; + let mut tasks = smallvec![]; + + tasks.push( + // Load user's relays in the local database + cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { Self::load(&client).await }) + .await; + + if let Ok(relays) = result { + this.update(cx, |this, cx| { + this.relays.extend(relays); + cx.notify(); + }) + .ok(); + } + }), + ); + + subscriptions.push( + // Subscribe to user's input events + cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.add(window, cx); + } + }), + ); + + Self { + name: "Update Relay List".into(), + focus_handle: cx.focus_handle(), + input, + metadata, + relays: HashSet::new(), + error: None, + _subscriptions: subscriptions, + _tasks: tasks, + } + } + + async fn load(client: &Client) -> Result)>, Error> { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + Ok(nip65::extract_owned_relay_list(event).collect()) + } else { + Err(anyhow!("Not found.")) + } + } + + fn add(&mut self, window: &mut Window, cx: &mut Context) { + let value = self.input.read(cx).value().to_string(); + let metadata = self.metadata.read(cx); + + if !value.starts_with("ws") { + self.set_error("Relay URl is invalid", window, cx); + return; + } + + if let Ok(url) = RelayUrl::parse(&value) { + if !self.relays.insert((url, metadata.to_owned())) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + cx.notify(); + } + } else { + self.set_error("Relay URl is invalid", window, cx); + } + } + + fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { + self.relays.retain(|(relay, _)| relay != url); + cx.notify(); + } + + fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) + where + E: Into, + { + self.error = Some(error.into()); + cx.notify(); + + cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + // Clear the error message after a delay + this.update(cx, |this, cx| { + this.error = None; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context) { + if self.relays.is_empty() { + self.set_error("You need to add at least 1 relay", window, cx); + return; + }; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let relays = self.relays.clone(); + + let task: Task> = cx.background_spawn(async move { + let builder = EventBuilder::relay_list(relays); + let event = client.sign_event_builder(builder).await?; + + // Set relay list for current user + client.send_event(&event).to(BOOTSTRAP_RELAYS).await?; + + Ok(()) + }); + + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + // TODO + } + Err(e) => { + this.update_in(cx, |this, window, cx| { + this.set_error(e.to_string(), window, cx); + }) + .ok(); + } + }; + }) + .detach(); + } + + fn render_list(&mut self, _window: &mut Window, cx: &mut Context) -> UniformList { + let relays = self.relays.clone(); + let total = relays.len(); + + uniform_list( + "relays", + total, + cx.processor(move |_v, range, _window, cx| { + let mut items = Vec::new(); + + for ix in range { + let Some((url, metadata)) = relays.iter().nth(ix) else { + continue; + }; + + items.push( + div() + .id(SharedString::from(url.to_string())) + .group("") + .w_full() + .h_9() + .py_0p5() + .child( + h_flex() + .px_2() + .flex() + .justify_between() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child( + div().text_sm().child(SharedString::from(url.to_string())), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .map(|this| { + if let Some(metadata) = metadata { + this.child(SharedString::from( + metadata.to_string(), + )) + } else { + this.child(SharedString::from("Read+Write")) + } + }) + .child( + Button::new("remove_{ix}") + .icon(IconName::Close) + .xsmall() + .ghost() + .invisible() + .group_hover("", |this| this.visible()) + .on_click({ + let url = url.to_owned(); + cx.listener( + move |this, _ev, _window, cx| { + this.remove(&url, cx); + }, + ) + }), + ), + ), + ), + ) + } + + items + }), + ) + .h_full() + } + + fn render_empty(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .mt_2() + .h_20() + .justify_center() + .border_2() + .border_dashed() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .text_sm() + .text_align(TextAlign::Center) + .child(SharedString::from("Please add some relays.")) + } +} + +impl Panel for RelayListPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for RelayListPanel {} + +impl Focusable for RelayListPanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RelayListPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_2() + .gap_10() + .child( + div() + .text_center() + .font_semibold() + .line_height(relative(1.25)) + .child(SharedString::from("Update Relay List")), + ) + .child( + v_flex() + .w_112() + .gap_2() + .text_sm() + .child( + v_flex() + .gap_1p5() + .child( + h_flex() + .gap_1() + .w_full() + .child(TextInput::new(&self.input).small()) + .child( + Button::new("add") + .icon(IconName::Plus) + .label("Add") + .ghost() + .on_click(cx.listener(move |this, _, window, cx| { + this.add(window, cx); + })), + ), + ) + .when_some(self.error.as_ref(), |this, error| { + this.child( + div() + .italic() + .text_xs() + .text_color(cx.theme().danger_foreground) + .child(error.clone()), + ) + }), + ) + .map(|this| { + if !self.relays.is_empty() { + this.child(self.render_list(window, cx)) + } else { + this.child(self.render_empty(window, cx)) + } + }) + .child(divider(cx)) + .child( + Button::new("submit") + .label("Update") + .primary() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.set_relays(window, cx); + })), + ), + ) + } +} diff --git a/crates/coop/src/sidebar/entry.rs b/crates/coop/src/sidebar/entry.rs new file mode 100644 index 0000000..2a4b73d --- /dev/null +++ b/crates/coop/src/sidebar/entry.rs @@ -0,0 +1,182 @@ +use std::rc::Rc; + +use chat::RoomKind; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, +}; +use nostr_sdk::prelude::*; +use settings::AppSettings; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::dock_area::ClosePanel; +use ui::modal::ModalButtonProps; +use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension}; + +use crate::dialogs::screening; + +#[derive(IntoElement)] +pub struct RoomEntry { + ix: usize, + public_key: Option, + name: Option, + avatar: Option, + created_at: Option, + kind: Option, + selected: bool, + #[allow(clippy::type_complexity)] + handler: Option>, +} + +impl RoomEntry { + pub fn new(ix: usize) -> Self { + Self { + ix, + public_key: None, + name: None, + avatar: None, + created_at: None, + kind: None, + handler: None, + selected: false, + } + } + + pub fn public_key(mut self, public_key: PublicKey) -> Self { + self.public_key = Some(public_key); + self + } + + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + pub fn avatar(mut self, avatar: impl Into) -> Self { + self.avatar = Some(avatar.into()); + self + } + + pub fn created_at(mut self, created_at: impl Into) -> Self { + self.created_at = Some(created_at.into()); + self + } + + pub fn kind(mut self, kind: RoomKind) -> Self { + self.kind = Some(kind); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.handler = Some(Rc::new(handler)); + self + } +} + +impl Selectable for RoomEntry { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl RenderOnce for RoomEntry { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let hide_avatar = AppSettings::get_hide_avatar(cx); + let screening = AppSettings::get_screening(cx); + + let public_key = self.public_key; + let is_selected = self.is_selected(); + + h_flex() + .id(self.ix) + .h_9() + .w_full() + .px_1p5() + .gap_2() + .text_sm() + .rounded(cx.theme().radius) + .when(!hide_avatar, |this| { + this.when_some(self.avatar, |this, avatar| { + this.child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .child(Avatar::new(avatar).size(rems(1.5))), + ) + }) + }) + .child( + div() + .flex_1() + .flex() + .items_center() + .justify_between() + .when_some(self.name, |this, name| { + this.child( + h_flex() + .flex_1() + .justify_between() + .line_clamp(1) + .text_ellipsis() + .truncate() + .font_medium() + .child(name) + .when(is_selected, |this| { + this.child( + Icon::new(IconName::CheckCircle) + .small() + .text_color(cx.theme().icon_accent), + ) + }), + ) + }) + .child( + h_flex() + .gap_1p5() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().text_placeholder) + .when_some(self.created_at, |this, created_at| this.child(created_at)), + ), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .when_some(self.handler, |this, handler| { + this.on_click(move |event, window, cx| { + handler(event, window, cx); + + if let Some(public_key) = public_key { + if self.kind != Some(RoomKind::Ongoing) && screening { + let screening = screening::init(public_key, window, cx); + + window.open_modal(cx, move |this, _window, _cx| { + this.confirm() + .child(screening.clone()) + .button_props( + ModalButtonProps::default() + .cancel_text("Ignore") + .ok_text("Response"), + ) + .on_cancel(move |_event, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + // Prevent closing the modal on click + // modal will be automatically closed after closing panel + false + }) + }); + } + } + }) + }) + } +} diff --git a/crates/coop/src/sidebar/list_item.rs b/crates/coop/src/sidebar/list_item.rs deleted file mode 100644 index a018f38..0000000 --- a/crates/coop/src/sidebar/list_item.rs +++ /dev/null @@ -1,199 +0,0 @@ -use std::rc::Rc; - -use chat::{ChatRegistry, RoomKind}; -use chat_ui::{CopyPublicKey, OpenPublicKey}; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, -}; -use nostr_sdk::prelude::*; -use settings::AppSettings; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::context_menu::ContextMenuExt; -use ui::modal::ModalButtonProps; -use ui::skeleton::Skeleton; -use ui::{h_flex, ContextModal, StyledExt}; - -use crate::views::screening; - -#[derive(IntoElement)] -pub struct RoomListItem { - ix: usize, - room_id: Option, - public_key: Option, - name: Option, - avatar: Option, - created_at: Option, - kind: Option, - #[allow(clippy::type_complexity)] - handler: Option>, -} - -impl RoomListItem { - pub fn new(ix: usize) -> Self { - Self { - ix, - room_id: None, - public_key: None, - name: None, - avatar: None, - created_at: None, - kind: None, - handler: None, - } - } - - pub fn room_id(mut self, room_id: u64) -> Self { - self.room_id = Some(room_id); - self - } - - pub fn public_key(mut self, public_key: PublicKey) -> Self { - self.public_key = Some(public_key); - self - } - - pub fn name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - pub fn avatar(mut self, avatar: impl Into) -> Self { - self.avatar = Some(avatar.into()); - self - } - - pub fn created_at(mut self, created_at: impl Into) -> Self { - self.created_at = Some(created_at.into()); - self - } - - pub fn kind(mut self, kind: RoomKind) -> Self { - self.kind = Some(kind); - self - } - - pub fn on_click( - mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.handler = Some(Rc::new(handler)); - self - } -} - -impl RenderOnce for RoomListItem { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let hide_avatar = AppSettings::get_hide_avatar(cx); - let screening = AppSettings::get_screening(cx); - - let ( - Some(public_key), - Some(room_id), - Some(name), - Some(avatar), - Some(created_at), - Some(kind), - Some(handler), - ) = ( - self.public_key, - self.room_id, - self.name, - self.avatar, - self.created_at, - self.kind, - self.handler, - ) - else { - return h_flex() - .id(self.ix) - .h_9() - .w_full() - .px_1p5() - .gap_2() - .child(Skeleton::new().flex_shrink_0().size_6().rounded_full()) - .child( - div() - .flex_1() - .flex() - .justify_between() - .child(Skeleton::new().w_32().h_2p5().rounded(cx.theme().radius)) - .child(Skeleton::new().w_6().h_2p5().rounded(cx.theme().radius)), - ); - }; - - h_flex() - .id(self.ix) - .h_9() - .w_full() - .px_1p5() - .gap_2() - .text_sm() - .rounded(cx.theme().radius) - .when(!hide_avatar, |this| { - this.child( - div() - .flex_shrink_0() - .size_6() - .rounded_full() - .overflow_hidden() - .child(Avatar::new(avatar).size(rems(1.5))), - ) - }) - .child( - div() - .flex_1() - .flex() - .items_center() - .justify_between() - .child( - div() - .flex_1() - .line_clamp(1) - .text_ellipsis() - .truncate() - .font_medium() - .child(name), - ) - .child( - div() - .flex_shrink_0() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(created_at), - ), - ) - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .context_menu(move |this, _window, _cx| { - this.menu("View Profile", Box::new(OpenPublicKey(public_key))) - .menu("Copy Public Key", Box::new(CopyPublicKey(public_key))) - }) - .on_click(move |event, window, cx| { - handler(event, window, cx); - - if kind != RoomKind::Ongoing && screening { - let screening = screening::init(public_key, window, cx); - - window.open_modal(cx, move |this, _window, _cx| { - this.confirm() - .child(screening.clone()) - .button_props( - ModalButtonProps::default() - .cancel_text("Ignore") - .ok_text("Response"), - ) - .on_cancel(move |_event, _window, cx| { - ChatRegistry::global(cx).update(cx, |this, cx| { - this.close_room(room_id, cx); - }); - // false to prevent closing the modal - // modal will be closed after closing panel - false - }) - }); - } - }) - } -} diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index bfb823b..63496d9 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -1,33 +1,36 @@ +use std::collections::HashSet; use std::ops::Range; use std::time::Duration; -use anyhow::{anyhow, Error}; +use anyhow::{Context as AnyhowContext, Error}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; +use common::{DebouncedDelay, RenderedTimestamp}; +use entry::RoomEntry; use gpui::prelude::FluentBuilder; use gpui::{ - deferred, div, relative, uniform_list, App, AppContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, - RetainAllImageCache, SharedString, Styled, Subscription, Task, Window, + div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, + Task, UniformListScrollHandle, Window, }; -use gpui_tokio::Tokio; -use list_item::RoomListItem; use nostr_sdk::prelude::*; +use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; -use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION}; -use theme::ActiveTheme; +use state::{NostrRegistry, FIND_DELAY}; +use theme::{ActiveTheme, TITLEBAR_HEIGHT}; use ui::button::{Button, ButtonVariants}; +use ui::divider::Divider; use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::indicator::Indicator; use ui::input::{InputEvent, InputState, TextInput}; -use ui::popup_menu::PopupMenuExt; -use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Selectable, Sizable, StyledExt}; +use ui::notification::Notification; +use ui::scroll::Scrollbar; +use ui::{ + h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, +}; -use crate::actions::{RelayStatus, Reload}; +mod entry; -mod list_item; - -const FIND_DELAY: u64 = 600; -const FIND_LIMIT: usize = 20; +const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Sidebar::new(window, cx)) @@ -36,67 +39,69 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { /// Sidebar. pub struct Sidebar { name: SharedString, - - /// Focus handle for the sidebar focus_handle: FocusHandle, + scroll_handle: UniformListScrollHandle, /// Image cache image_cache: Entity, - /// Search results - search_results: Entity>>>, - - /// Async search operation - search_task: Option>, - - /// Search input state + /// Find input state find_input: Entity, - /// Debounced delay for search input + /// Debounced delay for find input find_debouncer: DebouncedDelay, - /// Whether searching is in progress + /// Whether a search is in progress finding: bool, - /// New request flag - new_request: bool, + /// Whether the find input is focused + find_focused: bool, - /// Current chat room filter - active_filter: Entity, + /// Find results + find_results: Entity>>, + + /// Async find operation + find_task: Option>>, + + /// Whether there are search results + has_search: bool, + + /// Whether there are new chat requests + new_requests: bool, + + /// Selected public keys + selected_pkeys: Entity>, + + /// Chatroom filter + filter: Entity, + + /// User's contacts + contact_list: Entity>>, + + /// Async tasks + tasks: SmallVec<[Task>; 1]>, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, + _subscriptions: SmallVec<[Subscription; 1]>, } impl Sidebar { fn new(window: &mut Window, cx: &mut Context) -> Self { - let active_filter = cx.new(|_| RoomKind::Ongoing); - let search_results = cx.new(|_| None); - - // Define the find input state + let chat = ChatRegistry::global(cx); + let filter = cx.new(|_| RoomKind::Ongoing); + let contact_list = cx.new(|_| None); + let selected_pkeys = cx.new(|_| HashSet::new()); + let find_results = cx.new(|_| None); let find_input = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Find or start a conversation") + .placeholder(INPUT_PLACEHOLDER) .clean_on_escape() }); - // Get the chat registry - let chat = ChatRegistry::global(cx); - let mut subscriptions = smallvec![]; subscriptions.push( - // Subscribe for registry new events - cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { - if event == &ChatEvent::Ping { - this.new_request = true; - cx.notify(); - }; - }), - ); - - subscriptions.push( - // Subscribe for find input events + // Subscribe to find input events cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { let delay = Duration::from_millis(FIND_DELAY); @@ -106,8 +111,8 @@ impl Sidebar { } InputEvent::Change => { if state.read(cx).value().is_empty() { - // Clear the result when input is empty - this.clear(window, cx); + // Clear results when input is empty + this.reset(window, cx); } else { // Run debounced search this.find_debouncer @@ -116,7 +121,23 @@ impl Sidebar { }); } } - _ => {} + InputEvent::Focus => { + this.set_input_focus(window, cx); + this.get_contact_list(window, cx); + } + InputEvent::Blur => { + this.set_input_focus(window, cx); + } + }; + }), + ); + + subscriptions.push( + // Subscribe for registry new events + cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { + if event == &ChatEvent::Ping { + this.new_requests = true; + cx.notify(); }; }), ); @@ -124,72 +145,67 @@ impl Sidebar { Self { name: "Sidebar".into(), focus_handle: cx.focus_handle(), + scroll_handle: UniformListScrollHandle::new(), image_cache: RetainAllImageCache::new(cx), - find_debouncer: DebouncedDelay::new(), - finding: false, - new_request: false, - active_filter, find_input, - search_results, - search_task: None, + find_debouncer: DebouncedDelay::new(), + find_results, + find_task: None, + find_focused: false, + finding: false, + has_search: false, + new_requests: false, + contact_list, + selected_pkeys, + filter, + tasks: smallvec![], _subscriptions: subscriptions, } } - async fn nip50(client: &Client, query: &str) -> Result, Error> { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; + /// Get the contact list. + fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); - let filter = Filter::new() - .kind(Kind::Metadata) - .search(query.to_lowercase()) - .limit(FIND_LIMIT); + let task: Task, Error>> = cx.background_spawn(async move { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + let contacts = client.database().contacts_public_keys(public_key).await?; - let mut stream = client - .stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3)) - .await?; + Ok(contacts) + }); - let mut results: Vec = Vec::with_capacity(FIND_LIMIT); - - while let Some((_url, event)) = stream.next().await { - if let Ok(event) = event { - // Skip if author is match current user - if event.pubkey == public_key { - continue; + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.set_contact_list(contacts, cx); + })?; } - - // Skip if the event has already been added - if results.iter().any(|this| this.pubkey == event.pubkey) { - continue; + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + })?; } + }; - results.push(event); - } - } - - if results.is_empty() { - return Err(anyhow!("No results for query {query}")); - } - - // Get all public keys - let public_keys: Vec = results.iter().map(|event| event.pubkey).collect(); - - // Fetch metadata and contact lists if public keys is not empty - if !public_keys.is_empty() { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new() - .kinds(vec![Kind::Metadata, Kind::ContactList]) - .limit(public_keys.len() * 2) - .authors(public_keys); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - } - - Ok(results) + Ok(()) + })); } + /// Set the contact list with new contacts. + fn set_contact_list(&mut self, contacts: I, cx: &mut Context) + where + I: IntoIterator, + { + self.contact_list.update(cx, |this, cx| { + *this = Some(contacts.into_iter().collect()); + cx.notify(); + }); + } + + /// Trigger the debounced search fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { cx.spawn_in(window, async move |this, cx| { this.update_in(cx, |this, window, cx| { @@ -199,188 +215,40 @@ impl Sidebar { }) } - fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); - - let query = query.to_owned(); - - self.search_task = Some(cx.spawn_in(window, async move |this, cx| { - let result = Self::nip50(&client, &query).await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(results) => { - let rooms = results - .into_iter() - .map(|event| { - cx.new(|_| Room::new(None, public_key, vec![event.pubkey])) - }) - .collect(); - - this.set_results(rooms, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - this.set_finding(false, window, cx); - }) - .ok(); - })); - } - - fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let address = query.to_owned(); - - let task = Tokio::spawn(cx, async move { - match common::nip05_profile(&address).await { - Ok(profile) => { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let receivers = vec![profile.public_key]; - let room = Room::new(None, public_key, receivers); - - Ok(room) - } - Err(e) => Err(anyhow!(e)), - } - }); - - self.search_task = Some(cx.spawn_in(window, async move |this, cx| { - let result = task.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(Ok(room)) => { - this.set_results(vec![cx.new(|_| room)], cx); - } - Ok(Err(e)) => { - window.push_notification(e.to_string(), cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - } - this.set_finding(false, window, cx); - }) - .ok(); - })); - } - - fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let Ok(public_key) = query.to_public_key() else { - window.push_notification("Public Key is invalid", cx); - self.set_finding(false, window, cx); - return; - }; - - let task: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; - let author = signer.get_public_key().await?; - - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let receivers = vec![public_key]; - let room = Room::new(None, author, receivers); - - let filter = Filter::new() - .kinds(vec![Kind::Metadata, Kind::ContactList]) - .author(public_key) - .limit(2); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(room) - }); - - self.search_task = Some(cx.spawn_in(window, async move |this, cx| { - let result = task.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(room) => { - let chat = ChatRegistry::global(cx); - let local_results = chat.read(cx).search_by_public_key(public_key, cx); - - if !local_results.is_empty() { - this.set_results(local_results, cx); - } else { - this.set_results(vec![cx.new(|_| room)], cx); - } - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - this.set_finding(false, window, cx); - }) - .ok(); - })); - } - + /// Search fn search(&mut self, window: &mut Window, cx: &mut Context) { + // Get query + let query = self.find_input.read(cx).value(); + // Return if the query is empty - if self.find_input.read(cx).value().is_empty() { + if query.is_empty() { return; } - // Return if search is in progress - if self.finding { - if self.search_task.is_none() { - window.push_notification("There is another search in progress", cx); - return; - } else { - // Cancel ongoing search request - self.search_task = None; - } - } - - let input = self.find_input.read(cx).value(); - let query = input.to_string(); - - // Block the input until the search process completes + // Block the input until the search completes self.set_finding(true, window, cx); - // Process to search by pubkey if query starts with npub or nprofile - if query.starts_with("npub1") || query.starts_with("nprofile1") { - self.search_by_pubkey(&query, window, cx); - return; - }; + // Create the search task + let nostr = NostrRegistry::global(cx); + let find_users = nostr.read(cx).search(&query, cx); - // Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld) - if query.split('@').count() == 2 { - let parts: Vec<&str> = query.split('@').collect(); - if !parts[0].is_empty() && !parts[1].is_empty() && parts[1].contains('.') { - self.search_by_nip05(&query, window, cx); - return; - } - } + // Run task in the main thread + self.find_task = Some(cx.spawn_in(window, async move |this, cx| { + let rooms = find_users.await?; - // Get all local results with current query - let chat = ChatRegistry::global(cx); - let local_results = chat.read(cx).search(&query, cx); + // Update the UI with the search results + this.update_in(cx, |this, window, cx| { + this.set_results(rooms, cx); + this.set_finding(false, window, cx); + })?; - // Try to update with local results first - if !local_results.is_empty() { - self.set_results(local_results, cx); - return; - }; - - // If no local results, try global search via NIP-50 - self.search_by_nip50(&query, window, cx); + Ok(()) + })); } - fn set_results(&mut self, rooms: Vec>, cx: &mut Context) { - self.search_results.update(cx, |this, cx| { - *this = Some(rooms); + fn set_results(&mut self, results: Vec, cx: &mut Context) { + self.find_results.update(cx, |this, cx| { + *this = Some(results); cx.notify(); }); } @@ -391,185 +259,206 @@ impl Sidebar { this.set_disabled(status, cx); this.set_loading(status, cx); }); - // Set the finding status + // Set the search status self.finding = status; cx.notify(); } - fn clear(&mut self, window: &mut Window, cx: &mut Context) { - // Reset the input state - if self.finding { - self.set_finding(false, window, cx); - } + fn set_input_focus(&mut self, window: &mut Window, cx: &mut Context) { + self.find_focused = !self.find_focused; + cx.notify(); - // Clear all local results - self.search_results.update(cx, |this, cx| { + // Reset the find panel + if !self.find_focused { + self.reset(window, cx); + } + } + + fn reset(&mut self, window: &mut Window, cx: &mut Context) { + // Clear all search results + self.find_results.update(cx, |this, cx| { *this = None; cx.notify(); }); - } - fn filter(&self, kind: &RoomKind, cx: &Context) -> bool { - self.active_filter.read(cx) == kind - } - - fn set_filter(&mut self, kind: RoomKind, cx: &mut Context) { - self.active_filter.update(cx, |this, cx| { - *this = kind; + // Clear all selected public keys + self.selected_pkeys.update(cx, |this, cx| { + this.clear(); cx.notify(); }); - self.new_request = false; + + // Reset the search status + self.set_finding(false, window, cx); + + // Cancel the current search task + self.find_task = None; cx.notify(); } - fn open(&mut self, id: u64, window: &mut Window, cx: &mut Context) { + /// Select a public key in the sidebar. + fn select(&mut self, public_key: &PublicKey, cx: &mut Context) { + self.selected_pkeys.update(cx, |this, cx| { + if this.contains(public_key) { + this.remove(public_key); + } else { + this.insert(public_key.to_owned()); + } + cx.notify(); + }); + } + + /// Check if a public key is selected in the sidebar. + fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool { + self.selected_pkeys.read(cx).contains(public_key) + } + + /// Get all selected public keys in the sidebar. + fn get_selected(&self, cx: &Context) -> HashSet { + self.selected_pkeys.read(cx).clone() + } + + /// Create a new room + fn create_room(&mut self, window: &mut Window, cx: &mut Context) { let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); - match chat.read(cx).room(&id, cx) { - Some(room) => { - chat.update(cx, |this, cx| { - this.emit_room(room, cx); - }); - } - None => { - if let Some(room) = self - .search_results - .read(cx) - .as_ref() - .and_then(|results| results.iter().find(|this| this.read(cx).id == id)) - .map(|this| this.downgrade()) - { - chat.update(cx, |this, cx| { - this.emit_room(room, cx); - }); - // Clear all search results - self.clear(window, cx); - } - } - } - } - - fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context) { - ChatRegistry::global(cx).update(cx, |this, cx| { - this.get_rooms(cx); - }); - window.push_notification("Reload", cx); - } - - fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); - let task: Task, Error>> = cx.background_spawn(async move { - let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); - let subscription = client.subscription(&id).await; + // Get all selected public keys + let receivers = self.get_selected(cx); - let mut relays: Vec = vec![]; + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let public_key = signer.get_public_key().await?; - for (url, _filter) in subscription.into_iter() { - relays.push(client.pool().relay(url).await?); - } + // Create a new room and emit it + async_chat.update_in(cx, |this, _window, cx| { + let room = cx.new(|_| { + Room::new(public_key, receivers) + .organize(&public_key) + .kind(RoomKind::Ongoing) + }); + this.emit_room(&room, cx); + })?; - Ok(relays) - }); + // Reset the find panel + this.update_in(cx, |this, window, cx| { + this.reset(window, cx); + })?; - cx.spawn_in(window, async move |this, cx| { - if let Ok(relays) = task.await { - this.update_in(cx, |this, window, cx| { - this.manage_relays(relays, window, cx); - }) - .ok(); - } - }) - .detach(); + Ok(()) + })); } - fn manage_relays(&mut self, relays: Vec, window: &mut Window, cx: &mut Context) { - window.open_modal(cx, move |this, _window, cx| { - this.show_close(true) - .overlay_closable(true) - .keyboard(true) - .title(SharedString::from("Messaging Relay Status")) - .child(v_flex().pb_4().gap_2().children({ - let mut items = Vec::with_capacity(relays.len()); - - for relay in relays.clone().into_iter() { - let url = relay.url().to_string(); - let time = relay.stats().connected_at().to_ago(); - let connected = relay.is_connected(); - - items.push( - h_flex() - .h_8() - .px_2() - .justify_between() - .text_xs() - .bg(cx.theme().elevated_surface_background) - .rounded(cx.theme().radius) - .child( - h_flex() - .gap_1() - .font_semibold() - .child( - Icon::new(IconName::Signal) - .small() - .text_color(cx.theme().danger_active) - .when(connected, |this| { - this.text_color(gpui::green().alpha(0.75)) - }), - ) - .child(url), - ) - .child( - div().text_right().text_color(cx.theme().text_muted).child( - SharedString::from(format!("Last activity: {}", time)), - ), - ), - ); - } - - items - })) - }); + /// Get the active filter. + fn current_filter(&self, kind: &RoomKind, cx: &Context) -> bool { + self.filter.read(cx) == kind } - fn list_items( - &self, - rooms: &[Entity], - range: Range, - cx: &Context, - ) -> Vec { - let mut items = Vec::with_capacity(range.end - range.start); + /// Set the active filter for the sidebar. + fn set_filter(&mut self, kind: RoomKind, cx: &mut Context) { + self.filter.update(cx, |this, cx| { + *this = kind; + cx.notify(); + }); + self.new_requests = false; + } - for ix in range { - let Some(room) = rooms.get(ix) else { - items.push(RoomListItem::new(ix)); - continue; - }; + fn render_list_items(&self, range: Range, cx: &Context) -> Vec { + let chat = ChatRegistry::global(cx); + let rooms = chat.read(cx).rooms(self.filter.read(cx), cx); - let this = room.read(cx); - let room_id = this.id; - let member = this.display_member(cx); + rooms + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| { + let room = item.read(cx); + let room_clone = item.clone(); + let public_key = room.display_member(cx).public_key(); + let handler = cx.listener(move |_this, _ev, _window, cx| { + ChatRegistry::global(cx).update(cx, |s, cx| { + s.emit_room(&room_clone, cx); + }); + }); - let handler = cx.listener({ - move |this, _, window, cx| { - this.open(room_id, window, cx); - } - }); + RoomEntry::new(range.start + ix) + .name(room.display_name(cx)) + .avatar(room.display_image(cx)) + .public_key(public_key) + .kind(room.kind) + .created_at(room.created_at.to_ago()) + .on_click(handler) + .into_any_element() + }) + .collect() + } - items.push( - RoomListItem::new(ix) - .room_id(room_id) - .name(this.display_name(cx)) - .avatar(this.display_image(cx)) - .public_key(member.public_key()) - .kind(this.kind) - .created_at(this.created_at.to_ago()) - .on_click(handler), - ) - } + /// Render the contact list + fn render_results(&self, range: Range, cx: &Context) -> Vec { + let persons = PersonRegistry::global(cx); - items + // Get the contact list + let Some(results) = self.find_results.read(cx) else { + return vec![]; + }; + + // Map the contact list to a list of elements + results + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, public_key)| { + let selected = self.is_selected(public_key, cx); + let profile = persons.read(cx).get(public_key, cx); + let pkey_clone = public_key.to_owned(); + let handler = cx.listener(move |this, _ev, _window, cx| { + this.select(&pkey_clone, cx); + }); + + RoomEntry::new(range.start + ix) + .name(profile.name()) + .avatar(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() + }) + .collect() + } + + /// Render the contact list + fn render_contacts(&self, range: Range, cx: &Context) -> Vec { + let persons = PersonRegistry::global(cx); + + // Get the contact list + let Some(contacts) = self.contact_list.read(cx) else { + return vec![]; + }; + + // Map the contact list to a list of elements + contacts + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, public_key)| { + let selected = self.is_selected(public_key, cx); + let profile = persons.read(cx).get(public_key, cx); + let pkey_clone = public_key.to_owned(); + let handler = cx.listener(move |this, _ev, _window, cx| { + this.select(&pkey_clone, cx); + }); + + RoomEntry::new(range.start + ix) + .name(profile.name()) + .avatar(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() + }) + .collect() } } @@ -591,201 +480,282 @@ impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let chat = ChatRegistry::global(cx); let loading = chat.read(cx).loading(); + let total_rooms = chat.read(cx).count(self.filter.read(cx), cx); - // Get rooms from either search results or the chat registry - let rooms = if let Some(results) = self.search_results.read(cx).as_ref() { - results.to_owned() + // Whether the find panel should be shown + let show_find_panel = self.has_search || self.find_focused; + + // Set button label based on total selected users + let button_label = if self.selected_pkeys.read(cx).len() > 1 { + "Create Group DM" } else { - // Filter rooms based on the active filter - if self.active_filter.read(cx) == &RoomKind::Ongoing { - chat.read(cx).ongoing_rooms(cx) - } else { - chat.read(cx).request_rooms(cx) - } + "Create DM" }; - // Get total rooms count - let mut total_rooms = rooms.len(); - - // Add 3 dummy rooms to display as skeletons - if loading { - total_rooms += 3 - } - v_flex() - .on_action(cx.listener(Self::on_reload)) - .on_action(cx.listener(Self::on_manage)) .image_cache(self.image_cache.clone()) .size_full() .relative() - .gap_3() - // Search Input .child( - div() - .relative() - .mt_3() - .px_2p5() - .w_full() - .h_7() - .flex_none() - .flex() + h_flex() + .h(TITLEBAR_HEIGHT) + .border_b_1() + .border_color(cx.theme().border) .child( TextInput::new(&self.find_input) + .appearance(false) + .bordered(false) .small() - .cleanable() - .appearance(true) .text_xs() - .map(|this| { - if !self.find_input.read(cx).loading { - this.suffix( - Button::new("find") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .transparent() - .small(), - ) - } else { - this - } + .when(!self.find_input.read(cx).loading, |this| { + this.suffix( + Button::new("find-icon") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) }), ), ) - // Chat Rooms .child( - v_flex() - .gap_1() - .flex_1() - .px_1p5() - .w_full() - .overflow_y_hidden() - .child( - div() - .px_1() - .h_flex() - .gap_2() - .flex_none() - .child( - Button::new("all") - .label("All") - .tooltip("All ongoing conversations") - .small() - .cta() - .bold() - .secondary() - .rounded() - .selected(self.filter(&RoomKind::Ongoing, cx)) - .on_click(cx.listener(|this, _, _, cx| { - this.set_filter(RoomKind::Ongoing, cx); - })), - ) - .child( - Button::new("requests") - .label("Requests") - .tooltip("Incoming new conversations") - .when(self.new_request, |this| { - this.child( - div().size_1().rounded_full().bg(cx.theme().cursor), - ) - }) - .small() - .cta() - .bold() - .secondary() - .rounded() - .selected(!self.filter(&RoomKind::Ongoing, cx)) - .on_click(cx.listener(|this, _, _, cx| { - this.set_filter(RoomKind::default(), cx); - })), - ) - .child( - h_flex() - .flex_1() - .w_full() - .justify_end() - .items_center() - .text_xs() - .child( - Button::new("option") - .icon(IconName::Ellipsis) - .xsmall() - .ghost() - .rounded() - .popup_menu(move |this, _window, _cx| { - this.menu( - "Reload", - Box::new(Reload), - ) - .menu( - "Relay Status", - Box::new(RelayStatus), - ) - }), - ), - ), - ) - .when(!loading && total_rooms == 0, |this| { - this.map(|this| { - if self.filter(&RoomKind::Ongoing, cx) { - this.child(deferred( - v_flex() - .py_2() - .px_1p5() - .gap_1p5() - .items_center() - .justify_center() - .text_center() - .child( - div() - .text_sm() - .font_semibold() - .line_height(relative(1.25)) - .child(SharedString::from("No conversations")), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .line_height(relative(1.25)) - .child(SharedString::from("Start a conversation with someone to get started.")), - ), - )) - } else { - this.child(deferred( - v_flex() - .py_2() - .px_1p5() - .gap_1p5() - .items_center() - .justify_center() - .text_center() - .child( - div() - .text_sm() - .font_semibold() - .line_height(relative(1.25)) - .child(SharedString::from("No message requests")), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .line_height(relative(1.25)) - .child(SharedString::from("New message requests from people you don't know will appear here.")), - ), - )) - } - }) + h_flex() + .h(TITLEBAR_HEIGHT) + .justify_center() + .border_b_1() + .border_color(cx.theme().border) + .when(show_find_panel, |this| { + this.child( + Button::new("search-results") + .icon(IconName::Search) + .label("Search") + .tooltip("All search results") + .small() + .underline() + .ghost() + .font_semibold() + .rounded_none() + .h_full() + .flex_1() + .selected(true), + ) }) .child( - uniform_list( - "rooms", - total_rooms, - cx.processor(move |this, range, _window, cx| { - this.list_items(&rooms, range, cx) - }), - ) - .h_full(), + Button::new("all") + .map(|this| { + if self.current_filter(&RoomKind::Ongoing, cx) { + this.icon(IconName::InboxFill) + } else { + this.icon(IconName::Inbox) + } + }) + .when(!show_find_panel, |this| this.label("Inbox")) + .tooltip("All ongoing conversations") + .small() + .underline() + .ghost() + .font_semibold() + .rounded_none() + .h_full() + .flex_1() + .disabled(show_find_panel) + .selected( + !show_find_panel && self.current_filter(&RoomKind::Ongoing, cx), + ) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.set_filter(RoomKind::Ongoing, cx); + })), + ) + .child(Divider::vertical()) + .child( + Button::new("requests") + .map(|this| { + if self.current_filter(&RoomKind::Request, cx) { + this.icon(IconName::FistbumpFill) + } else { + this.icon(IconName::Fistbump) + } + }) + .when(!show_find_panel, |this| this.label("Requests")) + .tooltip("Incoming new conversations") + .small() + .ghost() + .underline() + .font_semibold() + .rounded_none() + .h_full() + .flex_1() + .disabled(show_find_panel) + .selected( + !show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx), + ) + .when(self.new_requests, |this| { + this.child(div().size_1().rounded_full().bg(cx.theme().cursor)) + }) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.set_filter(RoomKind::default(), cx); + })), ), ) + .when(!show_find_panel && !loading && total_rooms == 0, |this| { + this.child( + div().mt_2().px_2().child( + v_flex() + .p_3() + .h_24() + .w_full() + .border_2() + .border_dashed() + .border_color(cx.theme().border_variant) + .rounded(cx.theme().radius_lg) + .items_center() + .justify_center() + .text_center() + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("No conversations")), + ) + .child(div().text_xs().text_color(cx.theme().text_muted).child( + SharedString::from( + "Start a conversation with someone to get started.", + ), + )), + ), + ) + }) + .child( + v_flex() + .h_full() + .px_1p5() + .mt_2() + .flex_1() + .gap_1() + .overflow_y_hidden() + .when(show_find_panel, |this| { + this.gap_3() + .when_some(self.find_results.read(cx).as_ref(), |this, results| { + this.child( + v_flex() + .gap_1() + .flex_1() + .border_b_1() + .border_color(cx.theme().border_variant) + .child( + h_flex() + .gap_0p5() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::ChevronDown)) + .child(SharedString::from("Results")), + ) + .child( + uniform_list( + "rooms", + results.len(), + cx.processor(|this, range, _window, cx| { + this.render_results(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }) + .when_some(self.contact_list.read(cx).as_ref(), |this, contacts| { + this.child( + v_flex() + .gap_1() + .flex_1() + .child( + h_flex() + .gap_0p5() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::ChevronDown)) + .child(SharedString::from("Suggestions")), + ) + .child( + uniform_list( + "contacts", + contacts.len(), + cx.processor(move |this, range, _window, cx| { + this.render_contacts(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }) + }) + .when(!show_find_panel, |this| { + this.child( + uniform_list( + "rooms", + total_rooms, + cx.processor(|this, range, _window, cx| { + this.render_list_items(range, cx) + }), + ) + .track_scroll(&self.scroll_handle) + .flex_1() + .h_full(), + ) + .child(Scrollbar::vertical(&self.scroll_handle)) + }), + ) + .when(!self.selected_pkeys.read(cx).is_empty(), |this| { + this.child( + div() + .absolute() + .bottom_0() + .left_0() + .h_9() + .w_full() + .px_2() + .child( + Button::new("create") + .label(button_label) + .primary() + .small() + .shadow_lg() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.create_room(window, cx); + })), + ), + ) + }) + .when(loading, |this| { + this.child( + div() + .absolute() + .bottom_2() + .left_0() + .h_9() + .w_full() + .px_8() + .child( + h_flex() + .gap_2() + .w_full() + .h_9() + .justify_center() + .bg(cx.theme().background.opacity(0.85)) + .border_color(cx.theme().border_disabled) + .border_1() + .when(cx.theme().shadow, |this| this.shadow_sm()) + .rounded_full() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Indicator::new().small().color(cx.theme().icon_accent)) + .child(SharedString::from("Getting messages...")), + ), + ) + }) } } diff --git a/crates/coop/src/user/viewer.rs b/crates/coop/src/user/viewer.rs deleted file mode 100644 index e710dba..0000000 --- a/crates/coop/src/user/viewer.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::time::Duration; - -use common::{nip05_verify, shorten_pubkey}; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement, - ParentElement, Render, SharedString, Styled, Task, Window, -}; -use gpui_tokio::Tokio; -use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry}; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt}; - -pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| ProfileViewer::new(public_key, window, cx)) -} - -#[derive(Debug)] -pub struct ProfileViewer { - profile: Person, - - /// Follow status - followed: bool, - - /// Verification status - verified: bool, - - /// Copy status - copied: bool, - - /// Async operations - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl ProfileViewer { - pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&target, cx); - - let mut tasks = smallvec![]; - - let check_follow: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let contact_list = client.database().contacts_public_keys(public_key).await?; - - Ok(contact_list.contains(&target)) - }); - - let verify_nip05 = if let Some(address) = profile.metadata().nip05 { - Some(Tokio::spawn(cx, async move { - nip05_verify(target, &address).await.unwrap_or(false) - })) - } else { - None - }; - - tasks.push( - // Load user profile data - cx.spawn_in(window, async move |this, cx| { - let followed = check_follow.await.unwrap_or(false); - - // Update the followed status - this.update(cx, |this, cx| { - this.followed = followed; - cx.notify(); - }) - .ok(); - - // Update the NIP05 verification status if user has NIP05 address - if let Some(task) = verify_nip05 { - if let Ok(verified) = task.await { - this.update(cx, |this, cx| { - this.verified = verified; - cx.notify(); - }) - .ok(); - } - } - }), - ); - - Self { - profile, - followed: false, - verified: false, - copied: false, - _tasks: tasks, - } - } - - fn address(&self, _cx: &Context) -> Option { - self.profile.metadata().nip05 - } - - fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context) { - let Ok(bech32) = self.profile.public_key().to_bech32(); - let item = ClipboardItem::new_string(bech32); - cx.write_to_clipboard(item); - - self.set_copied(true, window, cx); - } - - fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - self.copied = status; - cx.notify(); - - if status { - self._tasks.push( - // Reset the copied state after a delay - cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - cx.update(|window, cx| { - this.update(cx, |this, cx| { - this.set_copied(false, window, cx); - }) - .ok(); - }) - .ok(); - }), - ); - } - } -} - -impl Render for ProfileViewer { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let bech32 = shorten_pubkey(self.profile.public_key(), 16); - let shared_bech32 = SharedString::from(bech32); - - v_flex() - .gap_4() - .text_sm() - .child( - v_flex() - .gap_3() - .items_center() - .justify_center() - .text_center() - .child(Avatar::new(self.profile.avatar()).size(rems(4.))) - .child( - v_flex() - .child( - div() - .font_semibold() - .line_height(relative(1.25)) - .child(self.profile.name()), - ) - .when_some(self.address(cx), |this, address| { - this.child( - h_flex() - .justify_center() - .gap_1() - .text_xs() - .text_color(cx.theme().text_muted) - .child(address) - .when(self.verified, |this| { - this.child( - div() - .relative() - .text_color(cx.theme().text_accent) - .child( - Icon::new(IconName::CheckCircleFill) - .small() - .block(), - ), - ) - }), - ) - }), - ) - .when(!self.followed, |this| { - this.child( - div() - .flex_none() - .w_32() - .p_1() - .rounded_full() - .bg(cx.theme().elevated_surface_background) - .text_xs() - .font_semibold() - .child(SharedString::from("Unknown contact")), - ) - }), - ) - .child( - v_flex() - .gap_1() - .text_sm() - .child( - div() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Bio:")), - ) - .child( - div() - .p_2() - .min_h_16() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .child( - self.profile - .metadata() - .about - .map(SharedString::from) - .unwrap_or(SharedString::from("No bio.")), - ), - ), - ) - .child(div().my_1().h_px().w_full().bg(cx.theme().border)) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .text_color(cx.theme().text_placeholder) - .font_semibold() - .child(SharedString::from("Public Key:")), - ) - .child( - h_flex() - .gap_2() - .w_full() - .h_12() - .justify_center() - .bg(cx.theme().surface_background) - .rounded(cx.theme().radius) - .text_sm() - .child(shared_bech32) - .child( - Button::new("copy") - .icon({ - if self.copied { - IconName::CheckCircleFill - } else { - IconName::Copy - } - }) - .xsmall() - .ghost() - .on_click(cx.listener(move |this, _e, window, cx| { - this.copy_pubkey(window, cx); - })), - ), - ), - ) - } -} diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs deleted file mode 100644 index cca222c..0000000 --- a/crates/coop/src/views/compose.rs +++ /dev/null @@ -1,509 +0,0 @@ -use std::ops::Range; -use std::time::Duration; - -use anyhow::{anyhow, Error}; -use chat::{ChatRegistry, Room}; -use common::{nip05_profile, TextUtils, BOOTSTRAP_RELAYS}; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement, - IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, - StatefulInteractiveElement, Styled, Subscription, Task, Window, -}; -use gpui_tokio::Tokio; -use nostr_sdk::prelude::*; -use person::PersonRegistry; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::modal::ModalButtonProps; -use ui::notification::Notification; -use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; - -pub fn compose_button() -> impl IntoElement { - div().child( - Button::new("compose") - .icon(IconName::Plus) - .ghost_alt() - .cta() - .small() - .rounded() - .on_click(move |_, window, cx| { - let compose = cx.new(|cx| Compose::new(window, cx)); - let weak_view = compose.downgrade(); - - window.open_modal(cx, move |modal, _window, cx| { - let weak_view = weak_view.clone(); - let label = if compose.read(cx).selected(cx).len() > 1 { - SharedString::from("Create Group DM") - } else { - SharedString::from("Create DM") - }; - - modal - .alert() - .overlay_closable(true) - .keyboard(true) - .show_close(true) - .button_props(ModalButtonProps::default().ok_text(label)) - .title(SharedString::from("Direct Messages")) - .child(compose.clone()) - .on_ok(move |_, window, cx| { - weak_view - .update(cx, |this, cx| { - this.submit(window, cx); - }) - .ok(); - - // false to prevent the modal from closing - false - }) - }) - }), - ) -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] -struct Contact { - public_key: PublicKey, - selected: bool, -} - -impl AsRef for Contact { - fn as_ref(&self) -> &PublicKey { - &self.public_key - } -} - -impl Contact { - pub fn new(public_key: PublicKey) -> Self { - Self { - public_key, - selected: false, - } - } - - pub fn selected(mut self) -> Self { - self.selected = true; - self - } -} - -pub struct Compose { - /// Input for the room's subject - title_input: Entity, - - /// Input for the room's members - user_input: Entity, - - /// User's contacts - contacts: Entity>, - - /// Error message - error_message: Entity>, - - image_cache: Entity, - _subscriptions: SmallVec<[Subscription; 2]>, - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl Compose { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let contacts = cx.new(|_| vec![]); - let error_message = cx.new(|_| None); - - let user_input = - cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile...")); - - let title_input = - cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)")); - - let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; - - let get_contacts: Task, Error>> = cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let profiles = client.database().contacts(public_key).await?; - let contacts: Vec = profiles - .into_iter() - .map(|profile| Contact::new(profile.public_key())) - .collect(); - - Ok(contacts) - }); - - tasks.push( - // Load all contacts - cx.spawn_in(window, async move |this, cx| { - match get_contacts.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.extend_contacts(contacts, cx); - }) - .ok(); - } - Err(e) => { - cx.update(|window, cx| { - window.push_notification(Notification::error(e.to_string()), cx); - }) - .ok(); - } - }; - }), - ); - - subscriptions.push( - // Clear the image cache when sidebar is closed - cx.on_release_in(window, move |this, window, cx| { - this.image_cache.update(cx, |this, cx| { - this.clear(window, cx); - }) - }), - ); - - subscriptions.push( - // Handle Enter event for user input - cx.subscribe_in( - &user_input, - window, - move |this, _input, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.add_and_select_contact(window, cx) - }; - }, - ), - ); - - Self { - title_input, - user_input, - error_message, - contacts, - image_cache: RetainAllImageCache::new(cx), - _subscriptions: subscriptions, - _tasks: tasks, - } - } - - async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let kinds = vec![Kind::Metadata, Kind::ContactList]; - let filter = Filter::new().author(public_key).kinds(kinds).limit(10); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(()) - } - - fn extend_contacts(&mut self, contacts: I, cx: &mut Context) - where - I: IntoIterator, - { - self.contacts.update(cx, |this, cx| { - this.extend(contacts); - cx.notify(); - }); - } - - fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let pk = contact.public_key; - - if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) { - self._tasks.push(cx.background_spawn(async move { - Self::request_metadata(&client, pk).await.ok(); - })); - - cx.defer_in(window, |this, window, cx| { - this.contacts.update(cx, |this, cx| { - this.insert(0, contact); - cx.notify(); - }); - this.user_input.update(cx, |this, cx| { - this.set_value("", window, cx); - this.set_loading(false, cx); - }); - }); - } else { - self.set_error("Contact already added", cx); - } - } - - fn select_contact(&mut self, public_key: PublicKey, cx: &mut Context) { - self.contacts.update(cx, |this, cx| { - if let Some(contact) = this.iter_mut().find(|c| c.public_key == public_key) { - contact.selected = true; - } - cx.notify(); - }); - } - - fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context) { - let content = self.user_input.read(cx).value().to_string(); - - // Show loading indicator in the input - self.user_input.update(cx, |this, cx| { - this.set_loading(true, cx); - }); - - if let Ok(public_key) = content.to_public_key() { - let contact = Contact::new(public_key).selected(); - self.push_contact(contact, window, cx); - } else if content.contains("@") { - let task = Tokio::spawn(cx, async move { - if let Ok(profile) = nip05_profile(&content).await { - let public_key = profile.public_key; - let contact = Contact::new(public_key).selected(); - - Ok(contact) - } else { - Err(anyhow!("Not found")) - } - }); - - cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(Ok(contact)) => { - this.update_in(cx, |this, window, cx| { - this.push_contact(contact, window, cx); - }) - .ok(); - } - Ok(Err(e)) => { - this.update(cx, |this, cx| { - this.set_error(e.to_string(), cx); - }) - .ok(); - } - Err(e) => { - log::error!("Tokio error: {e}"); - } - }; - }) - .detach(); - } - } - - fn selected(&self, cx: &App) -> Vec { - self.contacts - .read(cx) - .iter() - .filter_map(|contact| { - if contact.selected { - Some(contact.public_key) - } else { - None - } - }) - .collect() - } - - fn submit(&mut self, window: &mut Window, cx: &mut Context) { - let chat = ChatRegistry::global(cx); - let nostr = NostrRegistry::global(cx); - let public_key = nostr.read(cx).identity().read(cx).public_key(); - - let receivers: Vec = self.selected(cx); - let subject_input = self.title_input.read(cx).value(); - let subject = (!subject_input.is_empty()).then(|| subject_input.to_string()); - - if !self.user_input.read(cx).value().is_empty() { - self.add_and_select_contact(window, cx); - return; - }; - - chat.update(cx, |this, cx| { - let room = cx.new(|_| Room::new(subject, public_key, receivers)); - this.emit_room(room.downgrade(), cx); - }); - - window.close_modal(cx); - } - - fn set_error(&mut self, error: impl Into, cx: &mut Context) { - // Unlock the user input - self.user_input.update(cx, |this, cx| { - this.set_loading(false, cx); - }); - - // Update error message - self.error_message.update(cx, |this, cx| { - *this = Some(error.into()); - cx.notify(); - }); - - // Dismiss error after 2 seconds - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - this.update(cx, |this, cx| { - this.error_message.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - }) - .ok(); - }) - .detach(); - } - - fn list_items(&self, range: Range, cx: &Context) -> Vec { - let persons = PersonRegistry::global(cx); - let mut items = Vec::with_capacity(self.contacts.read(cx).len()); - - for ix in range { - let Some(contact) = self.contacts.read(cx).get(ix) else { - continue; - }; - - let public_key = contact.public_key; - let profile = persons.read(cx).get(&public_key, cx); - - items.push( - h_flex() - .id(ix) - .px_2() - .h_11() - .w_full() - .justify_between() - .rounded(cx.theme().radius) - .child( - h_flex() - .gap_1p5() - .text_sm() - .child(Avatar::new(profile.avatar()).size(rems(1.75))) - .child(profile.name()), - ) - .when(contact.selected, |this| { - this.child( - Icon::new(IconName::CheckCircleFill) - .small() - .text_color(cx.theme().text_accent), - ) - }) - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .on_click(cx.listener(move |this, _, _window, cx| { - this.select_contact(public_key, cx); - })), - ); - } - - items - } -} - -impl Render for Compose { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let error = self.error_message.read(cx).as_ref(); - let loading = self.user_input.read(cx).loading; - let contacts = self.contacts.read(cx); - - v_flex() - .image_cache(self.image_cache.clone()) - .gap_2() - .child( - div() - .text_sm() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).")), - ) - .when_some(error, |this, msg| { - this.child( - div() - .italic() - .text_sm() - .text_color(cx.theme().danger_foreground) - .child(msg.clone()), - ) - }) - .child( - h_flex() - .gap_1() - .h_10() - .border_b_1() - .border_color(cx.theme().border) - .child( - div() - .text_sm() - .font_semibold() - .child(SharedString::from("Subject:")), - ) - .child(TextInput::new(&self.title_input).small().appearance(false)), - ) - .child( - v_flex() - .pt_1() - .gap_2() - .child( - v_flex() - .gap_2() - .child( - div() - .text_sm() - .font_semibold() - .child(SharedString::from("To:")), - ) - .child( - TextInput::new(&self.user_input) - .small() - .disabled(loading) - .suffix( - Button::new("add") - .icon(IconName::PlusCircleFill) - .transparent() - .small() - .disabled(loading) - .on_click(cx.listener(move |this, _, window, cx| { - this.add_and_select_contact(window, cx); - })), - ), - ), - ) - .map(|this| { - if contacts.is_empty() { - this.child( - v_flex() - .h_24() - .w_full() - .items_center() - .justify_center() - .text_center() - .text_xs() - .child( - div() - .font_semibold() - .line_height(relative(1.2)) - .child(SharedString::from("No contacts")), - ) - .child( - div() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Your recently contacts will appear here.")), - ), - ) - } else { - this.child( - uniform_list( - "contacts", - contacts.len(), - cx.processor(move |this, range, _window, cx| { - this.list_items(range, cx) - }), - ) - .h(px(300.)), - ) - } - }), - ) - } -} diff --git a/crates/coop/src/views/mod.rs b/crates/coop/src/views/mod.rs deleted file mode 100644 index 2e6c806..0000000 --- a/crates/coop/src/views/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod compose; -pub mod onboarding; -pub mod preferences; -pub mod screening; -pub mod setup_relay; -pub mod startup; -pub mod welcome; diff --git a/crates/coop/src/views/onboarding.rs b/crates/coop/src/views/onboarding.rs deleted file mode 100644 index d7d9c8f..0000000 --- a/crates/coop/src/views/onboarding.rs +++ /dev/null @@ -1,363 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use common::{TextUtils, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT}; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, img, px, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render, - SharedString, StatefulInteractiveElement, Styled, Task, Window, -}; -use key_store::{KeyItem, KeyStore}; -use nostr_connect::prelude::*; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::notification::Notification; -use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt}; - -use crate::chatspace::{self}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - Onboarding::new(window, cx) -} - -#[derive(Debug, Clone)] -pub enum NostrConnectApp { - Nsec(String), - Amber(String), - Aegis(String), -} - -impl NostrConnectApp { - pub fn all() -> Vec { - vec![ - NostrConnectApp::Nsec("https://nsec.app".to_string()), - NostrConnectApp::Amber("https://github.com/greenart7c3/Amber".to_string()), - NostrConnectApp::Aegis("https://github.com/ZharlieW/Aegis".to_string()), - ] - } - - pub fn url(&self) -> &str { - match self { - Self::Nsec(url) | Self::Amber(url) | Self::Aegis(url) => url, - } - } - - pub fn as_str(&self) -> String { - match self { - NostrConnectApp::Nsec(_) => "nsec.app (Desktop)".into(), - NostrConnectApp::Amber(_) => "Amber (Android)".into(), - NostrConnectApp::Aegis(_) => "Aegis (iOS)".into(), - } - } -} - -pub struct Onboarding { - app_keys: Keys, - qr_code: Option>, - - /// Panel - name: SharedString, - focus_handle: FocusHandle, - - /// Background tasks - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl Onboarding { - pub fn new(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Self::view(window, cx)) - } - - fn view(window: &mut Window, cx: &mut Context) -> Self { - let app_keys = Keys::generate(); - let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); - - let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(); - let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME); - let qr_code = uri.to_string().to_qr(); - - // NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md - // - // Direct connection initiated by the client - let signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap(); - - let mut tasks = smallvec![]; - - tasks.push( - // Wait for nostr connect - cx.spawn_in(window, async move |this, cx| { - let result = signer.bunker_uri().await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(uri) => { - this.save_connection(&uri, window, cx); - this.connect(signer, cx); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); - }), - ); - - Self { - qr_code, - app_keys, - name: "Onboarding".into(), - focus_handle: cx.focus_handle(), - _tasks: tasks, - } - } - - fn save_connection( - &mut self, - uri: &NostrConnectUri, - window: &mut Window, - cx: &mut Context, - ) { - let keystore = KeyStore::global(cx).read(cx).backend(); - let username = self.app_keys.public_key().to_hex(); - let secret = self.app_keys.secret_key().to_secret_bytes(); - let mut clean_uri = uri.to_string(); - - // Clear the secret parameter in the URI if it exists - if let Some(s) = uri.secret() { - clean_uri = clean_uri.replace(s, ""); - } - - cx.spawn_in(window, async move |this, cx| { - let user_url = KeyItem::User.to_string(); - let bunker_url = KeyItem::Bunker.to_string(); - let user_password = clean_uri.into_bytes(); - - // Write bunker uri to keyring for further connection - if let Err(e) = keystore - .write_credentials(&user_url, "bunker", &user_password, cx) - .await - { - this.update_in(cx, |_, window, cx| { - window.push_notification(e.to_string(), cx); - }) - .ok(); - } - - // Write the app keys for further connection - if let Err(e) = keystore - .write_credentials(&bunker_url, &username, &secret, cx) - .await - { - this.update_in(cx, |_, window, cx| { - window.push_notification(e.to_string(), cx); - }) - .ok(); - } - }) - .detach(); - } - - fn connect(&mut self, signer: NostrConnect, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - cx.background_spawn(async move { - client.set_signer(signer).await; - }) - .detach(); - } - - fn render_apps(&self, cx: &Context) -> impl IntoIterator { - let all_apps = NostrConnectApp::all(); - let mut items = Vec::with_capacity(all_apps.len()); - - for (ix, item) in all_apps.into_iter().enumerate() { - items.push(self.render_app(ix, item.as_str(), item.url(), cx)); - } - - items - } - - fn render_app(&self, ix: usize, label: T, url: &str, cx: &Context) -> impl IntoElement - where - T: Into, - { - div() - .id(ix) - .flex_1() - .rounded(cx.theme().radius) - .py_0p5() - .px_2() - .bg(cx.theme().ghost_element_background_alt) - .child(label.into()) - .on_click({ - let url = url.to_owned(); - move |_e, _window, cx| { - cx.open_url(&url); - } - }) - } -} - -impl Panel for Onboarding { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for Onboarding {} - -impl Focusable for Onboarding { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for Onboarding { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .size_full() - .child( - v_flex() - .flex_1() - .h_full() - .gap_10() - .items_center() - .justify_center() - .child( - v_flex() - .items_center() - .justify_center() - .gap_4() - .child( - svg() - .path("brand/coop.svg") - .size_16() - .text_color(cx.theme().elevated_surface_background), - ) - .child( - div() - .text_center() - .child( - div() - .text_xl() - .font_semibold() - .line_height(relative(1.3)) - .child(SharedString::from("Welcome to Coop")), - ) - .child(div().text_color(cx.theme().text_muted).child( - SharedString::from("Chat Freely, Stay Private on Nostr."), - )), - ), - ) - .child( - v_flex() - .w_80() - .gap_3() - .child( - Button::new("continue_btn") - .icon(Icon::new(IconName::ArrowRight)) - .label(SharedString::from("Start Messaging on Nostr")) - .primary() - .large() - .bold() - .reverse() - .on_click(cx.listener(move |_, _, window, cx| { - chatspace::new_account(window, cx); - })), - ) - .child( - h_flex() - .my_1() - .gap_1() - .child(divider(cx)) - .child(div().text_sm().text_color(cx.theme().text_muted).child( - SharedString::from( - "Already have an account? Continue with", - ), - )) - .child(divider(cx)), - ) - .child( - Button::new("key") - .label("Secret Key or Bunker") - .large() - .ghost_alt() - .on_click(cx.listener(move |_, _, window, cx| { - chatspace::login(window, cx); - })), - ), - ), - ) - .child( - div() - .relative() - .p_2() - .flex_1() - .h_full() - .rounded(cx.theme().radius_lg) - .child( - v_flex() - .size_full() - .justify_center() - .bg(cx.theme().surface_background) - .rounded(cx.theme().radius_lg) - .child( - v_flex() - .gap_5() - .items_center() - .justify_center() - .when_some(self.qr_code.as_ref(), |this, qr| { - this.child( - img(qr.clone()) - .size(px(256.)) - .rounded(cx.theme().radius_lg) - .when(cx.theme().shadow, |this| this.shadow_lg()) - .border_1() - .border_color(cx.theme().element_active), - ) - }) - .child( - v_flex() - .justify_center() - .items_center() - .text_center() - .child( - div() - .font_semibold() - .line_height(relative(1.3)) - .child(SharedString::from( - "Continue with Nostr Connect", - )), - ) - .child( - div() - .text_sm() - .text_color(cx.theme().text_muted) - .child(SharedString::from( - "Use Nostr Connect apps to scan the code", - )), - ) - .child( - h_flex() - .mt_2() - .gap_1() - .text_xs() - .justify_center() - .children(self.render_apps(cx)), - ), - ), - ), - ), - ) - } -} diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs deleted file mode 100644 index 8c51dee..0000000 --- a/crates/coop/src/views/preferences.rs +++ /dev/null @@ -1,21 +0,0 @@ -use gpui::{div, App, AppContext, Context, Entity, IntoElement, Render, Window}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Preferences::new(window, cx)) -} - -pub struct Preferences { - // -} - -impl Preferences { - pub fn new(_window: &mut Window, _cx: &mut App) -> Self { - Self {} - } -} - -impl Render for Preferences { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - } -} diff --git a/crates/coop/src/views/setup_relay.rs b/crates/coop/src/views/setup_relay.rs deleted file mode 100644 index cbc2809..0000000 --- a/crates/coop/src/views/setup_relay.rs +++ /dev/null @@ -1,325 +0,0 @@ -use std::collections::HashSet; -use std::time::Duration; - -use anyhow::{anyhow, Error}; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign, UniformList, - Window, -}; -use nostr_sdk::prelude::*; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::{h_flex, v_flex, ContextModal, IconName, Sizable}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| SetupRelay::new(window, cx)) -} - -#[derive(Debug)] -pub struct SetupRelay { - input: Entity, - error: Option, - - // All relays - relays: HashSet, - - // Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, - - // Background tasks - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl SetupRelay { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); - - let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; - - tasks.push( - // Load user's relays in the local database - cx.spawn_in(window, async move |this, cx| { - let result = cx - .background_spawn(async move { Self::load(&client).await }) - .await; - - if let Ok(relays) = result { - this.update(cx, |this, cx| { - this.relays.extend(relays); - cx.notify(); - }) - .ok(); - } - }), - ); - - subscriptions.push( - // Subscribe to user's input events - cx.subscribe_in( - &input, - window, - move |this: &mut Self, _, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.add(window, cx); - } - }, - ), - ); - - Self { - input, - relays: HashSet::new(), - error: None, - _subscriptions: subscriptions, - _tasks: tasks, - } - } - - async fn load(client: &Client) -> Result, Error> { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); - - if let Some(event) = client.database().query(filter).await?.first_owned() { - let urls = nip17::extract_owned_relay_list(event).collect(); - Ok(urls) - } else { - Err(anyhow!("Not found.")) - } - } - - fn add(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.input.read(cx).value().to_string(); - - if !value.starts_with("ws") { - self.set_error("Relay URl is invalid", window, cx); - return; - } - - if let Ok(url) = RelayUrl::parse(&value) { - if !self.relays.insert(url) { - self.input.update(cx, |this, cx| { - this.set_value("", window, cx); - }); - cx.notify(); - } - } else { - self.set_error("Relay URl is invalid", window, cx); - } - } - - fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { - self.relays.remove(url); - cx.notify(); - } - - fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) - where - E: Into, - { - self.error = Some(error.into()); - cx.notify(); - - // Clear the error message after a delay - cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - this.update(cx, |this, cx| { - this.error = None; - cx.notify(); - }) - .ok(); - }) - .detach(); - } - - pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context) { - if self.relays.is_empty() { - self.set_error( - "You need to add at least 1 relay to receive messages from others.", - window, - cx, - ); - return; - }; - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); - let write_relays = nostr.read(cx).write_relays(&public_key, cx); - - let relays = self.relays.clone(); - - let task: Task> = cx.background_spawn(async move { - let urls = write_relays.await; - let signer = client.signer().await?; - - let tags: Vec = relays - .iter() - .map(|relay| Tag::relay(relay.clone())) - .collect(); - - let event = EventBuilder::new(Kind::InboxRelays, "") - .tags(tags) - .sign(&signer) - .await?; - - // Set messaging relays - client.send_event_to(urls, &event).await?; - - // Connect to messaging relays - for relay in relays.iter() { - client.add_relay(relay).await.ok(); - client.connect_relay(relay).await.ok(); - } - - Ok(()) - }); - - cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(_) => { - cx.update(|window, cx| { - window.close_modal(cx); - }) - .ok(); - } - Err(e) => { - this.update_in(cx, |this, window, cx| { - this.set_error(e.to_string(), window, cx); - }) - .ok(); - } - }; - }) - .detach(); - } - - fn render_list(&mut self, _window: &mut Window, cx: &mut Context) -> UniformList { - let relays = self.relays.clone(); - let total = relays.len(); - - uniform_list( - "relays", - total, - cx.processor(move |_v, range, _window, cx| { - let mut items = Vec::new(); - - for ix in range { - if let Some(url) = relays.iter().nth(ix) { - items.push( - div() - .id(SharedString::from(url.to_string())) - .group("") - .w_full() - .h_9() - .py_0p5() - .child( - div() - .px_2() - .h_full() - .w_full() - .flex() - .items_center() - .justify_between() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .text_xs() - .child(SharedString::from(url.to_string())) - .child( - Button::new("remove_{ix}") - .icon(IconName::Close) - .xsmall() - .ghost() - .invisible() - .group_hover("", |this| this.visible()) - .on_click({ - let url = url.to_owned(); - cx.listener(move |this, _ev, _window, cx| { - this.remove(&url, cx); - }) - }), - ), - ), - ) - } - } - - items - }), - ) - .w_full() - .min_h(px(200.)) - } - - fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - h_flex() - .h_20() - .mb_2() - .justify_center() - .text_sm() - .text_align(TextAlign::Center) - .child(SharedString::from("Please add some relays.")) - } -} - -impl Render for SetupRelay { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .gap_3() - .text_sm() - .child( - div() - .text_color(cx.theme().text_muted) - .child(SharedString::from("In order to receive messages from others, you need to set up at least one Messaging Relay.")), - ) - .child( - v_flex() - .gap_2() - .child( - h_flex() - .gap_1() - .w_full() - .child(TextInput::new(&self.input).small()) - .child( - Button::new("add") - .icon(IconName::PlusFill) - .label("Add") - .ghost() - .on_click(cx.listener(move |this, _, window, cx| { - this.add(window, cx); - })), - ), - ) - .when_some(self.error.as_ref(), |this, error| { - this.child( - div() - .italic() - .text_xs() - .text_color(cx.theme().danger_foreground) - .child(error.clone()), - ) - }), - ) - .map(|this| { - if !self.relays.is_empty() { - this.child(self.render_list(window, cx)) - } else { - this.child(self.render_empty(window, cx)) - } - }) - } -} diff --git a/crates/coop/src/views/startup.rs b/crates/coop/src/views/startup.rs deleted file mode 100644 index a79aa1c..0000000 --- a/crates/coop/src/views/startup.rs +++ /dev/null @@ -1,319 +0,0 @@ -use std::time::Duration; - -use common::BUNKER_TIMEOUT; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, - RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, - Window, -}; -use key_store::{Credential, KeyItem, KeyStore}; -use nostr_connect::prelude::*; -use person::PersonRegistry; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::indicator::Indicator; -use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt}; - -use crate::actions::{reset, CoopAuthUrlHandler}; - -pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Startup::new(cre, window, cx)) -} - -/// Startup -#[derive(Debug)] -pub struct Startup { - name: SharedString, - focus_handle: FocusHandle, - - /// Local user credentials - credential: Credential, - - /// Whether the loadng is in progress - loading: bool, - - /// Image cache - image_cache: Entity, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, - - /// Background tasks - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl Startup { - fn new(credential: Credential, window: &mut Window, cx: &mut Context) -> Self { - let tasks = smallvec![]; - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Clear the local state when user closes the account panel - cx.on_release_in(window, move |this, window, cx| { - this.image_cache.update(cx, |this, cx| { - this.clear(window, cx); - }); - }), - ); - - Self { - credential, - loading: false, - name: "Onboarding".into(), - focus_handle: cx.focus_handle(), - image_cache: RetainAllImageCache::new(cx), - _subscriptions: subscriptions, - _tasks: tasks, - } - } - - fn login(&mut self, window: &mut Window, cx: &mut Context) { - self.set_loading(true, cx); - - let secret = self.credential.secret(); - - // Try to login with bunker - if secret.starts_with("bunker://") { - match NostrConnectUri::parse(secret) { - Ok(uri) => { - self.login_with_bunker(uri, window, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - self.set_loading(false, cx); - } - } - return; - }; - - // Fall back to login with keys - match SecretKey::parse(secret) { - Ok(secret) => { - self.login_with_keys(secret, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - self.set_loading(false, cx); - } - } - } - - fn login_with_bunker( - &mut self, - uri: NostrConnectUri, - window: &mut Window, - cx: &mut Context, - ) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let keystore = KeyStore::global(cx).read(cx).backend(); - - // Handle connection in the background - cx.spawn_in(window, async move |this, cx| { - let result = keystore - .read_credentials(&KeyItem::Bunker.to_string(), cx) - .await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(Some((_, content))) => { - let secret = SecretKey::from_slice(&content).unwrap(); - let keys = Keys::new(secret); - let timeout = Duration::from_secs(BUNKER_TIMEOUT); - let mut signer = NostrConnect::new(uri, keys, timeout, None).unwrap(); - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); - - // Connect to the remote signer - this._tasks.push( - // Handle connection in the background - cx.spawn_in(window, async move |this, cx| { - match signer.bunker_uri().await { - Ok(_) => { - client.set_signer(signer).await; - } - Err(e) => { - this.update_in(cx, |this, window, cx| { - window.push_notification(e.to_string(), cx); - this.set_loading(false, cx); - }) - .ok(); - } - } - }), - ) - } - Ok(None) => { - window.push_notification( - "You must allow Coop access to the keyring to continue.", - cx, - ); - this.set_loading(false, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - this.set_loading(false, cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context) { - let keys = Keys::new(secret); - let nostr = NostrRegistry::global(cx); - - nostr.update(cx, |this, cx| { - this.set_signer(keys, cx); - }) - } - - fn set_loading(&mut self, status: bool, cx: &mut Context) { - self.loading = status; - cx.notify(); - } -} - -impl Panel for Startup { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for Startup {} - -impl Focusable for Startup { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for Startup { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - let persons = PersonRegistry::global(cx); - let bunker = self.credential.secret().starts_with("bunker://"); - let profile = persons.read(cx).get(&self.credential.public_key(), cx); - - v_flex() - .image_cache(self.image_cache.clone()) - .relative() - .size_full() - .gap_10() - .items_center() - .justify_center() - .child( - v_flex() - .items_center() - .justify_center() - .gap_4() - .child( - svg() - .path("brand/coop.svg") - .size_16() - .text_color(cx.theme().elevated_surface_background), - ) - .child( - div() - .text_center() - .child( - div() - .text_xl() - .font_semibold() - .line_height(relative(1.3)) - .child(SharedString::from("Welcome to Coop")), - ) - .child( - div() - .text_color(cx.theme().text_muted) - .child(SharedString::from( - "Chat Freely, Stay Private on Nostr.", - )), - ), - ), - ) - .child( - v_flex() - .gap_2() - .child( - div() - .id("account") - .h_10() - .w_72() - .bg(cx.theme().elevated_surface_background) - .rounded(cx.theme().radius_lg) - .text_sm() - .when(self.loading, |this| { - this.child( - div() - .size_full() - .flex() - .items_center() - .justify_center() - .child(Indicator::new().small()), - ) - }) - .when(!self.loading, |this| { - let avatar = profile.avatar(); - let name = profile.name(); - - this.child( - h_flex() - .h_full() - .justify_center() - .gap_2() - .child( - h_flex() - .gap_1() - .child(Avatar::new(avatar).size(rems(1.5))) - .child(div().pb_px().font_semibold().child(name)), - ) - .child(div().when(bunker, |this| { - let label = SharedString::from("Nostr Connect"); - - this.child( - div() - .py_0p5() - .px_2() - .text_xs() - .bg(cx.theme().secondary_active) - .text_color(cx.theme().secondary_foreground) - .rounded_full() - .child(label), - ) - })), - ) - }) - .text_color(cx.theme().text) - .active(|this| { - this.text_color(cx.theme().element_foreground) - .bg(cx.theme().element_active) - }) - .hover(|this| { - this.text_color(cx.theme().element_foreground) - .bg(cx.theme().element_hover) - }) - .on_click(cx.listener(move |this, _e, window, cx| { - this.login(window, cx); - })), - ) - .child(Button::new("logout").label("Sign out").ghost().on_click( - |_, _window, cx| { - reset(cx); - }, - )), - ) - } -} diff --git a/crates/coop/src/views/welcome.rs b/crates/coop/src/views/welcome.rs deleted file mode 100644 index ff5aff9..0000000 --- a/crates/coop/src/views/welcome.rs +++ /dev/null @@ -1,103 +0,0 @@ -use gpui::{ - div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Window, -}; -use theme::ActiveTheme; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::{v_flex, StyledExt}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - Welcome::new(window, cx) -} - -pub struct Welcome { - name: SharedString, - version: SharedString, - focus_handle: FocusHandle, -} - -impl Welcome { - pub fn new(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Self::view(window, cx)) - } - - fn view(_window: &mut Window, cx: &mut Context) -> Self { - let version = SharedString::from(format!("Version: {}", env!("CARGO_PKG_VERSION"))); - - Self { - version, - name: "Welcome".into(), - focus_handle: cx.focus_handle(), - } - } -} - -impl Panel for Welcome { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, cx: &App) -> AnyElement { - div() - .child( - svg() - .path("brand/coop.svg") - .size_4() - .text_color(cx.theme().element_background), - ) - .into_any_element() - } -} - -impl EventEmitter for Welcome {} - -impl Focusable for Welcome { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for Welcome { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .flex() - .items_center() - .justify_center() - .child( - v_flex() - .gap_2() - .items_center() - .justify_center() - .child( - svg() - .path("brand/coop.svg") - .size_12() - .text_color(cx.theme().elevated_surface_background), - ) - .child( - v_flex() - .items_center() - .justify_center() - .text_center() - .child( - div() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(SharedString::from("coop on nostr")), - ) - .child( - div() - .id("version") - .text_color(cx.theme().text_placeholder) - .text_xs() - .child(self.version.clone()) - .on_click(|_, _window, cx| { - cx.open_url("https://github.com/lumehq/coop/releases"); - }), - ), - ), - ) - } -} diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs new file mode 100644 index 0000000..9749ba9 --- /dev/null +++ b/crates/coop/src/workspace.rs @@ -0,0 +1,289 @@ +use std::sync::Arc; + +use chat::{ChatEvent, ChatRegistry}; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Window, +}; +use person::PersonRegistry; +use smallvec::{smallvec, SmallVec}; +use state::{NostrRegistry, RelayState}; +use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; +use title_bar::TitleBar; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::dock_area::dock::DockPlacement; +use ui::dock_area::panel::{PanelStyle, PanelView}; +use ui::dock_area::{ClosePanel, DockArea, DockItem}; +use ui::menu::DropdownMenu; +use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; + +use crate::panels::greeter; +use crate::sidebar; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Workspace::new(window, cx)) +} + +pub struct Workspace { + /// App's Title Bar + titlebar: Entity, + + /// App's Dock Area + dock: Entity, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 3]>, +} + +impl Workspace { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let chat = ChatRegistry::global(cx); + let titlebar = cx.new(|_| TitleBar::new()); + let dock = cx.new(|cx| DockArea::new(window, cx).panel_style(PanelStyle::TabBar)); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Observe all events emitted by the chat registry + cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { + match ev { + ChatEvent::OpenRoom(id) => { + if let Some(room) = chat.read(cx).room(id, cx) { + this.dock.update(cx, |this, cx| { + this.add_panel( + Arc::new(chat_ui::init(room, window, cx)), + DockPlacement::Center, + window, + cx, + ); + }); + } + } + ChatEvent::CloseRoom(..) => { + this.dock.update(cx, |this, cx| { + // Force focus to the tab panel + this.focus_tab_panel(window, cx); + + // Dispatch the close panel action + cx.defer_in(window, |_, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + window.close_all_modals(cx); + }); + }); + } + _ => {} + }; + }), + ); + + subscriptions.push( + // Observe the chat registry + cx.observe(&chat, move |this, chat, cx| { + let ids = this.panel_ids(cx); + + chat.update(cx, |this, cx| { + this.refresh_rooms(ids, cx); + }); + }), + ); + + // Set the default layout for app's dock + cx.defer_in(window, |this, window, cx| { + this.set_layout(window, cx); + }); + + Self { + titlebar, + dock, + _subscriptions: subscriptions, + } + } + + /// Add panel to the dock + pub fn add_panel

(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App) + where + P: PanelView, + { + if let Some(root) = window.root::().flatten() { + if let Ok(workspace) = root.read(cx).view().clone().downcast::() { + workspace.update(cx, |this, cx| { + this.dock.update(cx, |this, cx| { + this.add_panel(Arc::new(panel), placement, window, cx); + }); + }); + } + } + } + + /// Get all panel ids + fn panel_ids(&self, cx: &App) -> Option> { + let ids: Vec = self + .dock + .read(cx) + .items + .panel_ids(cx) + .into_iter() + .filter_map(|panel| panel.parse::().ok()) + .collect(); + + Some(ids) + } + + /// Set the dock layout + fn set_layout(&mut self, window: &mut Window, cx: &mut Context) { + let weak_dock = self.dock.downgrade(); + + // Sidebar + let left = DockItem::panel(Arc::new(sidebar::init(window, cx))); + + // Main workspace + let center = DockItem::split_with_sizes( + Axis::Vertical, + vec![DockItem::tabs( + vec![Arc::new(greeter::init(window, cx))], + None, + &weak_dock, + window, + cx, + )], + vec![None], + &weak_dock, + window, + cx, + ); + + // Update the dock layout + self.dock.update(cx, |this, cx| { + this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx); + this.set_center(center, window, cx); + }); + } + + fn titlebar_left(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { + let chat = ChatRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + let current_user = signer.public_key(); + + h_flex() + .h(TITLEBAR_HEIGHT) + .flex_shrink_0() + .justify_between() + .gap_2() + .when_some(current_user.as_ref(), |this, public_key| { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(public_key, cx); + + this.child( + Button::new("current-user") + .child(Avatar::new(profile.avatar()).size(rems(1.25))) + .small() + .caret() + .compact() + .transparent() + .dropdown_menu(move |this, _window, _cx| { + this.label(profile.name()) + .separator() + .menu("Profile", Box::new(ClosePanel)) + .menu("Backup", Box::new(ClosePanel)) + .menu("Themes", Box::new(ClosePanel)) + .menu("Settings", Box::new(ClosePanel)) + }), + ) + }) + .when(nostr.read(cx).creating(), |this| { + this.child(div().text_xs().text_color(cx.theme().text_muted).child( + SharedString::from("Coop is creating a new identity for you..."), + )) + }) + .when(!nostr.read(cx).connected(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Connecting...")), + ) + }) + .map(|this| match nostr.read(cx).relay_list_state() { + RelayState::Checking => this.child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Fetching user's relay list...")), + ), + RelayState::NotConfigured => this.child( + h_flex() + .h_6() + .w_full() + .px_1() + .text_xs() + .text_color(cx.theme().warning_foreground) + .bg(cx.theme().warning_background) + .rounded_sm() + .child(SharedString::from("User hasn't configured a relay list")), + ), + _ => this, + }) + .map(|this| match chat.read(cx).relay_state(cx) { + RelayState::Checking => { + this.child(div().text_xs().text_color(cx.theme().text_muted).child( + SharedString::from("Fetching user's messaging relay list..."), + )) + } + RelayState::NotConfigured => this.child( + h_flex() + .h_6() + .w_full() + .px_1() + .text_xs() + .text_color(cx.theme().warning_foreground) + .bg(cx.theme().warning_background) + .rounded_sm() + .child(SharedString::from( + "User hasn't configured a messaging relay list", + )), + ), + _ => this, + }) + } + + fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { + h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0() + } +} + +impl Render for Workspace { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let modal_layer = Root::render_modal_layer(window, cx); + let notification_layer = Root::render_notification_layer(window, cx); + + // Titlebar elements + let left = self.titlebar_left(window, cx).into_any_element(); + let right = self.titlebar_right(window, cx).into_any_element(); + + // Update title bar children + self.titlebar.update(cx, |this, _cx| { + this.set_children(vec![left, right]); + }); + + div() + .id(SharedString::from("workspace")) + .relative() + .size_full() + .child( + v_flex() + .relative() + .size_full() + // Title Bar + .child(self.titlebar.clone()) + // Dock + .child(self.dock.clone()), + ) + // Notifications + .children(notification_layer) + // Modals + .children(modal_layer) + } +} diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 2c587ea..85e6866 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -1,21 +1,20 @@ -use std::collections::HashSet; -use std::sync::Arc; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; -use common::app_name; -pub use device::*; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{NostrRegistry, RelayState, GIFTWRAP_SUBSCRIPTION, TIMEOUT}; +use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT}; mod device; +pub use device::*; + const IDENTIFIER: &str = "coop:device"; -pub fn init(cx: &mut App) { - DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx); +pub fn init(window: &mut Window, cx: &mut App) { + DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx); } struct GlobalDeviceRegistry(Entity); @@ -27,15 +26,12 @@ impl Global for GlobalDeviceRegistry {} /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md #[derive(Debug)] pub struct DeviceRegistry { - /// Device signer - pub device_signer: Entity>>, + /// Device state + state: DeviceState, /// Device requests requests: Entity>, - /// Device state - state: DeviceState, - /// Async tasks tasks: Vec>>, @@ -55,40 +51,79 @@ impl DeviceRegistry { } /// Create a new device registry instance - fn new(cx: &mut Context) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let identity = nostr.read(cx).identity(); - - let device_signer = cx.new(|_| None); let requests = cx.new(|_| HashSet::default()); - // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::(100); - let mut subscriptions = smallvec![]; - let mut tasks = vec![]; subscriptions.push( - // Observe the identity entity - cx.observe(&identity, |this, state, cx| { - if state.read(cx).has_public_key() { - if state.read(cx).relay_list_state() == RelayState::Set { + // Observe the NIP-65 state + cx.observe(&nostr, |this, state, cx| { + match state.read(cx).relay_list_state() { + RelayState::Idle => { + this.reset(cx); + } + RelayState::Configured => { this.get_announcement(cx); } - if state.read(cx).messaging_relays_state() == RelayState::Set { - this.get_messages(cx); - } - } + _ => {} + }; }), ); - tasks.push( - // Handle nostr notifications - cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }), - ); + // Run at the end of current cycle + cx.defer_in(window, |this, _window, cx| { + this.handle_notifications(cx); + }); - tasks.push( + Self { + requests, + state: DeviceState::default(), + tasks: vec![], + _subscriptions: subscriptions, + } + } + + fn handle_notifications(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let (tx, rx) = flume::bounded::(100); + + cx.background_spawn(async move { + let mut notifications = client.notifications(); + let mut processed_events = HashSet::new(); + + while let Some(notification) = notifications.next().await { + if let ClientNotification::Message { + message: RelayMessage::Event { event, .. }, + .. + } = notification + { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + match event.kind { + Kind::Custom(4454) => { + if verify_author(&client, event.as_ref()).await { + tx.send_async(event.into_owned()).await.ok(); + } + } + Kind::Custom(4455) => { + if verify_author(&client, event.as_ref()).await { + tx.send_async(event.into_owned()).await.ok(); + } + } + _ => {} + } + } + } + }) + .detach(); + + self.tasks.push( // Update GPUI states cx.spawn(async move |this, cx| { while let Ok(event) = rx.recv_async().await { @@ -110,117 +145,11 @@ impl DeviceRegistry { Ok(()) }), ); - - Self { - device_signer, - requests, - state: DeviceState::default(), - tasks, - _subscriptions: subscriptions, - } } - /// Handle nostr notifications - async fn handle_notifications(client: &Client, tx: &flume::Sender) -> Result<(), Error> { - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); - - while let Ok(notification) = notifications.recv().await { - if let RelayPoolNotification::Message { - message: RelayMessage::Event { event, .. }, - .. - } = notification - { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; - } - - match event.kind { - Kind::Custom(4454) => { - if Self::verify_author(client, event.as_ref()).await { - tx.send_async(event.into_owned()).await.ok(); - } - } - Kind::Custom(4455) => { - if Self::verify_author(client, event.as_ref()).await { - tx.send_async(event.into_owned()).await.ok(); - } - } - _ => {} - } - } - } - - Ok(()) - } - - /// Verify the author of an event - async fn verify_author(client: &Client, event: &Event) -> bool { - if let Ok(signer) = client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - return public_key == event.pubkey; - } - } - false - } - - /// Encrypt and store device keys in the local database. - async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - // Encrypt the value - let content = signer.nip44_encrypt(&public_key, secret).await?; - - // Construct the application data event - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tag(Tag::identifier(IDENTIFIER)) - .build(public_key) - .sign(&Keys::generate()) - .await?; - - // Save the event to the database - client.database().save_event(&event).await?; - - Ok(()) - } - - /// Get device keys from the local database. - async fn get_keys(client: &Client) -> Result { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(IDENTIFIER); - - if let Some(event) = client.database().query(filter).await?.first() { - let content = signer.nip44_decrypt(&public_key, &event.content).await?; - let secret = SecretKey::parse(&content)?; - let keys = Keys::new(secret); - - Ok(keys) - } else { - Err(anyhow!("Key not found")) - } - } - - /// Returns the device signer entity - pub fn signer(&self, cx: &App) -> Option> { - self.device_signer.read(cx).clone() - } - - /// Set the decoupled encryption key for the current user - fn set_device_signer(&mut self, signer: S, cx: &mut Context) - where - S: NostrSigner + 'static, - { - self.set_state(DeviceState::Set, cx); - self.device_signer.update(cx, |this, cx| { - *this = Some(Arc::new(signer)); - cx.notify(); - }); + /// Get the device state + pub fn state(&self) -> &DeviceState { + &self.state } /// Set the device state @@ -229,6 +158,37 @@ impl DeviceRegistry { cx.notify(); } + /// Set the decoupled encryption key for the current user + fn set_signer(&mut self, new: S, cx: &mut Context) + where + S: NostrSigner + 'static, + { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + + self.tasks.push(cx.spawn(async move |this, cx| { + signer.set_encryption_signer(new).await; + + // Update state + this.update(cx, |this, cx| { + this.set_state(DeviceState::Set, cx); + this.get_messages(cx); + })?; + + Ok(()) + })); + } + + /// Reset the device state + fn reset(&mut self, cx: &mut Context) { + self.state = DeviceState::Initial; + self.requests.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); + cx.notify(); + } + /// Add a request for device keys fn add_request(&mut self, request: Event, cx: &mut Context) { self.requests.update(cx, |this, cx| { @@ -237,35 +197,49 @@ impl DeviceRegistry { }); } - /// Continuously get gift wrap events for the current user in their messaging relays + /// Get all messages for encryption keys fn get_messages(&mut self, cx: &mut Context) { + let task = self.subscribe_to_giftwrap_events(cx); + + self.tasks.push(cx.spawn(async move |_this, _cx| { + task.await?; + + // Update state + + Ok(()) + })); + } + + /// Continuously get gift wrap events for the current user in their messaging relays + fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let device_signer = self.device_signer.read(cx).clone(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx); cx.background_spawn(async move { - let urls = messaging_relays.await; - let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); - let mut filters = vec![]; + let relay_urls = messaging_relays.await; + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let id = SubscriptionId::new(DEVICE_GIFTWRAP); - // Construct a filter to get user messages - filters.push(Filter::new().kind(Kind::GiftWrap).pubkey(public_key)); + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = relay_urls + .iter() + .map(|relay| (relay, filter.clone())) + .collect(); - // Construct a filter to get dekey messages if available - if let Some(signer) = device_signer.as_ref() { - if let Ok(pubkey) = signer.get_public_key().await { - filters.push(Filter::new().kind(Kind::GiftWrap).pubkey(pubkey)); - } - } + let output = client.subscribe(target).with_id(id).await?; - if let Err(e) = client.subscribe_with_id_to(urls, id, filters, None).await { - log::error!("Failed to subscribe to gift wrap events: {e}"); - } + log::info!( + "Successfully subscribed to encryption gift-wrap messages on: {:?}", + output.success + ); + + Ok(()) }) - .detach(); } /// Get device announcement for current user @@ -273,7 +247,9 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); let task: Task> = cx.background_spawn(async move { @@ -285,8 +261,14 @@ impl DeviceRegistry { .author(public_key) .limit(1); + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = + urls.iter().map(|relay| (relay, filter.clone())).collect(); + + // Stream events from user's write relays let mut stream = client - .stream_events_from(&urls, vec![filter], Duration::from_secs(TIMEOUT)) + .stream_events(target) + .timeout(Duration::from_secs(TIMEOUT)) .await?; while let Some((_url, res)) = stream.next().await { @@ -327,7 +309,9 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); let keys = Keys::generate(); @@ -335,23 +319,21 @@ impl DeviceRegistry { let n = keys.public_key(); let task: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; let urls = write_relays.await; // Construct an announcement event - let event = EventBuilder::new(Kind::Custom(10044), "") - .tags(vec![ + let event = client + .sign_event_builder(EventBuilder::new(Kind::Custom(10044), "").tags(vec![ Tag::custom(TagKind::custom("n"), vec![n]), Tag::client(app_name()), - ]) - .sign(&signer) + ])) .await?; // Publish announcement - client.send_event_to(&urls, &event).await?; + client.send_event(&event).to(urls).await?; // Save device keys to the database - Self::set_keys(&client, &secret).await?; + set_keys(&client, &secret).await?; Ok(()) }); @@ -359,7 +341,7 @@ impl DeviceRegistry { cx.spawn(async move |this, cx| { if task.await.is_ok() { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); this.listen_device_request(cx); }) .ok(); @@ -377,7 +359,7 @@ impl DeviceRegistry { let device_pubkey = announcement.public_key(); let task: Task> = cx.background_spawn(async move { - if let Ok(keys) = Self::get_keys(&client).await { + if let Ok(keys) = get_keys(&client).await { if keys.public_key() != device_pubkey { return Err(anyhow!("Key mismatch")); }; @@ -392,7 +374,7 @@ impl DeviceRegistry { match task.await { Ok(keys) => { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); this.listen_device_request(cx); }) .ok(); @@ -416,7 +398,9 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); let task: Task> = cx.background_spawn(async move { @@ -428,8 +412,12 @@ impl DeviceRegistry { .author(public_key) .since(Timestamp::now()); + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = + urls.iter().map(|relay| (relay, filter.clone())).collect(); + // Subscribe to the device key requests on user's write relays - client.subscribe_to(&urls, vec![filter], None).await?; + client.subscribe(target).await?; Ok(()) }); @@ -442,7 +430,9 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); let task: Task> = cx.background_spawn(async move { @@ -454,8 +444,12 @@ impl DeviceRegistry { .author(public_key) .since(Timestamp::now()); + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = + urls.iter().map(|relay| (relay, filter.clone())).collect(); + // Subscribe to the device key requests on user's write relays - client.subscribe_to(&urls, vec![filter], None).await?; + client.subscribe(target).await?; Ok(()) }); @@ -468,14 +462,15 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); let app_keys = nostr.read(cx).app_keys().clone(); let app_pubkey = app_keys.public_key(); let task: Task, Error>> = cx.background_spawn(async move { - let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let filter = Filter::new() @@ -505,16 +500,15 @@ impl DeviceRegistry { let urls = write_relays.await; // Construct an event for device key request - let event = EventBuilder::new(Kind::Custom(4454), "") - .tags(vec![ + let event = client + .sign_event_builder(EventBuilder::new(Kind::Custom(4454), "").tags(vec![ Tag::client(app_name()), Tag::custom(TagKind::custom("P"), vec![app_pubkey]), - ]) - .sign(&signer) + ])) .await?; // Send the event to write relays - client.send_event_to(&urls, &event).await?; + client.send_event(&event).to(urls).await?; Ok(None) } @@ -525,7 +519,7 @@ impl DeviceRegistry { match task.await { Ok(Some(keys)) => { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); }) .ok(); } @@ -569,7 +563,7 @@ impl DeviceRegistry { match task.await { Ok(keys) => { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); }) .ok(); } @@ -587,15 +581,16 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let public_key = nostr.read(cx).identity().read(cx).public_key(); + let signer = nostr.read(cx).signer(); + let public_key = signer.public_key().unwrap(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); let task: Task> = cx.background_spawn(async move { let urls = write_relays.await; - let signer = client.signer().await?; // Get device keys - let keys = Self::get_keys(&client).await?; + let keys = get_keys(&client).await?; let secret = keys.secret_key().to_secret_hex(); // Extract the target public key from the event tags @@ -613,16 +608,15 @@ impl DeviceRegistry { // // P tag: the current device's public key // p tag: the requester's public key - let event = EventBuilder::new(Kind::Custom(4455), payload) - .tags(vec![ + let event = client + .sign_event_builder(EventBuilder::new(Kind::Custom(4455), payload).tags(vec![ Tag::custom(TagKind::custom("P"), vec![keys.public_key()]), Tag::public_key(target), - ]) - .sign(&signer) + ])) .await?; // Send the response event to the user's relay list - client.send_event_to(&urls, &event).await?; + client.send_event(&event).to(urls).await?; Ok(()) }); @@ -630,3 +624,54 @@ impl DeviceRegistry { task.detach(); } } + +/// Verify the author of an event +async fn verify_author(client: &Client, event: &Event) -> bool { + if let Some(signer) = client.signer() { + if let Ok(public_key) = signer.get_public_key().await { + return public_key == event.pubkey; + } + } + false +} + +/// Encrypt and store device keys in the local database. +async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + // Encrypt the value + let content = signer.nip44_encrypt(&public_key, secret).await?; + + // Construct the application data event + let event = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tag(Tag::identifier(IDENTIFIER)) + .build(public_key) + .sign(&Keys::generate()) + .await?; + + // Save the event to the database + client.database().save_event(&event).await?; + + Ok(()) +} + +/// Get device keys from the local database. +async fn get_keys(client: &Client) -> Result { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(IDENTIFIER); + + if let Some(event) = client.database().query(filter).await?.first() { + let content = signer.nip44_decrypt(&public_key, &event.content).await?; + let secret = SecretKey::parse(&content)?; + let keys = Keys::new(secret); + + Ok(keys) + } else { + Err(anyhow!("Key not found")) + } +} diff --git a/crates/person/Cargo.toml b/crates/person/Cargo.toml index f0239bf..9d59298 100644 --- a/crates/person/Cargo.toml +++ b/crates/person/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] common = { path = "../common" } state = { path = "../state" } +device = { path = "../device" } gpui.workspace = true nostr-sdk.workspace = true diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index f1b8424..baa81fc 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -4,15 +4,17 @@ use std::rc::Rc; use std::time::Duration; use anyhow::{anyhow, Error}; -use common::{EventUtils, BOOTSTRAP_RELAYS}; +use common::EventUtils; +use device::Announcement; use gpui::{App, AppContext, Context, Entity, Global, Task}; use nostr_sdk::prelude::*; -pub use person::*; use smallvec::{smallvec, SmallVec}; -use state::{Announcement, NostrRegistry, TIMEOUT}; +use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT}; mod person; +pub use person::*; + pub fn init(cx: &mut App) { PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); } @@ -25,6 +27,7 @@ impl Global for GlobalPersonRegistry {} enum Dispatch { Person(Box), Announcement(Box), + Relays(Box), } /// Person Registry @@ -99,6 +102,9 @@ impl PersonRegistry { Dispatch::Announcement(event) => { this.set_announcement(&event, cx); } + Dispatch::Relays(event) => { + this.set_messaging_relays(&event, cx); + } }; }) .ok(); @@ -111,7 +117,7 @@ impl PersonRegistry { cx.spawn(async move |this, cx| { let result = cx .background_executor() - .await_on_background(async move { Self::load_persons(&client).await }) + .await_on_background(async move { load_persons(&client).await }) .await; match result { @@ -139,17 +145,17 @@ impl PersonRegistry { /// Handle nostr notifications async fn handle_notifications(client: &Client, tx: &flume::Sender) { let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); + let mut processed: HashSet = HashSet::new(); - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, .. } = notification else { + while let Some(notification) = notifications.next().await { + let ClientNotification::Message { message, .. } = notification else { // Skip if the notification is not a message continue; }; if let RelayMessage::Event { event, .. } = message { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed + // Skip if the event has already been processed + if !processed.insert(event.id) { continue; } @@ -162,18 +168,24 @@ impl PersonRegistry { // Send tx.send_async(Dispatch::Person(val)).await.ok(); } + Kind::ContactList => { + let public_keys = event.extract_public_keys(); + + // Get metadata for all public keys + get_metadata(client, public_keys).await.ok(); + } + Kind::InboxRelays => { + let val = Box::new(event.into_owned()); + + // Send + tx.send_async(Dispatch::Relays(val)).await.ok(); + } Kind::Custom(10044) => { let val = Box::new(event.into_owned()); // Send tx.send_async(Dispatch::Announcement(val)).await.ok(); } - Kind::ContactList => { - let public_keys = event.extract_public_keys(); - - // Get metadata for all public keys - Self::get_metadata(client, public_keys).await.ok(); - } _ => {} } } @@ -190,70 +202,19 @@ impl PersonRegistry { .wait_timeout(Duration::from_secs(2)) { Ok(Some(public_key)) => { - log::info!("Received public key: {}", public_key); batch.insert(public_key); // Process the batch if it's full if batch.len() >= 20 { - Self::get_metadata(client, std::mem::take(&mut batch)) - .await - .ok(); + get_metadata(client, std::mem::take(&mut batch)).await.ok(); } } _ => { - Self::get_metadata(client, std::mem::take(&mut batch)) - .await - .ok(); + get_metadata(client, std::mem::take(&mut batch)).await.ok(); } } } } - /// Get metadata for all public keys in a event - async fn get_metadata(client: &Client, public_keys: I) -> Result<(), Error> - where - I: IntoIterator, - { - let authors: Vec = public_keys.into_iter().collect(); - let limit = authors.len(); - - if authors.is_empty() { - return Err(anyhow!("You need at least one public key")); - } - - // Construct the subscription option - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(Duration::from_secs(TIMEOUT))); - - // Construct the filter for metadata - let filter = Filter::new() - .kind(Kind::Metadata) - .authors(authors) - .limit(limit); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(()) - } - - /// Load all user profiles from the database - async fn load_persons(client: &Client) -> Result, Error> { - let filter = Filter::new().kind(Kind::Metadata).limit(200); - let events = client.database().query(filter).await?; - - let mut persons = vec![]; - - for event in events.into_iter() { - let metadata = Metadata::from_json(event.content).unwrap_or_default(); - let person = Person::new(event.pubkey, metadata); - persons.push(person); - } - - Ok(persons) - } - /// Set profile encryption keys announcement fn set_announcement(&mut self, event: &Event, cx: &mut App) { if let Some(person) = self.persons.get(&event.pubkey) { @@ -266,6 +227,18 @@ impl PersonRegistry { } } + /// Set messaging relays for a person + fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) { + if let Some(person) = self.persons.get(&event.pubkey) { + let urls: Vec = nip17::extract_relay_list(event).cloned().collect(); + + person.update(cx, |person, cx| { + person.set_messaging_relays(urls); + cx.notify(); + }); + } + } + /// Insert batch of persons fn bulk_inserts(&mut self, persons: Vec, cx: &mut Context) { for person in persons.into_iter() { @@ -316,3 +289,53 @@ impl PersonRegistry { Person::new(public_key, Metadata::default()) } } + +/// Get metadata for all public keys in a event +async fn get_metadata(client: &Client, public_keys: I) -> Result<(), Error> +where + I: IntoIterator, +{ + let authors: Vec = public_keys.into_iter().collect(); + let limit = authors.len(); + + if authors.is_empty() { + return Err(anyhow!("You need at least one public key")); + } + + // Construct the subscription option + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(Duration::from_secs(TIMEOUT))); + + // Construct the filter for metadata + let filter = Filter::new() + .kind(Kind::Metadata) + .authors(authors) + .limit(limit); + + // Construct target for subscription + let target = BOOTSTRAP_RELAYS + .into_iter() + .map(|relay| (relay, vec![filter.clone()])) + .collect::>(); + + client.subscribe(target).close_on(opts).await?; + + Ok(()) +} + +/// Load all user profiles from the database +async fn load_persons(client: &Client) -> Result, Error> { + let filter = Filter::new().kind(Kind::Metadata).limit(200); + let events = client.database().query(filter).await?; + + let mut persons = vec![]; + + for event in events.into_iter() { + let metadata = Metadata::from_json(event.content).unwrap_or_default(); + let person = Person::new(event.pubkey, metadata); + persons.push(person); + } + + Ok(persons) +} diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index a66a2eb..e852fd5 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -1,9 +1,11 @@ use std::cmp::Ordering; use std::hash::{Hash, Hasher}; +use device::Announcement; use gpui::SharedString; use nostr_sdk::prelude::*; -use state::Announcement; + +const IMAGE_RESIZER: &str = "https://wsrv.nl"; /// Person #[derive(Debug, Clone)] @@ -16,6 +18,9 @@ pub struct Person { /// Dekey (NIP-4e) announcement announcement: Option, + + /// Messaging relays + messaging_relays: Vec, } impl PartialEq for Person { @@ -56,6 +61,7 @@ impl Person { public_key, metadata, announcement: None, + messaging_relays: vec![], } } @@ -80,13 +86,37 @@ impl Person { log::info!("Updated announcement for: {}", self.public_key()); } + /// Get profile messaging relays + pub fn messaging_relays(&self) -> &Vec { + &self.messaging_relays + } + + /// Get relay hint for messaging relay list + pub fn messaging_relay_hint(&self) -> Option { + self.messaging_relays.first().cloned() + } + + /// Set profile messaging relays + pub fn set_messaging_relays(&mut self, relays: I) + where + I: IntoIterator, + { + self.messaging_relays = relays.into_iter().collect(); + log::info!("Updated messaging relays for: {}", self.public_key()); + } + /// Get profile avatar pub fn avatar(&self) -> SharedString { self.metadata() .picture .as_ref() .filter(|picture| !picture.is_empty()) - .map(|picture| picture.into()) + .map(|picture| { + let url = format!( + "{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1" + ); + url.into() + }) .unwrap_or_else(|| "brand/avatar.png".into()) } diff --git a/crates/relay_auth/src/lib.rs b/crates/relay_auth/src/lib.rs index b753261..c19c847 100644 --- a/crates/relay_auth/src/lib.rs +++ b/crates/relay_auth/src/lib.rs @@ -1,22 +1,23 @@ use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; -use std::hash::{Hash, Hasher}; +use std::hash::Hash; use std::rc::Rc; +use std::sync::Arc; -use anyhow::{anyhow, Error}; +use anyhow::{anyhow, Context as AnyhowContext, Error}; use gpui::{ App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled, - Subscription, Task, Window, + Task, Window, }; use nostr_sdk::prelude::*; use settings::{AppSettings, AuthMode}; use smallvec::{smallvec, SmallVec}; -use state::{tracker, NostrRegistry}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::notification::Notification; -use ui::{v_flex, ContextModal, Disableable, IconName, Sizable}; +use ui::{v_flex, Disableable, IconName, Sizable, WindowExtension}; const AUTH_MESSAGE: &str = "Approve the authentication request to allow Coop to continue sending or receiving events."; @@ -26,16 +27,10 @@ pub fn init(window: &mut Window, cx: &mut App) { } /// Authentication request -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct AuthRequest { - pub url: RelayUrl, - pub challenge: String, -} - -impl Hash for AuthRequest { - fn hash(&self, state: &mut H) { - self.challenge.hash(state); - } +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +struct AuthRequest { + url: RelayUrl, + challenge: String, } impl AuthRequest { @@ -45,6 +40,20 @@ impl AuthRequest { url, } } + + pub fn url(&self) -> &RelayUrl { + &self.url + } + + pub fn challenge(&self) -> &str { + &self.challenge + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Signal { + Auth(Arc), + Pending((EventId, RelayUrl)), } struct GlobalRelayAuth(Entity); @@ -54,14 +63,11 @@ impl Global for GlobalRelayAuth {} // Relay authentication #[derive(Debug)] pub struct RelayAuth { - /// Entity for managing auth requests - requests: HashSet, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, + /// Pending events waiting for resend after authentication + pending_events: HashSet<(EventId, RelayUrl)>, /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 1]>, + tasks: SmallVec<[Task<()>; 2]>, } impl RelayAuth { @@ -77,206 +83,242 @@ impl RelayAuth { /// Create a new relay auth instance fn new(window: &mut Window, cx: &mut Context) -> Self { + cx.defer_in(window, |this, window, cx| { + this.handle_notifications(window, cx); + }); + + Self { + pending_events: HashSet::default(), + tasks: smallvec![], + } + } + + /// Handle nostr notifications + fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - // Get the current entity - let entity = cx.entity(); - // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::(100); + let (tx, rx) = flume::bounded::(256); - let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; + self.tasks.push(cx.background_spawn(async move { + log::info!("Started handling nostr notifications"); + let mut notifications = client.notifications(); + let mut challenges: HashSet> = HashSet::default(); - subscriptions.push( - // Observe the current state - cx.observe_in(&entity, window, |this, _, window, cx| { - let settings = AppSettings::global(cx); - let mode = AppSettings::get_auth_mode(cx); + while let Some(notification) = notifications.next().await { + if let ClientNotification::Message { relay_url, message } = notification { + match message { + RelayMessage::Auth { challenge } => { + if challenges.insert(challenge.clone()) { + let request = Arc::new(AuthRequest::new(challenge, relay_url)); + let signal = Signal::Auth(request); - for req in this.requests.clone().into_iter() { - let is_trusted_relay = settings.read(cx).is_trusted_relay(&req.url, cx); + tx.send_async(signal).await.ok(); + } + } + RelayMessage::Ok { + event_id, message, .. + } => { + let msg = MachineReadablePrefix::parse(&message); - if is_trusted_relay && mode == AuthMode::Auto { - // Automatically authenticate if the relay is authenticated before - this.response(req, window, cx); - } else { - // Otherwise open the auth request popup - this.ask_for_approval(req, window, cx); + // Handle authentication messages + if let Some(MachineReadablePrefix::AuthRequired) = msg { + let signal = Signal::Pending((event_id, relay_url)); + tx.send_async(signal).await.ok(); + } + } + _ => {} } } - }), - ); + } + })); - tasks.push( - // Handle nostr notifications - cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }), - ); - - tasks.push( - // Update GPUI states - cx.spawn(async move |this, cx| { - while let Ok(request) = rx.recv_async().await { - this.update(cx, |this, cx| { - this.add_request(request, cx); - }) - .ok(); - } - }), - ); - - Self { - requests: HashSet::new(), - _subscriptions: subscriptions, - _tasks: tasks, - } - } - - // Handle nostr notifications - async fn handle_notifications(client: &Client, tx: &flume::Sender) { - let mut notifications = client.notifications(); - - while let Ok(notification) = notifications.recv().await { - if let RelayPoolNotification::Message { - message: RelayMessage::Auth { challenge }, - relay_url, - } = notification - { - let request = AuthRequest::new(challenge, relay_url); - - if let Err(e) = tx.send_async(request).await { - log::error!("Failed to send auth request: {}", e); + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + while let Ok(signal) = rx.recv_async().await { + match signal { + Signal::Auth(req) => { + this.update_in(cx, |this, window, cx| { + this.handle_auth(&req, window, cx); + }) + .ok(); + } + Signal::Pending((event_id, relay_url)) => { + this.update_in(cx, |this, _window, cx| { + this.insert_pending_event(event_id, relay_url, cx); + }) + .ok(); + } } } - } + })); } - /// Add a new authentication request. - fn add_request(&mut self, request: AuthRequest, cx: &mut Context) { - self.requests.insert(request); + /// Insert a pending event waiting for resend after authentication + fn insert_pending_event(&mut self, id: EventId, relay: RelayUrl, cx: &mut Context) { + self.pending_events.insert((id, relay)); cx.notify(); } - /// Get the number of pending requests. - pub fn pending_requests(&self, _cx: &App) -> usize { - self.requests.len() + /// Get all pending events for a specific relay, + fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec { + let pending_events: Vec = self + .pending_events + .iter() + .filter(|(_, pending_relay)| pending_relay == relay) + .map(|(id, _relay)| id) + .cloned() + .collect(); + + pending_events } - /// Reask for approval for all pending requests. - pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context) { - for request in self.requests.clone().into_iter() { - self.ask_for_approval(request, window, cx); + /// Clear all pending events for a specific relay, + fn clear_pending_events(&mut self, relay: &RelayUrl, cx: &mut Context) { + self.pending_events + .retain(|(_, pending_relay)| pending_relay != relay); + cx.notify(); + } + + /// Handle authentication request + fn handle_auth(&mut self, req: &Arc, window: &mut Window, cx: &mut Context) { + let settings = AppSettings::global(cx); + let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx); + let mode = AppSettings::get_auth_mode(cx); + + if trusted_relay && mode == AuthMode::Auto { + // Automatically authenticate if the relay is authenticated before + self.response(req, window, cx); + } else { + // Otherwise open the auth request popup + self.ask_for_approval(req, window, cx); } } - /// Respond to an authentication request. - fn response(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { - let settings = AppSettings::global(cx); - + /// Send auth response and wait for confirmation + fn auth(&self, req: &Arc, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let req = req.clone(); - let challenge = req.challenge.to_owned(); - let url = req.url.to_owned(); - - let challenge_clone = challenge.clone(); - let url_clone = url.clone(); - - let task: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; + // Get all pending events for the relay + let pending_events = self.get_pending_events(req.url(), cx); + cx.background_spawn(async move { // Construct event - let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone()) - .sign(&signer) - .await?; + let builder = EventBuilder::auth(req.challenge(), req.url().clone()); + let event = client.sign_event_builder(builder).await?; // Get the event ID let id = event.id; // Get the relay - let relay = client.pool().relay(url_clone).await?; - let relay_url = relay.url(); + let relay = client.relay(req.url()).await?.context("Relay not found")?; // Subscribe to notifications let mut notifications = relay.notifications(); // Send the AUTH message - relay.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))?; + relay + .send_msg(ClientMessage::Auth(Cow::Borrowed(&event))) + .await?; - while let Ok(notification) = notifications.recv().await { + log::info!("Sending AUTH event"); + + while let Some(notification) = notifications.next().await { match notification { RelayNotification::Message { message: RelayMessage::Ok { event_id, .. }, } => { - if id == event_id { - // Re-subscribe to previous subscription - relay.resubscribe().await?; - - // Get all pending events that need to be resent - let mut tracker = tracker().write().await; - let ids: Vec = tracker.pending_resend(relay_url); - - for id in ids.into_iter() { - if let Some(event) = client.database().event_by_id(&id).await? { - let event_id = relay.send_event(&event).await?; - tracker.sent(event_id); - } - } - - return Ok(()); + if id != event_id { + continue; } + + // Get all subscriptions + let subscriptions = relay.subscriptions().await; + + // Re-subscribe to previous subscriptions + for (id, filters) in subscriptions.into_iter() { + if !filters.is_empty() { + relay.send_msg(ClientMessage::req(id, filters)).await?; + } + } + + // Re-send pending events + for id in pending_events { + if let Some(event) = client.database().event_by_id(&id).await? { + relay.send_event(&event).await?; + } + } + + return Ok(()); } RelayNotification::AuthenticationFailed => break, - RelayNotification::Shutdown => break, _ => {} } } Err(anyhow!("Authentication failed")) - }); + }) + } - self._tasks.push( - // Handle response in the background - cx.spawn_in(window, async move |this, cx| { - match task.await { + /// Respond to an authentication request. + fn response(&self, req: &Arc, window: &Window, cx: &Context) { + let settings = AppSettings::global(cx); + let req = req.clone(); + let challenge = req.challenge().to_string(); + + // Create a task for authentication + let task = self.auth(&req, cx); + + cx.spawn_in(window, async move |this, cx| { + let result = task.await; + let url = req.url(); + + this.update_in(cx, |this, window, cx| { + window.clear_notification(challenge, cx); + + match result { Ok(_) => { - this.update_in(cx, |this, window, cx| { - // Clear the current notification - window.clear_notification_by_id(SharedString::from(&challenge), cx); - - // Push a new notification - window.push_notification(format!("{url} has been authenticated"), cx); - - // Save the authenticated relay to automatically authenticate future requests - settings.update(cx, |this, cx| { - this.add_trusted_relay(url, cx); - }); - - // Remove the challenge from the list of pending authentications - this.requests.remove(&req); - cx.notify(); - }) - .expect("Entity has been released"); + // Clear pending events for the authenticated relay + this.clear_pending_events(url, cx); + // Save the authenticated relay to automatically authenticate future requests + settings.update(cx, |this, cx| { + this.add_trusted_relay(url, cx); + }); + window.push_notification(format!("{} has been authenticated", url), cx); } Err(e) => { - this.update_in(cx, |_, window, cx| { - window.push_notification(Notification::error(e.to_string()), cx); - }) - .expect("Entity has been released"); + window.push_notification(Notification::error(e.to_string()), cx); } - }; - }), - ); + } + }) + .ok(); + }) + .detach(); } /// Push a popup to approve the authentication request. - fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { - let url = SharedString::from(req.url.clone().to_string()); + fn ask_for_approval(&self, req: &Arc, window: &Window, cx: &Context) { + let notification = self.notification(req, cx); + + cx.spawn_in(window, async move |_this, cx| { + cx.update(|window, cx| { + window.push_notification(notification, cx); + }) + .ok(); + }) + .detach(); + } + + /// Build a notification for the authentication request. + fn notification(&self, req: &Arc, cx: &Context) -> Notification { + let req = req.clone(); + let url = SharedString::from(req.url().to_string()); let entity = cx.entity().downgrade(); let loading = Rc::new(Cell::new(false)); - let note = Notification::new() + Notification::new() .custom_id(SharedString::from(&req.challenge)) .autohide(false) .icon(IconName::Info) @@ -299,7 +341,7 @@ impl RelayAuth { .into_any_element() }) .action(move |_window, _cx| { - let entity = entity.clone(); + let view = entity.clone(); let req = req.clone(); Button::new("approve") @@ -310,24 +352,18 @@ impl RelayAuth { .disabled(loading.get()) .on_click({ let loading = Rc::clone(&loading); + move |_ev, window, cx| { // Set loading state to true loading.set(true); // Process to approve the request - entity - .update(cx, |this, cx| { - this.response(req.clone(), window, cx); - }) - .ok(); + view.update(cx, |this, cx| { + this.response(&req, window, cx); + }) + .ok(); } }) - }); - - // Push the notification to the current window - window.push_notification(note, cx); - - // Bring the window to the front - cx.activate(true); + }) } } diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 35602cd..e330b85 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -5,10 +5,11 @@ edition.workspace = true publish.workspace = true [dependencies] -state = { path = "../state" } +common = { path = "../common" } nostr-sdk.workspace = true gpui.workspace = true +smol.workspace = true anyhow.workspace = true log.workspace = true smallvec.workspace = true diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 013880c..977d97b 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,13 +1,11 @@ use std::collections::{HashMap, HashSet}; use anyhow::{anyhow, Error}; +use common::config_dir; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; - -const SETTINGS_IDENTIFIER: &str = "coop:settings"; pub fn init(cx: &mut App) { AppSettings::set_global(cx.new(AppSettings::new), cx) @@ -47,17 +45,31 @@ setting_accessors! { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum AuthMode { #[default] - Manual, Auto, + Manual, } /// Signer kind #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum SignerKind { - #[default] Auto, + #[default] User, - Device, + Encryption, +} + +impl SignerKind { + pub fn auto(&self) -> bool { + matches!(self, SignerKind::Auto) + } + + pub fn user(&self) -> bool { + matches!(self, SignerKind::User) + } + + pub fn encryption(&self) -> bool { + matches!(self, SignerKind::Encryption) + } } /// Room configuration @@ -67,6 +79,16 @@ pub struct RoomConfig { signer_kind: SignerKind, } +impl RoomConfig { + pub fn backup(&self) -> bool { + self.backup + } + + pub fn signer_kind(&self) -> &SignerKind { + &self.signer_kind + } +} + /// Settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { @@ -118,10 +140,7 @@ pub struct AppSettings { values: Settings, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, - - /// Background tasks - _tasks: SmallVec<[Task<()>; 1]>, + _subscriptions: SmallVec<[Subscription; 2]>, } impl AppSettings { @@ -136,9 +155,6 @@ impl AppSettings { } fn new(cx: &mut Context) -> Self { - let load_settings = Self::get_from_database(false, cx); - - let mut tasks = smallvec![]; let mut subscriptions = smallvec![]; subscriptions.push( @@ -148,108 +164,77 @@ impl AppSettings { }), ); - tasks.push( - // Load the initial settings - cx.spawn(async move |this, cx| { - if let Ok(settings) = load_settings.await { - this.update(cx, |this, cx| { - this.values = settings; - cx.notify(); - }) - .ok(); - } - }), - ); + cx.defer(|cx| { + let settings = AppSettings::global(cx); + + settings.update(cx, |this, cx| { + this.load(cx); + }); + }); Self { values: Settings::default(), _subscriptions: subscriptions, - _tasks: tasks, } } - /// Get settings from the database - /// - /// If `current_user` is true, the settings will be retrieved for current user. - /// Otherwise, Coop will load the latest settings from the database. - fn get_from_database(current_user: bool, cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - cx.background_spawn(async move { - // Construct a filter to get the latest settings - let mut filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(SETTINGS_IDENTIFIER) - .limit(1); - - if current_user { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - // Push author to the filter - filter = filter.author(public_key); - } - - if let Some(event) = client.database().query(filter).await?.first_owned() { - Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default())) - } else { - Err(anyhow!("Not found")) - } - }) + /// Update settings + fn set_settings(&mut self, settings: Settings, cx: &mut Context) { + self.values = settings; + cx.notify(); } /// Load settings - pub fn load(&mut self, cx: &mut Context) { - let task = Self::get_from_database(true, cx); + fn load(&mut self, cx: &mut Context) { + let task: Task> = cx.background_spawn(async move { + let path = config_dir().join(".settings"); - self._tasks.push( - // Run task in the background - cx.spawn(async move |this, cx| { - if let Ok(settings) = task.await { - this.update(cx, |this, cx| { - this.values = settings; - cx.notify(); - }) - .ok(); - } - }), - ); + if let Ok(content) = smol::fs::read_to_string(&path).await { + Ok(serde_json::from_str(&content)?) + } else { + Err(anyhow!("Not found")) + } + }); + + cx.spawn(async move |this, cx| { + let settings = task.await.unwrap_or(Settings::default()); + + // Update settings + this.update(cx, |this, cx| { + this.set_settings(settings, cx); + }) + .ok(); + }) + .detach(); } /// Save settings pub fn save(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); + let settings = self.values.clone(); - if let Ok(content) = serde_json::to_string(&self.values) { - let task: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; + let task: Task> = cx.background_spawn(async move { + let path = config_dir().join(".settings"); + let content = serde_json::to_string(&settings)?; - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tag(Tag::identifier(SETTINGS_IDENTIFIER)) - .build(public_key) - .sign(&Keys::generate()) - .await?; + // Write settings to file + smol::fs::write(&path, content).await?; - client.database().save_event(&event).await?; + Ok(()) + }); - Ok(()) - }); - - task.detach(); - } + task.detach(); } - /// Check if the given relay is trusted - pub fn is_trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool { - self.values.trusted_relays.contains(url) + /// Check if the given relay is already authenticated + pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool { + self.values.trusted_relays.iter().any(|relay| { + relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash() + }) } /// Add a relay to the trusted list - pub fn add_trusted_relay(&mut self, url: RelayUrl, cx: &mut Context) { - self.values.trusted_relays.insert(url); + pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context) { + self.values.trusted_relays.insert(url.clone()); cx.notify(); } diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 51ac44e..6f440ef 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -9,11 +9,19 @@ common = { path = "../common" } nostr-sdk.workspace = true nostr-lmdb.workspace = true +nostr-connect.workspace = true gpui.workspace = true +gpui_tokio.workspace = true smol.workspace = true +reqwest.workspace = true flume.workspace = true log.workspace = true anyhow.workspace = true +webbrowser.workspace = true +serde.workspace = true +serde_json.workspace = true rustls = "0.23" +petname = "2.0.2" +whoami = "1.6.1" diff --git a/crates/state/src/constants.rs b/crates/state/src/constants.rs new file mode 100644 index 0000000..2c0d539 --- /dev/null +++ b/crates/state/src/constants.rs @@ -0,0 +1,59 @@ +use std::sync::OnceLock; + +/// Client name (Application name) +pub const CLIENT_NAME: &str = "Coop"; + +/// COOP's public key +pub const COOP_PUBKEY: &str = "npub126kl5fruqan90py77gf6pvfvygefl2mu2ukew6xdx5pc5uqscwgsnkgarv"; + +/// App ID +pub const APP_ID: &str = "su.reya.coop"; + +/// Keyring name +pub const KEYRING: &str = "Coop Safe Storage"; + +/// Default timeout for subscription +pub const TIMEOUT: u64 = 3; + +/// Default delay for searching +pub const FIND_DELAY: u64 = 600; + +/// Default limit for searching +pub const FIND_LIMIT: usize = 20; + +/// Default timeout for Nostr Connect +pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; + +/// Default Nostr Connect relay +pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; + +/// Default subscription id for device gift wrap events +pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; + +/// Default subscription id for user gift wrap events +pub const USER_GIFTWRAP: &str = "user-gift-wraps"; + +/// Default vertex relays +pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"]; + +/// Default search relays +pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"]; + +/// Default bootstrap relays +pub const BOOTSTRAP_RELAYS: [&str; 3] = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://user.kindpag.es", +]; + +static APP_NAME: OnceLock = OnceLock::new(); + +/// Get the app name +pub fn app_name() -> &'static String { + APP_NAME.get_or_init(|| { + let devicename = whoami::devicename(); + let platform = whoami::platform(); + + format!("{CLIENT_NAME} on {platform} ({devicename})") + }) +} diff --git a/crates/state/src/device.rs b/crates/state/src/device.rs deleted file mode 100644 index f809c38..0000000 --- a/crates/state/src/device.rs +++ /dev/null @@ -1,62 +0,0 @@ -use gpui::SharedString; -use nostr_sdk::prelude::*; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] -pub enum DeviceState { - #[default] - Initial, - Requesting, - Set, -} - -/// Announcement -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Announcement { - /// The public key of the device that created this announcement. - public_key: PublicKey, - - /// The name of the device that created this announcement. - client_name: Option, -} - -impl From<&Event> for Announcement { - fn from(val: &Event) -> Self { - let public_key = val - .tags - .iter() - .find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "P") - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .unwrap_or(val.pubkey); - - let client_name = val - .tags - .find(TagKind::Client) - .and_then(|tag| tag.content()) - .map(|c| c.to_string()); - - Self::new(public_key, client_name) - } -} - -impl Announcement { - pub fn new(public_key: PublicKey, client_name: Option) -> Self { - Self { - public_key, - client_name, - } - } - - /// Returns the public key of the device that created this announcement. - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - /// Returns the client name of the device that created this announcement. - pub fn client_name(&self) -> SharedString { - self.client_name - .as_ref() - .map(SharedString::from) - .unwrap_or(SharedString::from("Unknown")) - } -} diff --git a/crates/state/src/event.rs b/crates/state/src/event.rs deleted file mode 100644 index e7de936..0000000 --- a/crates/state/src/event.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::collections::HashSet; -use std::sync::{Arc, OnceLock}; - -use nostr_sdk::prelude::*; -use smol::lock::RwLock; - -static TRACKER: OnceLock>> = OnceLock::new(); - -pub fn tracker() -> &'static Arc> { - TRACKER.get_or_init(|| Arc::new(RwLock::new(EventTracker::default()))) -} - -/// Event tracker -#[derive(Debug, Clone, Default)] -pub struct EventTracker { - /// Tracking events sent by Coop in the current session - sent_ids: HashSet, - - /// Events that need to be resent later - pending_resend: HashSet<(EventId, RelayUrl)>, -} - -impl EventTracker { - /// Check if an event was sent by Coop in the current session. - pub fn is_sent_by_coop(&self, id: &EventId) -> bool { - self.sent_ids.contains(id) - } - - /// Mark an event as sent by Coop. - pub fn sent(&mut self, id: EventId) { - self.sent_ids.insert(id); - } - - /// Get all events that need to be resent later for a specific relay. - pub fn pending_resend(&mut self, relay: &RelayUrl) -> Vec { - self.pending_resend - .extract_if(|(_id, url)| url == relay) - .map(|(id, _url)| id) - .collect() - } - - /// Add an event (id and relay url) to the pending resend set. - pub fn add_to_pending(&mut self, id: EventId, url: RelayUrl) { - self.pending_resend.insert((id, url)); - } -} diff --git a/crates/state/src/identity.rs b/crates/state/src/identity.rs deleted file mode 100644 index 8c59c18..0000000 --- a/crates/state/src/identity.rs +++ /dev/null @@ -1,86 +0,0 @@ -use nostr_sdk::prelude::*; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum RelayState { - #[default] - Initial, - NotSet, - Set, -} - -impl RelayState { - pub fn is_initial(&self) -> bool { - matches!(self, RelayState::Initial) - } -} - -/// Identity -#[derive(Debug, Clone, Default)] -pub struct Identity { - /// The public key of the account - pub public_key: Option, - - /// Status of the current user NIP-65 relays - relay_list: RelayState, - - /// Status of the current user NIP-17 relays - messaging_relays: RelayState, -} - -impl AsRef for Identity { - fn as_ref(&self) -> &Identity { - self - } -} - -impl Identity { - pub fn new() -> Self { - Self { - public_key: None, - relay_list: RelayState::default(), - messaging_relays: RelayState::default(), - } - } - - /// Sets the state of the NIP-65 relays. - pub fn set_relay_list_state(&mut self, state: RelayState) { - self.relay_list = state; - } - - /// Returns the state of the NIP-65 relays. - pub fn relay_list_state(&self) -> RelayState { - self.relay_list - } - - /// Sets the state of the NIP-17 relays. - pub fn set_messaging_relays_state(&mut self, state: RelayState) { - self.messaging_relays = state; - } - - /// Returns the state of the NIP-17 relays. - pub fn messaging_relays_state(&self) -> RelayState { - self.messaging_relays - } - - /// Force getting the public key of the identity. - /// - /// Panics if the public key is not set. - pub fn public_key(&self) -> PublicKey { - self.public_key.unwrap() - } - - /// Returns true if the identity has a public key. - pub fn has_public_key(&self) -> bool { - self.public_key.is_some() - } - - /// Sets the public key of the identity. - pub fn set_public_key(&mut self, public_key: PublicKey) { - self.public_key = Some(public_key); - } - - /// Unsets the public key of the identity. - pub fn unset_public_key(&mut self) { - self.public_key = None; - } -} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 201027c..07a6330 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,34 +1,39 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; use std::time::Duration; -use anyhow::Error; -use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; -use nostr_lmdb::NostrLmdb; +use anyhow::{anyhow, Context as AnyhowContext, Error}; +use common::config_dir; +use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; +use nostr_connect::prelude::*; +use nostr_lmdb::prelude::*; use nostr_sdk::prelude::*; -mod device; -mod event; +mod constants; mod gossip; -mod identity; +mod nip05; +mod signer; -pub use device::*; -pub use event::*; +pub use constants::*; pub use gossip::*; -pub use identity::*; +pub use nip05::*; +pub use signer::*; -use crate::identity::Identity; +pub fn init(window: &mut Window, cx: &mut App) { + // rustls uses the `aws_lc_rs` provider by default + // This only errors if the default provider has already + // been installed. We can ignore this `Result`. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); -pub fn init(cx: &mut App) { - NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); + // Initialize the tokio runtime + gpui_tokio::init(cx); + + NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx); } -/// Default timeout for subscription -pub const TIMEOUT: u64 = 3; - -/// Default subscription id for gift wrap events -pub const GIFTWRAP_SUBSCRIPTION: &str = "giftwrap-events"; - struct GlobalNostrRegistry(Entity); impl Global for GlobalNostrRegistry {} @@ -39,24 +44,28 @@ pub struct NostrRegistry { /// Nostr client client: Client, + /// Nostr signer + signer: Arc, + /// App keys /// /// Used for Nostr Connect and NIP-4e operations app_keys: Keys, - /// Current identity (user's public key) - /// - /// Set by the current Nostr signer - identity: Entity, - - /// Gossip implementation + /// Custom gossip implementation gossip: Entity, + /// Relay list state + relay_list_state: RelayState, + + /// Whether Coop is connected to all bootstrap relays + connected: bool, + + /// Whether Coop is creating a new signer + creating: bool, + /// Tasks for asynchronous operations tasks: Vec>>, - - /// Subscriptions - _subscriptions: Vec, } impl NostrRegistry { @@ -71,23 +80,15 @@ impl NostrRegistry { } /// Create a new nostr instance - fn new(cx: &mut Context) -> Self { - // rustls uses the `aws_lc_rs` provider by default - // This only errors if the default provider has already - // been installed. We can ignore this `Result`. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .ok(); + fn new(window: &mut Window, cx: &mut Context) -> Self { + // Construct the nostr signer + let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate()); + let signer = Arc::new(CoopSigner::new(app_keys.clone())); - // Construct the nostr client options - let opts = ClientOptions::new() - .automatic_authentication(false) - .verify_subscriptions(false) - .sleep_when_idle(SleepWhenIdle::Enabled { - timeout: Duration::from_secs(600), - }); + // Construct the gossip entity + let gossip = cx.new(|_| Gossip::default()); - // Construct the lmdb + // Construct the nostr lmdb instance let lmdb = cx.foreground_executor().block_on(async move { NostrLmdb::open(config_dir().join("nostr")) .await @@ -95,221 +96,141 @@ impl NostrRegistry { }); // Construct the nostr client - let client = ClientBuilder::default().database(lmdb).opts(opts).build(); - let _ = tracker(); + let client = ClientBuilder::default() + .signer(signer.clone()) + .database(lmdb) + .automatic_authentication(false) + .verify_subscriptions(false) + .connect_timeout(Duration::from_secs(TIMEOUT)) + .sleep_when_idle(SleepWhenIdle::Enabled { + timeout: Duration::from_secs(600), + }) + .build(); - // Get the app keys - let app_keys = Self::create_or_init_app_keys().unwrap(); + // Run at the end of current cycle + cx.defer_in(window, |this, _window, cx| { + this.connect(cx); + this.handle_notifications(cx); + }); - // Construct the gossip entity - let gossip = cx.new(|_| Gossip::default()); - let async_gossip = gossip.downgrade(); + Self { + client, + signer, + app_keys, + gossip, + relay_list_state: RelayState::Idle, + connected: false, + creating: false, + tasks: vec![], + } + } - // Construct the identity entity - let identity = cx.new(|_| Identity::default()); + fn connect(&mut self, cx: &mut Context) { + let client = self.client(); + + self.tasks.push(cx.spawn(async move |this, cx| { + cx.background_executor() + .await_on_background(async move { + // Add search relay to the relay pool + for url in SEARCH_RELAYS.into_iter() { + client.add_relay(url).await.ok(); + } + + // Add bootstrap relay to the relay pool + for url in BOOTSTRAP_RELAYS.into_iter() { + client.add_relay(url).await.ok(); + } + + // Connect to all added relays + client + .connect() + .and_wait(Duration::from_secs(TIMEOUT)) + .await; + }) + .await; + + // Update the state + this.update(cx, |this, cx| { + this.set_connected(cx); + this.get_signer(cx); + })?; + + Ok(()) + })); + } + + /// Handle nostr notifications + fn handle_notifications(&mut self, cx: &mut Context) { + let client = self.client(); + let gossip = self.gossip.downgrade(); // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(2048); - let mut subscriptions = vec![]; - let mut tasks = vec![]; - - subscriptions.push( - // Observe the identity entity - cx.observe(&identity, |this, state, cx| { - if state.read(cx).has_public_key() { - match state.read(cx).relay_list_state() { - RelayState::Initial => { - this.get_relay_list(cx); - } - RelayState::Set => { - if state.read(cx).messaging_relays_state() == RelayState::Initial { - this.get_profile(cx); - this.get_messaging_relays(cx); - }; - } - _ => {} - } - } - }), - ); - - tasks.push( + let task: Task> = cx.background_spawn(async move { // Handle nostr notifications - cx.background_spawn({ - let client = client.clone(); + let mut notifications = client.notifications(); + let mut processed_events = HashSet::new(); - async move { Self::handle_notifications(&client, &tx).await } - }), - ); + while let Some(notification) = notifications.next().await { + if let ClientNotification::Message { + message: + RelayMessage::Event { + event, + subscription_id, + }, + .. + } = notification + { + // Skip if the event has already been processed + if !processed_events.insert(event.id) { + continue; + } - tasks.push( - // Update GPUI states - cx.spawn(async move |_this, cx| { - while let Ok(event) = rx.recv_async().await { match event.kind { Kind::RelayList => { - async_gossip.update(cx, |this, cx| { - this.insert_relays(&event); - cx.notify(); - })?; + // Automatically get messaging relays for each member when the user opens a room + if subscription_id.as_str().starts_with("room-") { + get_adv_events_by(&client, event.as_ref()).await?; + } + + tx.send_async(event.into_owned()).await?; } Kind::InboxRelays => { - async_gossip.update(cx, |this, cx| { - this.insert_messaging_relays(&event); - cx.notify(); - })?; + tx.send_async(event.into_owned()).await?; } _ => {} } } + } - Ok(()) - }), - ); + Ok(()) + }); - Self { - client, - app_keys, - identity, - gossip, - _subscriptions: subscriptions, - tasks, - } - } + // Run task in the background + task.detach(); - /// Handle nostr notifications - async fn handle_notifications(client: &Client, tx: &flume::Sender) -> Result<(), Error> { - // Add bootstrap relay to the relay pool - for url in BOOTSTRAP_RELAYS.into_iter() { - client.add_relay(url).await?; - } - - // Add search relay to the relay pool - for url in SEARCH_RELAYS.into_iter() { - client.add_relay(url).await?; - } - - // Connect to all added relays - client.connect().await; - - // Handle nostr notifications - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); - - while let Ok(notification) = notifications.recv().await { - if let RelayPoolNotification::Message { message, relay_url } = notification { - match message { - RelayMessage::Event { - event, - subscription_id, - } => { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; - } - - match event.kind { - Kind::RelayList => { - // Automatically get messaging relays for each member when the user opens a room - if subscription_id.as_str().starts_with("room-") { - Self::get_adv_events_by(client, event.as_ref()).await?; - } - - tx.send_async(event.into_owned()).await?; - } - Kind::InboxRelays => { - tx.send_async(event.into_owned()).await?; - } - _ => {} - } + self.tasks.push(cx.spawn(async move |_this, cx| { + while let Ok(event) = rx.recv_async().await { + match event.kind { + Kind::RelayList => { + gossip.update(cx, |this, cx| { + this.insert_relays(&event); + cx.notify(); + })?; } - RelayMessage::Ok { - event_id, message, .. - } => { - let msg = MachineReadablePrefix::parse(&message); - let mut tracker = tracker().write().await; - - // Handle authentication messages - if let Some(MachineReadablePrefix::AuthRequired) = msg { - // Keep track of events that need to be resent after authentication - tracker.add_to_pending(event_id, relay_url); - } else { - // Keep track of events sent by Coop - tracker.sent(event_id) - } + Kind::InboxRelays => { + gossip.update(cx, |this, cx| { + this.insert_messaging_relays(&event); + cx.notify(); + })?; } _ => {} } } - } - Ok(()) - } - - /// Automatically get messaging relays and encryption announcement from a received relay list - async fn get_adv_events_by(client: &Client, event: &Event) -> Result<(), Error> { - // Subscription options - let opts = SubscribeAutoCloseOptions::default() - .timeout(Some(Duration::from_secs(TIMEOUT))) - .exit_policy(ReqExitPolicy::ExitOnEOSE); - - // Extract write relays from event - let write_relays: Vec<&RelayUrl> = nip65::extract_relay_list(event) - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url) - } else { - None - } - }) - .collect(); - - // Ensure relay connections - for relay in write_relays.iter() { - client.add_relay(*relay).await?; - client.connect_relay(*relay).await?; - } - - // Construct filter for inbox relays - let inbox = Filter::new() - .kind(Kind::InboxRelays) - .author(event.pubkey) - .limit(1); - - // Construct filter for encryption announcement - let announcement = Filter::new() - .kind(Kind::Custom(10044)) - .author(event.pubkey) - .limit(1); - - client - .subscribe_to(write_relays, vec![inbox, announcement], Some(opts)) - .await?; - - Ok(()) - } - - /// Get or create a new app keys - fn create_or_init_app_keys() -> Result { - let dir = config_dir().join(".app_keys"); - let content = match std::fs::read(&dir) { - Ok(content) => content, - Err(_) => { - // Generate new keys if file doesn't exist - let keys = Keys::generate(); - let secret_key = keys.secret_key(); - - std::fs::create_dir_all(dir.parent().unwrap())?; - std::fs::write(&dir, secret_key.to_secret_bytes())?; - - return Ok(keys); - } - }; - let secret_key = SecretKey::from_slice(&content)?; - let keys = Keys::new(secret_key); - - Ok(keys) + Ok(()) + })); } /// Get the nostr client @@ -317,14 +238,29 @@ impl NostrRegistry { self.client.clone() } + /// Get the nostr signer + pub fn signer(&self) -> Arc { + self.signer.clone() + } + /// Get the app keys pub fn app_keys(&self) -> &Keys { &self.app_keys } - /// Get current identity - pub fn identity(&self) -> Entity { - self.identity.clone() + /// Get the connected status of the client + pub fn connected(&self) -> bool { + self.connected + } + + /// Get the creating status + pub fn creating(&self) -> bool { + self.creating + } + + /// Get the relay list state + pub fn relay_list_state(&self) -> RelayState { + self.relay_list_state.clone() } /// Get a relay hint (messaging relay) for a given public key @@ -344,8 +280,7 @@ impl NostrRegistry { cx.background_spawn(async move { // Ensure relay connections for url in relays.iter() { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); + client.add_relay(url).and_connect().await.ok(); } relays @@ -360,8 +295,7 @@ impl NostrRegistry { cx.background_spawn(async move { // Ensure relay connections for url in relays.iter() { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); + client.add_relay(url).and_connect().await.ok(); } relays @@ -376,97 +310,259 @@ impl NostrRegistry { cx.background_spawn(async move { // Ensure relay connections for url in relays.iter() { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); + client.add_relay(url).and_connect().await.ok(); } relays }) } + /// Set the connected status of the client + fn set_connected(&mut self, cx: &mut Context) { + self.connected = true; + cx.notify(); + } + + /// Get local stored signer + fn get_signer(&mut self, cx: &mut Context) { + let read_credential = cx.read_credentials(KEYRING); + + self.tasks.push(cx.spawn(async move |this, cx| { + match read_credential.await { + Ok(Some((_user, secret))) => { + let secret = SecretKey::from_slice(&secret)?; + let keys = Keys::new(secret); + + this.update(cx, |this, cx| { + this.set_signer(keys, false, cx); + })?; + } + _ => { + this.update(cx, |this, cx| { + this.get_bunker(cx); + })?; + } + } + + Ok(()) + })); + } + + /// Get local stored bunker connection + fn get_bunker(&mut self, cx: &mut Context) { + let client = self.client(); + let app_keys = self.app_keys().clone(); + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); + + let task: Task> = cx.background_spawn(async move { + log::info!("Getting bunker connection"); + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier("coop:account") + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + let uri = NostrConnectUri::parse(event.content)?; + let signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None)?; + + Ok(signer) + } else { + Err(anyhow!("No account found")) + } + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(signer) => { + this.update(cx, |this, cx| { + this.set_signer(signer, true, cx); + }) + .ok(); + } + Err(e) => { + log::warn!("Failed to get bunker: {e}"); + // Create a new identity if no stored bunker exists + this.update(cx, |this, cx| { + this.set_default_signer(cx); + }) + .ok(); + } + } + + Ok(()) + })); + } + /// Set the signer for the nostr client and verify the public key - pub fn set_signer(&mut self, signer: T, cx: &mut Context) + pub fn set_signer(&mut self, new: T, owned: bool, cx: &mut Context) where T: NostrSigner + 'static, { let client = self.client(); - let identity = self.identity.downgrade(); + let signer = self.signer(); // Create a task to update the signer and verify the public key - let task: Task> = cx.background_spawn(async move { + let task: Task> = cx.background_spawn(async move { // Update signer - client.set_signer(signer).await; + signer.switch(new, owned).await; + + // Unsubscribe from all subscriptions + client.unsubscribe_all().await?; // Verify signer - let signer = client.signer().await?; + let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; + log::info!("Signer's public key: {}", public_key); - Ok(public_key) + Ok(()) }); - self.tasks.push(cx.spawn(async move |_this, cx| { - match task.await { - Ok(public_key) => { - identity.update(cx, |this, cx| { - this.set_public_key(public_key); - cx.notify(); - })?; - } - Err(e) => { - log::error!("Failed to set signer: {e}"); - } - }; + self.tasks.push(cx.spawn(async move |this, cx| { + // set signer + task.await?; + + // Update states + this.update(cx, |this, cx| { + this.ensure_relay_list(cx); + })?; Ok(()) })); } - /// Unset the current signer - pub fn unset_signer(&mut self, cx: &mut Context) { + /// Create a new identity + fn set_default_signer(&mut self, cx: &mut Context) { let client = self.client(); - let async_identity = self.identity.downgrade(); + let keys = Keys::generate(); + let async_keys = keys.clone(); - self.tasks.push(cx.spawn(async move |_this, cx| { - // Unset the signer from nostr client - cx.background_executor() - .await_on_background(async move { - client.unset_signer().await; - }) - .await; + // Create a write credential task + let write_credential = cx.write_credentials( + KEYRING, + &keys.public_key().to_hex(), + &keys.secret_key().to_secret_bytes(), + ); - // Unset the current identity - async_identity - .update(cx, |this, cx| { - this.unset_public_key(); - cx.notify(); - }) - .ok(); + // Set the creating signer status + self.set_creating_signer(true, cx); + + // Run async tasks in background + let task: Task> = cx.background_spawn(async move { + let signer = async_keys.into_nostr_signer(); + + // Get default relay list + let relay_list = default_relay_list(); + + // Publish relay list event + let event = EventBuilder::relay_list(relay_list).sign(&signer).await?; + client + .send_event(&event) + .broadcast() + .ok_timeout(Duration::from_secs(TIMEOUT)) + .await?; + + // Construct the default metadata + let name = petname::petname(2, "-").unwrap_or("Cooper".to_string()); + let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap(); + let metadata = Metadata::new().display_name(&name).picture(avatar); + + // Publish metadata event + let event = EventBuilder::metadata(&metadata).sign(&signer).await?; + client + .send_event(&event) + .ok_timeout(Duration::from_secs(TIMEOUT)) + .ack_policy(AckPolicy::none()) + .await?; + + // Construct the default contact list + let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())]; + + // Publish contact list event + let event = EventBuilder::contact_list(contacts).sign(&signer).await?; + client + .send_event(&event) + .broadcast() + .ok_timeout(Duration::from_secs(TIMEOUT)) + .ack_policy(AckPolicy::none()) + .await?; + + // Construct the default messaging relay list + let relays = default_messaging_relays(); + + // Publish messaging relay list event + let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?; + client + .send_event(&event) + .ok_timeout(Duration::from_secs(TIMEOUT)) + .ack_policy(AckPolicy::none()) + .await?; + + // Write user's credentials to the system keyring + write_credential.await?; + + Ok(()) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + // Wait for the task to complete + task.await?; + + this.update(cx, |this, cx| { + this.set_creating_signer(false, cx); + this.set_signer(keys, false, cx); + })?; Ok(()) })); } - // Get relay list for current user - fn get_relay_list(&mut self, cx: &mut Context) { - let client = self.client(); - let async_identity = self.identity.downgrade(); - let public_key = self.identity().read(cx).public_key(); + /// Set whether Coop is creating a new signer + fn set_creating_signer(&mut self, creating: bool, cx: &mut Context) { + self.creating = creating; + cx.notify(); + } + + fn ensure_relay_list(&mut self, cx: &mut Context) { + let task = self.verify_relay_list(cx); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await?; + + // Update state + this.update(cx, |this, cx| { + this.relay_list_state = result; + cx.notify(); + })?; + + Ok(()) + })); + } + + // Verify relay list for current user + fn verify_relay_list(&mut self, cx: &mut Context) -> Task> { + let client = self.client(); + + cx.background_spawn(async move { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; - let task: Task> = cx.background_spawn(async move { let filter = Filter::new() .kind(Kind::RelayList) .author(public_key) .limit(1); + // Stream events from the bootstrap relays let mut stream = client - .stream_events_from(BOOTSTRAP_RELAYS, vec![filter], Duration::from_secs(TIMEOUT)) + .stream_events(filter) + .timeout(Duration::from_secs(TIMEOUT)) .await?; while let Some((_url, res)) = stream.next().await { match res { Ok(event) => { log::info!("Received relay list event: {event:?}"); - return Ok(RelayState::Set); + return Ok(RelayState::Configured); } Err(e) => { log::error!("Failed to receive relay list event: {e}"); @@ -474,122 +570,348 @@ impl NostrRegistry { } } - Ok(RelayState::NotSet) - }); + Ok(RelayState::NotConfigured) + }) + } - self.tasks.push(cx.spawn(async move |_this, cx| { - match task.await { - Ok(state) => { - async_identity - .update(cx, |this, cx| { - this.set_relay_list_state(state); - cx.notify(); - }) - .ok(); - } - Err(e) => { - log::error!("Failed to get relay list: {e}"); - } - } + /// Generate a direct nostr connection initiated by the client + pub fn client_connect(&self, relay: Option) -> (NostrConnect, NostrConnectUri) { + let app_keys = self.app_keys(); + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); + + // Determine the relay will be used for Nostr Connect + let relay = match relay { + Some(relay) => relay, + None => RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(), + }; + + // Generate the nostr connect uri + let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME); + + // Generate the nostr connect + let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap(); + + // Handle the auth request + signer.auth_url_handler(CoopAuthUrlHandler); + + (signer, uri) + } + + /// Store the bunker connection for the next login + pub fn persist_bunker(&mut self, uri: NostrConnectUri, cx: &mut App) { + let client = self.client(); + let rng_keys = Keys::generate(); + + self.tasks.push(cx.background_spawn(async move { + // Construct the event for application-specific data + let event = EventBuilder::new(Kind::ApplicationSpecificData, uri.to_string()) + .tag(Tag::identifier("coop:account")) + .sign(&rng_keys) + .await?; + + // Store the event in the database + client.database().save_event(&event).await?; Ok(()) })); } - /// Get profile and contact list for current user - fn get_profile(&mut self, cx: &mut Context) { + /// Get the public key of a NIP-05 address + pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task> { let client = self.client(); - let public_key = self.identity().read(cx).public_key(); - let write_relays = self.write_relays(&public_key, cx); + let http_client = cx.http_client(); - let task: Task> = cx.background_spawn(async move { - let mut urls = vec![]; - urls.extend(write_relays.await); - urls.extend( - BOOTSTRAP_RELAYS - .iter() - .filter_map(|url| RelayUrl::parse(url).ok()), - ); + cx.background_spawn(async move { + let profile = addr.profile(&http_client).await?; + let public_key = profile.public_key; - // Construct subscription options let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(Duration::from_secs(TIMEOUT))); + .timeout(Some(Duration::from_secs(3))); - // Filter for metadata - let metadata = Filter::new() - .kind(Kind::Metadata) - .limit(1) - .author(public_key); - - // Filter for contact list - let contact_list = Filter::new() - .kind(Kind::ContactList) - .limit(1) - .author(public_key); - - client - .subscribe_to(urls, vec![metadata, contact_list], Some(opts)) - .await?; - - Ok(()) - }); - - task.detach(); - } - - /// Get messaging relays for current user - fn get_messaging_relays(&mut self, cx: &mut Context) { - let client = self.client(); - let async_identity = self.identity.downgrade(); - let public_key = self.identity().read(cx).public_key(); - let write_relays = self.write_relays(&public_key, cx); - - let task: Task> = cx.background_spawn(async move { - let urls = write_relays.await; - - // Construct the filter for inbox relays + // Construct the filter for the metadata event let filter = Filter::new() - .kind(Kind::InboxRelays) + .kind(Kind::Metadata) .author(public_key) .limit(1); - // Stream events from the write relays + // Construct target for subscription + let target = BOOTSTRAP_RELAYS + .into_iter() + .map(|relay| (relay, vec![filter.clone()])) + .collect::>(); + + client.subscribe(target).close_on(opts).await?; + + Ok(public_key) + }) + } + + /// Perform a NIP-50 global search for user profiles based on a given query + pub fn search(&self, query: &str, cx: &App) -> Task, Error>> { + let client = self.client(); + let query = query.to_string(); + + // Get the address task if the query is a valid NIP-05 address + let address_task = if let Ok(addr) = Nip05Address::parse(&query) { + Some(self.get_address(addr, cx)) + } else { + None + }; + + cx.background_spawn(async move { + let mut results: Vec = Vec::with_capacity(FIND_LIMIT); + + // Return early if the query is a valid NIP-05 address + if let Some(task) = address_task { + if let Ok(public_key) = task.await { + results.push(public_key); + return Ok(results); + } + } + + // Return early if the query is a valid public key + if let Ok(public_key) = PublicKey::parse(&query) { + results.push(public_key); + return Ok(results); + } + + // Construct the filter for the search query + let filter = Filter::new() + .search(query.to_lowercase()) + .kind(Kind::Metadata) + .limit(FIND_LIMIT); + + // Construct target for subscription + let target = SEARCH_RELAYS + .into_iter() + .map(|relay| (relay, vec![filter.clone()])) + .collect::>(); + + // Stream events from the search relays let mut stream = client - .stream_events_from(urls, vec![filter], Duration::from_secs(TIMEOUT)) + .stream_events(target) + .timeout(Duration::from_secs(TIMEOUT)) + .await?; + + // Collect the results + while let Some((_url, res)) = stream.next().await { + if let Ok(event) = res { + results.push(event.pubkey); + } + } + + if results.is_empty() { + return Err(anyhow!("No results for query {query}")); + } + + Ok(results) + }) + } + + /// Perform a WoT (via Vertex) search for a given query. + pub fn wot_search(&self, query: &str, cx: &App) -> Task, Error>> { + let client = self.client(); + let query = query.to_string(); + + cx.background_spawn(async move { + // Construct a vertex request event + let builder = EventBuilder::new(Kind::Custom(5315), "").tags(vec![ + Tag::custom(TagKind::custom("param"), vec!["search", &query]), + Tag::custom(TagKind::custom("param"), vec!["limit", "10"]), + ]); + let event = client.sign_event_builder(builder).await?; + + // Send the event to vertex relays + let output = client.send_event(&event).to(WOT_RELAYS).await?; + + // Construct a filter to get the response or error from vertex + let filter = Filter::new() + .kinds(vec![Kind::Custom(6315), Kind::Custom(7000)]) + .event(output.id().to_owned()); + + // Construct target for subscription + let target = WOT_RELAYS + .into_iter() + .map(|relay| (relay, vec![filter.clone()])) + .collect::>(); + + // Stream events from the wot relays + let mut stream = client + .stream_events(target) + .timeout(Duration::from_secs(TIMEOUT)) .await?; while let Some((_url, res)) = stream.next().await { - match res { - Ok(event) => { - log::info!("Received messaging relays event: {event:?}"); - return Ok(RelayState::Set); - } - Err(e) => { - log::error!("Failed to get messaging relays: {e}"); + if let Ok(event) = res { + match event.kind { + Kind::Custom(6315) => { + let content: serde_json::Value = serde_json::from_str(&event.content)?; + let pubkeys: Vec = content + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.as_object()) + .filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str())) + .filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok()) + .collect(); + + return Ok(pubkeys); + } + Kind::Custom(7000) => { + return Err(anyhow!("Search error")); + } + _ => {} } } } - Ok(RelayState::NotSet) - }); - - self.tasks.push(cx.spawn(async move |_this, cx| { - match task.await { - Ok(state) => { - async_identity - .update(cx, |this, cx| { - this.set_messaging_relays_state(state); - cx.notify(); - }) - .ok(); - } - Err(e) => { - log::error!("Failed to get messaging relays: {e}"); - } - } - - Ok(()) - })); + Err(anyhow!("No results for query: {query}")) + }) + } +} + +/// Automatically get messaging relays and encryption announcement from a received relay list +async fn get_adv_events_by(client: &Client, event: &Event) -> Result<(), Error> { + // Subscription options + let opts = SubscribeAutoCloseOptions::default() + .timeout(Some(Duration::from_secs(TIMEOUT))) + .exit_policy(ReqExitPolicy::ExitOnEOSE); + + // Extract write relays from event + let write_relays: Vec<&RelayUrl> = nip65::extract_relay_list(event) + .filter_map(|(url, metadata)| { + if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { + Some(url) + } else { + None + } + }) + .collect(); + + // Ensure relay connections + for relay in write_relays.iter() { + client.add_relay(*relay).await?; + client.connect_relay(*relay).await?; + } + + // Construct filter for inbox relays + let inbox = Filter::new() + .kind(Kind::InboxRelays) + .author(event.pubkey) + .limit(1); + + // Construct filter for encryption announcement + let announcement = Filter::new() + .kind(Kind::Custom(10044)) + .author(event.pubkey) + .limit(1); + + // Construct target for subscription + let target = write_relays + .into_iter() + .map(|relay| (relay, vec![inbox.clone(), announcement.clone()])) + .collect::>(); + + client.subscribe(target).close_on(opts).await?; + + Ok(()) +} + +/// Get or create a new app keys +fn get_or_init_app_keys() -> Result { + let dir = config_dir().join(".app_keys"); + + let content = match std::fs::read(&dir) { + Ok(content) => content, + Err(_) => { + // Generate new keys if file doesn't exist + let keys = Keys::generate(); + let secret_key = keys.secret_key(); + + // Create directory and write secret key + std::fs::create_dir_all(dir.parent().unwrap())?; + std::fs::write(&dir, secret_key.to_secret_bytes())?; + + // Set permissions to readonly + let mut perms = std::fs::metadata(&dir)?.permissions(); + perms.set_mode(0o400); + std::fs::set_permissions(&dir, perms)?; + + return Ok(keys); + } + }; + + let secret_key = SecretKey::from_slice(&content)?; + let keys = Keys::new(secret_key); + + Ok(keys) +} + +fn default_relay_list() -> Vec<(RelayUrl, Option)> { + vec![ + ( + RelayUrl::parse("wss://relay.gulugulu.moe").unwrap(), + Some(RelayMetadata::Write), + ), + ( + RelayUrl::parse("wss://relay.primal.net/").unwrap(), + Some(RelayMetadata::Write), + ), + ( + RelayUrl::parse("wss://relay.damus.io/").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://nos.lol/").unwrap(), + Some(RelayMetadata::Read), + ), + ] +} + +fn default_messaging_relays() -> Vec { + vec![ + //RelayUrl::parse("wss://auth.nostr1.com/").unwrap(), + RelayUrl::parse("wss://nip17.com/").unwrap(), + ] +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum RelayState { + #[default] + Idle, + Checking, + NotConfigured, + Configured, +} + +impl RelayState { + pub fn idle(&self) -> bool { + matches!(self, RelayState::Idle) + } + + pub fn checking(&self) -> bool { + matches!(self, RelayState::Checking) + } + + pub fn not_configured(&self) -> bool { + matches!(self, RelayState::NotConfigured) + } + + pub fn configured(&self) -> bool { + matches!(self, RelayState::Configured) + } +} + +#[derive(Debug, Clone)] +pub struct CoopAuthUrlHandler; + +impl AuthUrlHandler for CoopAuthUrlHandler { + #[allow(mismatched_lifetime_syntaxes)] + fn on_auth_url(&self, auth_url: Url) -> BoxedFuture> { + Box::pin(async move { + webbrowser::open(auth_url.as_str())?; + Ok(()) + }) } } diff --git a/crates/state/src/nip05.rs b/crates/state/src/nip05.rs new file mode 100644 index 0000000..afd3d65 --- /dev/null +++ b/crates/state/src/nip05.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use anyhow::Error; +use gpui::http_client::{AsyncBody, HttpClient}; +use nostr_sdk::prelude::*; +use smol::io::AsyncReadExt; + +#[allow(async_fn_in_trait)] +pub trait NostrAddress { + /// Get the NIP-05 profile + async fn profile(&self, client: &Arc) -> Result; + + /// Verify the NIP-05 address + async fn verify( + &self, + client: &Arc, + public_key: &PublicKey, + ) -> Result; +} + +impl NostrAddress for Nip05Address { + async fn profile(&self, client: &Arc) -> Result { + let mut body = Vec::new(); + let mut res = client + .get(self.url().as_str(), AsyncBody::default(), false) + .await?; + + // Read the response body into a vector + res.body_mut().read_to_end(&mut body).await?; + + // Parse the JSON response + let json: Value = serde_json::from_slice(&body)?; + + let profile = Nip05Profile::from_json(self, &json)?; + + Ok(profile) + } + + async fn verify( + &self, + client: &Arc, + public_key: &PublicKey, + ) -> Result { + let mut body = Vec::new(); + let mut res = client + .get(self.url().as_str(), AsyncBody::default(), false) + .await?; + + // Read the response body into a vector + res.body_mut().read_to_end(&mut body).await?; + + // Parse the JSON response + let json: Value = serde_json::from_slice(&body)?; + + // Verify the NIP-05 address + let verified = nip05::verify_from_json(public_key, self, &json); + + Ok(verified) + } +} diff --git a/crates/state/src/signer.rs b/crates/state/src/signer.rs new file mode 100644 index 0000000..4cb1691 --- /dev/null +++ b/crates/state/src/signer.rs @@ -0,0 +1,146 @@ +use std::borrow::Cow; +use std::result::Result; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use nostr_sdk::prelude::*; +use smol::lock::RwLock; + +#[derive(Debug)] +pub struct CoopSigner { + /// User's signer + signer: RwLock>, + + /// User's signer public key + signer_pkey: RwLock>, + + /// Specific signer for encryption purposes + encryption_signer: RwLock>>, + + /// By default, Coop generates a new signer for new users. + /// + /// This flag indicates whether the signer is user-owned or Coop-generated. + owned: AtomicBool, +} + +impl CoopSigner { + pub fn new(signer: T) -> Self + where + T: IntoNostrSigner, + { + Self { + signer: RwLock::new(signer.into_nostr_signer()), + signer_pkey: RwLock::new(None), + encryption_signer: RwLock::new(None), + owned: AtomicBool::new(false), + } + } + + /// Get the current signer. + pub async fn get(&self) -> Arc { + self.signer.read().await.clone() + } + + /// Get the encryption signer. + pub async fn get_encryption_signer(&self) -> Option> { + self.encryption_signer.read().await.clone() + } + + /// Get public key + pub fn public_key(&self) -> Option { + self.signer_pkey.read_blocking().to_owned() + } + + /// Get the flag indicating whether the signer is user-owned. + pub fn owned(&self) -> bool { + self.owned.load(Ordering::SeqCst) + } + + /// Switch the current signer to a new signer. + pub async fn switch(&self, new: T, owned: bool) + where + T: IntoNostrSigner, + { + let new_signer = new.into_nostr_signer(); + let public_key = new_signer.get_public_key().await.ok(); + let mut signer = self.signer.write().await; + let mut signer_pkey = self.signer_pkey.write().await; + let mut encryption_signer = self.encryption_signer.write().await; + + // Switch to the new signer + *signer = new_signer; + + // Update the public key + *signer_pkey = public_key; + + // Reset the encryption signer + *encryption_signer = None; + + // Update the owned flag + self.owned.store(owned, Ordering::SeqCst); + } + + /// Set the encryption signer. + pub async fn set_encryption_signer(&self, new: T) + where + T: IntoNostrSigner, + { + let mut encryption_signer = self.encryption_signer.write().await; + *encryption_signer = Some(new.into_nostr_signer()); + } +} + +impl NostrSigner for CoopSigner { + #[allow(mismatched_lifetime_syntaxes)] + fn backend(&self) -> SignerBackend { + SignerBackend::Custom(Cow::Borrowed("custom")) + } + + fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result> { + Box::pin(async move { self.get().await.get_public_key().await }) + } + + fn sign_event<'a>( + &'a self, + unsigned: UnsignedEvent, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { self.get().await.sign_event(unsigned).await }) + } + + fn nip04_encrypt<'a>( + &'a self, + public_key: &'a PublicKey, + content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await }) + } + + fn nip04_decrypt<'a>( + &'a self, + public_key: &'a PublicKey, + encrypted_content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { + self.get() + .await + .nip04_decrypt(public_key, encrypted_content) + .await + }) + } + + fn nip44_encrypt<'a>( + &'a self, + public_key: &'a PublicKey, + content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await }) + } + + fn nip44_decrypt<'a>( + &'a self, + public_key: &'a PublicKey, + payload: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await }) + } +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 14a06c3..8d60b00 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -4,12 +4,14 @@ use std::rc::Rc; use gpui::{px, App, Global, Pixels, SharedString, Window}; mod colors; +mod platform_kind; mod registry; mod scale; mod scrollbar_mode; mod theme; pub use colors::*; +pub use platform_kind::PlatformKind; pub use registry::*; pub use scale::*; pub use scrollbar_mode::*; @@ -21,6 +23,15 @@ pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0); /// Defines window shadow size for platforms that use client side decorations. pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0); +/// Defines window border size for platforms that use client side decorations. +pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0); + +/// Defines window titlebar height +pub const TITLEBAR_HEIGHT: Pixels = px(36.0); + +/// Defines default sidebar width +pub const SIDEBAR_WIDTH: Pixels = px(240.); + pub fn init(cx: &mut App) { registry::init(cx); @@ -164,7 +175,16 @@ impl Theme { impl From for Theme { fn from(family: ThemeFamily) -> Self { + let platform = PlatformKind::platform(); let mode = ThemeMode::default(); + + // Define the font family based on the platform. + // TODO: Use native fonts on Linux too. + let font_family = match platform { + PlatformKind::Linux => "Inter", + _ => ".SystemUIFont", + }; + // Define the theme colors based on the appearance let colors = match mode { ThemeMode::Light => family.light(), @@ -173,7 +193,7 @@ impl From for Theme { Theme { font_size: px(15.), - font_family: ".SystemUIFont".into(), + font_family: font_family.into(), radius: px(5.), radius_lg: px(10.), shadow: true, diff --git a/crates/title_bar/src/platform_kind.rs b/crates/theme/src/platform_kind.rs similarity index 100% rename from crates/title_bar/src/platform_kind.rs rename to crates/theme/src/platform_kind.rs diff --git a/crates/title_bar/src/lib.rs b/crates/title_bar/src/lib.rs index f4f9471..c49ddcb 100644 --- a/crates/title_bar/src/lib.rs +++ b/crates/title_bar/src/lib.rs @@ -9,15 +9,13 @@ use gpui::{ WindowControlArea, }; use smallvec::{smallvec, SmallVec}; -use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING}; +use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING}; use ui::h_flex; -use crate::platform_kind::PlatformKind; #[cfg(target_os = "linux")] use crate::platforms::linux::LinuxWindowControls; use crate::platforms::windows::WindowsWindowControls; -mod platform_kind; mod platforms; pub struct TitleBar { diff --git a/crates/ui/src/anchored.rs b/crates/ui/src/anchored.rs new file mode 100644 index 0000000..0b471b9 --- /dev/null +++ b/crates/ui/src/anchored.rs @@ -0,0 +1,333 @@ +//! This is a fork of gpui's anchored element that adds support for offsetting +//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs +use gpui::{ + point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half, + InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style, + Window, +}; +use smallvec::SmallVec; + +use crate::Anchor; + +/// The state that the anchored element element uses to track its children. +pub struct AnchoredState { + child_layout_ids: SmallVec<[LayoutId; 4]>, +} + +/// An anchored element that can be used to display UI that +/// will avoid overflowing the window bounds. +pub(crate) struct Anchored { + children: SmallVec<[AnyElement; 2]>, + anchor_corner: Anchor, + fit_mode: AnchoredFitMode, + anchor_position: Option>, + position_mode: AnchoredPositionMode, + offset: Option>, +} + +/// anchored gives you an element that will avoid overflowing the window bounds. +/// Its children should have no margin to avoid measurement issues. +pub(crate) fn anchored() -> Anchored { + Anchored { + children: SmallVec::new(), + anchor_corner: Anchor::TopLeft, + fit_mode: AnchoredFitMode::SwitchAnchor, + anchor_position: None, + position_mode: AnchoredPositionMode::Window, + offset: None, + } +} + +#[allow(dead_code)] +impl Anchored { + /// Sets which corner of the anchored element should be anchored to the current position. + pub fn anchor(mut self, anchor: Anchor) -> Self { + self.anchor_corner = anchor; + self + } + + /// Sets the position in window coordinates + /// (otherwise the location the anchored element is rendered is used) + pub fn position(mut self, anchor: Point) -> Self { + self.anchor_position = Some(anchor); + self + } + + /// Offset the final position by this amount. + /// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu. + pub fn offset(mut self, offset: Point) -> Self { + self.offset = Some(offset); + self + } + + /// Sets the position mode for this anchored element. Local will have this + /// interpret its [`Anchored::position`] as relative to the parent element. + /// While Window will have it interpret the position as relative to the window. + pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self { + self.position_mode = mode; + self + } + + /// Snap to window edge instead of switching anchor corner when an overflow would occur. + pub fn snap_to_window(mut self) -> Self { + self.fit_mode = AnchoredFitMode::SnapToWindow; + self + } + + /// Snap to window edge and leave some margins. + pub fn snap_to_window_with_margin(mut self, edges: impl Into>) -> Self { + self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into()); + self + } +} + +impl ParentElement for Anchored { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Element for Anchored { + type PrepaintState = (); + type RequestLayoutState = AnchoredState; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let child_layout_ids = self + .children + .iter_mut() + .map(|child| child.request_layout(window, cx)) + .collect::>(); + + let anchored_style = Style { + position: Position::Absolute, + display: Display::Flex, + ..Style::default() + }; + + let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx); + + (layout_id, AnchoredState { child_layout_ids }) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) { + if request_layout.child_layout_ids.is_empty() { + return; + } + + let mut child_min = point(Pixels::MAX, Pixels::MAX); + let mut child_max = Point::default(); + for child_layout_id in &request_layout.child_layout_ids { + let child_bounds = window.layout_bounds(*child_layout_id); + child_min = child_min.min(&child_bounds.origin); + child_max = child_max.max(&child_bounds.bottom_right()); + } + let size: Size = (child_max - child_min).into(); + + let (origin, mut desired) = self.position_mode.get_position_and_bounds( + self.anchor_position, + self.anchor_corner, + size, + bounds, + self.offset, + ); + + let limits = Bounds { + origin: Point::default(), + size: window.viewport_size(), + }; + + if self.fit_mode == AnchoredFitMode::SwitchAnchor { + let mut anchor_corner = self.anchor_corner; + + if desired.left() < limits.left() || desired.right() > limits.right() { + let switched = Bounds::from_corner_and_size( + anchor_corner + .other_side_corner_along(Axis::Horizontal) + .into(), + origin, + size, + ); + if !(switched.left() < limits.left() || switched.right() > limits.right()) { + anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal); + desired = switched + } + } + + if desired.top() < limits.top() || desired.bottom() > limits.bottom() { + let switched = Bounds::from_corner_and_size( + anchor_corner.other_side_corner_along(Axis::Vertical).into(), + origin, + size, + ); + if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) { + desired = switched; + } + } + } + + let client_inset = window.client_inset().unwrap_or(px(0.)); + let edges = match self.fit_mode { + AnchoredFitMode::SnapToWindowWithMargin(edges) => edges, + _ => Edges::default(), + } + .map(|edge| *edge + client_inset); + + // Snap the horizontal edges of the anchored element to the horizontal edges of the window if + // its horizontal bounds overflow, aligning to the left if it is wider than the limits. + if desired.right() > limits.right() { + desired.origin.x -= desired.right() - limits.right() + edges.right; + } + if desired.left() < limits.left() { + desired.origin.x = limits.origin.x + edges.left; + } + + // Snap the vertical edges of the anchored element to the vertical edges of the window if + // its vertical bounds overflow, aligning to the top if it is taller than the limits. + if desired.bottom() > limits.bottom() { + desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom; + } + if desired.top() < limits.top() { + desired.origin.y = limits.origin.y + edges.top; + } + + let offset = desired.origin - bounds.origin; + let offset = point(offset.x.round(), offset.y.round()); + + window.with_element_offset(offset, |window| { + for child in &mut self.children { + child.prepaint(window, cx); + } + }) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + for child in &mut self.children { + child.paint(window, cx); + } + } +} + +impl IntoElement for Anchored { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// Which algorithm to use when fitting the anchored element to be inside the window. +#[allow(dead_code)] +#[derive(Copy, Clone, PartialEq)] +pub enum AnchoredFitMode { + /// Snap the anchored element to the window edge. + SnapToWindow, + /// Snap to window edge and leave some margins. + SnapToWindowWithMargin(Edges), + /// Switch which corner anchor this anchored element is attached to. + SwitchAnchor, +} + +/// Which algorithm to use when positioning the anchored element. +#[allow(dead_code)] +#[derive(Copy, Clone, PartialEq)] +pub enum AnchoredPositionMode { + /// Position the anchored element relative to the window. + Window, + /// Position the anchored element relative to its parent. + Local, +} + +impl AnchoredPositionMode { + fn get_position_and_bounds( + &self, + anchor_position: Option>, + anchor_corner: Anchor, + size: Size, + bounds: Bounds, + offset: Option>, + ) -> (Point, Bounds) { + let offset = offset.unwrap_or_default(); + + match self { + AnchoredPositionMode::Window => { + let anchor_position = anchor_position.unwrap_or(bounds.origin); + let bounds = + Self::from_corner_and_size(anchor_corner, anchor_position + offset, size); + (anchor_position, bounds) + } + AnchoredPositionMode::Local => { + let anchor_position = anchor_position.unwrap_or_default(); + let bounds = Self::from_corner_and_size( + anchor_corner, + bounds.origin + anchor_position + offset, + size, + ); + (anchor_position, bounds) + } + } + } + + // Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863 + fn from_corner_and_size( + anchor: Anchor, + origin: Point, + size: Size, + ) -> Bounds { + let origin = match anchor { + Anchor::TopLeft => origin, + Anchor::TopCenter => Point { + x: origin.x - size.width.half(), + y: origin.y, + }, + Anchor::TopRight => Point { + x: origin.x - size.width, + y: origin.y, + }, + Anchor::BottomLeft => Point { + x: origin.x, + y: origin.y - size.height, + }, + Anchor::BottomCenter => Point { + x: origin.x - size.width.half(), + y: origin.y - size.height, + }, + Anchor::BottomRight => Point { + x: origin.x - size.width, + y: origin.y - size.height, + }, + }; + + Bounds { origin, size } + } +} diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 90d7bef..e15b2ea 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -10,7 +10,7 @@ use theme::ActiveTheme; use crate::indicator::Indicator; use crate::tooltip::Tooltip; -use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt}; +use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt}; #[derive(Clone, Copy, PartialEq, Eq)] pub struct ButtonCustomVariant { @@ -20,50 +20,6 @@ pub struct ButtonCustomVariant { active: Hsla, } -pub trait ButtonVariants: Sized { - fn with_variant(self, variant: ButtonVariant) -> Self; - - /// With the primary style for the Button. - fn primary(self) -> Self { - self.with_variant(ButtonVariant::Primary) - } - - /// With the secondary style for the Button. - fn secondary(self) -> Self { - self.with_variant(ButtonVariant::Secondary) - } - - /// With the danger style for the Button. - fn danger(self) -> Self { - self.with_variant(ButtonVariant::Danger) - } - - /// With the warning style for the Button. - fn warning(self) -> Self { - self.with_variant(ButtonVariant::Warning) - } - - /// With the ghost style for the Button. - fn ghost(self) -> Self { - self.with_variant(ButtonVariant::Ghost { alt: false }) - } - - /// With the ghost style for the Button. - fn ghost_alt(self) -> Self { - self.with_variant(ButtonVariant::Ghost { alt: true }) - } - - /// With the transparent style for the Button. - fn transparent(self) -> Self { - self.with_variant(ButtonVariant::Transparent) - } - - /// With the custom style for the Button. - fn custom(self, style: ButtonCustomVariant) -> Self { - self.with_variant(ButtonVariant::Custom(style)) - } -} - impl ButtonCustomVariant { pub fn new(_window: &Window, cx: &App) -> Self { Self { @@ -110,6 +66,50 @@ pub enum ButtonVariant { Custom(ButtonCustomVariant), } +pub trait ButtonVariants: Sized { + fn with_variant(self, variant: ButtonVariant) -> Self; + + /// With the primary style for the Button. + fn primary(self) -> Self { + self.with_variant(ButtonVariant::Primary) + } + + /// With the secondary style for the Button. + fn secondary(self) -> Self { + self.with_variant(ButtonVariant::Secondary) + } + + /// With the danger style for the Button. + fn danger(self) -> Self { + self.with_variant(ButtonVariant::Danger) + } + + /// With the warning style for the Button. + fn warning(self) -> Self { + self.with_variant(ButtonVariant::Warning) + } + + /// With the ghost style for the Button. + fn ghost(self) -> Self { + self.with_variant(ButtonVariant::Ghost { alt: false }) + } + + /// With the ghost style for the Button. + fn ghost_alt(self) -> Self { + self.with_variant(ButtonVariant::Ghost { alt: true }) + } + + /// With the transparent style for the Button. + fn transparent(self) -> Self { + self.with_variant(ButtonVariant::Transparent) + } + + /// With the custom style for the Button. + fn custom(self, style: ButtonCustomVariant) -> Self { + self.with_variant(ButtonVariant::Custom(style)) + } +} + /// A Button element. #[derive(IntoElement)] #[allow(clippy::type_complexity)] @@ -124,16 +124,15 @@ pub struct Button { children: Vec, variant: ButtonVariant, - rounded: bool, size: Size, disabled: bool, - reverse: bool, - bold: bool, - cta: bool, - loading: bool, - loading_icon: Option, + + rounded: bool, + compact: bool, + underline: bool, + caret: bool, on_click: Option>, on_hover: Option>, @@ -160,20 +159,19 @@ impl Button { style: StyleRefinement::default(), icon: None, label: None, + variant: ButtonVariant::default(), disabled: false, selected: false, - variant: ButtonVariant::default(), + underline: false, + compact: false, + caret: false, rounded: false, size: Size::Medium, tooltip: None, on_click: None, on_hover: None, loading: false, - reverse: false, - bold: false, - cta: false, children: Vec::new(), - loading_icon: None, tab_index: 0, tab_stop: true, } @@ -209,27 +207,21 @@ impl Button { self } - /// Set reverse the position between icon and label. - pub fn reverse(mut self) -> Self { - self.reverse = true; + /// Set true to make the button compact (no padding). + pub fn compact(mut self) -> Self { + self.compact = true; self } - /// Set bold the button (label will be use the semi-bold font). - pub fn bold(mut self) -> Self { - self.bold = true; + /// Set true to show the caret indicator. + pub fn caret(mut self) -> Self { + self.caret = true; self } - /// Set the cta style of the button. - pub fn cta(mut self) -> Self { - self.cta = true; - self - } - - /// Set the loading icon of the button. - pub fn loading_icon(mut self, icon: impl Into) -> Self { - self.loading_icon = Some(icon.into()); + /// Set true to show the underline indicator. + pub fn underline(mut self) -> Self { + self.underline = true; self } @@ -338,7 +330,7 @@ impl RenderOnce for Button { }; let focus_handle = window - .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle()) .read(cx) .clone(); @@ -350,6 +342,7 @@ impl RenderOnce for Button { .tab_stop(self.tab_stop), ) }) + .relative() .flex_shrink_0() .flex() .items_center() @@ -361,39 +354,15 @@ impl RenderOnce for Button { false => this.rounded(cx.theme().radius), true => this.rounded_full(), }) - .map(|this| { + .when(!self.compact, |this| { if self.label.is_none() && self.children.is_empty() { // Icon Button match self.size { Size::Size(px) => this.size(px), - Size::XSmall => { - if self.cta { - this.w_10().h_5() - } else { - this.size_5() - } - } - Size::Small => { - if self.cta { - this.w_12().h_6() - } else { - this.size_6() - } - } - Size::Medium => { - if self.cta { - this.w_12().h_7() - } else { - this.size_7() - } - } - _ => { - if self.cta { - this.w_16().h_9() - } else { - this.size_9() - } - } + Size::XSmall => this.size_5(), + Size::Small => this.size_6(), + Size::Medium => this.size_7(), + _ => this.size_9(), } } else { // Normal Button @@ -402,8 +371,6 @@ impl RenderOnce for Button { Size::XSmall => { if self.icon.is_some() { this.h_6().pl_2().pr_2p5() - } else if self.cta { - this.h_6().px_4() } else { this.h_6().px_2() } @@ -411,8 +378,6 @@ impl RenderOnce for Button { Size::Small => { if self.icon.is_some() { this.h_7().pl_2().pr_2p5() - } else if self.cta { - this.h_7().px_4() } else { this.h_7().px_2() } @@ -434,13 +399,27 @@ impl RenderOnce for Button { } } }) - .on_mouse_down(gpui::MouseButton::Left, |_, window, _| { + .refine_style(&self.style) + .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { + // Stop handle any click event when disabled. + // To avoid handle dropdown menu open when button is disabled. + if self.disabled { + cx.stop_propagation(); + return; + } // Avoid focus on mouse down. window.prevent_default(); }) - .when_some(self.on_click.filter(|_| clickable), |this, on_click| { + .when_some(self.on_click, |this, on_click| { this.on_click(move |event, window, cx| { - (on_click)(event, window, cx); + // Stop handle any click event when disabled. + // To avoid handle dropdown menu open when button is disabled. + if !clickable { + cx.stop_propagation(); + return; + } + + on_click(event, window, cx); }) }) .when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| { @@ -451,7 +430,6 @@ impl RenderOnce for Button { .child({ h_flex() .id("label") - .when(self.reverse, |this| this.flex_row_reverse()) .justify_center() .map(|this| match self.size { Size::XSmall => this.text_xs().gap_1(), @@ -463,22 +441,18 @@ impl RenderOnce for Button { this.child(icon.with_size(icon_size)) }) }) - .when(self.loading, |this| { - this.child( - Indicator::new() - .when_some(self.loading_icon, |this, icon| this.icon(icon)), - ) - }) + .when(self.loading, |this| this.child(Indicator::new())) .when_some(self.label, |this, label| { - this.child( - div() - .flex_none() - .line_height(relative(1.)) - .child(label) - .when(self.bold, |this| this.font_semibold()), - ) + this.child(div().flex_none().line_height(relative(1.)).child(label)) }) .children(self.children) + .when(self.caret, |this| { + this.justify_between().gap_0p5().child( + Icon::new(IconName::ChevronDown) + .small() + .text_color(cx.theme().text_muted), + ) + }) }) .text_color(normal_style.fg) .when(!self.disabled && !self.selected, |this| { @@ -496,6 +470,17 @@ impl RenderOnce for Button { let selected_style = style.selected(cx); this.bg(selected_style.bg).text_color(selected_style.fg) }) + .when(self.selected && self.underline, |this| { + this.child( + div() + .absolute() + .bottom_0() + .left_0() + .h_px() + .w_full() + .bg(cx.theme().element_background), + ) + }) .when(self.disabled, |this| { let disabled_style = style.disabled(cx); this.cursor_not_allowed() diff --git a/crates/ui/src/divider.rs b/crates/ui/src/divider.rs index 4086dce..45aa100 100644 --- a/crates/ui/src/divider.rs +++ b/crates/ui/src/divider.rs @@ -61,8 +61,8 @@ impl RenderOnce for Divider { .absolute() .rounded_full() .map(|this| match self.axis { - Axis::Vertical => this.w(px(2.)).h_full(), - Axis::Horizontal => this.h(px(2.)).w_full(), + Axis::Vertical => this.w(px(1.)).h_full(), + Axis::Horizontal => this.h(px(1.)).w_full(), }) .bg(self.color.unwrap_or(cx.theme().border_variant)), ) diff --git a/crates/ui/src/dock_area/panel.rs b/crates/ui/src/dock_area/panel.rs index 8e64dc6..1efe1de 100644 --- a/crates/ui/src/dock_area/panel.rs +++ b/crates/ui/src/dock_area/panel.rs @@ -4,7 +4,7 @@ use gpui::{ }; use crate::button::Button; -use crate::popup_menu::PopupMenu; +use crate::menu::PopupMenu; pub enum PanelEvent { ZoomIn, diff --git a/crates/ui/src/dock_area/tab_panel.rs b/crates/ui/src/dock_area/tab_panel.rs index f92b7d3..ee67cb7 100644 --- a/crates/ui/src/dock_area/tab_panel.rs +++ b/crates/ui/src/dock_area/tab_panel.rs @@ -14,7 +14,7 @@ use super::stack_panel::StackPanel; use super::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom}; use crate::button::{Button, ButtonVariants as _}; use crate::dock_area::panel::Panel; -use crate::popup_menu::{PopupMenu, PopupMenuExt}; +use crate::menu::{DropdownMenu, PopupMenu}; use crate::tab::tab_bar::TabBar; use crate::tab::Tab; use crate::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt}; @@ -423,7 +423,7 @@ impl TabPanel { .when(self.is_zoomed, |this| { this.child( Button::new("zoom") - .icon(IconName::ArrowIn) + .icon(IconName::Zoom) .small() .ghost() .tooltip("Zoom Out") @@ -442,7 +442,7 @@ impl TabPanel { .small() .ghost() .rounded() - .popup_menu({ + .dropdown_menu({ let zoomable = state.zoomable; let closable = state.closable; diff --git a/crates/ui/src/element_ext.rs b/crates/ui/src/element_ext.rs new file mode 100644 index 0000000..90de5ec --- /dev/null +++ b/crates/ui/src/element_ext.rs @@ -0,0 +1,27 @@ +use gpui::{canvas, App, Bounds, ParentElement, Pixels, Styled as _, Window}; + +/// A trait to extend [`gpui::Element`] with additional functionality. +pub trait ElementExt: ParentElement + Sized { + /// Add a prepaint callback to the element. + /// + /// This is a helper method to get the bounds of the element after paint. + /// + /// The first argument is the bounds of the element in pixels. + /// + /// See also [`gpui::canvas`]. + fn on_prepaint(self, f: F) -> Self + where + F: FnOnce(Bounds, &mut Window, &mut App) + 'static, + { + self.child( + canvas( + move |bounds, window, cx| f(bounds, window, cx), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + } +} + +impl ElementExt for T {} diff --git a/crates/ui/src/geometry.rs b/crates/ui/src/geometry.rs new file mode 100644 index 0000000..4f6fbe7 --- /dev/null +++ b/crates/ui/src/geometry.rs @@ -0,0 +1,294 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels}; +use serde::{Deserialize, Serialize}; + +/// A enum for defining the placement of the element. +/// +/// See also: [`Side`] if you need to define the left, right side. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Placement { + #[serde(rename = "top")] + Top, + #[serde(rename = "bottom")] + Bottom, + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Display for Placement { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Placement::Top => write!(f, "Top"), + Placement::Bottom => write!(f, "Bottom"), + Placement::Left => write!(f, "Left"), + Placement::Right => write!(f, "Right"), + } + } +} + +impl Placement { + #[inline] + pub fn is_horizontal(&self) -> bool { + matches!(self, Placement::Left | Placement::Right) + } + + #[inline] + pub fn is_vertical(&self) -> bool { + matches!(self, Placement::Top | Placement::Bottom) + } + + #[inline] + pub fn axis(&self) -> Axis { + match self { + Placement::Top | Placement::Bottom => Axis::Vertical, + Placement::Left | Placement::Right => Axis::Horizontal, + } + } +} + +/// The anchor position of an element. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Anchor { + #[default] + #[serde(rename = "top-left")] + TopLeft, + #[serde(rename = "top-center")] + TopCenter, + #[serde(rename = "top-right")] + TopRight, + #[serde(rename = "bottom-left")] + BottomLeft, + #[serde(rename = "bottom-center")] + BottomCenter, + #[serde(rename = "bottom-right")] + BottomRight, +} + +impl Display for Anchor { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Anchor::TopLeft => write!(f, "TopLeft"), + Anchor::TopCenter => write!(f, "TopCenter"), + Anchor::TopRight => write!(f, "TopRight"), + Anchor::BottomLeft => write!(f, "BottomLeft"), + Anchor::BottomCenter => write!(f, "BottomCenter"), + Anchor::BottomRight => write!(f, "BottomRight"), + } + } +} + +impl Anchor { + /// Returns true if the anchor is at the top. + #[inline] + pub fn is_top(&self) -> bool { + matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight) + } + + /// Returns true if the anchor is at the bottom. + #[inline] + pub fn is_bottom(&self) -> bool { + matches!( + self, + Self::BottomLeft | Self::BottomCenter | Self::BottomRight + ) + } + + /// Returns true if the anchor is at the left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::TopLeft | Self::BottomLeft) + } + + /// Returns true if the anchor is at the right. + #[inline] + pub fn is_right(&self) -> bool { + matches!(self, Self::TopRight | Self::BottomRight) + } + + /// Returns true if the anchor is at the center. + #[inline] + pub fn is_center(&self) -> bool { + matches!(self, Self::TopCenter | Self::BottomCenter) + } + + /// Swaps the vertical position of the anchor. + pub fn swap_vertical(&self) -> Self { + match self { + Anchor::TopLeft => Anchor::BottomLeft, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::TopRight => Anchor::BottomRight, + Anchor::BottomLeft => Anchor::TopLeft, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::BottomRight => Anchor::TopRight, + } + } + + /// Swaps the horizontal position of the anchor. + pub fn swap_horizontal(&self) -> Self { + match self { + Anchor::TopLeft => Anchor::TopRight, + Anchor::TopCenter => Anchor::TopCenter, + Anchor::TopRight => Anchor::TopLeft, + Anchor::BottomLeft => Anchor::BottomRight, + Anchor::BottomCenter => Anchor::BottomCenter, + Anchor::BottomRight => Anchor::BottomLeft, + } + } + + pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor { + match axis { + Axis::Vertical => match self { + Self::TopLeft => Self::BottomLeft, + Self::TopCenter => Self::BottomCenter, + Self::TopRight => Self::BottomRight, + Self::BottomLeft => Self::TopLeft, + Self::BottomCenter => Self::TopCenter, + Self::BottomRight => Self::TopRight, + }, + Axis::Horizontal => match self { + Self::TopLeft => Self::TopRight, + Self::TopCenter => Self::TopCenter, + Self::TopRight => Self::TopLeft, + Self::BottomLeft => Self::BottomRight, + Self::BottomCenter => Self::BottomCenter, + Self::BottomRight => Self::BottomLeft, + }, + } + } +} + +impl From for Anchor { + fn from(corner: Corner) -> Self { + match corner { + Corner::TopLeft => Anchor::TopLeft, + Corner::TopRight => Anchor::TopRight, + Corner::BottomLeft => Anchor::BottomLeft, + Corner::BottomRight => Anchor::BottomRight, + } + } +} + +impl From for Corner { + fn from(anchor: Anchor) -> Self { + match anchor { + Anchor::TopLeft => Corner::TopLeft, + Anchor::TopRight => Corner::TopRight, + Anchor::BottomLeft => Corner::BottomLeft, + Anchor::BottomRight => Corner::BottomRight, + Anchor::TopCenter => Corner::TopLeft, + Anchor::BottomCenter => Corner::BottomLeft, + } + } +} + +/// A enum for defining the side of the element. +/// +/// See also: [`Placement`] if you need to define the 4 edges. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Side { + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Side { + /// Returns true if the side is left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + /// Returns true if the side is right. + #[inline] + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +/// A trait to extend the [`Axis`] enum with utility methods. +pub trait AxisExt { + #[allow(clippy::wrong_self_convention)] + fn is_horizontal(self) -> bool; + #[allow(clippy::wrong_self_convention)] + fn is_vertical(self) -> bool; +} + +impl AxisExt for Axis { + #[inline] + fn is_horizontal(self) -> bool { + self == Axis::Horizontal + } + + #[inline] + fn is_vertical(self) -> bool { + self == Axis::Vertical + } +} + +/// A trait for converting [`Pixels`] to `f32` and `f64`. +pub trait PixelsExt { + fn as_f32(&self) -> f32; + #[allow(clippy::wrong_self_convention)] + fn as_f64(self) -> f64; +} +impl PixelsExt for Pixels { + fn as_f32(&self) -> f32 { + f32::from(self) + } + + fn as_f64(self) -> f64 { + f64::from(self) + } +} + +/// A trait to extend the [`Length`] enum with utility methods. +pub trait LengthExt { + /// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`. + /// + /// If the [`Length`] is [`Length::Auto`], it returns `None`. + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option; +} + +impl LengthExt for Length { + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option { + match self { + Length::Auto => None, + Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)), + } + } +} + +/// A struct for defining the edges of an element. +/// +/// A extend version of [`gpui::Edges`] to serialize/deserialize. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)] +#[repr(C)] +pub struct Edges { + /// The size of the top edge. + pub top: T, + /// The size of the right edge. + pub right: T, + /// The size of the bottom edge. + pub bottom: T, + /// The size of the left edge. + pub left: T, +} + +impl Edges +where + T: Clone + Debug + Default + PartialEq, +{ + /// Creates a new `Edges` instance with all edges set to the same value. + pub fn all(value: T) -> Self { + Self { + top: value.clone(), + right: value.clone(), + bottom: value.clone(), + left: value, + } + } +} diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index d3dd4de..1dca603 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -9,127 +9,113 @@ use crate::{Sizable, Size}; #[derive(IntoElement, Clone)] pub enum IconName { - ArrowIn, - ArrowDown, ArrowLeft, ArrowRight, - ArrowUp, - CaretUp, + Boom, + ChevronDown, CaretDown, - CaretDownFill, CaretRight, + CaretUp, Check, CheckCircle, - CheckCircleFill, Close, CloseCircle, CloseCircleFill, Copy, - Edit, + Door, Ellipsis, - Encryption, + Emoji, Eye, - EyeOff, - EmojiFill, Info, + Invite, + Inbox, + InboxFill, + Link, Loader, - Logout, Moon, - PanelBottom, - PanelBottomOpen, - PanelLeft, - PanelLeftClose, - PanelLeftOpen, - PanelRight, - PanelRightClose, - PanelRightOpen, Plus, - PlusFill, - PlusCircleFill, - Group, - ResizeCorner, + PlusCircle, + Profile, + Relay, Reply, - Report, - Refresh, - Signal, Search, Settings, - Server, - SortAscending, - SortDescending, Sun, - ThumbsDown, - ThumbsUp, + Ship, + Shield, Upload, - OpenUrl, + Usb, + PanelLeft, + PanelLeftOpen, + PanelRight, + PanelRightOpen, + PanelBottom, + PanelBottomOpen, + PaperPlaneFill, Warning, WindowClose, WindowMaximize, WindowMinimize, WindowRestore, + Fistbump, + FistbumpFill, + Zoom, } impl IconName { pub fn path(self) -> SharedString { match self { - Self::ArrowIn => "icons/arrows-in.svg", - Self::ArrowDown => "icons/arrow-down.svg", Self::ArrowLeft => "icons/arrow-left.svg", Self::ArrowRight => "icons/arrow-right.svg", - Self::ArrowUp => "icons/arrow-up.svg", + Self::Boom => "icons/boom.svg", + Self::ChevronDown => "icons/chevron-down.svg", + Self::CaretDown => "icons/caret-down.svg", Self::CaretRight => "icons/caret-right.svg", Self::CaretUp => "icons/caret-up.svg", - Self::CaretDown => "icons/caret-down.svg", - Self::CaretDownFill => "icons/caret-down-fill.svg", Self::Check => "icons/check.svg", Self::CheckCircle => "icons/check-circle.svg", - Self::CheckCircleFill => "icons/check-circle-fill.svg", Self::Close => "icons/close.svg", Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::Copy => "icons/copy.svg", - Self::Edit => "icons/edit.svg", + Self::Door => "icons/door.svg", Self::Ellipsis => "icons/ellipsis.svg", + Self::Emoji => "icons/emoji.svg", Self::Eye => "icons/eye.svg", - Self::Encryption => "icons/encryption.svg", - Self::EmojiFill => "icons/emoji-fill.svg", - Self::EyeOff => "icons/eye-off.svg", Self::Info => "icons/info.svg", + Self::Invite => "icons/invite.svg", + Self::Inbox => "icons/inbox.svg", + Self::InboxFill => "icons/inbox-fill.svg", + Self::Link => "icons/link.svg", Self::Loader => "icons/loader.svg", - Self::Logout => "icons/logout.svg", Self::Moon => "icons/moon.svg", - Self::PanelBottom => "icons/panel-bottom.svg", - Self::PanelBottomOpen => "icons/panel-bottom-open.svg", - Self::PanelLeft => "icons/panel-left.svg", - Self::PanelLeftClose => "icons/panel-left-close.svg", - Self::PanelLeftOpen => "icons/panel-left-open.svg", - Self::PanelRight => "icons/panel-right.svg", - Self::PanelRightClose => "icons/panel-right-close.svg", - Self::PanelRightOpen => "icons/panel-right-open.svg", Self::Plus => "icons/plus.svg", - Self::PlusFill => "icons/plus-fill.svg", - Self::PlusCircleFill => "icons/plus-circle-fill.svg", - Self::Group => "icons/group.svg", - Self::ResizeCorner => "icons/resize-corner.svg", + Self::PlusCircle => "icons/plus-circle.svg", + Self::Profile => "icons/profile.svg", + Self::Relay => "icons/relay.svg", Self::Reply => "icons/reply.svg", - Self::Report => "icons/report.svg", - Self::Refresh => "icons/refresh.svg", - Self::Signal => "icons/signal.svg", Self::Search => "icons/search.svg", Self::Settings => "icons/settings.svg", - Self::Server => "icons/server.svg", - Self::SortAscending => "icons/sort-ascending.svg", - Self::SortDescending => "icons/sort-descending.svg", Self::Sun => "icons/sun.svg", - Self::ThumbsDown => "icons/thumbs-down.svg", - Self::ThumbsUp => "icons/thumbs-up.svg", + Self::Ship => "icons/ship.svg", + Self::Shield => "icons/shield.svg", Self::Upload => "icons/upload.svg", - Self::OpenUrl => "icons/open-url.svg", + Self::Usb => "icons/usb.svg", + Self::PanelLeft => "icons/panel-left.svg", + Self::PanelLeftOpen => "icons/panel-left-open.svg", + Self::PanelRight => "icons/panel-right.svg", + Self::PanelRightOpen => "icons/panel-right-open.svg", + Self::PanelBottom => "icons/panel-bottom.svg", + Self::PanelBottomOpen => "icons/panel-bottom-open.svg", + Self::PaperPlaneFill => "icons/paper-plane-fill.svg", Self::Warning => "icons/warning.svg", Self::WindowClose => "icons/window-close.svg", Self::WindowMaximize => "icons/window-maximize.svg", Self::WindowMinimize => "icons/window-minimize.svg", Self::WindowRestore => "icons/window-restore.svg", + Self::Fistbump => "icons/fistbump.svg", + Self::FistbumpFill => "icons/fistbump-fill.svg", + Self::Zoom => "icons/zoom.svg", } .into() } diff --git a/crates/ui/src/index_path.rs b/crates/ui/src/index_path.rs new file mode 100644 index 0000000..987412e --- /dev/null +++ b/crates/ui/src/index_path.rs @@ -0,0 +1,69 @@ +use std::fmt::{Debug, Display}; + +use gpui::ElementId; + +/// Represents an index path in a list, which consists of a section index, +/// +/// The default values for section, row, and column are all set to 0. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct IndexPath { + /// The section index. + pub section: usize, + /// The item index in the section. + pub row: usize, + /// The column index. + pub column: usize, +} + +impl From for ElementId { + fn from(path: IndexPath) -> Self { + ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into()) + } +} + +impl Display for IndexPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "IndexPath(section: {}, row: {}, column: {})", + self.section, self.row, self.column + ) + } +} + +impl IndexPath { + /// Create a new index path with the specified section and row. + /// + /// The `section` is set to 0 by default. + /// The `column` is set to 0 by default. + pub fn new(row: usize) -> Self { + IndexPath { + section: 0, + row, + ..Default::default() + } + } + + /// Set the section for the index path. + pub fn section(mut self, section: usize) -> Self { + self.section = section; + self + } + + /// Set the row for the index path. + pub fn row(mut self, row: usize) -> Self { + self.row = row; + self + } + + /// Set the column for the index path. + pub fn column(mut self, column: usize) -> Self { + self.column = column; + self + } + + /// Check if the self is equal to the given index path (Same section and row). + pub fn eq_row(&self, index: IndexPath) -> bool { + self.section == index.section && self.row == index.row + } +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 1dbbf84..dbaea0d 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -1009,8 +1009,7 @@ impl InputState { let left_part = self.text.slice(0..offset).to_string(); UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) - .filter(|(_, s)| !s.trim_start().is_empty()) - .next_back() + .rfind(|(_, s)| !s.trim_start().is_empty()) .map(|(i, _)| i) .unwrap_or(0) } diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index 9f5d702..d38d031 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -145,6 +145,7 @@ impl Styled for TextInput { impl RenderOnce for TextInput { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { const LINE_HEIGHT: Rems = Rems(1.25); + let font = window.text_style().font(); let font_size = window.text_style().font_size.to_pixels(window.rem_size()); @@ -155,6 +156,7 @@ impl RenderOnce for TextInput { }); let state = self.state.read(cx); + let focused = state.focus_handle.is_focused(window) && !state.disabled; let gap_x = match self.size { Size::Small => px(4.), @@ -266,7 +268,16 @@ impl RenderOnce for TextInput { .when_some(self.height, |this, height| this.h(height)) }) .when(self.appearance, |this| { - this.bg(bg).rounded(cx.theme().radius) + this.bg(bg) + .rounded(cx.theme().radius) + .when(self.bordered, |this| { + this.border_color(cx.theme().border) + .border_1() + .when(cx.theme().shadow, |this| this.shadow_xs()) + .when(focused && self.focus_bordered, |this| { + this.border_color(cx.theme().border_focused) + }) + }) }) .items_center() .gap(gap_x) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 5cbbe7e..f66f0d7 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,11 +1,14 @@ +pub use anchored::*; +pub use element_ext::ElementExt; pub use event::InteractiveElementExt; pub use focusable::FocusableCycle; +pub use geometry::*; pub use icon::*; +pub use index_path::IndexPath; pub use kbd::*; -pub use menu::{context_menu, popup_menu}; -pub use root::{ContextModal, Root}; +pub use root::{window_paddings, Root}; pub use styled::*; -pub use window_border::{window_border, WindowBorder}; +pub use window_ext::*; pub use crate::Disableable; @@ -16,7 +19,6 @@ pub mod button; pub mod checkbox; pub mod divider; pub mod dock_area; -pub mod dropdown; pub mod history; pub mod indicator; pub mod input; @@ -32,20 +34,23 @@ pub mod switch; pub mod tab; pub mod tooltip; +mod anchored; +mod element_ext; mod event; mod focusable; +mod geometry; mod icon; +mod index_path; mod kbd; mod root; mod styled; -mod window_border; +mod window_ext; /// Initialize the UI module. /// /// This must be called before using any of the UI components. /// You can initialize the UI module at your application's entry point. pub fn init(cx: &mut gpui::App) { - dropdown::init(cx); input::init(cx); list::init(cx); modal::init(cx); diff --git a/crates/ui/src/list/cache.rs b/crates/ui/src/list/cache.rs new file mode 100644 index 0000000..3de7a8c --- /dev/null +++ b/crates/ui/src/list/cache.rs @@ -0,0 +1,221 @@ +use std::rc::Rc; + +use gpui::{App, Pixels, Size}; + +use crate::IndexPath; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RowEntry { + Entry(IndexPath), + SectionHeader(usize), + SectionFooter(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct MeasuredEntrySize { + pub(crate) item_size: Size, + pub(crate) section_header_size: Size, + pub(crate) section_footer_size: Size, +} + +impl RowEntry { + #[inline] + #[allow(unused)] + pub(crate) fn is_section_header(&self) -> bool { + matches!(self, RowEntry::SectionHeader(_)) + } + + pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool { + match self { + RowEntry::Entry(index_path) => index_path == path, + RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false, + } + } + + #[allow(unused)] + pub(crate) fn index(&self) -> IndexPath { + match self { + RowEntry::Entry(index_path) => *index_path, + RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix), + RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix), + } + } + + #[inline] + #[allow(unused)] + pub(crate) fn is_section_footer(&self) -> bool { + matches!(self, RowEntry::SectionFooter(_)) + } + + #[inline] + pub(crate) fn is_entry(&self) -> bool { + matches!(self, RowEntry::Entry(_)) + } + + #[inline] + #[allow(unused)] + pub(crate) fn section_ix(&self) -> Option { + match self { + RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix), + _ => None, + } + } +} + +#[derive(Default, Clone)] +pub(crate) struct RowsCache { + /// Only have section's that have rows. + pub(crate) entities: Rc>, + pub(crate) items_count: usize, + /// The sections, the item is number of rows in each section. + pub(crate) sections: Rc>, + pub(crate) entries_sizes: Rc>>, + measured_size: MeasuredEntrySize, +} + +impl RowsCache { + pub(crate) fn get(&self, flatten_ix: usize) -> Option { + self.entities.get(flatten_ix).cloned() + } + + /// Returns the number of flattened rows (Includes header, item, footer). + pub(crate) fn len(&self) -> usize { + self.entities.len() + } + + /// Return the number of items in the cache. + pub(crate) fn items_count(&self) -> usize { + self.items_count + } + + /// Returns the index of the Entry with given path in the flattened rows. + pub(crate) fn position_of(&self, path: &IndexPath) -> Option { + self.entities + .iter() + .position(|p| p.is_entry() && p.eq_index_path(path)) + } + + /// Return prev row, if the row is the first in the first section, goes to the last row. + /// + /// Empty rows section are skipped. + pub(crate) fn prev(&self, path: Option) -> IndexPath { + let path = path.unwrap_or_default(); + let Some(pos) = self.position_of(&path) else { + return self + .entities + .iter() + .rfind(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default(); + }; + + if let Some(path) = self + .entities + .iter() + .take(pos) + .rev() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + { + path + } else { + self.entities + .iter() + .rfind(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default() + } + } + + /// Returns the next row, if the row is the last in the last section, goes to the first row. + /// + /// Empty rows section are skipped. + pub(crate) fn next(&self, path: Option) -> IndexPath { + let Some(mut path) = path else { + return IndexPath::default(); + }; + + let Some(pos) = self.position_of(&path) else { + return self + .entities + .iter() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default(); + }; + + if let Some(next_path) = self + .entities + .iter() + .skip(pos + 1) + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + { + path = next_path; + } else { + path = self + .entities + .iter() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default() + } + + path + } + + pub(crate) fn prepare_if_needed( + &mut self, + sections_count: usize, + measured_size: MeasuredEntrySize, + cx: &App, + rows_count_f: F, + ) where + F: Fn(usize, &App) -> usize, + { + let mut new_sections = vec![]; + for section_ix in 0..sections_count { + new_sections.push(rows_count_f(section_ix, cx)); + } + + let need_update = new_sections != *self.sections || self.measured_size != measured_size; + + if !need_update { + return; + } + + let mut entries_sizes = vec![]; + let mut total_items_count = 0; + self.measured_size = measured_size; + self.sections = Rc::new(new_sections); + self.entities = Rc::new( + self.sections + .iter() + .enumerate() + .flat_map(|(section, items_count)| { + total_items_count += items_count; + let mut children = vec![]; + if *items_count == 0 { + return children; + } + + children.push(RowEntry::SectionHeader(section)); + entries_sizes.push(measured_size.section_header_size); + for row in 0..*items_count { + children.push(RowEntry::Entry(IndexPath { + section, + row, + ..Default::default() + })); + entries_sizes.push(measured_size.item_size); + } + children.push(RowEntry::SectionFooter(section)); + entries_sizes.push(measured_size.section_footer_size); + children + }) + .collect(), + ); + self.entries_sizes = Rc::new(entries_sizes); + self.items_count = total_items_count; + } +} diff --git a/crates/ui/src/list/delegate.rs b/crates/ui/src/list/delegate.rs new file mode 100644 index 0000000..2899d2f --- /dev/null +++ b/crates/ui/src/list/delegate.rs @@ -0,0 +1,171 @@ +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window}; +use theme::ActiveTheme; + +use crate::list::loading::Loading; +use crate::list::ListState; +use crate::{h_flex, Icon, IconName, IndexPath, Selectable}; + +/// A delegate for the List. +#[allow(unused)] +pub trait ListDelegate: Sized + 'static { + type Item: Selectable + IntoElement; + + /// When Query Input change, this method will be called. + /// You can perform search here. + fn perform_search( + &mut self, + query: &str, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + Task::ready(()) + } + + /// Return the number of sections in the list, default is 1. + /// + /// Min value is 1. + fn sections_count(&self, cx: &App) -> usize { + 1 + } + + /// Return the number of items in the section at the given index. + /// + /// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items, + /// the section header and footer will also be skipped. + fn items_count(&self, section: usize, cx: &App) -> usize; + + /// Render the item at the given index. + /// + /// Return None will skip the item. + /// + /// NOTE: Every item should have same height. + fn render_item( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context>, + ) -> Option; + + /// Render the section header at the given index, default is None. + /// + /// NOTE: Every header should have same height. + fn render_section_header( + &mut self, + section: usize, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None:: + } + + /// Render the section footer at the given index, default is None. + /// + /// NOTE: Every footer should have same height. + fn render_section_footer( + &mut self, + section: usize, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None:: + } + + /// Return a Element to show when list is empty. + fn render_empty( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_color(cx.theme().text_muted.opacity(0.6)) + .child(Icon::new(IconName::Inbox).size_12()) + .into_any_element() + } + + /// Returns Some(AnyElement) to render the initial state of the list. + /// + /// This can be used to show a view for the list before the user has + /// interacted with it. + /// + /// For example: The last search results, or the last selected item. + /// + /// Default is None, that means no initial state. + fn render_initial( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None + } + + /// Returns the loading state to show the loading view. + fn loading(&self, cx: &App) -> bool { + false + } + + /// Returns a Element to show when loading, default is built-in Skeleton + /// loading view. + fn render_loading( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + Loading + } + + /// Set the selected index, just store the ix, don't confirm. + fn set_selected_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context>, + ); + + /// Set the index of the item that has been right clicked. + fn set_right_clicked_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context>, + ) { + } + + /// Set the confirm and give the selected index, + /// this is means user have clicked the item or pressed Enter. + /// + /// This will always to `set_selected_index` before confirm. + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + } + + /// Cancel the selection, e.g.: Pressed ESC. + fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} + + /// Return true to enable load more data when scrolling to the bottom. + /// + /// Default: false + fn has_more(&self, cx: &App) -> bool { + false + } + + /// Returns a threshold value (n entities), of course, + /// when scrolling to the bottom, the remaining number of rows + /// triggers `load_more`. + /// + /// This should smaller than the total number of first load rows. + /// + /// Default: 20 entities (section header, footer and row) + fn load_more_threshold(&self) -> usize { + 20 + } + + /// Load more data when the table is scrolled to the bottom. + /// + /// This will performed in a background task. + /// + /// This is always called when the table is near the bottom, + /// so you must check if there is more data to load or lock + /// the loading state. + fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} +} diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index 79d52ab..5aab352 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -3,21 +3,23 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - div, px, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, - MouseButton, MouseDownEvent, ParentElement, Render, ScrollStrategy, Styled, Subscription, Task, - UniformListScrollHandle, Window, + div, px, size, uniform_list, App, AppContext, AvailableSpace, ClickEvent, Context, + DefiniteLength, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton, + ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Subscription, Task, UniformListScrollHandle, Window, }; use smol::Timer; use theme::ActiveTheme; -use super::loading::Loading; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::input::{InputEvent, InputState, TextInput}; -use crate::scroll::{Scrollbar, ScrollbarState}; -use crate::{v_flex, Icon, IconName, Sizable as _, Size}; +use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache}; +use crate::list::ListDelegate; +use crate::scroll::{Scrollbar, ScrollbarHandle}; +use crate::{v_flex, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt}; -pub fn init(cx: &mut App) { +pub(crate) fn init(cx: &mut App) { let context: Option<&str> = Some("List"); cx.bind_keys([ KeyBinding::new("escape", Cancel, context), @@ -31,138 +33,57 @@ pub fn init(cx: &mut App) { #[derive(Clone)] pub enum ListEvent { /// Move to select item. - Select(usize), + Select(IndexPath), /// Click on item or pressed Enter. - Confirm(usize), + Confirm(IndexPath), /// Pressed ESC to deselect the item. Cancel, } -/// A delegate for the List. -#[allow(unused)] -pub trait ListDelegate: Sized + 'static { - type Item: IntoElement; - - /// When Query Input change, this method will be called. - /// You can perform search here. - fn perform_search( - &mut self, - query: &str, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - Task::ready(()) - } - - /// Return the number of items in the list. - fn items_count(&self, cx: &App) -> usize; - - /// Render the item at the given index. - /// - /// Return None will skip the item. - fn render_item( - &self, - ix: usize, - window: &mut Window, - cx: &mut Context>, - ) -> Option; - - /// Return a Element to show when list is empty. - fn render_empty(&self, window: &mut Window, cx: &mut Context>) -> impl IntoElement { - div() - } - - /// Returns Some(AnyElement) to render the initial state of the list. - /// - /// This can be used to show a view for the list before the user has interacted with it. - /// - /// For example: The last search results, or the last selected item. - /// - /// Default is None, that means no initial state. - fn render_initial( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> Option { - None - } - - /// Returns the loading state to show the loading view. - fn loading(&self, cx: &App) -> bool { - false - } - - /// Returns a Element to show when loading, default is built-in Skeleton loading view. - fn render_loading( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> impl IntoElement { - Loading - } - - /// Set the selected index, just store the ix, don't confirm. - fn set_selected_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context>, - ); - - /// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter. - /// - /// This will always to `set_selected_index` before confirm. - fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) {} - - /// Cancel the selection, e.g.: Pressed ESC. - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} - - /// Return true to enable load more data when scrolling to the bottom. - /// - /// Default: true - fn can_load_more(&self, cx: &App) -> bool { - true - } - - /// Returns a threshold value (n rows), of course, when scrolling to the bottom, - /// the remaining number of rows triggers `load_more`. - /// This should smaller than the total number of first load rows. - /// - /// Default: 20 rows - fn load_more_threshold(&self) -> usize { - 20 - } - - /// Load more data when the table is scrolled to the bottom. - /// - /// This will performed in a background task. - /// - /// This is always called when the table is near the bottom, - /// so you must check if there is more data to load or lock the loading state. - fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} +struct ListOptions { + size: Size, + scrollbar_visible: bool, + search_placeholder: Option, + max_height: Option, + paddings: EdgesRefinement, } -pub struct List { - focus_handle: FocusHandle, +impl Default for ListOptions { + fn default() -> Self { + Self { + size: Size::default(), + scrollbar_visible: true, + max_height: None, + search_placeholder: None, + paddings: EdgesRefinement::default(), + } + } +} + +/// The state for List. +/// +/// List required all items has the same height. +pub struct ListState { + pub(crate) focus_handle: FocusHandle, + pub(crate) query_input: Entity, + options: ListOptions, delegate: D, - max_height: Option, - query_input: Option>, last_query: Option, - selectable: bool, - querying: bool, - scrollbar_visible: bool, - vertical_scroll_handle: UniformListScrollHandle, - scrollbar_state: ScrollbarState, - pub(crate) size: Size, - selected_index: Option, - right_clicked_index: Option, + scroll_handle: UniformListScrollHandle, + rows_cache: RowsCache, + selected_index: Option, + item_to_measure_index: IndexPath, + deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>, + mouse_right_clicked_index: Option, reset_on_cancel: bool, + searchable: bool, + selectable: bool, _search_task: Task<()>, _load_more_task: Task<()>, _query_input_subscription: Subscription, } -impl List +impl ListState where D: ListDelegate, { @@ -173,18 +94,18 @@ where Self { focus_handle: cx.focus_handle(), + options: ListOptions::default(), delegate, - query_input: Some(query_input), + rows_cache: RowsCache::default(), + query_input, last_query: None, selected_index: None, - right_clicked_index: None, - vertical_scroll_handle: UniformListScrollHandle::new(), - scrollbar_state: ScrollbarState::default(), - max_height: None, - scrollbar_visible: true, selectable: true, - querying: false, - size: Size::default(), + searchable: false, + item_to_measure_index: IndexPath::default(), + deferred_scroll_to_index: None, + mouse_right_clicked_index: None, + scroll_handle: UniformListScrollHandle::new(), reset_on_cancel: true, _search_task: Task::ready(()), _load_more_task: Task::ready(()), @@ -192,25 +113,17 @@ where } } - /// Set the size - pub fn set_size(&mut self, size: Size, _: &mut Window, _: &mut Context) { - self.size = size; - } - - pub fn max_h(mut self, height: impl Into) -> Self { - self.max_height = Some(height.into()); + /// Sets whether the list is searchable, default is `false`. + /// + /// When `true`, there will be a search input at the top of the list. + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; self } - /// Set the visibility of the scrollbar, default is true. - pub fn scrollbar_visible(mut self, visible: bool) -> Self { - self.scrollbar_visible = visible; - self - } - - pub fn no_query(mut self) -> Self { - self.query_input = None; - self + pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context) { + self.searchable = searchable; + cx.notify(); } /// Sets whether the list is selectable, default is true. @@ -219,20 +132,10 @@ where self } - pub fn set_query_input( - &mut self, - query_input: Entity, - window: &mut Window, - cx: &mut Context, - ) { - self._query_input_subscription = - cx.subscribe_in(&query_input, window, Self::on_query_input_event); - self.query_input = Some(query_input); - } - - /// Get the query input entity. - pub fn query_input(&self) -> Option<&Entity> { - self.query_input.as_ref() + /// Sets whether the list is selectable, default is true. + pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context) { + self.selectable = selectable; + cx.notify(); } pub fn delegate(&self) -> &D { @@ -243,57 +146,106 @@ where &mut self.delegate } + /// Focus the list, if the list is searchable, focus the search input. pub fn focus(&mut self, window: &mut Window, cx: &mut App) { self.focus_handle(cx).focus(window, cx); } - /// Set the selected index of the list, this will also scroll to the selected item. - pub fn set_selected_index( + /// Return true if either the list or the search input is focused. + #[allow(dead_code)] + pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool { + self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window) + } + + /// Set the selected index of the list, + /// this will also scroll to the selected item. + pub(crate) fn _set_selected_index( &mut self, - ix: Option, + ix: Option, window: &mut Window, cx: &mut Context, ) { + if !self.selectable { + return; + } + self.selected_index = ix; self.delegate.set_selected_index(ix, window, cx); self.scroll_to_selected_item(window, cx); } - pub fn selected_index(&self) -> Option { + /// Set the selected index of the list, + /// this method will not scroll to the selected item. + pub fn set_selected_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.selected_index = ix; + self.delegate.set_selected_index(ix, window, cx); + } + + pub fn selected_index(&self) -> Option { self.selected_index } - fn render_scrollbar( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - if !self.scrollbar_visible { - return None; - } + /// Set the index of the item that has been right clicked. + pub fn set_right_clicked_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.mouse_right_clicked_index = ix; + self.delegate.set_right_clicked_index(ix, window, cx); + } - Some(Scrollbar::uniform_scroll( - &self.scrollbar_state, - &self.vertical_scroll_handle, - )) + /// Returns the index of the item that has been right clicked. + pub fn right_clicked_index(&self) -> Option { + self.mouse_right_clicked_index + } + + /// Set a specific list item for measurement. + pub fn set_item_to_measure_index( + &mut self, + ix: IndexPath, + _: &mut Window, + cx: &mut Context, + ) { + self.item_to_measure_index = ix; + cx.notify(); } /// Scroll to the item at the given index. - pub fn scroll_to_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { - self.vertical_scroll_handle - .scroll_to_item(ix, ScrollStrategy::Top); + pub fn scroll_to_item( + &mut self, + ix: IndexPath, + strategy: ScrollStrategy, + _: &mut Window, + cx: &mut Context, + ) { + if ix.section == 0 && ix.row == 0 { + // If the item is the first item, scroll to the top. + let mut offset = self.scroll_handle.offset(); + offset.y = px(0.); + self.scroll_handle.set_offset(offset); + cx.notify(); + return; + } + self.deferred_scroll_to_index = Some((ix, strategy)); cx.notify(); } /// Get scroll handle pub fn scroll_handle(&self) -> &UniformListScrollHandle { - &self.vertical_scroll_handle + &self.scroll_handle } - fn scroll_to_selected_item(&mut self, _window: &mut Window, _cx: &mut Context) { + pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context) { if let Some(ix) = self.selected_index { - self.vertical_scroll_handle - .scroll_to_item(ix, ScrollStrategy::Top); + self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top)); + cx.notify(); } } @@ -308,33 +260,31 @@ where InputEvent::Change => { let text = state.read(cx).value(); let text = text.trim().to_string(); - if Some(&text) == self.last_query.as_ref() { return; } - self.set_querying(true, window, cx); + self.set_searching(true, window, cx); let search = self.delegate.perform_search(&text, window, cx); - if self.delegate.items_count(cx) > 0 { - self.set_selected_index(Some(0), window, cx); + if self.rows_cache.len() > 0 { + self._set_selected_index(Some(IndexPath::default()), window, cx); } else { - self.set_selected_index(None, window, cx); + self._set_selected_index(None, window, cx); } self._search_task = cx.spawn_in(window, async move |this, window| { search.await; _ = this.update_in(window, |this, _, _| { - this.vertical_scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); + this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); this.last_query = Some(text); }); // Always wait 100ms to avoid flicker Timer::after(Duration::from_millis(100)).await; _ = this.update_in(window, |this, window, cx| { - this.set_querying(false, window, cx); + this.set_searching(false, window, cx); }); }); } @@ -349,26 +299,27 @@ where } } - fn set_querying(&mut self, querying: bool, _window: &mut Window, cx: &mut Context) { - self.querying = querying; - if let Some(input) = &self.query_input { - input.update(cx, |input, cx| input.set_loading(querying, cx)) - } - cx.notify(); + fn set_searching(&mut self, searching: bool, _window: &mut Window, cx: &mut Context) { + self.query_input + .update(cx, |input, cx| input.set_loading(searching, cx)); } - /// Dispatch delegate's `load_more` method when the visible range is near the end. + /// Dispatch delegate's `load_more` method when the + /// visible range is near the end. fn load_more_if_need( &mut self, - items_count: usize, + entities_count: usize, visible_end: usize, window: &mut Window, cx: &mut Context, ) { + // FIXME: Here need void sections items count. + let threshold = self.delegate.load_more_threshold(); - // Securely handle subtract logic to prevent attempt to subtract with overflow - if visible_end >= items_count.saturating_sub(threshold) { - if !self.delegate.can_load_more(cx) { + // Securely handle subtract logic to prevent attempt + // to subtract with overflow + if visible_end >= entities_count.saturating_sub(threshold) { + if !self.delegate.has_more(cx) { return; } @@ -380,18 +331,16 @@ where } } + #[allow(dead_code)] pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self { self.reset_on_cancel = reset; self } fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if self.selected_index.is_none() { - cx.propagate(); - } - + cx.propagate(); if self.reset_on_cancel { - self.set_selected_index(None, window, cx); + self._set_selected_index(None, window, cx); } self.delegate.cancel(window, cx); @@ -405,7 +354,7 @@ where window: &mut Window, cx: &mut Context, ) { - if self.delegate.items_count(cx) == 0 { + if self.rows_cache.len() == 0 { return; } @@ -420,7 +369,11 @@ where cx.notify(); } - fn select_item(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + fn select_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context) { + if !self.selectable { + return; + } + self.selected_index = Some(ix); self.delegate.set_selected_index(Some(ix), window, cx); self.scroll_to_selected_item(window, cx); @@ -428,222 +381,365 @@ where cx.notify(); } - fn on_select_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - let items_count = self.delegate.items_count(cx); - if items_count == 0 { + pub(crate) fn on_action_select_prev( + &mut self, + _: &SelectUp, + window: &mut Window, + cx: &mut Context, + ) { + if self.rows_cache.len() == 0 { return; } - let mut selected_index = self.selected_index.unwrap_or(0); - if selected_index > 0 { - selected_index -= 1; - } else { - selected_index = items_count - 1; - } - self.select_item(selected_index, window, cx); + let prev_ix = self.rows_cache.prev(self.selected_index); + self.select_item(prev_ix, window, cx); } - fn on_select_next(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { - let items_count = self.delegate.items_count(cx); - if items_count == 0 { + pub(crate) fn on_action_select_next( + &mut self, + _: &SelectDown, + window: &mut Window, + cx: &mut Context, + ) { + if self.rows_cache.len() == 0 { return; } - let selected_index; - if let Some(ix) = self.selected_index { - if ix < items_count - 1 { - selected_index = ix + 1; - } else { - // When the last item is selected, select the first item. - selected_index = 0; - } - } else { - // When no selected index, select the first item. - selected_index = 0; + let next_ix = self.rows_cache.next(self.selected_index); + self.select_item(next_ix, window, cx); + } + + fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context) { + let sections_count = self.delegate.sections_count(cx).max(1); + let mut measured_size = MeasuredEntrySize::default(); + + // Measure the item_height and section header/footer height. + let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); + measured_size.item_size = self + .render_list_item(self.item_to_measure_index, window, cx) + .into_any_element() + .layout_as_root(available_space, window, cx); + + if let Some(mut el) = self + .delegate + .render_section_header(0, window, cx) + .map(|r| r.into_any_element()) + { + measured_size.section_header_size = el.layout_as_root(available_space, window, cx); + } + if let Some(mut el) = self + .delegate + .render_section_footer(0, window, cx) + .map(|r| r.into_any_element()) + { + measured_size.section_footer_size = el.layout_as_root(available_space, window, cx); } - self.select_item(selected_index, window, cx); + self.rows_cache + .prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| { + self.delegate.items_count(section_ix, cx) + }); } fn render_list_item( &mut self, - ix: usize, + ix: IndexPath, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let selected = self.selected_index == Some(ix); - let right_clicked = self.right_clicked_index == Some(ix); + let selectable = self.selectable; + let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false); + let mouse_right_clicked = self + .mouse_right_clicked_index + .map(|s| s.eq_row(ix)) + .unwrap_or(false); + let id = SharedString::from(format!("list-item-{}", ix)); div() - .id("list-item") + .id(id) .w_full() .relative() - .children(self.delegate.render_item(ix, window, cx)) - .when(self.selectable, |this| { - this.when(selected || right_clicked, |this| { - this.child( - div() - .absolute() - .top(px(0.)) - .left(px(0.)) - .right(px(0.)) - .bottom(px(0.)) - .when(selected, |this| this.bg(cx.theme().element_background)) - .border_1() - .border_color(cx.theme().border_selected), - ) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, ev: &MouseDownEvent, window, cx| { - this.right_clicked_index = None; - this.selected_index = Some(ix); - this.on_action_confirm( - &Confirm { - secondary: ev.modifiers.secondary(), - }, - window, - cx, - ); - }), - ) + .overflow_hidden() + .children(self.delegate.render_item(ix, window, cx).map(|item| { + item.selected(selected) + .secondary_selected(mouse_right_clicked) + })) + .when(selectable, |this| { + this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| { + this.set_right_clicked_index(None, window, cx); + this.selected_index = Some(ix); + this.on_action_confirm( + &Confirm { + secondary: e.modifiers().secondary(), + }, + window, + cx, + ); + })) .on_mouse_down( MouseButton::Right, - cx.listener(move |this, _, _, cx| { - this.right_clicked_index = Some(ix); + cx.listener(move |this, _, window, cx| { + this.set_right_clicked_index(Some(ix), window, cx); cx.notify(); }), ) }) } + + fn render_items( + &mut self, + items_count: usize, + entities_count: usize, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let rows_cache = self.rows_cache.clone(); + let scrollbar_visible = self.options.scrollbar_visible; + let scroll_handle = self.scroll_handle.clone(); + + v_flex() + .flex_grow() + .relative() + .size_full() + .when_some(self.options.max_height, |this, h| this.max_h(h)) + .overflow_hidden() + .when(items_count == 0, |this| { + this.child(self.delegate.render_empty(window, cx)) + }) + .when(items_count > 0, { + |this| { + this.child( + uniform_list( + "virtual-list", + rows_cache.items_count(), + cx.processor(move |this, range: Range, window, cx| { + this.load_more_if_need(entities_count, range.end, window, cx); + + // NOTE: Here the v_virtual_list would not able to have gap_y, + // because the section header, footer is always have rendered as a empty child item, + // even the delegate give a None result. + + range + .map(|ix| { + let Some(entry) = rows_cache.get(ix) else { + return div(); + }; + + div().children(match entry { + RowEntry::Entry(index) => Some( + this.render_list_item(index, window, cx) + .into_any_element(), + ), + RowEntry::SectionHeader(section_ix) => this + .delegate_mut() + .render_section_header(section_ix, window, cx) + .map(|r| r.into_any_element()), + RowEntry::SectionFooter(section_ix) => this + .delegate_mut() + .render_section_footer(section_ix, window, cx) + .map(|r| r.into_any_element()), + }) + }) + .collect::>() + }), + ) + .when(self.options.max_height.is_some(), |this| { + this.with_sizing_behavior(ListSizingBehavior::Infer) + }) + .track_scroll(&scroll_handle) + .into_any_element(), + ) + } + }) + .when(scrollbar_visible, |this| { + this.child(Scrollbar::vertical(&scroll_handle)) + }) + } } -impl Focusable for List +impl Focusable for ListState where D: ListDelegate, { fn focus_handle(&self, cx: &App) -> FocusHandle { - if let Some(query_input) = &self.query_input { - query_input.focus_handle(cx) + if self.searchable { + self.query_input.focus_handle(cx) } else { self.focus_handle.clone() } } } -impl EventEmitter for List where D: ListDelegate {} -impl Render for List +impl EventEmitter for ListState where D: ListDelegate {} +impl Render for ListState where D: ListDelegate, { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let vertical_scroll_handle = self.vertical_scroll_handle.clone(); - let items_count = self.delegate.items_count(cx); - let loading = self.delegate.loading(cx); - let sizing_behavior = if self.max_height.is_some() { - ListSizingBehavior::Infer + self.prepare_items_if_needed(window, cx); + + // Scroll to the selected item if it is set. + if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() { + if let Some(item_ix) = self.rows_cache.position_of(&ix) { + self.scroll_handle.scroll_to_item(item_ix, strategy); + } + } + + let loading = self.delegate().loading(cx); + let query_input = if self.searchable { + // sync placeholder + if let Some(placeholder) = &self.options.search_placeholder { + self.query_input.update(cx, |input, cx| { + input.set_placeholder(placeholder.clone(), window, cx); + }); + } + Some(self.query_input.clone()) } else { - ListSizingBehavior::Auto + None }; - let initial_view = if let Some(input) = &self.query_input { + let loading_view = if loading { + Some(self.delegate.render_loading(window, cx).into_any_element()) + } else { + None + }; + let initial_view = if let Some(input) = &query_input { if input.read(cx).value().is_empty() { - self.delegate().render_initial(window, cx) + self.delegate.render_initial(window, cx) } else { None } } else { None }; + let items_count = self.rows_cache.items_count(); + let entities_count = self.rows_cache.len(); + let mouse_right_clicked_index = self.mouse_right_clicked_index; v_flex() .key_context("List") - .id("list") + .id("list-state") .track_focus(&self.focus_handle) .size_full() .relative() .overflow_hidden() - .when_some(self.query_input.clone(), |this, input| { + .when_some(query_input, |this, input| { this.child( div() - .map(|this| match self.size { - Size::Small => this.py_0().px_1p5(), - _ => this.py_1().px_2(), + .map(|this| match self.options.size { + Size::Small => this.px_1p5(), + _ => this.px_2(), }) .border_b_1() .border_color(cx.theme().border) .child( TextInput::new(&input) - .with_size(self.size) + .with_size(self.options.size) + .appearance(false) + .cleanable() + .p_0() .prefix( Icon::new(IconName::Search).text_color(cx.theme().text_muted), - ) - .cleanable() - .appearance(false), + ), ), ) }) - .when(loading, |this| { - this.child(self.delegate().render_loading(window, cx)) - }) .when(!loading, |this| { this.on_action(cx.listener(Self::on_action_cancel)) .on_action(cx.listener(Self::on_action_confirm)) - .on_action(cx.listener(Self::on_select_next)) - .on_action(cx.listener(Self::on_select_prev)) + .on_action(cx.listener(Self::on_action_select_next)) + .on_action(cx.listener(Self::on_action_select_prev)) .map(|this| { if let Some(view) = initial_view { this.child(view) } else { - this.child( - v_flex() - .flex_grow() - .relative() - .when_some(self.max_height, |this, h| this.max_h(h)) - .overflow_hidden() - .when(items_count == 0, |this| { - this.child(self.delegate().render_empty(window, cx)) - }) - .when(items_count > 0, |this| { - this.child( - uniform_list( - "list", - items_count, - cx.processor( - move |list, range: Range, window, cx| { - list.load_more_if_need( - items_count, - range.end, - window, - cx, - ); - - range - .map(|ix| { - list.render_list_item( - ix, window, cx, - ) - }) - .collect::>() - }, - ), - ) - .flex_grow() - .with_sizing_behavior(sizing_behavior) - .track_scroll(&vertical_scroll_handle) - .into_any_element(), - ) - }) - .children(self.render_scrollbar(window, cx)), - ) + this.child(self.render_items(items_count, entities_count, window, cx)) } }) // Click out to cancel right clicked row - .when(self.right_clicked_index.is_some(), |this| { - this.on_mouse_down_out(cx.listener(|this, _, _, cx| { - this.right_clicked_index = None; + .when(mouse_right_clicked_index.is_some(), |this| { + this.on_mouse_down_out(cx.listener(|this, _, window, cx| { + this.set_right_clicked_index(None, window, cx); cx.notify(); })) }) }) + .children(loading_view) + } +} + +/// The List element. +#[derive(IntoElement)] +pub struct List { + state: Entity>, + style: StyleRefinement, + options: ListOptions, +} + +impl List +where + D: ListDelegate + 'static, +{ + /// Create a new List element with the given ListState entity. + pub fn new(state: &Entity>) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + options: ListOptions::default(), + } + } + + /// Set whether the scrollbar is visible, default is `true`. + pub fn scrollbar_visible(mut self, visible: bool) -> Self { + self.options.scrollbar_visible = visible; + self + } + + /// Sets the placeholder text for the search input. + pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { + self.options.search_placeholder = Some(placeholder.into()); + self + } +} + +impl Styled for List +where + D: ListDelegate + 'static, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for List +where + D: ListDelegate + 'static, +{ + fn with_size(mut self, size: impl Into) -> Self { + self.options.size = size.into(); + self + } +} + +impl RenderOnce for List +where + D: ListDelegate + 'static, +{ + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + // Take paddings, max_height to options, and clear them from style, + // because they would be applied to the inner virtual list. + self.options.paddings = self.style.padding.clone(); + self.options.max_height = self.style.max_size.height; + self.style.padding = EdgesRefinement::default(); + self.style.max_size.height = None; + + self.state.update(cx, |state, _| { + state.options = self.options; + }); + + div() + .id("list") + .size_full() + .refine_style(&self.style) + .child(self.state.clone()) } } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index 6a14edb..d2d872a 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,39 +1,57 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, - MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, - Window, + div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, + MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, }; use smallvec::SmallVec; use theme::ActiveTheme; -use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable as _}; +use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt}; -type OnClick = Option>; -type OnMouseEnter = Option>; -type Suffix = Option AnyElement + 'static>>; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ListItemMode { + #[default] + Entry, + Separator, +} + +impl ListItemMode { + #[inline] + fn is_separator(&self) -> bool { + matches!(self, ListItemMode::Separator) + } +} #[derive(IntoElement)] pub struct ListItem { base: Stateful

, + mode: ListItemMode, + style: StyleRefinement, disabled: bool, selected: bool, + secondary_selected: bool, confirmed: bool, check_icon: Option, - on_click: OnClick, - on_mouse_enter: OnMouseEnter, - suffix: Suffix, + #[allow(clippy::type_complexity)] + on_click: Option>, + #[allow(clippy::type_complexity)] + on_mouse_enter: Option>, + #[allow(clippy::type_complexity)] + suffix: Option AnyElement + 'static>>, children: SmallVec<[AnyElement; 2]>, } impl ListItem { pub fn new(id: impl Into) -> Self { let id: ElementId = id.into(); - Self { - base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(), + mode: ListItemMode::Entry, + base: h_flex().id(id), + style: StyleRefinement::default(), disabled: false, selected: false, + secondary_selected: false, confirmed: false, on_click: None, on_mouse_enter: None, @@ -43,9 +61,15 @@ impl ListItem { } } + /// Set this list item to as a separator, it not able to be selected. + pub fn separator(mut self) -> Self { + self.mode = ListItemMode::Separator; + self + } + /// Set to show check icon, default is None. - pub fn check_icon(mut self, icon: IconName) -> Self { - self.check_icon = Some(Icon::new(icon)); + pub fn check_icon(mut self, icon: impl Into) -> Self { + self.check_icon = Some(icon.into()); self } @@ -111,11 +135,16 @@ impl Selectable for ListItem { fn is_selected(&self) -> bool { self.selected } + + fn secondary_selected(mut self, selected: bool) -> Self { + self.secondary_selected = selected; + self + } } impl Styled for ListItem { fn style(&mut self) -> &mut gpui::StyleRefinement { - self.base.style() + &mut self.style } } @@ -127,35 +156,39 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_active = self.selected || self.confirmed; + let is_active = self.confirmed || self.selected; + + let corner_radii = self.style.corner_radii.clone(); + + let _selected_style = StyleRefinement { + corner_radii, + ..Default::default() + }; + + let is_selectable = !(self.disabled || self.mode.is_separator()); self.base + .relative() + .gap_x_1() + .py_1() + .px_3() + .text_base() .text_color(cx.theme().text) .relative() .items_center() .justify_between() - .when_some(self.on_click, |this, on_click| { - if !self.disabled { - this.cursor_pointer() - .on_mouse_down(MouseButton::Left, move |_, _window, cx| { - cx.stop_propagation(); - }) - .on_click(on_click) - } else { - this - } + .refine_style(&self.style) + .when(is_selectable, |this| { + this.when_some(self.on_click, |this, on_click| this.on_click(on_click)) + .when_some(self.on_mouse_enter, |this, on_mouse_enter| { + this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) + }) + .when(!is_active, |this| { + this.hover(|this| this.bg(cx.theme().ghost_element_hover)) + }) }) - .when(is_active, |this| this.bg(cx.theme().element_active)) - .when(!is_active && !self.disabled, |this| { - this.hover(|this| this.bg(cx.theme().elevated_surface_background)) - }) - // Mouse enter - .when_some(self.on_mouse_enter, |this, on_mouse_enter| { - if !self.disabled { - this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) - } else { - this - } + .when(!is_selectable, |this| { + this.text_color(cx.theme().text_muted) }) .child( h_flex() @@ -177,5 +210,17 @@ impl RenderOnce for ListItem { }), ) .when_some(self.suffix, |this, suffix| this.child(suffix(window, cx))) + .map(|this| { + if is_selectable && (self.selected || self.secondary_selected) { + let bg = if self.selected { + cx.theme().ghost_element_active + } else { + cx.theme().ghost_element_background + }; + this.bg(bg) + } else { + this + } + }) } } diff --git a/crates/ui/src/list/loading.rs b/crates/ui/src/list/loading.rs index 8c3fa21..9ad64d0 100644 --- a/crates/ui/src/list/loading.rs +++ b/crates/ui/src/list/loading.rs @@ -17,7 +17,7 @@ impl RenderOnce for LoadingItem { .gap_1p5() .overflow_hidden() .child(Skeleton::new().h_5().w_48().max_w_full()) - .child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()), + .child(Skeleton::new().secondary().h_3().w_64().max_w_full()), ) } } diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs index 88baf0f..11105c1 100644 --- a/crates/ui/src/list/mod.rs +++ b/crates/ui/src/list/mod.rs @@ -1,7 +1,28 @@ +pub(crate) mod cache; +mod delegate; #[allow(clippy::module_inception)] mod list; mod list_item; mod loading; +mod separator_item; +pub use delegate::*; pub use list::*; pub use list_item::*; +pub use separator_item::*; +use serde::{Deserialize, Serialize}; + +/// Settings for List. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListSettings { + /// Whether to use active highlight style on ListItem, default + pub active_highlight: bool, +} + +impl Default for ListSettings { + fn default() -> Self { + Self { + active_highlight: true, + } + } +} diff --git a/crates/ui/src/list/separator_item.rs b/crates/ui/src/list/separator_item.rs new file mode 100644 index 0000000..b419a4e --- /dev/null +++ b/crates/ui/src/list/separator_item.rs @@ -0,0 +1,50 @@ +use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement}; +use smallvec::SmallVec; + +use crate::list::ListItem; +use crate::{Selectable, StyledExt}; + +pub struct ListSeparatorItem { + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl ListSeparatorItem { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Default for ListSeparatorItem { + fn default() -> Self { + Self::new() + } +} + +impl ParentElement for ListSeparatorItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Selectable for ListSeparatorItem { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } +} + +impl RenderOnce for ListSeparatorItem { + fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement { + ListItem::new("separator") + .refine_style(&self.style) + .children(self.children) + .disabled(true) + } +} diff --git a/crates/ui/src/menu/app_menu_bar.rs b/crates/ui/src/menu/app_menu_bar.rs index 9548a49..ea90217 100644 --- a/crates/ui/src/menu/app_menu_bar.rs +++ b/crates/ui/src/menu/app_menu_bar.rs @@ -1,16 +1,17 @@ use gpui::prelude::FluentBuilder; use gpui::{ anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, - Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render, - SharedString, StatefulInteractiveElement, Styled, Subscription, Window, + Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; use crate::actions::{Cancel, SelectLeft, SelectRight}; use crate::button::{Button, ButtonVariants}; -use crate::popup_menu::PopupMenu; +use crate::menu::PopupMenu; use crate::{h_flex, Selectable, Sizable}; const CONTEXT: &str = "AppMenuBar"; + pub fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", Cancel, Some(CONTEXT)), @@ -22,67 +23,74 @@ pub fn init(cx: &mut App) { /// The application menu bar, for Windows and Linux. pub struct AppMenuBar { menus: Vec>, - selected_ix: Option, + selected_index: Option, } impl AppMenuBar { /// Create a new app menu bar. - pub fn new(window: &mut Window, cx: &mut App) -> Entity { + pub fn new(cx: &mut App) -> Entity { cx.new(|cx| { - let menu_bar = cx.entity(); - let menus = cx - .get_menus() - .unwrap_or_default() - .iter() - .enumerate() - .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx)) - .collect(); - - Self { - selected_ix: None, - menus, - } + let mut this = Self { + selected_index: None, + menus: Vec::new(), + }; + this.reload(cx); + this }) } - fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { - let Some(selected_ix) = self.selected_ix else { + /// Reload the menus from the app. + pub fn reload(&mut self, cx: &mut Context) { + let menu_bar = cx.entity(); + self.menus = cx + .get_menus() + .unwrap_or_default() + .iter() + .enumerate() + .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx)) + .collect(); + self.selected_index = None; + cx.notify(); + } + + fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { + let Some(selected_index) = self.selected_index else { return; }; - let new_ix = if selected_ix == 0 { + let new_ix = if selected_index == 0 { self.menus.len().saturating_sub(1) } else { - selected_ix.saturating_sub(1) + selected_index.saturating_sub(1) }; - self.set_selected_ix(Some(new_ix), window, cx); + self.set_selected_index(Some(new_ix), window, cx); } - fn move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { - let Some(selected_ix) = self.selected_ix else { + fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { + let Some(selected_index) = self.selected_index else { return; }; - let new_ix = if selected_ix + 1 >= self.menus.len() { + let new_ix = if selected_index + 1 >= self.menus.len() { 0 } else { - selected_ix + 1 + selected_index + 1 }; - self.set_selected_ix(Some(new_ix), window, cx); + self.set_selected_index(Some(new_ix), window, cx); } - fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - self.set_selected_ix(None, window, cx); + fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.set_selected_index(None, window, cx); } - fn set_selected_ix(&mut self, ix: Option, _: &mut Window, cx: &mut Context) { - self.selected_ix = ix; + fn set_selected_index(&mut self, ix: Option, _: &mut Window, cx: &mut Context) { + self.selected_index = ix; cx.notify(); } #[inline] fn has_activated_menu(&self) -> bool { - self.selected_ix.is_some() + self.selected_index.is_some() } } @@ -91,9 +99,9 @@ impl Render for AppMenuBar { h_flex() .id("app-menu-bar") .key_context(CONTEXT) - .on_action(cx.listener(Self::move_left)) - .on_action(cx.listener(Self::move_right)) - .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::on_move_left)) + .on_action(cx.listener(Self::on_move_right)) + .on_action(cx.listener(Self::on_cancel)) .size_full() .gap_x_1() .overflow_x_scroll() @@ -117,7 +125,6 @@ impl AppMenu { ix: usize, menu: &OwnedMenu, menu_bar: Entity, - _: &mut Window, cx: &mut App, ) -> Entity { let name = menu.name.clone(); @@ -173,7 +180,7 @@ impl AppMenu { self._subscription.take(); self.popup_menu.take(); self.menu_bar.update(cx, |state, cx| { - state.cancel(&Cancel, window, cx); + state.on_cancel(&Cancel, window, cx); }); } @@ -183,11 +190,11 @@ impl AppMenu { window: &mut Window, cx: &mut Context, ) { - let is_selected = self.menu_bar.read(cx).selected_ix == Some(self.ix); + let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix); self.menu_bar.update(cx, |state, cx| { let new_ix = if is_selected { None } else { Some(self.ix) }; - state.set_selected_ix(new_ix, window, cx); + state.set_selected_index(new_ix, window, cx); }); } @@ -202,7 +209,7 @@ impl AppMenu { } self.menu_bar.update(cx, |state, cx| { - state.set_selected_ix(Some(self.ix), window, cx); + state.set_selected_index(Some(self.ix), window, cx); }); } } @@ -210,7 +217,7 @@ impl AppMenu { impl Render for AppMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let menu_bar = self.menu_bar.read(cx); - let is_selected = menu_bar.selected_ix == Some(self.ix); + let is_selected = menu_bar.selected_index == Some(self.ix); div() .id(self.ix) @@ -219,10 +226,15 @@ impl Render for AppMenu { Button::new("menu") .small() .py_0p5() - .xsmall() + .compact() .ghost() .label(self.name.clone()) .selected(is_selected) + .on_mouse_down(MouseButton::Left, |_, window, cx| { + // Stop propagation to avoid dragging the window. + window.prevent_default(); + cx.stop_propagation(); + }) .on_click(cx.listener(Self::handle_trigger_click)), ) .on_hover(cx.listener(Self::handle_hover)) diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index a371e84..679c4c6 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -3,49 +3,66 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element, - ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, - IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful, - Style, Subscription, Window, + anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element, + ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, + StyleRefinement, Styled, Subscription, Window, }; -use crate::popup_menu::PopupMenu; +use crate::menu::PopupMenu; -pub trait ContextMenuExt: ParentElement + Sized { +/// A extension trait for adding a context menu to an element. +pub trait ContextMenuExt: ParentElement + Styled { + /// Add a context menu to the element. + /// + /// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element. + /// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element. fn context_menu( self, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.child(ContextMenu::new("context-menu").menu(f)) + ) -> ContextMenu + where + Self: Sized, + { + // Generate a unique ID based on the element's memory address to ensure + // each context menu has its own state and doesn't share with others + let id = format!("context-menu-{:p}", &self as *const _); + ContextMenu::new(id, self).menu(f) } } -impl ContextMenuExt for Stateful where E: ParentElement {} +impl ContextMenuExt for E {} /// A context menu that can be shown on right-click. -#[allow(clippy::type_complexity)] -pub struct ContextMenu { +pub struct ContextMenu { id: ElementId, - menu: - Option) -> PopupMenu + 'static>>, + element: Option, + #[allow(clippy::type_complexity)] + menu: Option) -> PopupMenu>>, + // This is not in use, just for style refinement forwarding. + _ignore_style: StyleRefinement, anchor: Corner, } -impl ContextMenu { - pub fn new(id: impl Into) -> Self { +impl ContextMenu { + /// Create a new context menu with the given ID. + pub fn new(id: impl Into, element: E) -> Self { Self { id: id.into(), + element: Some(element), menu: None, anchor: Corner::TopLeft, + _ignore_style: StyleRefinement::default(), } } + /// Build the context menu using the given builder function. #[must_use] - pub fn menu(mut self, builder: F) -> Self + fn menu(mut self, builder: F) -> Self where F: Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, { - self.menu = Some(Box::new(builder)); + self.menu = Some(Rc::new(builder)); self } @@ -67,7 +84,25 @@ impl ContextMenu { } } -impl IntoElement for ContextMenu { +impl ParentElement for ContextMenu { + fn extend(&mut self, elements: impl IntoIterator) { + if let Some(element) = &mut self.element { + element.extend(elements); + } + } +} + +impl Styled for ContextMenu { + fn style(&mut self) -> &mut StyleRefinement { + if let Some(element) = &mut self.element { + element.style() + } else { + &mut self._ignore_style + } + } +} + +impl IntoElement for ContextMenu { type Element = Self; fn into_element(self) -> Self::Element { @@ -83,14 +118,14 @@ struct ContextMenuSharedState { } pub struct ContextMenuState { - menu_element: Option, + element: Option, shared_state: Rc>, } impl Default for ContextMenuState { fn default() -> Self { Self { - menu_element: None, + element: None, shared_state: Rc::new(RefCell::new(ContextMenuSharedState { menu_view: None, open: false, @@ -101,8 +136,8 @@ impl Default for ContextMenuState { } } -impl Element for ContextMenu { - type PrepaintState = (); +impl Element for ContextMenu { + type PrepaintState = Hitbox; type RequestLayoutState = ContextMenuState; fn id(&self) -> Option { @@ -113,7 +148,6 @@ impl Element for ContextMenu { None } - #[allow(clippy::field_reassign_with_default)] fn request_layout( &mut self, id: Option<&gpui::GlobalElementId>, @@ -121,71 +155,73 @@ impl Element for ContextMenu { window: &mut Window, cx: &mut App, ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - // Set the layout style relative to the table view to get same size. - style.position = Position::Absolute; - style.flex_grow = 1.0; - style.flex_shrink = 1.0; - style.size.width = relative(1.).into(); - style.size.height = relative(1.).into(); - let anchor = self.anchor; self.with_element_state( id.unwrap(), window, cx, - |_, state: &mut ContextMenuState, window, cx| { + |this, state: &mut ContextMenuState, window, cx| { let (position, open) = { let shared_state = state.shared_state.borrow(); (shared_state.position, shared_state.open) }; let menu_view = state.shared_state.borrow().menu_view.clone(); - let (menu_element, menu_layout_id) = if open { + let mut menu_element = None; + if open { let has_menu_item = menu_view .as_ref() .map(|menu| !menu.read(cx).is_empty()) .unwrap_or(false); if has_menu_item { - let mut menu_element = deferred( - anchored() - .position(position) - .snap_to_window_with_margin(px(8.)) - .anchor(anchor) - .when_some(menu_view, |this, menu| { - // Focus the menu, so that can be handle the action. - if !menu.focus_handle(cx).contains_focused(window, cx) { - menu.focus_handle(cx).focus(window, cx); - } + menu_element = Some( + deferred( + anchored().child( + div() + .w(window.bounds().size.width) + .h(window.bounds().size.height) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child( + anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .when_some(menu_view, |this, menu| { + // Focus the menu, so that can be handle the action. + if !menu + .focus_handle(cx) + .contains_focused(window, cx) + { + menu.focus_handle(cx).focus(window, cx); + } - this.child(div().occlude().child(menu.clone())) - }), - ) - .with_priority(1) - .into_any(); - - let menu_layout_id = menu_element.request_layout(window, cx); - (Some(menu_element), Some(menu_layout_id)) - } else { - (None, None) + this.child(menu.clone()) + }), + ), + ), + ) + .with_priority(1) + .into_any(), + ); } - } else { - (None, None) - }; - - let mut layout_ids = vec![]; - if let Some(menu_layout_id) = menu_layout_id { - layout_ids.push(menu_layout_id); } - let layout_id = window.request_layout(style, layout_ids, cx); + let mut element = this + .element + .take() + .expect("Element should exists.") + .children(menu_element) + .into_any_element(); + + let layout_id = element.request_layout(window, cx); ( layout_id, ContextMenuState { - menu_element, - + element: Some(element), ..Default::default() }, ) @@ -197,33 +233,33 @@ impl Element for ContextMenu { &mut self, _: Option<&gpui::GlobalElementId>, _: Option<&InspectorElementId>, - _: gpui::Bounds, + bounds: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { - if let Some(menu_element) = &mut request_layout.menu_element { - menu_element.prepaint(window, cx); + if let Some(element) = &mut request_layout.element { + element.prepaint(window, cx); } + window.insert_hitbox(bounds, HitboxBehavior::Normal) } fn paint( &mut self, id: Option<&gpui::GlobalElementId>, _: Option<&InspectorElementId>, - bounds: gpui::Bounds, + _: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, + hitbox: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { - if let Some(menu_element) = &mut request_layout.menu_element { - menu_element.paint(window, cx); + if let Some(element) = &mut request_layout.element { + element.paint(window, cx); } - let Some(builder) = self.menu.take() else { - return; - }; + // Take the builder before setting up element state to avoid borrow issues + let builder = self.menu.clone(); self.with_element_state( id.unwrap(), @@ -232,34 +268,53 @@ impl Element for ContextMenu { |_view, state: &mut ContextMenuState, window, _| { let shared_state = state.shared_state.clone(); + let hitbox = hitbox.clone(); // When right mouse click, to build content menu, and show it at the mouse position. window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.bubble() && event.button == MouseButton::Right - && bounds.contains(&event.position) + && hitbox.is_hovered(window) { { let mut shared_state = shared_state.borrow_mut(); + // Clear any existing menu view to allow immediate replacement + // Set the new position and open the menu + shared_state.menu_view = None; + shared_state._subscription = None; shared_state.position = event.position; shared_state.open = true; } - let menu = PopupMenu::build(window, cx, |menu, window, cx| { - (builder)(menu, window, cx) - }) - .into_element(); - - let _subscription = window.subscribe(&menu, cx, { + // Use defer to build the menu in the next frame, avoiding race conditions + window.defer(cx, { let shared_state = shared_state.clone(); - move |_, _: &DismissEvent, window, _| { - shared_state.borrow_mut().open = false; - window.refresh(); + let builder = builder.clone(); + move |window, cx| { + let menu = PopupMenu::build(window, cx, move |menu, window, cx| { + let Some(build) = &builder else { + return menu; + }; + build(menu, window, cx) + }); + + // Set up the subscription for dismiss handling + let _subscription = window.subscribe(&menu, cx, { + let shared_state = shared_state.clone(); + move |_, _: &DismissEvent, window, _cx| { + shared_state.borrow_mut().open = false; + window.refresh(); + } + }); + + // Update the shared state with the built menu and subscription + { + let mut state = shared_state.borrow_mut(); + state.menu_view = Some(menu.clone()); + state._subscription = Some(_subscription); + window.refresh(); + } } }); - - shared_state.borrow_mut().menu_view = Some(menu.clone()); - shared_state.borrow_mut()._subscription = Some(_subscription); - window.refresh(); } }); }, diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs new file mode 100644 index 0000000..c5938cc --- /dev/null +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -0,0 +1,142 @@ +use std::rc::Rc; + +use gpui::{ + Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, + RenderOnce, SharedString, StyleRefinement, Styled, Window, +}; + +use crate::button::Button; +use crate::menu::PopupMenu; +use crate::popover::Popover; +use crate::Selectable; + +/// A dropdown menu trait for buttons and other interactive elements +pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { + /// Create a dropdown menu with the given items, anchored to the TopLeft corner + fn dropdown_menu( + self, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> DropdownMenuPopover { + self.dropdown_menu_with_anchor(Corner::TopLeft, f) + } + + /// Create a dropdown menu with the given items, anchored to the given corner + fn dropdown_menu_with_anchor( + mut self, + anchor: impl Into, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> DropdownMenuPopover { + let style = self.style().clone(); + let id = self.interactivity().element_id.clone(); + + DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style) + } +} + +impl DropdownMenu for Button {} + +#[derive(IntoElement)] +pub struct DropdownMenuPopover { + id: ElementId, + style: StyleRefinement, + anchor: Corner, + trigger: T, + #[allow(clippy::type_complexity)] + builder: Rc) -> PopupMenu>, +} + +impl DropdownMenuPopover +where + T: Selectable + IntoElement + 'static, +{ + fn new( + id: ElementId, + anchor: impl Into, + trigger: T, + builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + Self { + id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(), + style: StyleRefinement::default(), + anchor: anchor.into(), + trigger, + builder: Rc::new(builder), + } + } + + /// Set the anchor corner for the dropdown menu popover. + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + /// Set the style refinement for the dropdown menu trigger. + fn trigger_style(mut self, style: StyleRefinement) -> Self { + self.style = style; + self + } +} + +#[derive(Default)] +struct DropdownMenuState { + menu: Option>, +} + +impl RenderOnce for DropdownMenuPopover +where + T: Selectable + IntoElement + 'static, +{ + fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { + let builder = self.builder.clone(); + let menu_state = + window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default()); + + Popover::new(SharedString::from(format!("popover:{}", self.id))) + .appearance(false) + .overlay_closable(false) + .trigger(self.trigger) + .trigger_style(self.style) + .anchor(self.anchor) + .content(move |_, window, cx| { + // Here is special logic to only create the PopupMenu once and reuse it. + // Because this `content` will called in every time render, so we need to store the menu + // in state to avoid recreating at every render. + // + // And we also need to rebuild the menu when it is dismissed, to rebuild menu items + // dynamically for support `dropdown_menu` method, so we listen for DismissEvent below. + let menu = match menu_state.read(cx).menu.clone() { + Some(menu) => menu, + None => { + let builder = builder.clone(); + let menu = PopupMenu::build(window, cx, move |menu, window, cx| { + builder(menu, window, cx) + }); + menu_state.update(cx, |state, _| { + state.menu = Some(menu.clone()); + }); + menu.focus_handle(cx).focus(window, cx); + + // Listen for dismiss events from the PopupMenu to close the popover. + let popover_state = cx.entity(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &DismissEvent, window, cx| { + popover_state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + + menu.clone() + } + }; + + menu.clone() + }) + } +} diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index 95f2b7f..cb905d1 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -10,20 +10,22 @@ use theme::ActiveTheme; use crate::{h_flex, Disableable, StyledExt}; #[derive(IntoElement)] -#[allow(clippy::type_complexity)] pub(crate) struct MenuItemElement { id: ElementId, group_name: SharedString, style: StyleRefinement, disabled: bool, selected: bool, + #[allow(clippy::type_complexity)] on_click: Option>, + #[allow(clippy::type_complexity)] on_hover: Option>, children: SmallVec<[AnyElement; 2]>, } impl MenuItemElement { - pub fn new(id: impl Into, group_name: impl Into) -> Self { + /// Create a new MenuItem with the given ID and group name. + pub(crate) fn new(id: impl Into, group_name: impl Into) -> Self { let id: ElementId = id.into(); Self { id: id.clone(), @@ -38,17 +40,19 @@ impl MenuItemElement { } /// Set ListItem as the selected item style. - pub fn selected(mut self, selected: bool) -> Self { + pub(crate) fn selected(mut self, selected: bool) -> Self { self.selected = selected; self } - pub fn disabled(mut self, disabled: bool) -> Self { + /// Set the disabled state of the MenuItem. + pub(crate) fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } - pub fn on_click( + /// Set a handler for when the MenuItem is clicked. + pub(crate) fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { @@ -88,7 +92,7 @@ impl RenderOnce for MenuItemElement { h_flex() .id(self.id) .group(&self.group_name) - .gap_x_2() + .gap_x_1() .py_1() .px_2() .text_base() @@ -102,12 +106,12 @@ impl RenderOnce for MenuItemElement { }) .when(!self.disabled, |this| { this.group_hover(self.group_name, |this| { - this.bg(cx.theme().elevated_surface_background) - .text_color(cx.theme().text) + this.bg(cx.theme().secondary_background) + .text_color(cx.theme().secondary_foreground) }) .when(self.selected, |this| { - this.bg(cx.theme().elevated_surface_background) - .text_color(cx.theme().text) + this.bg(cx.theme().secondary_background) + .text_color(cx.theme().secondary_foreground) }) .when_some(self.on_click, |this, on_click| { this.on_mouse_down(MouseButton::Left, move |_, _, cx| { diff --git a/crates/ui/src/menu/mod.rs b/crates/ui/src/menu/mod.rs index 0d91c7f..3152a15 100644 --- a/crates/ui/src/menu/mod.rs +++ b/crates/ui/src/menu/mod.rs @@ -1,12 +1,15 @@ use gpui::App; mod app_menu_bar; +mod context_menu; +mod dropdown_menu; mod menu_item; - -pub mod context_menu; -pub mod popup_menu; +mod popup_menu; pub use app_menu_bar::AppMenuBar; +pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState}; +pub use dropdown_menu::DropdownMenu; +pub use popup_menu::{PopupMenu, PopupMenuItem}; pub(crate) fn init(cx: &mut App) { app_menu_bar::init(cx); diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 7e08e52..a092eab 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -2,20 +2,19 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Bounds, Context, Corner, - DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, - IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Render, - ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, - Window, + anchored, div, px, rems, Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, + Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, + InteractiveElement, IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, + Pixels, Point, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, + Subscription, WeakEntity, Window, }; use theme::ActiveTheme; use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}; -use crate::button::Button; +use crate::kbd::Kbd; use crate::menu::menu_item::MenuItemElement; -use crate::popover::Popover; -use crate::scroll::{Scrollbar, ScrollbarState}; -use crate::{h_flex, v_flex, Icon, IconName, Kbd, Selectable, Side, Sizable as _, Size, StyledExt}; +use crate::scroll::ScrollableElement; +use crate::{h_flex, v_flex, ElementExt, Icon, IconName, Side, Sizable as _, Size, StyledExt}; const CONTEXT: &str = "PopupMenu"; @@ -30,56 +29,38 @@ pub fn init(cx: &mut App) { ]); } -pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + 'static { - /// Create a popup menu with the given items, anchored to the TopLeft corner - fn popup_menu( - self, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Popover { - self.popup_menu_with_anchor(Corner::TopLeft, f) - } - - /// Create a popup menu with the given items, anchored to the given corner - fn popup_menu_with_anchor( - mut self, - anchor: impl Into, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Popover { - let style = self.style().clone(); - let id = self.interactivity().element_id.clone(); - - Popover::new(SharedString::from(format!("popup-menu:{id:?}"))) - .no_style() - .trigger(self) - .trigger_style(style) - .anchor(anchor.into()) - .content(move |window, cx| { - PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx)) - }) - } -} -impl PopupMenuExt for Button {} - -#[allow(clippy::type_complexity)] -pub(crate) enum PopupMenuItem { +/// An menu item in a popup menu. +pub enum PopupMenuItem { + /// A menu separator item. Separator, + /// A non-interactive label item. Label(SharedString), + /// A standard menu item. Item { icon: Option, label: SharedString, disabled: bool, + checked: bool, is_link: bool, action: Option>, // For link item - handler: Option>, + #[allow(clippy::type_complexity)] + handler: Option>, }, + /// A menu item with custom element render. ElementItem { icon: Option, disabled: bool, - action: Box, + checked: bool, + action: Option>, + #[allow(clippy::type_complexity)] render: Box AnyElement + 'static>, - handler: Option>, + #[allow(clippy::type_complexity)] + handler: Option>, }, + /// A submenu item that opens another popup menu. + /// + /// NOTE: This is only supported when the parent menu is not `scrollable`. Submenu { icon: Option, label: SharedString, @@ -88,7 +69,166 @@ pub(crate) enum PopupMenuItem { }, } +impl FluentBuilder for PopupMenuItem {} impl PopupMenuItem { + /// Create a new menu item with the given label. + #[inline] + pub fn new(label: impl Into) -> Self { + PopupMenuItem::Item { + icon: None, + label: label.into(), + disabled: false, + checked: false, + action: None, + is_link: false, + handler: None, + } + } + + /// Create a new menu item with custom element render. + #[inline] + pub fn element(builder: F) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + PopupMenuItem::ElementItem { + icon: None, + disabled: false, + checked: false, + action: None, + render: Box::new(move |window, cx| builder(window, cx).into_any_element()), + handler: None, + } + } + + /// Create a new submenu item that opens another popup menu. + #[inline] + pub fn submenu(label: impl Into, menu: Entity) -> Self { + PopupMenuItem::Submenu { + icon: None, + label: label.into(), + disabled: false, + menu, + } + } + + /// Create a separator menu item. + #[inline] + pub fn separator() -> Self { + PopupMenuItem::Separator + } + + /// Creates a label menu item. + #[inline] + pub fn label(label: impl Into) -> Self { + PopupMenuItem::Label(label.into()) + } + + /// Set the icon for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`]. + pub fn icon(mut self, icon: impl Into) -> Self { + match &mut self { + PopupMenuItem::Item { icon: i, .. } => { + *i = Some(icon.into()); + } + PopupMenuItem::ElementItem { icon: i, .. } => { + *i = Some(icon.into()); + } + PopupMenuItem::Submenu { icon: i, .. } => { + *i = Some(icon.into()); + } + _ => {} + } + self + } + + /// Set the action for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn action(mut self, action: Box) -> Self { + match &mut self { + PopupMenuItem::Item { action: a, .. } => { + *a = Some(action); + } + PopupMenuItem::ElementItem { action: a, .. } => { + *a = Some(action); + } + _ => {} + } + self + } + + /// Set the disabled state for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`]. + pub fn disabled(mut self, disabled: bool) -> Self { + match &mut self { + PopupMenuItem::Item { disabled: d, .. } => { + *d = disabled; + } + PopupMenuItem::ElementItem { disabled: d, .. } => { + *d = disabled; + } + PopupMenuItem::Submenu { disabled: d, .. } => { + *d = disabled; + } + _ => {} + } + self + } + + /// Set checked state for the menu item. + /// + /// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon. + pub fn checked(mut self, checked: bool) -> Self { + match &mut self { + PopupMenuItem::Item { checked: c, .. } => { + *c = checked; + } + PopupMenuItem::ElementItem { checked: c, .. } => { + *c = checked; + } + _ => {} + } + self + } + + /// Add a click handler for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&ClickEvent, &mut Window, &mut App) + 'static, + { + match &mut self { + PopupMenuItem::Item { handler: h, .. } => { + *h = Some(Rc::new(handler)); + } + PopupMenuItem::ElementItem { handler: h, .. } => { + *h = Some(Rc::new(handler)); + } + _ => {} + } + self + } + + /// Create a link menu item. + #[inline] + pub fn link(label: impl Into, href: impl Into) -> Self { + let href = href.into(); + PopupMenuItem::Item { + icon: None, + label: label.into(), + disabled: false, + checked: false, + action: None, + is_link: true, + handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))), + } + } + #[inline] fn is_clickable(&self) -> bool { !matches!(self, PopupMenuItem::Separator) @@ -111,28 +251,53 @@ impl PopupMenuItem { fn is_separator(&self) -> bool { matches!(self, PopupMenuItem::Separator) } + + fn has_left_icon(&self, check_side: Side) -> bool { + match self { + PopupMenuItem::Item { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } + PopupMenuItem::ElementItem { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } + PopupMenuItem::Submenu { icon, .. } => icon.is_some(), + _ => false, + } + } + + #[inline] + fn is_checked(&self) -> bool { + match self { + PopupMenuItem::Item { checked, .. } => *checked, + PopupMenuItem::ElementItem { checked, .. } => *checked, + _ => false, + } + } } pub struct PopupMenu { pub(crate) focus_handle: FocusHandle, pub(crate) menu_items: Vec, + /// The focus handle of Entity to handle actions. pub(crate) action_context: Option, - has_icon: bool, + + axis: Axis, selected_index: Option, min_width: Option, max_width: Option, max_height: Option, bounds: Bounds, size: Size, + check_side: Side, /// The parent menu of this menu, if this is a submenu parent_menu: Option>, scrollable: bool, external_link_icon: bool, scroll_handle: ScrollHandle, - scroll_state: ScrollbarState, - // This will update on render + + /// This will update on render submenu_anchor: (Corner, Pixels), _subscriptions: Vec, @@ -146,14 +311,14 @@ impl PopupMenu { parent_menu: None, menu_items: Vec::new(), selected_index: None, + axis: Axis::Vertical, min_width: None, max_width: None, max_height: None, - has_icon: false, + check_side: Side::Left, bounds: Bounds::default(), scrollable: false, scroll_handle: ScrollHandle::default(), - scroll_state: ScrollbarState::default(), external_link_icon: true, size: Size::default(), submenu_anchor: (Corner::TopLeft, Pixels::ZERO), @@ -197,11 +362,23 @@ impl PopupMenu { self } + /// Set the axis of children to horizontal. + pub fn horizontal(mut self) -> Self { + self.axis = Axis::Horizontal; + self + } + /// Set the menu to be scrollable to show vertical scrollbar. /// /// NOTE: If this is true, the sub-menus will cannot be support. - pub fn scrollable(mut self) -> Self { - self.scrollable = true; + pub fn scrollable(mut self, scrollable: bool) -> Self { + self.scrollable = scrollable; + self + } + + /// Set the side to show check icon, default is `Side::Left`. + pub fn check_side(mut self, side: Side) -> Self { + self.check_side = side; self } @@ -223,7 +400,7 @@ impl PopupMenu { action: Box, enable: bool, ) -> Self { - self.add_menu_item(label, None, action, !enable); + self.add_menu_item(label, None, action, !enable, false); self } @@ -234,13 +411,13 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - self.add_menu_item(label, None, action, disabled); + self.add_menu_item(label, None, action, disabled, false); self } /// Add label pub fn label(mut self, label: impl Into) -> Self { - self.menu_items.push(PopupMenuItem::Label(label.into())); + self.menu_items.push(PopupMenuItem::label(label.into())); self } @@ -257,14 +434,8 @@ impl PopupMenu { disabled: bool, ) -> Self { let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: None, - label: label.into(), - disabled, - action: None, - is_link: true, - handler: Some(Rc::new(move |_, cx| cx.open_url(&href))), - }); + self.menu_items + .push(PopupMenuItem::link(label, href).disabled(disabled)); self } @@ -279,7 +450,7 @@ impl PopupMenu { } /// Add Menu to open link with icon and disabled state - pub fn link_with_icon_and_disabled( + fn link_with_icon_and_disabled( mut self, label: impl Into, icon: impl Into, @@ -287,14 +458,11 @@ impl PopupMenu { disabled: bool, ) -> Self { let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: Some(icon.into()), - label: label.into(), - disabled, - action: None, - is_link: true, - handler: Some(Rc::new(move |_, cx| cx.open_url(&href))), - }); + self.menu_items.push( + PopupMenuItem::link(label, href) + .icon(icon) + .disabled(disabled), + ); self } @@ -316,7 +484,7 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - self.add_menu_item(label, Some(icon.into()), action, disabled); + self.add_menu_item(label, Some(icon.into()), action, disabled, false); self } @@ -338,12 +506,7 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - if checked { - self.add_menu_item(label, Some(IconName::Check.into()), action, disabled); - } else { - self.add_menu_item(label, None, action, disabled); - } - + self.add_menu_item(label, None, action, disabled, checked); self } @@ -384,29 +547,6 @@ impl PopupMenu { self.menu_element_with_icon_and_disabled(icon, action, false, builder) } - /// Add Menu Item with custom element render with icon and disabled state - pub fn menu_element_with_icon_and_disabled( - mut self, - icon: impl Into, - action: Box, - disabled: bool, - builder: F, - ) -> Self - where - F: Fn(&mut Window, &mut App) -> E + 'static, - E: IntoElement, - { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - icon: Some(icon.into()), - disabled, - handler: None, - }); - self.has_icon = true; - self - } - /// Add Menu Item with custom element render with check state pub fn menu_element_with_check( self, @@ -421,8 +561,29 @@ impl PopupMenu { self.menu_element_with_check_and_disabled(checked, action, false, builder) } + /// Add Menu Item with custom element render with icon and disabled state + fn menu_element_with_icon_and_disabled( + mut self, + icon: impl Into, + action: Box, + disabled: bool, + builder: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.menu_items.push( + PopupMenuItem::element(builder) + .action(action) + .icon(icon) + .disabled(disabled), + ); + self + } + /// Add Menu Item with custom element render with check state and disabled state - pub fn menu_element_with_check_and_disabled( + fn menu_element_with_check_and_disabled( mut self, checked: bool, action: Box, @@ -433,31 +594,12 @@ impl PopupMenu { F: Fn(&mut Window, &mut App) -> E + 'static, E: IntoElement, { - if checked { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - handler: None, - icon: Some(IconName::Check.into()), - disabled, - }); - self.has_icon = true; - } else { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - handler: None, - icon: None, - disabled, - }); - } - self - } - - /// Use small size, the menu item will have smaller height. - #[allow(dead_code)] - pub(crate) fn small(mut self) -> Self { - self.size = Size::Small; + self.menu_items.push( + PopupMenuItem::element(builder) + .action(action) + .checked(checked) + .disabled(disabled), + ); self } @@ -471,7 +613,7 @@ impl PopupMenu { return self; } - self.menu_items.push(PopupMenuItem::Separator); + self.menu_items.push(PopupMenuItem::separator()); self } @@ -486,36 +628,11 @@ impl PopupMenu { self.submenu_with_icon(None, label, window, cx, f) } - /// Add a Submenu item with disabled state - pub fn submenu_with_disabled( - self, - label: impl Into, - disabled: bool, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.submenu_with_icon_with_disabled(None, label, disabled, window, cx, f) - } - /// Add a Submenu item with icon pub fn submenu_with_icon( - self, - icon: Option, - label: impl Into, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.submenu_with_icon_with_disabled(icon, label, false, window, cx, f) - } - - /// Add a Submenu item with icon and disabled state - pub fn submenu_with_icon_with_disabled( mut self, icon: Option, label: impl Into, - disabled: bool, window: &mut Window, cx: &mut Context, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, @@ -526,12 +643,23 @@ impl PopupMenu { view.parent_menu = Some(parent_menu); }); - self.menu_items.push(PopupMenuItem::Submenu { - icon, - label: label.into(), - menu: submenu, - disabled, - }); + self.menu_items.push( + PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)), + ); + self + } + + /// Add menu item. + pub fn item(mut self, item: impl Into) -> Self { + let item: PopupMenuItem = item.into(); + self.menu_items.push(item); + self + } + + /// Use small size, the menu item will have smaller height. + #[allow(dead_code)] + pub(crate) fn small(mut self) -> Self { + self.size = Size::Small; self } @@ -541,19 +669,15 @@ impl PopupMenu { icon: Option, action: Box, disabled: bool, + checked: bool, ) -> &mut Self { - if icon.is_some() { - self.has_icon = true; - } - - self.menu_items.push(PopupMenuItem::Item { - icon, - label: label.into(), - disabled, - action: Some(action.boxed_clone()), - is_link: false, - handler: None, - }); + self.menu_items.push( + PopupMenuItem::new(label) + .when_some(icon, |item, icon| item.icon(icon)) + .disabled(disabled) + .checked(checked) + .action(action), + ); self } @@ -568,9 +692,12 @@ impl PopupMenu { { for item in items { match item.into() { - OwnedMenuItem::Action { name, action, .. } => { - self = self.menu(name, action.boxed_clone()) - } + OwnedMenuItem::Action { + name, + action, + checked, + .. + } => self = self.menu_with_check(name, checked, action.boxed_clone()), OwnedMenuItem::Separator => { self = self.separator(); } @@ -624,13 +751,12 @@ impl PopupMenu { fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { if let Some(index) = self.selected_index { let item = self.menu_items.get(index); - match item { Some(PopupMenuItem::Item { handler, action, .. }) => { if let Some(handler) = handler { - handler(window, cx); + handler(&ClickEvent::default(), window, cx); } else if let Some(action) = action.as_ref() { self.dispatch_confirm_action(action.as_ref(), window, cx); } @@ -641,8 +767,8 @@ impl PopupMenu { handler, action, .. }) => { if let Some(handler) = handler { - handler(window, cx); - } else { + handler(&ClickEvent::default(), window, cx); + } else if let Some(action) = action.as_ref() { self.dispatch_confirm_action(action.as_ref(), window, cx); } self.dismiss(&Cancel, window, cx) @@ -764,7 +890,6 @@ impl PopupMenu { cx.notify(); return true; } - false } @@ -776,7 +901,6 @@ impl PopupMenu { }); return true; } - false } @@ -833,12 +957,39 @@ impl PopupMenu { }); } + fn handle_dismiss( + &mut self, + position: &Point, + window: &mut Window, + cx: &mut Context, + ) { + // Do not dismiss, if click inside the parent menu + if let Some(parent) = self.parent_menu.as_ref() { + if let Some(parent) = parent.upgrade() { + if parent.read(cx).bounds.contains(position) { + return; + } + } + } + + self.dismiss(&Cancel, window, cx); + } + + fn on_mouse_down_out( + &mut self, + e: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.handle_dismiss(&e.position, window, cx); + } + fn render_key_binding( &self, action: Option>, window: &mut Window, _: &mut Context, - ) -> Option { + ) -> Option { let action = action?; match self @@ -860,22 +1011,24 @@ impl PopupMenu { fn render_icon( has_icon: bool, + checked: bool, icon: Option, - _window: &mut Window, - _cx: &mut Context, + _: &mut Window, + _: &mut Context, ) -> Option { if !has_icon { return None; } - let icon = h_flex() - .w_3p5() - .h_3p5() - .justify_center() - .text_sm() - .when_some(icon, |this, icon| this.child(icon.clone().small())); + let icon = if let Some(icon) = icon { + icon.clone() + } else if checked { + Icon::new(IconName::Check) + } else { + Icon::empty() + }; - Some(icon) + Some(icon.xsmall()) } #[inline] @@ -905,22 +1058,28 @@ impl PopupMenu { &self, ix: usize, item: &PopupMenuItem, - state: ItemState, + options: RenderOptions, window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { + ) -> MenuItemElement { + let has_left_icon = options.has_left_icon; + let is_left_check = options.check_side.is_left() && item.is_checked(); + let right_check_icon = if options.check_side.is_right() && item.is_checked() { + Some(Icon::new(IconName::Check).xsmall()) + } else { + None + }; + + let selected = self.selected_index == Some(ix); const EDGE_PADDING: Pixels = px(4.); const INNER_PADDING: Pixels = px(8.); - let has_icon = self.has_icon; - let selected = self.selected_index == Some(ix); - let is_submenu = matches!(item, PopupMenuItem::Submenu { .. }); - let group_name = format!("popup-menu-item-{ix}"); + let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); let (item_height, radius) = match self.size { - Size::Small => (px(20.), state.radius.half()), - _ => (px(26.), state.radius), + Size::Small => (px(20.), options.radius.half()), + _ => (px(26.), options.radius), }; let this = MenuItemElement::new(ix, &group_name) @@ -948,16 +1107,16 @@ impl PopupMenu { .p_0() .my_0p5() .mx_neg_1() - .h(px(1.)) - .bg(cx.theme().border) + .border_b(px(2.)) + .border_color(cx.theme().border) .disabled(true), PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child( h_flex() .cursor_default() .items_center() - .font_semibold() - .text_xs() - .child(label.clone()), + .gap_x_1() + .children(Self::render_icon(has_left_icon, false, None, window, cx)) + .child(div().flex_1().child(label.clone())), ), PopupMenuItem::ElementItem { render, @@ -977,8 +1136,15 @@ impl PopupMenu { .min_h(item_height) .items_center() .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) - .child((render)(window, cx)), + .children(Self::render_icon( + has_left_icon, + is_left_check, + icon.clone(), + window, + cx, + )) + .child((render)(window, cx)) + .children(right_check_icon.map(|icon| icon.ml_3())), ), PopupMenuItem::Item { icon, @@ -999,14 +1165,22 @@ impl PopupMenu { }) .disabled(*disabled) .h(item_height) - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .gap_x_1() + .children(Self::render_icon( + has_left_icon, + is_left_check, + icon.clone(), + window, + cx, + )) .child( h_flex() .w_full() - .gap_2() + .gap_3() .items_center() .justify_between() .when(!show_link_icon, |this| this.child(label.clone())) + .children(right_check_icon) .when(show_link_icon, |this| { this.child( h_flex() @@ -1015,7 +1189,7 @@ impl PopupMenu { .gap_1p5() .child(label.clone()) .child( - Icon::new(IconName::OpenUrl) + Icon::new(IconName::Link) .xsmall() .text_color(cx.theme().text_muted), ), @@ -1039,7 +1213,13 @@ impl PopupMenu { .size_full() .items_center() .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .children(Self::render_icon( + has_left_icon, + false, + icon.clone(), + window, + cx, + )) .child( h_flex() .flex_1() @@ -1047,7 +1227,11 @@ impl PopupMenu { .items_center() .justify_between() .child(label.clone()) - .child(IconName::CaretRight), + .child( + Icon::new(IconName::CaretRight) + .xsmall() + .text_color(cx.theme().text_muted), + ), ), ) .when(selected, |this| { @@ -1074,7 +1258,9 @@ impl PopupMenu { } impl FluentBuilder for PopupMenu {} + impl EventEmitter for PopupMenu {} + impl Focusable for PopupMenu { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() @@ -1082,7 +1268,9 @@ impl Focusable for PopupMenu { } #[derive(Clone, Copy)] -struct ItemState { +struct RenderOptions { + has_left_icon: bool, + check_side: Side, radius: Pixels, } @@ -1090,18 +1278,23 @@ impl Render for PopupMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.update_submenu_menu_anchor(window); - let max_width = self.max_width(); - let max_height = self.max_height.map_or_else( - || { - let window_half_height = window.window_bounds().get_bounds().size.height * 0.5; - window_half_height.min(px(450.)) - }, - |height| height, - ); - let view = cx.entity().clone(); let items_count = self.menu_items.len(); - let item_state = ItemState { + + let max_height = self.max_height.unwrap_or_else(|| { + let window_half_height = window.window_bounds().get_bounds().size.height * 0.5; + window_half_height.min(px(450.)) + }); + + let has_left_icon = self + .menu_items + .iter() + .any(|item| item.has_left_icon(self.check_side)); + + let max_width = self.max_width(); + let options = RenderOptions { + has_left_icon, + check_side: self.check_side, radius: cx.theme().radius.min(px(8.)), }; @@ -1115,29 +1308,23 @@ impl Render for PopupMenu { .on_action(cx.listener(Self::select_right)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) - .on_mouse_down_out(cx.listener(|this, ev: &MouseDownEvent, window, cx| { - // Do not dismiss, if click inside the parent menu - if let Some(parent) = this.parent_menu.as_ref() { - if let Some(parent) = parent.upgrade() { - if parent.read(cx).bounds.contains(&ev.position) { - return; - } - } - } - - this.dismiss(&Cancel, window, cx); - })) + .on_mouse_down_out(cx.listener(Self::on_mouse_down_out)) .popover_style(cx) .text_color(cx.theme().text) .relative() + .occlude() .child( - v_flex() + div() .id("items") .p_1() .gap_y_0p5() .min_w(rems(8.)) .when_some(self.min_width, |this, min_width| this.min_w(min_width)) .max_w(max_width) + .map(|this| match self.axis { + Axis::Horizontal => this.flex().flex_row().items_center(), + Axis::Vertical => this.flex().flex_col(), + }) .when(self.scrollable, |this| { this.max_h(max_height) .overflow_y_scroll() @@ -1149,28 +1336,13 @@ impl Render for PopupMenu { .enumerate() // Ignore last separator .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator())) - .map(|(ix, item)| self.render_item(ix, item, item_state, window, cx)), + .map(|(ix, item)| self.render_item(ix, item, options, window, cx)), ) - .child({ - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .absolute() - .size_full() - }), + .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)), ) .when(self.scrollable, |this| { // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed. - this.child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)), - ) + this.vertical_scrollbar(&self.scroll_handle) }) } } diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index 6678b88..1e445c4 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -3,7 +3,7 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, + anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, Window, @@ -13,7 +13,8 @@ use theme::ActiveTheme; use crate::actions::{Cancel, Confirm}; use crate::animation::cubic_bezier; use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}; -use crate::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt}; +use crate::scroll::ScrollableElement; +use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension}; const CONTEXT: &str = "Modal"; @@ -97,9 +98,9 @@ pub struct Modal { button_props: ModalButtonProps, /// This will be change when open the modal, the focus handle is create when open the modal. - pub(crate) focus_handle: FocusHandle, - pub(crate) layer_ix: usize, - pub(crate) overlay_visible: bool, + pub focus_handle: FocusHandle, + pub layer_ix: usize, + pub overlay_visible: bool, } impl Modal { @@ -255,7 +256,7 @@ impl Modal { self } - pub(crate) fn has_overlay(&self) -> bool { + pub fn has_overlay(&self) -> bool { self.overlay } } @@ -341,7 +342,7 @@ impl RenderOnce for Modal { } }); - let window_paddings = crate::window_border::window_paddings(window, cx); + let window_paddings = crate::root::window_paddings(window, cx); let radius = (cx.theme().radius_lg * 2.).min(px(20.)); let view_size = window.viewport_size() @@ -489,13 +490,13 @@ impl RenderOnce for Modal { .w_full() .h_auto() .flex_1() - .relative() .overflow_hidden() .child( v_flex() .pr(padding_right) .pl(padding_left) - .scrollable(Axis::Vertical) + .size_full() + .overflow_y_scrollbar() .child(self.content), ), ) diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index 8c8b2e4..6b60319 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -425,7 +425,7 @@ impl NotificationList { cx.notify(); } - pub(crate) fn close(&mut self, key: T, window: &mut Window, cx: &mut Context) + pub fn close(&mut self, key: T, window: &mut Window, cx: &mut Context) where T: Into, { diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index df42c93..9f5d826 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,129 +1,78 @@ -use std::cell::RefCell; use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - actions, anchored, deferred, div, px, AnyElement, App, Bounds, Context, Corner, DismissEvent, - DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, - GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, - LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, - ScrollHandle, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window, + deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, + EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, + Styled, Subscription, Window, }; -use crate::{Selectable, StyledExt as _}; +use crate::actions::Cancel; +use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _}; const CONTEXT: &str = "Popover"; -actions!(popover, [Escape]); - -pub fn init(cx: &mut App) { - cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))]) +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]) } -type PopoverChild = Rc) -> AnyElement>; - -pub struct PopoverContent { - focus_handle: FocusHandle, - scroll_handle: ScrollHandle, - max_width: Option, - max_height: Option, - scrollable: bool, - child: PopoverChild, -} - -impl PopoverContent { - pub fn new(_window: &mut Window, cx: &mut App, content: B) -> Self - where - B: Fn(&mut Window, &mut Context) -> AnyElement + 'static, - { - let focus_handle = cx.focus_handle(); - let scroll_handle = ScrollHandle::default(); - - Self { - focus_handle, - scroll_handle, - child: Rc::new(content), - max_width: None, - max_height: None, - scrollable: false, - } - } - - pub fn max_w(mut self, max_width: Pixels) -> Self { - self.max_width = Some(max_width); - self - } - - pub fn max_h(mut self, max_height: Pixels) -> Self { - self.max_height = Some(max_height); - self - } - - pub fn scrollable(mut self) -> Self { - self.scrollable = true; - self - } -} - -impl EventEmitter for PopoverContent {} - -impl Focusable for PopoverContent { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for PopoverContent { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .id("popup-content") - .track_focus(&self.focus_handle) - .key_context(CONTEXT) - .on_action(cx.listener(|_, _: &Escape, _, cx| cx.emit(DismissEvent))) - .p_2() - .when(self.scrollable, |this| { - this.overflow_y_scroll().track_scroll(&self.scroll_handle) - }) - .when_some(self.max_width, |this, v| this.max_w(v)) - .when_some(self.max_height, |this, v| this.max_h(v)) - .child(self.child.clone()(window, cx)) - } -} - -type Trigger = Option AnyElement + 'static>>; -type Content = Option Entity + 'static>>; - -pub struct Popover { +/// A popover element that can be triggered by a button or any other element. +#[derive(IntoElement)] +pub struct Popover { id: ElementId, - anchor: Corner, - trigger: Trigger, - content: Content, + style: StyleRefinement, + anchor: Anchor, + default_open: bool, + open: Option, + tracked_focus_handle: Option, + #[allow(clippy::type_complexity)] + trigger: Option AnyElement + 'static>>, + #[allow(clippy::type_complexity)] + content: Option< + Rc< + dyn Fn(&mut PopoverState, &mut Window, &mut Context) -> AnyElement + + 'static, + >, + >, + children: Vec, /// Style for trigger element. /// This is used for hotfix the trigger element style to support w_full. trigger_style: Option, mouse_button: MouseButton, - no_style: bool, + appearance: bool, + overlay_closable: bool, + #[allow(clippy::type_complexity)] + on_open_change: Option>, } -impl Popover -where - M: ManagedView, -{ +impl Popover { /// Create a new Popover with `view` mode. pub fn new(id: impl Into) -> Self { Self { id: id.into(), - anchor: Corner::TopLeft, + style: StyleRefinement::default(), + anchor: Anchor::TopLeft, trigger: None, trigger_style: None, content: None, + tracked_focus_handle: None, + children: vec![], mouse_button: MouseButton::Left, - no_style: false, + appearance: true, + overlay_closable: true, + default_open: false, + open: None, + on_open_change: None, } } - pub fn anchor(mut self, anchor: Corner) -> Self { - self.anchor = anchor; + /// Set the anchor corner of the popover, default is `Corner::TopLeft`. + /// + /// This method is kept for backward compatibility with `Corner` type. + /// Internally, it converts `Corner` to `Anchor`. + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); self } @@ -133,29 +82,75 @@ where self } + /// Set the trigger element of the popover. pub fn trigger(mut self, trigger: T) -> Self where T: Selectable + IntoElement + 'static, { self.trigger = Some(Box::new(|is_open, _, _| { - trigger.selected(is_open).into_any_element() + let selected = trigger.is_selected(); + trigger.selected(selected || is_open).into_any_element() })); self } + /// Set the default open state of the popover, default is `false`. + /// + /// This is only used to initialize the open state of the popover. + /// + /// And please note that if you use the `open` method, this value will be ignored. + pub fn default_open(mut self, open: bool) -> Self { + self.default_open = open; + self + } + + /// Force set the open state of the popover. + /// + /// If this is set, the popover will be controlled by this value. + /// + /// NOTE: You must be used in conjunction with `on_open_change` to handle state changes. + pub fn open(mut self, open: bool) -> Self { + self.open = Some(open); + self + } + + /// Add a callback to be called when the open state changes. + /// + /// The first `&bool` parameter is the **new open state**. + /// + /// This is useful when using the `open` method to control the popover state. + pub fn on_open_change(mut self, callback: F) -> Self + where + F: Fn(&bool, &mut Window, &mut App) + 'static, + { + self.on_open_change = Some(Rc::new(callback)); + self + } + + /// Set the style for the trigger element. pub fn trigger_style(mut self, style: StyleRefinement) -> Self { self.trigger_style = Some(style); self } - /// Set the content of the popover. + /// Set whether clicking outside the popover will dismiss it, default is `true`. + pub fn overlay_closable(mut self, closable: bool) -> Self { + self.overlay_closable = closable; + self + } + + /// Set the content builder for content of the Popover. /// - /// The `content` is a closure that returns an `AnyElement`. - pub fn content(mut self, content: C) -> Self + /// This callback will called every time on render the popover. + /// So, you should avoid creating new elements or entities in the content closure. + pub fn content(mut self, content: F) -> Self where - C: Fn(&mut Window, &mut App) -> Entity + 'static, + E: IntoElement, + F: Fn(&mut PopoverState, &mut Window, &mut Context) -> E + 'static, { - self.content = Some(Rc::new(content)); + self.content = Some(Rc::new(move |state, window, cx| { + content(state, window, cx).into_any_element() + })); self } @@ -165,302 +160,265 @@ where /// /// - The popover will not have a bg, border, shadow, or padding. /// - The click out of the popover will not dismiss it. - pub fn no_style(mut self) -> Self { - self.no_style = true; + pub fn appearance(mut self, appearance: bool) -> Self { + self.appearance = appearance; self } - fn render_trigger(&mut self, is_open: bool, window: &mut Window, cx: &mut App) -> AnyElement { - let Some(trigger) = self.trigger.take() else { - return div().into_any_element(); + /// Bind the focus handle to receive focus when the popover is opened. + /// If you not set this, a new focus handle will be created for the popover to + /// + /// If popover is opened, the focus will be moved to the focus handle. + pub fn track_focus(mut self, handle: &FocusHandle) -> Self { + self.tracked_focus_handle = Some(handle.clone()); + self + } + + fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds) -> Point { + let offset = if anchor.is_center() { + gpui::point(trigger_bounds.size.width.half(), px(0.)) + } else { + Point::default() }; - (trigger)(is_open, window, cx) - } - - fn resolved_corner(&self, bounds: Bounds) -> Point { - bounds.corner(match self.anchor { - Corner::TopLeft => Corner::BottomLeft, - Corner::TopRight => Corner::BottomRight, - Corner::BottomLeft => Corner::TopLeft, - Corner::BottomRight => Corner::TopRight, - }) - } - - fn with_element_state( - &mut self, - id: &GlobalElementId, - window: &mut Window, - cx: &mut App, - f: impl FnOnce(&mut Self, &mut PopoverElementState, &mut Window, &mut App) -> R, - ) -> R { - window.with_optional_element_state::, _>( - Some(id), - |element_state, window| { - let mut element_state = element_state.unwrap().unwrap_or_default(); - let result = f(self, &mut element_state, window, cx); - (result, Some(element_state)) - }, - ) + trigger_bounds.corner(anchor.swap_vertical().into()) + + offset + + Point { + x: px(0.), + y: -trigger_bounds.size.height, + } } } -impl IntoElement for Popover -where - M: ManagedView, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self +impl ParentElement for Popover { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); } } -pub struct PopoverElementState { - trigger_layout_id: Option, - popover_layout_id: Option, - popover_element: Option, - trigger_element: Option, - content_view: Rc>>>, - /// Trigger bounds for positioning the popover. - trigger_bounds: Option>, +impl Styled for Popover { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } } -impl Default for PopoverElementState { - fn default() -> Self { +pub struct PopoverState { + focus_handle: FocusHandle, + pub(crate) tracked_focus_handle: Option, + trigger_bounds: Bounds, + open: bool, + #[allow(clippy::type_complexity)] + on_open_change: Option>, + + _dismiss_subscription: Option, +} + +impl PopoverState { + pub fn new(default_open: bool, cx: &mut App) -> Self { Self { - trigger_layout_id: None, - popover_layout_id: None, - popover_element: None, - trigger_element: None, - content_view: Rc::new(RefCell::new(None)), - trigger_bounds: None, - } - } -} - -pub struct PrepaintState { - hitbox: Hitbox, - /// Trigger bounds for limit a rect to handle mouse click. - trigger_bounds: Option>, -} - -impl Element for Popover { - type PrepaintState = PrepaintState; - type RequestLayoutState = PopoverElementState; - - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - id: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - - // FIXME: Remove this and find a better way to handle this. - // Apply trigger style, for support w_full for trigger. - // - // If remove this, the trigger will not support w_full. - if let Some(trigger_style) = self.trigger_style.clone() { - if let Some(width) = trigger_style.size.width { - style.size.width = width; - } - if let Some(display) = trigger_style.display { - style.display = display; - } - } - - self.with_element_state( - id.unwrap(), - window, - cx, - |view, element_state, window, cx| { - let mut popover_layout_id = None; - let mut popover_element = None; - let mut is_open = false; - - if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() { - is_open = true; - - let mut anchored = anchored() - .snap_to_window_with_margin(px(8.)) - .anchor(view.anchor); - if let Some(trigger_bounds) = element_state.trigger_bounds { - anchored = anchored.position(view.resolved_corner(trigger_bounds)); - } - - let mut element = { - let content_view_mut = element_state.content_view.clone(); - let anchor = view.anchor; - let no_style = view.no_style; - deferred( - anchored.child( - div() - .size_full() - .occlude() - .when(!no_style, |this| this.popover_style(cx)) - .map(|this| match anchor { - Corner::TopLeft | Corner::TopRight => this.top_1p5(), - Corner::BottomLeft | Corner::BottomRight => { - this.bottom_1p5() - } - }) - .child(content_view.clone()) - .when(!no_style, |this| { - this.on_mouse_down_out(move |_, window, _| { - // Update the element_state.content_view to `None`, - // so that the `paint`` method will not paint it. - *content_view_mut.borrow_mut() = None; - window.refresh(); - }) - }), - ), - ) - .with_priority(1) - .into_any() - }; - - popover_layout_id = Some(element.request_layout(window, cx)); - popover_element = Some(element); - } - - let mut trigger_element = view.render_trigger(is_open, window, cx); - let trigger_layout_id = trigger_element.request_layout(window, cx); - - let layout_id = window.request_layout( - style, - Some(trigger_layout_id).into_iter().chain(popover_layout_id), - cx, - ); - - ( - layout_id, - PopoverElementState { - trigger_layout_id: Some(trigger_layout_id), - popover_layout_id, - popover_element, - trigger_element: Some(trigger_element), - ..Default::default() - }, - ) - }, - ) - } - - fn prepaint( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _bounds: gpui::Bounds, - request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - if let Some(element) = &mut request_layout.trigger_element { - element.prepaint(window, cx); - } - if let Some(element) = &mut request_layout.popover_element { - element.prepaint(window, cx); - } - - let trigger_bounds = request_layout - .trigger_layout_id - .map(|id| window.layout_bounds(id)); - - // Prepare the popover, for get the bounds of it for open window size. - let _ = request_layout - .popover_layout_id - .map(|id| window.layout_bounds(id)); - - let hitbox = - window.insert_hitbox(trigger_bounds.unwrap_or_default(), HitboxBehavior::Normal); - - PrepaintState { - trigger_bounds, - hitbox, + focus_handle: cx.focus_handle(), + tracked_focus_handle: None, + trigger_bounds: Bounds::default(), + open: default_open, + on_open_change: None, + _dismiss_subscription: None, } } - fn paint( - &mut self, - id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - self.with_element_state( - id.unwrap(), - window, - cx, - |this, element_state, window, cx| { - element_state.trigger_bounds = prepaint.trigger_bounds; + /// Check if the popover is open. + pub fn is_open(&self) -> bool { + self.open + } - if let Some(mut element) = request_layout.trigger_element.take() { - element.paint(window, cx); - } + /// Dismiss the popover if it is open. + pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { + if self.open { + self.toggle_open(window, cx); + } + } - if let Some(mut element) = request_layout.popover_element.take() { - element.paint(window, cx); - return; - } + /// Open the popover if it is closed. + pub fn show(&mut self, window: &mut Window, cx: &mut Context) { + if !self.open { + self.toggle_open(window, cx); + } + } - // When mouse click down in the trigger bounds, open the popover. - let Some(content_build) = this.content.take() else { - return; - }; - let old_content_view = element_state.content_view.clone(); - let hitbox_id = prepaint.hitbox.id; - let mouse_button = this.mouse_button; - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble - && event.button == mouse_button - && hitbox_id.is_hovered(window) - { - cx.stop_propagation(); - window.prevent_default(); + fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { + self.open = !self.open; + if self.open { + let state = cx.entity(); + let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() + { + tracked_focus_handle + } else { + self.focus_handle.clone() + }; + focus_handle.focus(window, cx); - let new_content_view = (content_build)(window, cx); - let old_content_view1 = old_content_view.clone(); - - let previous_focus_handle = window.focused(cx); - - window - .subscribe( - &new_content_view, - cx, - move |modal, _: &DismissEvent, window, cx| { - if modal.focus_handle(cx).contains_focused(window, cx) { - if let Some(previous_focus_handle) = - previous_focus_handle.as_ref() - { - window.focus(previous_focus_handle, cx); - } - } - *old_content_view1.borrow_mut() = None; - - window.refresh(); - }, - ) - .detach(); - - window.focus(&new_content_view.focus_handle(cx), cx); - *old_content_view.borrow_mut() = Some(new_content_view); + self._dismiss_subscription = + Some( + window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| { + state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); window.refresh(); - } - }); - }, - ); + }), + ); + } else { + self._dismiss_subscription = None; + } + + if let Some(callback) = self.on_open_change.as_ref() { + callback(&self.open, window, cx); + } + cx.notify(); + } + + fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.dismiss(window, cx); + } +} + +impl Focusable for PopoverState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PopoverState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +impl EventEmitter for PopoverState {} + +impl Popover { + pub(crate) fn render_popover( + anchor: Anchor, + trigger_bounds: Bounds, + content: E, + _: &mut Window, + _: &mut App, + ) -> Deferred + where + E: IntoElement + 'static, + { + deferred( + anchored() + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .position(Self::resolved_corner(anchor, trigger_bounds)) + .child(div().relative().child(content)), + ) + .with_priority(1) + } + + pub(crate) fn render_popover_content( + anchor: Anchor, + appearance: bool, + _: &mut Window, + cx: &mut App, + ) -> Stateful
{ + v_flex() + .id("content") + .occlude() + .tab_group() + .when(appearance, |this| this.popover_style(cx).p_3()) + .map(|this| match anchor { + Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), + Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(), + }) + } +} + +impl RenderOnce for Popover { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let force_open = self.open; + let default_open = self.default_open; + let tracked_focus_handle = self.tracked_focus_handle.clone(); + let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| { + PopoverState::new(default_open, cx) + }); + + state.update(cx, |state, _| { + if let Some(tracked_focus_handle) = tracked_focus_handle { + state.tracked_focus_handle = Some(tracked_focus_handle); + } + state.on_open_change = self.on_open_change.clone(); + if let Some(force_open) = force_open { + state.open = force_open; + } + }); + + let open = state.read(cx).open; + let focus_handle = state.read(cx).focus_handle.clone(); + let trigger_bounds = state.read(cx).trigger_bounds; + + let Some(trigger) = self.trigger else { + return div().id("empty"); + }; + + let parent_view_id = window.current_view(); + + let el = div() + .id(self.id) + .child((trigger)(open, window, cx)) + .on_mouse_down(self.mouse_button, { + let state = state.clone(); + move |_, window, cx| { + cx.stop_propagation(); + state.update(cx, |state, cx| { + // We force set open to false to toggle it correctly. + // Because if the mouse down out will toggle open first. + state.open = open; + state.toggle_open(window, cx); + }); + cx.notify(parent_view_id); + } + }) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, _| { + state.trigger_bounds = bounds; + }) + } + }); + + if !open { + return el; + } + + let popover_content = + Self::render_popover_content(self.anchor, self.appearance, window, cx) + .track_focus(&focus_handle) + .key_context(CONTEXT) + .on_action(window.listener_for(&state, PopoverState::on_action_cancel)) + .when_some(self.content, |this, content| { + this.child(state.update(cx, |state, cx| (content)(state, window, cx))) + }) + .children(self.children) + .when(self.overlay_closable, |this| { + this.on_mouse_down_out({ + let state = state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + cx.notify(parent_view_id); + } + }) + }) + .refine_style(&self.style); + + el.child(Self::render_popover( + self.anchor, + trigger_bounds, + popover_content, + window, + cx, + )) } } diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 3dbddbc..7c6e0ca 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -2,168 +2,63 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - div, AnyView, App, AppContext, Context, Decorations, Entity, FocusHandle, InteractiveElement, - IntoElement, ParentElement as _, Render, SharedString, Styled, Window, + canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle, + Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, + Tiling, WeakFocusHandle, Window, +}; +use theme::{ + ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING, + CLIENT_SIDE_DECORATION_SHADOW, }; -use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING}; use crate::input::InputState; use crate::modal::Modal; use crate::notification::{Notification, NotificationList}; -use crate::window_border; - -/// Extension trait for [`WindowContext`] and [`ViewContext`] to add drawer functionality. -pub trait ContextModal: Sized { - /// Opens a Modal. - fn open_modal(&mut self, cx: &mut App, build: F) - where - F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static; - - /// Return true, if there is an active Modal. - fn has_active_modal(&mut self, cx: &mut App) -> bool; - - /// Closes the last active Modal. - fn close_modal(&mut self, cx: &mut App); - - /// Closes all active Modals. - fn close_all_modals(&mut self, cx: &mut App); - - /// Returns number of notifications. - fn notifications(&mut self, cx: &mut App) -> Rc>>; - - /// Pushes a notification to the notification list. - fn push_notification(&mut self, note: impl Into, cx: &mut App); - - /// Clears a notification by its ID. - fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App); - - /// Clear all notifications - fn clear_notifications(&mut self, cx: &mut App); - - /// Return current focused Input entity. - fn focused_input(&mut self, cx: &mut App) -> Option>; - - /// Returns true if there is a focused Input entity. - fn has_focused_input(&mut self, cx: &mut App) -> bool; -} - -impl ContextModal for Window { - fn open_modal(&mut self, cx: &mut App, build: F) - where - F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static, - { - Root::update(self, cx, move |root, window, cx| { - // Only save focus handle if there are no active modals. - // This is used to restore focus when all modals are closed. - if root.active_modals.is_empty() { - root.previous_focus_handle = window.focused(cx); - } - - let focus_handle = cx.focus_handle(); - focus_handle.focus(window, cx); - - root.active_modals.push(ActiveModal { - focus_handle, - builder: Rc::new(build), - }); - - cx.notify(); - }) - } - - fn has_active_modal(&mut self, cx: &mut App) -> bool { - !Root::read(self, cx).active_modals.is_empty() - } - - fn close_modal(&mut self, cx: &mut App) { - Root::update(self, cx, move |root, window, cx| { - root.active_modals.pop(); - - if let Some(top_modal) = root.active_modals.last() { - // Focus the next modal. - top_modal.focus_handle.focus(window, cx); - } else { - // Restore focus if there are no more modals. - root.focus_back(window, cx); - } - cx.notify(); - }) - } - - fn close_all_modals(&mut self, cx: &mut App) { - Root::update(self, cx, |root, window, cx| { - root.active_modals.clear(); - root.focus_back(window, cx); - cx.notify(); - }) - } - - fn push_notification(&mut self, note: impl Into, cx: &mut App) { - let note = note.into(); - Root::update(self, cx, move |root, window, cx| { - root.notification - .update(cx, |view, cx| view.push(note, window, cx)); - cx.notify(); - }) - } - - fn clear_notifications(&mut self, cx: &mut App) { - Root::update(self, cx, move |root, window, cx| { - root.notification - .update(cx, |view, cx| view.clear(window, cx)); - cx.notify(); - }) - } - - fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App) { - Root::update(self, cx, move |root, window, cx| { - root.notification.update(cx, |view, cx| { - view.close(id.clone(), window, cx); - }); - cx.notify(); - }) - } - - fn notifications(&mut self, cx: &mut App) -> Rc>> { - let entity = Root::read(self, cx).notification.clone(); - Rc::new(entity.read(cx).notifications()) - } - - fn has_focused_input(&mut self, cx: &mut App) -> bool { - Root::read(self, cx).focused_input.is_some() - } - - fn focused_input(&mut self, cx: &mut App) -> Option> { - Root::read(self, cx).focused_input.clone() - } -} - -type Builder = Rc Modal + 'static>; #[derive(Clone)] -pub(crate) struct ActiveModal { +#[allow(clippy::type_complexity)] +pub struct ActiveModal { focus_handle: FocusHandle, - builder: Builder, + /// The previous focused handle before opening the modal. + previous_focused_handle: Option, + builder: Rc Modal + 'static>, +} + +impl ActiveModal { + fn new( + focus_handle: FocusHandle, + previous_focused_handle: Option, + builder: impl Fn(Modal, &mut Window, &mut App) -> Modal + 'static, + ) -> Self { + Self { + focus_handle, + previous_focused_handle, + builder: Rc::new(builder), + } + } } /// Root is a view for the App window for as the top level view (Must be the first view in the window). /// /// It is used to manage the Modal, and Notification. pub struct Root { + /// All active models pub(crate) active_modals: Vec, - pub notification: Entity, - pub focused_input: Option>, - /// Used to store the focus handle of the previous view. - /// - /// When the Modal closes, we will focus back to the previous view. - previous_focus_handle: Option, + + /// Notification layer + pub(crate) notification: Entity, + + /// Current focused input + pub(crate) focused_input: Option>, + + /// App view view: AnyView, } impl Root { pub fn new(view: AnyView, window: &mut Window, cx: &mut Context) -> Self { Self { - previous_focus_handle: None, focused_input: None, active_modals: Vec::new(), notification: cx.new(|cx| NotificationList::new(window, cx)), @@ -188,13 +83,11 @@ impl Root { .read(cx) } - fn focus_back(&mut self, window: &mut Window, cx: &mut App) { - if let Some(handle) = self.previous_focus_handle.clone() { - window.focus(&handle, cx); - } + pub fn view(&self) -> &AnyView { + &self.view } - /// Render Notification layer. + /// Render the notification layer. pub fn render_notification_layer( window: &mut Window, cx: &mut App, @@ -210,10 +103,9 @@ impl Root { ) } - /// Render the Modal layer. + /// Render the modal layer. pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option { let root = window.root::()??; - let active_modals = root.read(cx).active_modals.clone(); if active_modals.is_empty() { @@ -255,50 +147,316 @@ impl Root { Some(div().children(modals)) } - /// Return the root view of the Root. - pub fn view(&self) -> &AnyView { - &self.view + /// Open a modal. + pub fn open_modal(&mut self, builder: F, window: &mut Window, cx: &mut Context<'_, Self>) + where + F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static, + { + let previous_focused_handle = window.focused(cx).map(|h| h.downgrade()); + let focus_handle = cx.focus_handle(); + focus_handle.focus(window, cx); + + self.active_modals.push(ActiveModal::new( + focus_handle, + previous_focused_handle, + builder, + )); + + cx.notify(); } - /// Replace the root view of the Root. - pub fn replace_view(&mut self, view: AnyView) { - self.view = view; + /// Close the topmost modal. + pub fn close_modal(&mut self, window: &mut Window, cx: &mut Context) { + self.focused_input = None; + + if let Some(handle) = self + .active_modals + .pop() + .and_then(|d| d.previous_focused_handle) + .and_then(|h| h.upgrade()) + { + window.focus(&handle, cx); + } + + cx.notify(); + } + + /// Close all modals. + pub fn close_all_modals(&mut self, window: &mut Window, cx: &mut Context) { + self.focused_input = None; + self.active_modals.clear(); + + let previous_focused_handle = self + .active_modals + .first() + .and_then(|d| d.previous_focused_handle.clone()); + + if let Some(handle) = previous_focused_handle.and_then(|h| h.upgrade()) { + window.focus(&handle, cx); + } + + cx.notify(); + } + + /// Check if there are any active modals. + pub fn has_active_modals(&self) -> bool { + !self.active_modals.is_empty() + } + + /// Push a notification to the notification layer. + pub fn push_notification(&mut self, note: T, window: &mut Window, cx: &mut Context<'_, Root>) + where + T: Into, + { + self.notification + .update(cx, |view, cx| view.push(note, window, cx)); + cx.notify(); + } + + /// Clear a notification by its ID. + pub fn clear_notification(&mut self, id: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + self.notification + .update(cx, |view, cx| view.close(id.into(), window, cx)); + cx.notify(); + } + + /// Clear all notifications from the notification layer. + pub fn clear_notifications(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { + self.notification + .update(cx, |view, cx| view.clear(window, cx)); + cx.notify(); } } impl Render for Root { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let base_font_size = cx.theme().font_size; + let rem_size = cx.theme().font_size; let font_family = cx.theme().font_family.clone(); let decorations = window.window_decorations(); - window.set_rem_size(base_font_size); + // Set the base font size + window.set_rem_size(rem_size); - window_border().child( - div() - .id("root") - .map(|this| match decorations { - Decorations::Server => this, - Decorations::Client { tiling, .. } => this - .when(!(tiling.top || tiling.right), |el| { - el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.top || tiling.left), |el| { - el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.right), |el| { - el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.left), |el| { - el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) - }), - }) - .relative() - .size_full() - .font_family(font_family) - .bg(cx.theme().background) - .text_color(cx.theme().text) - .child(self.view.clone()), - ) + // Set the client inset (linux only) + match decorations { + Decorations::Client { .. } => window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW), + Decorations::Server => window.set_client_inset(px(0.0)), + } + + div() + .id("window") + .size_full() + .bg(gpui::transparent_black()) + .map(|div| match decorations { + Decorations::Server => div, + Decorations::Client { tiling } => div + .bg(gpui::transparent_black()) + .child( + canvas( + |_bounds, window, _cx| { + window.insert_hitbox( + Bounds::new( + point(px(0.0), px(0.0)), + window.window_bounds().get_bounds().size, + ), + HitboxBehavior::Normal, + ) + }, + move |_bounds, hitbox, window, _cx| { + let mouse = window.mouse_position(); + let size = window.window_bounds().get_bounds().size; + + let Some(edge) = + resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size, tiling) + else { + return; + }; + + window.set_cursor_style( + match edge { + ResizeEdge::Top | ResizeEdge::Bottom => { + CursorStyle::ResizeUpDown + } + ResizeEdge::Left | ResizeEdge::Right => { + CursorStyle::ResizeLeftRight + } + ResizeEdge::TopLeft | ResizeEdge::BottomRight => { + CursorStyle::ResizeUpLeftDownRight + } + ResizeEdge::TopRight | ResizeEdge::BottomLeft => { + CursorStyle::ResizeUpRightDownLeft + } + }, + &hitbox, + ); + }, + ) + .size_full() + .absolute(), + ) + .when(!(tiling.top || tiling.right), |div| { + div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.top || tiling.left), |div| { + div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.bottom || tiling.right), |div| { + div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.bottom || tiling.left), |div| { + div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!tiling.top, |div| div.pt(CLIENT_SIDE_DECORATION_SHADOW)) + .when(!tiling.bottom, |div| div.pb(CLIENT_SIDE_DECORATION_SHADOW)) + .when(!tiling.left, |div| div.pl(CLIENT_SIDE_DECORATION_SHADOW)) + .when(!tiling.right, |div| div.pr(CLIENT_SIDE_DECORATION_SHADOW)) + .on_mouse_down(MouseButton::Left, move |e, window, _cx| { + let size = window.window_bounds().get_bounds().size; + let pos = e.position; + + if let Some(edge) = + resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size, tiling) + { + window.start_window_resize(edge) + }; + }), + }) + .child( + div() + .map(|div| match decorations { + Decorations::Server => div, + Decorations::Client { tiling } => div + .border_color(cx.theme().border) + .when(!(tiling.top || tiling.right), |div| { + div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.top || tiling.left), |div| { + div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.bottom || tiling.right), |div| { + div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.bottom || tiling.left), |div| { + div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!tiling.top, |div| { + div.border_t(CLIENT_SIDE_DECORATION_BORDER) + }) + .when(!tiling.bottom, |div| { + div.border_b(CLIENT_SIDE_DECORATION_BORDER) + }) + .when(!tiling.left, |div| { + div.border_l(CLIENT_SIDE_DECORATION_BORDER) + }) + .when(!tiling.right, |div| { + div.border_r(CLIENT_SIDE_DECORATION_BORDER) + }) + .when(!tiling.is_tiled(), |div| { + div.shadow(vec![gpui::BoxShadow { + color: Hsla { + h: 0., + s: 0., + l: 0., + a: 0.4, + }, + blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2., + spread_radius: px(0.), + offset: point(px(0.0), px(0.0)), + }]) + }), + }) + .on_mouse_move(|_e, _, cx| { + cx.stop_propagation(); + }) + .size_full() + .font_family(font_family) + .bg(cx.theme().background) + .text_color(cx.theme().text) + .child(self.view.clone()), + ) + } +} + +/// Get the window paddings. +pub fn window_paddings(window: &Window, _cx: &App) -> Edges { + match window.window_decorations() { + Decorations::Server => Edges::all(px(0.0)), + Decorations::Client { tiling } => { + let mut paddings = Edges::all(CLIENT_SIDE_DECORATION_SHADOW); + if tiling.top { + paddings.top = px(0.0); + } + if tiling.bottom { + paddings.bottom = px(0.0); + } + if tiling.left { + paddings.left = px(0.0); + } + if tiling.right { + paddings.right = px(0.0); + } + paddings + } + } +} + +/// Get the window resize edge. +fn resize_edge( + pos: Point, + shadow_size: Pixels, + window_size: Size, + tiling: Tiling, +) -> Option { + let bounds = Bounds::new(Point::default(), window_size).inset(shadow_size * 1.5); + if bounds.contains(&pos) { + return None; + } + + let corner_size = size(shadow_size * 1.5, shadow_size * 1.5); + let top_left_bounds = Bounds::new(Point::new(px(0.), px(0.)), corner_size); + if !tiling.top && top_left_bounds.contains(&pos) { + return Some(ResizeEdge::TopLeft); + } + + let top_right_bounds = Bounds::new( + Point::new(window_size.width - corner_size.width, px(0.)), + corner_size, + ); + if !tiling.top && top_right_bounds.contains(&pos) { + return Some(ResizeEdge::TopRight); + } + + let bottom_left_bounds = Bounds::new( + Point::new(px(0.), window_size.height - corner_size.height), + corner_size, + ); + if !tiling.bottom && bottom_left_bounds.contains(&pos) { + return Some(ResizeEdge::BottomLeft); + } + + let bottom_right_bounds = Bounds::new( + Point::new( + window_size.width - corner_size.width, + window_size.height - corner_size.height, + ), + corner_size, + ); + if !tiling.bottom && bottom_right_bounds.contains(&pos) { + return Some(ResizeEdge::BottomRight); + } + + if !tiling.top && pos.y < shadow_size { + Some(ResizeEdge::Top) + } else if !tiling.bottom && pos.y > window_size.height - shadow_size { + Some(ResizeEdge::Bottom) + } else if !tiling.left && pos.x < shadow_size { + Some(ResizeEdge::Left) + } else if !tiling.right && pos.x > window_size.width - shadow_size { + Some(ResizeEdge::Right) + } else { + None } } diff --git a/crates/ui/src/scroll/scrollable.rs b/crates/ui/src/scroll/scrollable.rs index 0738775..821694e 100644 --- a/crates/ui/src/scroll/scrollable.rs +++ b/crates/ui/src/scroll/scrollable.rs @@ -1,232 +1,209 @@ +use std::panic::Location; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; use gpui::{ - div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId, - InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, - Pixels, Position, ScrollHandle, SharedString, Size, Stateful, StatefulInteractiveElement, - Style, StyleRefinement, Styled, Window, + div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, + ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, }; -use super::{Scrollbar, ScrollbarAxis, ScrollbarState}; +use super::{Scrollbar, ScrollbarAxis}; +use crate::scroll::ScrollbarHandle; +use crate::StyledExt; -/// A scroll view is a container that allows the user to scroll through a large amount of content. -pub struct Scrollable { +/// A trait for elements that can be made scrollable with scrollbars. +pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element { + /// Adds a scrollbar to the element. + #[track_caller] + fn scrollbar( + self, + scroll_handle: &H, + axis: impl Into, + ) -> Self { + self.child(ScrollbarLayer { + id: "scrollbar_layer".into(), + axis: axis.into(), + scroll_handle: Rc::new(scroll_handle.clone()), + }) + } + + /// Adds a vertical scrollbar to the element. + #[track_caller] + fn vertical_scrollbar(self, scroll_handle: &H) -> Self { + self.scrollbar(scroll_handle, ScrollbarAxis::Vertical) + } + /// Adds a horizontal scrollbar to the element. + #[track_caller] + fn horizontal_scrollbar(self, scroll_handle: &H) -> Self { + self.scrollbar(scroll_handle, ScrollbarAxis::Horizontal) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_scroll`], but adds scrollbars. + #[track_caller] + fn overflow_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Both) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar. + #[track_caller] + fn overflow_x_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Horizontal) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar. + #[track_caller] + fn overflow_y_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Vertical) + } +} + +/// A scrollable element wrapper that adds scrollbars to an interactive element. +#[derive(IntoElement)] +pub struct Scrollable { id: ElementId, - element: Option, + element: E, axis: ScrollbarAxis, - /// This is a fake element to handle Styled, InteractiveElement, not used. - _element: Stateful
, } impl Scrollable where - E: Element, + E: InteractiveElement + Styled + ParentElement + Element, { - pub(crate) fn new(axis: impl Into, element: E) -> Self { - let id = ElementId::Name(SharedString::from( - format!("scrollable-{:?}", element.id(),), - )); - + #[track_caller] + fn new(element: E, axis: impl Into) -> Self { + let caller = Location::caller(); Self { - element: Some(element), - _element: div().id("fake"), - id, + id: ElementId::CodeLocation(*caller), + element, axis: axis.into(), } } - - /// Set only a vertical scrollbar. - pub fn vertical(mut self) -> Self { - self.set_axis(ScrollbarAxis::Vertical); - self - } - - /// Set only a horizontal scrollbar. - /// In current implementation, this is not supported yet. - pub fn horizontal(mut self) -> Self { - self.set_axis(ScrollbarAxis::Horizontal); - self - } - - /// Set the axis of the scroll view. - pub fn set_axis(&mut self, axis: impl Into) { - self.axis = axis.into(); - } - - fn with_element_state( - &mut self, - id: &GlobalElementId, - window: &mut Window, - cx: &mut App, - f: impl FnOnce(&mut Self, &mut ScrollViewState, &mut Window, &mut App) -> R, - ) -> R { - window.with_optional_element_state::( - Some(id), - |element_state, window| { - let mut element_state = element_state.unwrap().unwrap_or_default(); - let result = f(self, &mut element_state, window, cx); - (result, Some(element_state)) - }, - ) - } -} - -pub struct ScrollViewState { - state: ScrollbarState, - handle: ScrollHandle, -} - -impl Default for ScrollViewState { - fn default() -> Self { - Self { - handle: ScrollHandle::new(), - state: ScrollbarState::default(), - } - } -} - -impl ParentElement for Scrollable -where - E: Element + ParentElement, -{ - fn extend(&mut self, elements: impl IntoIterator) { - if let Some(element) = &mut self.element { - element.extend(elements); - } - } } impl Styled for Scrollable where - E: Element + Styled, + E: InteractiveElement + Styled + ParentElement + Element, { fn style(&mut self) -> &mut StyleRefinement { - if let Some(element) = &mut self.element { - element.style() - } else { - self._element.style() - } + self.element.style() } } -impl InteractiveElement for Scrollable +impl ParentElement for Scrollable where - E: Element + InteractiveElement, + E: InteractiveElement + Styled + ParentElement + Element, { - fn interactivity(&mut self) -> &mut Interactivity { - if let Some(element) = &mut self.element { - element.interactivity() - } else { - self._element.interactivity() - } - } -} -impl StatefulInteractiveElement for Scrollable where E: Element + StatefulInteractiveElement {} - -impl IntoElement for Scrollable -where - E: Element, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self + fn extend(&mut self, elements: impl IntoIterator) { + self.element.extend(elements) } } -impl Element for Scrollable +impl InteractiveElement for Scrollable
{ + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.element.interactivity() + } +} + +impl InteractiveElement for Scrollable> { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.element.interactivity() + } +} + +impl RenderOnce for Scrollable where - E: Element, + E: InteractiveElement + Styled + ParentElement + Element + 'static, { - type PrepaintState = ScrollViewState; - type RequestLayoutState = AnyElement; + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let scroll_handle = window + .use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default()) + .read(cx) + .clone(); - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - id: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = Style { - position: Position::Relative, - flex_grow: 1.0, - flex_shrink: 1.0, - size: Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, + // Inherit the size from the element style. + let style = StyleRefinement { + size: self.element.style().size.clone(), ..Default::default() }; - let axis = self.axis; - let scroll_id = self.id.clone(); - let content = self.element.take().map(|c| c.into_any_element()); - - self.with_element_state(id.unwrap(), window, cx, |_, element_state, window, cx| { - let mut element = div() - .relative() - .size_full() - .overflow_hidden() - .child( - div() - .id(scroll_id) - .track_scroll(&element_state.handle) - .overflow_scroll() - .relative() - .size_full() - .child(div().children(content)), - ) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child( - Scrollbar::both(&element_state.state, &element_state.handle).axis(axis), - ), - ) - .into_any_element(); - - let element_id = element.request_layout(window, cx); - let layout_id = window.request_layout(style, vec![element_id], cx); - - (layout_id, element) - }) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - element.prepaint(window, cx); - // do nothing - ScrollViewState::default() - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - element.paint(window, cx) + div() + .id(self.id) + .size_full() + .refine_style(&style) + .relative() + .child( + div() + .id("scroll-area") + .flex() + .size_full() + .track_scroll(&scroll_handle) + .map(|this| match self.axis { + ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(), + ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(), + ScrollbarAxis::Both => this.overflow_scroll(), + }) + .child( + self.element + // Refine element size to `flex_1`. + .size_auto() + .flex_1(), + ), + ) + .child(render_scrollbar( + "scrollbar", + &scroll_handle, + self.axis, + window, + cx, + )) } } + +impl ScrollableElement for Div {} +impl ScrollableElement for Stateful +where + E: ParentElement + Styled + Element, + Self: InteractiveElement, +{ +} + +#[derive(IntoElement)] +struct ScrollbarLayer { + id: ElementId, + axis: ScrollbarAxis, + scroll_handle: Rc, +} + +impl RenderOnce for ScrollbarLayer +where + H: ScrollbarHandle + Clone + 'static, +{ + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx) + } +} + +#[inline] +#[track_caller] +fn render_scrollbar( + id: impl Into, + scroll_handle: &H, + axis: ScrollbarAxis, + window: &mut Window, + cx: &mut App, +) -> Div { + // Do not render scrollbar when inspector is picking elements, + // to allow us to pick the background elements. + let is_inspector_picking = window.is_inspector_picking(cx); + if is_inspector_picking { + return div(); + } + + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::new(scroll_handle).id(id).axis(axis)) +} diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index 394dff7..e78a3b7 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -1,43 +1,50 @@ use std::cell::Cell; use std::ops::Deref; +use std::panic::Location; use std::rc::Rc; use std::time::{Duration, Instant}; use gpui::{ fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner, - CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, - IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window, + CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, + UniformListScrollHandle, Window, }; -use theme::ActiveTheme; +use theme::{ActiveTheme, ScrollbarMode}; use crate::AxisExt; -const WIDTH: Pixels = px(2. * 2. + 8.); +/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) +const WIDTH: Pixels = px(1. * 2. + 8.); const MIN_THUMB_SIZE: f32 = 48.; const THUMB_WIDTH: Pixels = px(6.); const THUMB_RADIUS: Pixels = px(6. / 2.); -const THUMB_INSET: Pixels = px(2.); +const THUMB_INSET: Pixels = px(1.); const THUMB_ACTIVE_WIDTH: Pixels = px(8.); const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.); -const THUMB_ACTIVE_INSET: Pixels = px(2.); +const THUMB_ACTIVE_INSET: Pixels = px(1.); const FADE_OUT_DURATION: f32 = 3.0; const FADE_OUT_DELAY: f32 = 2.0; -pub trait ScrollHandleOffsetable { +/// A trait for scroll handles that can get and set offset. +pub trait ScrollbarHandle: 'static { + /// Get the current offset of the scroll handle. fn offset(&self) -> Point; + /// Set the offset of the scroll handle. fn set_offset(&self, offset: Point); - fn is_uniform_list(&self) -> bool { - false - } /// The full size of the content, including padding. fn content_size(&self) -> Size; + /// Called when start dragging the scrollbar thumb. + fn start_drag(&self) {} + /// Called when end dragging the scrollbar thumb. + fn end_drag(&self) {} } -impl ScrollHandleOffsetable for ScrollHandle { +impl ScrollbarHandle for ScrollHandle { fn offset(&self) -> Point { self.offset() } @@ -51,7 +58,7 @@ impl ScrollHandleOffsetable for ScrollHandle { } } -impl ScrollHandleOffsetable for UniformListScrollHandle { +impl ScrollbarHandle for UniformListScrollHandle { fn offset(&self) -> Point { self.0.borrow().base_handle.offset() } @@ -60,21 +67,41 @@ impl ScrollHandleOffsetable for UniformListScrollHandle { self.0.borrow_mut().base_handle.set_offset(offset) } - fn is_uniform_list(&self) -> bool { - true - } - fn content_size(&self) -> Size { let base_handle = &self.0.borrow().base_handle; base_handle.max_offset() + base_handle.bounds().size } } -#[derive(Debug, Clone)] -pub struct ScrollbarState(Rc>); +impl ScrollbarHandle for ListState { + fn offset(&self) -> Point { + self.scroll_px_offset_for_scrollbar() + } + fn set_offset(&self, offset: Point) { + self.set_offset_from_scrollbar(offset); + } + + fn content_size(&self) -> Size { + self.viewport_bounds().size + self.max_offset_for_scrollbar() + } + + fn start_drag(&self) { + self.scrollbar_drag_started(); + } + + fn end_drag(&self) { + self.scrollbar_drag_ended(); + } +} + +#[doc(hidden)] +#[derive(Debug, Clone)] +struct ScrollbarState(Rc>); + +#[doc(hidden)] #[derive(Debug, Clone, Copy)] -pub struct ScrollbarStateInner { +struct ScrollbarStateInner { hovered_axis: Option, hovered_on_thumb: Option, dragged_axis: Option, @@ -83,6 +110,7 @@ pub struct ScrollbarStateInner { last_scroll_time: Option, // Last update offset last_update: Instant, + idle_timer_scheduled: bool, } impl Default for ScrollbarState { @@ -95,6 +123,7 @@ impl Default for ScrollbarState { last_scroll_offset: point(px(0.), px(0.)), last_scroll_time: None, last_update: Instant::now(), + idle_timer_scheduled: false, }))) } } @@ -167,6 +196,12 @@ impl ScrollbarStateInner { state } + fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self { + let mut state = *self; + state.idle_timer_scheduled = scheduled; + state + } + fn is_scrollbar_visible(&self) -> bool { // On drag if self.dragged_axis.is_some() { @@ -182,10 +217,14 @@ impl ScrollbarStateInner { } } +/// Scrollbar axis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScrollbarAxis { + /// Vertical scrollbar. Vertical, + /// Horizontal scrollbar. Horizontal, + /// Show both vertical and horizontal scrollbars. Both, } @@ -200,25 +239,30 @@ impl From for ScrollbarAxis { impl ScrollbarAxis { /// Return true if the scrollbar axis is vertical. + #[inline] pub fn is_vertical(&self) -> bool { matches!(self, Self::Vertical) } /// Return true if the scrollbar axis is horizontal. + #[inline] pub fn is_horizontal(&self) -> bool { matches!(self, Self::Horizontal) } /// Return true if the scrollbar axis is both vertical and horizontal. + #[inline] pub fn is_both(&self) -> bool { matches!(self, Self::Both) } + /// Return true if the scrollbar has vertical axis. #[inline] pub fn has_vertical(&self) -> bool { matches!(self, Self::Vertical | Self::Both) } + /// Return true if the scrollbar has horizontal axis. #[inline] pub fn has_horizontal(&self) -> bool { matches!(self, Self::Horizontal | Self::Both) @@ -238,9 +282,10 @@ impl ScrollbarAxis { /// Scrollbar control for scroll-area or a uniform-list. pub struct Scrollbar { + pub(crate) id: ElementId, axis: ScrollbarAxis, - scroll_handle: Rc>, - state: ScrollbarState, + scrollbar_mode: Option, + scroll_handle: Rc, scroll_size: Option>, /// Maximum frames per second for scrolling by drag. Default is 120 FPS. /// @@ -250,50 +295,46 @@ pub struct Scrollbar { } impl Scrollbar { - fn new( - axis: impl Into, - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { + /// Create a new scrollbar. + /// + /// This will have both vertical and horizontal scrollbars. + #[track_caller] + pub fn new(scroll_handle: &H) -> Self { + let caller = Location::caller(); Self { - state: state.clone(), - axis: axis.into(), - scroll_handle: Rc::new(Box::new(scroll_handle.clone())), + id: ElementId::CodeLocation(*caller), + axis: ScrollbarAxis::Both, + scrollbar_mode: None, + scroll_handle: Rc::new(scroll_handle.clone()), max_fps: 120, scroll_size: None, } } - /// Create with vertical and horizontal scrollbar. - pub fn both( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Both, state, scroll_handle) - } - /// Create with horizontal scrollbar. - pub fn horizontal( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Horizontal, state, scroll_handle) + #[track_caller] + pub fn horizontal(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal) } /// Create with vertical scrollbar. - pub fn vertical( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Vertical, state, scroll_handle) + #[track_caller] + pub fn vertical(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Vertical) } - /// Create vertical scrollbar for uniform list. - pub fn uniform_scroll( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Vertical, state, scroll_handle) + /// Set a specific element id, default is the [`Location::caller`]. + /// + /// NOTE: In most cases, you don't need to set a specific id for scrollbar. + pub fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`. + pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self { + self.scrollbar_mode = Some(mode); + self } /// Set a special scroll size of the content area, default is None. @@ -315,11 +356,18 @@ impl Scrollbar { /// If you have very high CPU usage, consider reducing this value to improve performance. /// /// Available values: 30..120 - pub fn max_fps(mut self, max_fps: usize) -> Self { + #[allow(dead_code)] + pub(crate) fn max_fps(mut self, max_fps: usize) -> Self { self.max_fps = max_fps.clamp(30, 120); self } + // Get the width of the scrollbar. + #[allow(dead_code)] + pub(crate) const fn width() -> Pixels { + WIDTH + } + fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_hover_background, @@ -353,11 +401,28 @@ impl Scrollbar { ) } - fn style_for_idle(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - let (width, inset, radius) = if cx.theme().scrollbar_mode.is_scrolling() { - (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS) - } else { - (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS) + fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { + let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); + let (width, inset, radius) = match scrollbar_mode { + ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), + _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), + }; + + ( + cx.theme().scrollbar_thumb_background, + cx.theme().scrollbar_track_background, + gpui::transparent_black(), + width, + inset, + radius, + ) + } + + fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { + let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always); + let (width, inset, radius) = match scrollbar_mode { + ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), + _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), }; ( @@ -379,11 +444,14 @@ impl IntoElement for Scrollbar { } } +#[doc(hidden)] pub struct PrepaintState { hitbox: Hitbox, + scrollbar_state: ScrollbarState, states: Vec, } +#[doc(hidden)] pub struct AxisPrepaintState { axis: Axis, bar_hitbox: Hitbox, @@ -406,7 +474,7 @@ impl Element for Scrollbar { type RequestLayoutState = (); fn id(&self) -> Option { - None + Some(self.id.clone()) } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { @@ -420,11 +488,11 @@ impl Element for Scrollbar { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - let style = gpui::Style { + let style = Style { position: Position::Absolute, flex_grow: 1.0, flex_shrink: 1.0, - size: gpui::Size { + size: Size { width: relative(1.).into(), height: relative(1.).into(), }, @@ -447,6 +515,11 @@ impl Element for Scrollbar { window.insert_hitbox(bounds, HitboxBehavior::Normal) }); + let state = window + .use_state(cx, |_, _| ScrollbarState::default()) + .read(cx) + .clone(); + let mut states = vec![]; let mut has_both = self.axis.is_both(); let scroll_size = self @@ -470,9 +543,8 @@ impl Element for Scrollbar { }; // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. - let margin_end = if has_both && !is_vertical { - THUMB_ACTIVE_WIDTH + WIDTH } else { px(0.) }; @@ -512,11 +584,12 @@ impl Element for Scrollbar { }, }; - let state = self.state.clone(); - let is_always_to_show = cx.theme().scrollbar_mode.is_always(); - let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); + let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); + let is_always_to_show = scrollbar_show.is_always(); + let is_hover_to_show = scrollbar_show.is_hover(); let is_hovered_on_bar = state.get().hovered_axis == Some(axis); let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); + let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset(); let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) = if state.get().dragged_axis == Some(axis) { @@ -527,38 +600,47 @@ impl Element for Scrollbar { } else { Self::style_for_hovered_bar(cx) } + } else if is_offset_changed { + self.style_for_normal(cx) } else if is_always_to_show { - #[allow(clippy::if_same_then_else)] if is_hovered_on_thumb { Self::style_for_hovered_thumb(cx) } else { Self::style_for_hovered_bar(cx) } } else { - let mut idle_state = Self::style_for_idle(cx); + let mut idle_state = self.style_for_idle(cx); // Delay 2s to fade out the scrollbar thumb (in 1s) if let Some(last_time) = state.get().last_scroll_time { let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); - if elapsed < FADE_OUT_DURATION { - if is_hovered_on_bar { - state.set(state.get().with_last_scroll_time(Some(Instant::now()))); - idle_state = if is_hovered_on_thumb { - Self::style_for_hovered_thumb(cx) - } else { - Self::style_for_hovered_bar(cx) - }; + if is_hovered_on_bar { + state.set(state.get().with_last_scroll_time(Some(Instant::now()))); + idle_state = if is_hovered_on_thumb { + Self::style_for_hovered_thumb(cx) } else { - if elapsed < FADE_OUT_DELAY { - idle_state.0 = cx.theme().scrollbar_thumb_background; - } else { - // opacity = 1 - (x - 2)^10 - let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); - idle_state.0 = - cx.theme().scrollbar_thumb_background.opacity(opacity); - }; + Self::style_for_hovered_bar(cx) + }; + } else if elapsed < FADE_OUT_DELAY { + idle_state.0 = cx.theme().scrollbar_thumb_background; - window.request_animation_frame(); + if !state.get().idle_timer_scheduled { + let state = state.clone(); + state.set(state.get().with_idle_timer_scheduled(true)); + let current_view = window.current_view(); + let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(next_delay).await; + state.set(state.get().with_idle_timer_scheduled(false)); + cx.update(|_, cx| cx.notify(current_view)).ok(); + }) + .detach(); } + } else if elapsed < FADE_OUT_DURATION { + let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); + idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity); + + window.request_animation_frame(); } } @@ -617,7 +699,11 @@ impl Element for Scrollbar { }) } - PrepaintState { hitbox, states } + PrepaintState { + hitbox, + states, + scrollbar_state: state, + } } fn paint( @@ -630,19 +716,21 @@ impl Element for Scrollbar { window: &mut Window, cx: &mut App, ) { + let scrollbar_state = &prepaint.scrollbar_state; + let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); let view_id = window.current_view(); let hitbox_bounds = prepaint.hitbox.bounds; - let is_visible = - self.state.get().is_scrollbar_visible() || cx.theme().scrollbar_mode.is_always(); - let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); + let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always(); + let is_hover_to_show = scrollbar_show.is_hover(); // Update last_scroll_time when offset is changed. - if self.scroll_handle.offset() != self.state.get().last_scroll_offset { - self.state.set( - self.state + if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset { + scrollbar_state.set( + scrollbar_state .get() .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())), ); + cx.notify(view_id); } window.with_content_mask( @@ -652,7 +740,10 @@ impl Element for Scrollbar { |window| { for state in prepaint.states.iter() { let axis = state.axis; - let radius = state.radius; + let mut radius = state.radius; + if cx.theme().radius.is_zero() { + radius = px(0.); + } let bounds = state.bounds; let thumb_bounds = state.thumb_bounds; let scroll_area_size = state.scroll_size; @@ -686,7 +777,7 @@ impl Element for Scrollbar { }); window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &ScrollWheelEvent, phase, _, cx| { @@ -707,7 +798,7 @@ impl Element for Scrollbar { if is_hover_to_show || is_visible { window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &MouseDownEvent, phase, _, cx| { @@ -718,6 +809,7 @@ impl Element for Scrollbar { // click on the thumb bar, set the drag position let pos = event.position - thumb_bounds.origin; + scroll_handle.start_drag(); state.set(state.get().with_drag_pos(axis, pos)); cx.notify(view_id); @@ -755,7 +847,7 @@ impl Element for Scrollbar { window.on_mouse_event({ let scroll_handle = self.scroll_handle.clone(); - let state = self.state.clone(); + let state = scrollbar_state.clone(); let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64); move |event: &MouseMoveEvent, _, _, cx| { @@ -770,9 +862,7 @@ impl Element for Scrollbar { if state.get().hovered_axis != Some(axis) { notify = true; } - } else if state.get().hovered_axis == Some(axis) - && state.get().hovered_axis.is_some() - { + } else if state.get().hovered_axis == Some(axis) { state.set(state.get().with_hovered(None)); notify = true; } @@ -790,6 +880,9 @@ impl Element for Scrollbar { // Move thumb position on dragging if state.get().dragged_axis == Some(axis) && event.dragging() { + // Stop the event propagation to avoid selecting text or other side effects. + cx.stop_propagation(); + // drag_pos is the position of the mouse down event // We need to keep the thumb bar still at the origin down position let drag_pos = state.get().drag_pos; @@ -836,10 +929,12 @@ impl Element for Scrollbar { }); window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); + let scroll_handle = self.scroll_handle.clone(); move |_event: &MouseUpEvent, phase, _, cx| { if phase.bubble() { + scroll_handle.end_drag(); state.set(state.get().with_unset_drag_pos()); cx.notify(view_id); } diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs index ef09230..441296d 100644 --- a/crates/ui/src/skeleton.rs +++ b/crates/ui/src/skeleton.rs @@ -22,8 +22,8 @@ impl Skeleton { } } - pub fn secondary(mut self, secondary: bool) -> Self { - self.secondary = secondary; + pub fn secondary(mut self) -> Self { + self.secondary = true; self } } diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 8e40768..16c6515 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,11 +1,7 @@ -use std::fmt::{self, Display, Formatter}; - -use gpui::{div, px, App, Axis, Div, Element, Pixels, Refineable, StyleRefinement, Styled}; +use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled}; use serde::{Deserialize, Serialize}; use theme::ActiveTheme; -use crate::scroll::{Scrollable, ScrollbarAxis}; - /// Returns a `Div` as horizontal flex layout. pub fn h_flex() -> Div { div().h_flex() @@ -18,7 +14,7 @@ pub fn v_flex() -> Div { /// Returns a `Div` as divider. pub fn divider(cx: &App) -> Div { - div().my_2().w_full().h_px().bg(cx.theme().border) + div().my_2().w_full().h_px().bg(cx.theme().border_variant) } macro_rules! font_weight { @@ -50,17 +46,6 @@ pub trait StyledExt: Styled + Sized { self.flex().flex_col() } - /// Wraps the element in a ScrollView. - /// - /// Current this is only have a vertical scrollbar. - #[inline] - fn scrollable(self, axis: impl Into) -> Scrollable - where - Self: Element, - { - Scrollable::new(axis, self) - } - font_weight!(font_thin, THIN); font_weight!(font_extralight, EXTRA_LIGHT); font_weight!(font_light, LIGHT); @@ -183,39 +168,43 @@ impl StyleSized for T { fn input_pl(self, size: Size) -> Self { match size { - Size::Large => self.pl_5(), + Size::XSmall => self.pl_1(), Size::Medium => self.pl_3(), + Size::Large => self.pl_5(), _ => self.pl_2(), } } fn input_pr(self, size: Size) -> Self { match size { - Size::Large => self.pr_5(), + Size::XSmall => self.pr_1(), Size::Medium => self.pr_3(), + Size::Large => self.pr_5(), _ => self.pr_2(), } } fn input_px(self, size: Size) -> Self { match size { - Size::Large => self.px_5(), + Size::XSmall => self.px_1(), Size::Medium => self.px_3(), + Size::Large => self.px_5(), _ => self.px_2(), } } fn input_py(self, size: Size) -> Self { match size { - Size::Large => self.py_5(), + Size::XSmall => self.py_0p5(), Size::Medium => self.py_2(), + Size::Large => self.py_5(), _ => self.py_1(), } } fn input_h(self, size: Size) -> Self { match size { - Size::XSmall => self.h_7(), + Size::XSmall => self.h_6(), Size::Small => self.h_8(), Size::Medium => self.h_9(), Size::Large => self.h_12(), @@ -255,74 +244,6 @@ impl StyleSized for T { } } -pub trait AxisExt { - fn is_horizontal(&self) -> bool; - fn is_vertical(&self) -> bool; -} - -impl AxisExt for Axis { - fn is_horizontal(&self) -> bool { - self == &Axis::Horizontal - } - - fn is_vertical(&self) -> bool { - self == &Axis::Vertical - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Placement { - Top, - Bottom, - Left, - Right, -} - -impl Display for Placement { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Placement::Top => write!(f, "Top"), - Placement::Bottom => write!(f, "Bottom"), - Placement::Left => write!(f, "Left"), - Placement::Right => write!(f, "Right"), - } - } -} - -impl Placement { - pub fn is_horizontal(&self) -> bool { - matches!(self, Placement::Left | Placement::Right) - } - - pub fn is_vertical(&self) -> bool { - matches!(self, Placement::Top | Placement::Bottom) - } - - pub fn axis(&self) -> Axis { - match self { - Placement::Top | Placement::Bottom => Axis::Vertical, - Placement::Left | Placement::Right => Axis::Horizontal, - } - } -} - -/// A enum for defining the side of the element. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Side { - Left, - Right, -} - -impl Side { - pub(crate) fn is_left(&self) -> bool { - matches!(self, Self::Left) - } - - pub(crate) fn is_right(&self) -> bool { - matches!(self, Self::Right) - } -} - /// A trait for defining element that can be collapsed. pub trait Collapsible { fn collapsed(self, collapsed: bool) -> Self; diff --git a/crates/ui/src/window_border.rs b/crates/ui/src/window_border.rs deleted file mode 100644 index 8caddf3..0000000 --- a/crates/ui/src/window_border.rs +++ /dev/null @@ -1,204 +0,0 @@ -use gpui::prelude::FluentBuilder as _; -use gpui::{ - canvas, div, point, px, AnyElement, App, Bounds, CursorStyle, Decorations, Edges, - HitboxBehavior, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, - Point, RenderOnce, ResizeEdge, Size, Styled as _, Window, -}; -use theme::{CLIENT_SIDE_DECORATION_ROUNDING, CLIENT_SIDE_DECORATION_SHADOW}; - -const WINDOW_BORDER_WIDTH: Pixels = px(1.0); - -/// Create a new window border. -pub fn window_border() -> WindowBorder { - WindowBorder::new() -} - -/// Window border use to render a custom window border and shadow for Linux. -#[derive(IntoElement, Default)] -pub struct WindowBorder { - children: Vec, -} - -/// Get the window paddings. -pub fn window_paddings(window: &Window, _cx: &App) -> Edges { - match window.window_decorations() { - Decorations::Server => Edges::all(px(0.0)), - Decorations::Client { tiling } => { - let mut paddings = Edges::all(CLIENT_SIDE_DECORATION_SHADOW); - if tiling.top { - paddings.top = px(0.0); - } - if tiling.bottom { - paddings.bottom = px(0.0); - } - if tiling.left { - paddings.left = px(0.0); - } - if tiling.right { - paddings.right = px(0.0); - } - paddings - } - } -} - -impl WindowBorder { - pub fn new() -> Self { - Self { - ..Default::default() - } - } -} - -impl ParentElement for WindowBorder { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl RenderOnce for WindowBorder { - fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { - let decorations = window.window_decorations(); - window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW); - - div() - .id("window-backdrop") - .bg(gpui::transparent_black()) - .map(|div| match decorations { - Decorations::Server => div, - Decorations::Client { tiling, .. } => div - .bg(gpui::transparent_black()) - .child( - canvas( - |_bounds, window, _cx| { - window.insert_hitbox( - Bounds::new( - point(px(0.0), px(0.0)), - window.window_bounds().get_bounds().size, - ), - HitboxBehavior::Normal, - ) - }, - move |_bounds, hitbox, window, _cx| { - let mouse = window.mouse_position(); - let size = window.window_bounds().get_bounds().size; - let Some(edge) = - resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size) - else { - return; - }; - window.set_cursor_style( - match edge { - ResizeEdge::Top | ResizeEdge::Bottom => { - CursorStyle::ResizeUpDown - } - ResizeEdge::Left | ResizeEdge::Right => { - CursorStyle::ResizeLeftRight - } - ResizeEdge::TopLeft | ResizeEdge::BottomRight => { - CursorStyle::ResizeUpLeftDownRight - } - ResizeEdge::TopRight | ResizeEdge::BottomLeft => { - CursorStyle::ResizeUpRightDownLeft - } - }, - &hitbox, - ); - }, - ) - .size_full() - .absolute(), - ) - .when(!(tiling.top || tiling.right), |div| { - div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.top || tiling.left), |div| { - div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.right), |div| { - div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.left), |div| { - div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!tiling.top, |div| div.pt(CLIENT_SIDE_DECORATION_SHADOW)) - .when(!tiling.bottom, |div| div.pb(CLIENT_SIDE_DECORATION_SHADOW)) - .when(!tiling.left, |div| div.pl(CLIENT_SIDE_DECORATION_SHADOW)) - .when(!tiling.right, |div| div.pr(CLIENT_SIDE_DECORATION_SHADOW)) - .on_mouse_down(MouseButton::Left, move |_, window, _cx| { - let size = window.window_bounds().get_bounds().size; - let pos = window.mouse_position(); - - if let Some(edge) = resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size) { - window.start_window_resize(edge) - }; - }), - }) - .size_full() - .child( - div() - .map(|div| match decorations { - Decorations::Server => div, - Decorations::Client { tiling } => div - .when(!(tiling.top || tiling.right), |div| { - div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.top || tiling.left), |div| { - div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.right), |div| { - div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.left), |div| { - div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!tiling.top, |div| div.border_t(WINDOW_BORDER_WIDTH)) - .when(!tiling.bottom, |div| div.border_b(WINDOW_BORDER_WIDTH)) - .when(!tiling.left, |div| div.border_l(WINDOW_BORDER_WIDTH)) - .when(!tiling.right, |div| div.border_r(WINDOW_BORDER_WIDTH)) - .when(!tiling.is_tiled(), |div| { - div.shadow(vec![gpui::BoxShadow { - color: Hsla { - h: 0., - s: 0., - l: 0., - a: 0.3, - }, - blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2., - spread_radius: px(0.), - offset: point(px(0.0), px(0.0)), - }]) - }), - }) - .on_mouse_move(|_e, _window, cx| { - cx.stop_propagation(); - }) - .bg(gpui::transparent_black()) - .size_full() - .children(self.children), - ) - } -} - -fn resize_edge(pos: Point, shadow_size: Pixels, size: Size) -> Option { - let edge = if pos.y < shadow_size && pos.x < shadow_size { - ResizeEdge::TopLeft - } else if pos.y < shadow_size && pos.x > size.width - shadow_size { - ResizeEdge::TopRight - } else if pos.y < shadow_size { - ResizeEdge::Top - } else if pos.y > size.height - shadow_size && pos.x < shadow_size { - ResizeEdge::BottomLeft - } else if pos.y > size.height - shadow_size && pos.x > size.width - shadow_size { - ResizeEdge::BottomRight - } else if pos.y > size.height - shadow_size { - ResizeEdge::Bottom - } else if pos.x < shadow_size { - ResizeEdge::Left - } else if pos.x > size.width - shadow_size { - ResizeEdge::Right - } else { - return None; - }; - Some(edge) -} diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs new file mode 100644 index 0000000..dd71dd7 --- /dev/null +++ b/crates/ui/src/window_ext.rs @@ -0,0 +1,120 @@ +use std::rc::Rc; + +use gpui::{App, Entity, SharedString, Window}; + +use crate::input::InputState; +use crate::modal::Modal; +use crate::notification::Notification; +use crate::Root; + +/// Extension trait for [`Window`] to add modal, notification .. functionality. +pub trait WindowExtension: Sized { + /// Opens a Modal. + fn open_modal(&mut self, cx: &mut App, builder: F) + where + F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static; + + /// Return true, if there is an active Modal. + fn has_active_modal(&mut self, cx: &mut App) -> bool; + + /// Closes the last active Modal. + fn close_modal(&mut self, cx: &mut App); + + /// Closes all active Modals. + fn close_all_modals(&mut self, cx: &mut App); + + /// Returns number of notifications. + fn notifications(&mut self, cx: &mut App) -> Rc>>; + + /// Pushes a notification to the notification list. + fn push_notification(&mut self, note: T, cx: &mut App) + where + T: Into; + + /// Clears a notification by its ID. + fn clear_notification(&mut self, id: T, cx: &mut App) + where + T: Into; + + /// Clear all notifications + fn clear_notifications(&mut self, cx: &mut App); + + /// Return current focused Input entity. + fn focused_input(&mut self, cx: &mut App) -> Option>; + + /// Returns true if there is a focused Input entity. + fn has_focused_input(&mut self, cx: &mut App) -> bool; +} + +impl WindowExtension for Window { + #[inline] + fn open_modal(&mut self, cx: &mut App, builder: F) + where + F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static, + { + Root::update(self, cx, move |root, window, cx| { + root.open_modal(builder, window, cx); + }) + } + + #[inline] + fn has_active_modal(&mut self, cx: &mut App) -> bool { + Root::read(self, cx).has_active_modals() + } + + #[inline] + fn close_modal(&mut self, cx: &mut App) { + Root::update(self, cx, move |root, window, cx| { + root.close_modal(window, cx); + }) + } + + #[inline] + fn close_all_modals(&mut self, cx: &mut App) { + Root::update(self, cx, |root, window, cx| { + root.close_all_modals(window, cx); + }) + } + + #[inline] + fn push_notification(&mut self, note: T, cx: &mut App) + where + T: Into, + { + let note = note.into(); + Root::update(self, cx, move |root, window, cx| { + root.push_notification(note, window, cx); + }) + } + + #[inline] + fn clear_notification(&mut self, id: T, cx: &mut App) + where + T: Into, + { + let id = id.into(); + Root::update(self, cx, move |root, window, cx| { + root.clear_notification(id, window, cx); + }) + } + + #[inline] + fn clear_notifications(&mut self, cx: &mut App) { + Root::update(self, cx, move |root, window, cx| { + root.clear_notifications(window, cx); + }) + } + + fn notifications(&mut self, cx: &mut App) -> Rc>> { + let entity = Root::read(self, cx).notification.clone(); + Rc::new(entity.read(cx).notifications()) + } + + fn has_focused_input(&mut self, cx: &mut App) -> bool { + Root::read(self, cx).focused_input.is_some() + } + + fn focused_input(&mut self, cx: &mut App) -> Option> { + Root::read(self, cx).focused_input.clone() + } +}