diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8c89a97..56a3127 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -13,7 +13,7 @@ jobs: build: strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest] rustup: [stable] runs-on: ${{ matrix.os }} diff --git a/Cargo.lock b/Cargo.lock index 5192d4d..8777f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.100" @@ -102,11 +152,11 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[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]] @@ -477,7 +527,7 @@ dependencies = [ "crc32fast", "futures-lite 2.6.1", "pin-project", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -537,7 +587,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.17", + "thiserror 2.0.18", "v_frame", "y4m", ] @@ -567,9 +617,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", "zeroize", @@ -577,9 +627,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" dependencies = [ "cc", "cmake", @@ -597,7 +647,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object 0.37.3", + "object", "rustc-demangle", "windows-link 0.2.1", ] @@ -934,9 +984,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.52" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "jobserver", @@ -1034,6 +1084,7 @@ dependencies = [ "anyhow", "chat", "common", + "dock", "emojis", "gpui", "gpui_tokio", @@ -1056,9 +1107,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 +1147,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +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 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "cmake" version = "0.1.57" @@ -1179,7 +1270,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1191,6 +1282,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 +1305,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]] @@ -1295,12 +1392,12 @@ dependencies = [ "chat_ui", "common", "device", + "dock", "futures", "gpui", "gpui_tokio", "indexset", "itertools 0.13.0", - "key_store", "log", "nostr-connect", "nostr-sdk", @@ -1315,10 +1412,9 @@ dependencies = [ "smol", "state", "theme", - "title_bar", + "titlebar", "tracing-subscriber", "ui", - "webbrowser", ] [[package]] @@ -1373,6 +1469,19 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core-graphics-helmer-fork" version = "0.24.0" @@ -1423,14 +1532,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]] @@ -1607,7 +1715,7 @@ dependencies = [ [[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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "proc-macro2", "quote", @@ -1729,6 +1837,20 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "dock" +version = "0.3.0" +dependencies = [ + "anyhow", + "common", + "gpui", + "linicon", + "log", + "smallvec", + "theme", + "ui", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1907,9 +2029,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", ] @@ -2022,21 +2144,20 @@ dependencies = [ [[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.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "flatbuffers" @@ -2517,7 +2638,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2592,7 +2713,7 @@ dependencies = [ "sum_tree", "swash", "taffy", - "thiserror 2.0.17", + "thiserror 2.0.18", "usvg", "util", "util_macros", @@ -2619,7 +2740,7 @@ dependencies = [ [[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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2630,7 +2751,7 @@ dependencies = [ [[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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "anyhow", "gpui", @@ -2852,7 +2973,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "anyhow", "async-compression", @@ -2877,7 +2998,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2972,9 +3093,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", @@ -3116,8 +3237,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.0", - "zune-jpeg 0.5.8", + "zune-core 0.5.1", + "zune-jpeg 0.5.11", ] [[package]] @@ -3249,6 +3370,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -3307,30 +3434,14 @@ 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", ] -[[package]] -name = "key_store" -version = "0.3.0" -dependencies = [ - "anyhow", - "common", - "futures", - "gpui", - "log", - "nostr-sdk", - "serde", - "serde_json", - "smallvec", - "smol", -] - [[package]] name = "khronos-egl" version = "6.0.0" @@ -3419,9 +3530,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" @@ -3638,7 +3749,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "anyhow", "bindgen", @@ -3778,7 +3889,7 @@ dependencies = [ "rustc-hash 1.1.0", "spirv", "strum 0.26.3", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-ident", ] @@ -3878,7 +3989,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#07daf26c737a712b74c8edb9a9697929f4de8b21" dependencies = [ "aes", "base64", @@ -3903,11 +4014,11 @@ 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#07daf26c737a712b74c8edb9a9697929f4de8b21" dependencies = [ "async-utility", "nostr", - "nostr-relay-pool", + "nostr-sdk", "tokio", "tracing", ] @@ -3915,7 +4026,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#07daf26c737a712b74c8edb9a9697929f4de8b21" dependencies = [ "btreecap", "flatbuffers", @@ -3927,7 +4038,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#07daf26c737a712b74c8edb9a9697929f4de8b21" dependencies = [ "nostr", ] @@ -3935,7 +4046,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#07daf26c737a712b74c8edb9a9697929f4de8b21" dependencies = [ "async-utility", "flume", @@ -3947,32 +4058,20 @@ 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#07daf26c737a712b74c8edb9a9697929f4de8b21" 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", "tracing", ] @@ -4234,15 +4333,6 @@ dependencies = [ "objc", ] -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", -] - [[package]] name = "object" version = "0.37.3" @@ -4259,10 +4349,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" @@ -4350,9 +4446,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-probe" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" @@ -4498,7 +4594,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "collections", "serde", @@ -4520,6 +4616,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" @@ -4752,9 +4862,9 @@ dependencies = [ [[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", ] @@ -4780,9 +4890,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" dependencies = [ "ar_archive_writer", "cc", @@ -4853,7 +4963,7 @@ dependencies = [ "rustc-hash 2.1.1", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -4874,7 +4984,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4896,9 +5006,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", ] @@ -5004,7 +5114,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", ] @@ -5133,7 +5243,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "derive_refineable", ] @@ -5232,7 +5342,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "anyhow", "bytes", @@ -5286,7 +5396,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "arrayvec", "log", @@ -5306,9 +5416,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,9 +5427,9 @@ 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", @@ -5330,9 +5440,9 @@ dependencies = [ [[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 +5461,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" @@ -5424,7 +5534,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.0", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -5441,9 +5551,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", @@ -5478,9 +5588,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", @@ -5565,7 +5675,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "async-task", "backtrace", @@ -5938,9 +6048,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" @@ -5998,9 +6108,9 @@ 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", @@ -6081,11 +6191,18 @@ dependencies = [ "common", "flume", "gpui", + "gpui_tokio", "log", + "nostr-connect", "nostr-lmdb", "nostr-sdk", + "petname", + "reqwest", "rustls", + "serde", + "serde_json", "smol", + "webbrowser", ] [[package]] @@ -6103,6 +6220,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.26.3" @@ -6155,7 +6278,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "arrayvec", "log", @@ -6452,11 +6575,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]] @@ -6472,9 +6595,9 @@ dependencies = [ [[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", @@ -6565,7 +6688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] -name = "title_bar" +name = "titlebar" version = "0.3.0" dependencies = [ "anyhow", @@ -6573,9 +6696,7 @@ dependencies = [ "gpui", "linicon", "log", - "nostr-sdk", "smallvec", - "smol", "theme", "ui", "windows 0.61.3", @@ -6905,7 +7026,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.18", "utf-8", ] @@ -7110,10 +7231,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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "anyhow", "async-fs", @@ -7130,6 +7257,7 @@ dependencies = [ "log", "mach2", "nix 0.29.0", + "percent-encoding", "regex", "rust-embed", "schemars", @@ -7142,6 +7270,7 @@ dependencies = [ "tempfile", "tendril", "unicase", + "url", "walkdir", "which", ] @@ -7149,7 +7278,7 @@ 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#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "perf", "quote", @@ -7158,9 +7287,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -7287,9 +7416,9 @@ 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", ] @@ -7302,9 +7431,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 +7444,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 +7458,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,9 +7468,9 @@ 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", @@ -7351,9 +7481,9 @@ dependencies = [ [[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", ] @@ -7471,9 +7601,9 @@ dependencies = [ [[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", @@ -7632,7 +7762,7 @@ checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ "parking_lot", "rayon", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows 0.61.3", "windows-future", ] @@ -8190,9 +8320,9 @@ 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" [[package]] name = "writeable" @@ -8355,9 +8485,9 @@ dependencies = [ [[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,9 +8520,9 @@ 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", @@ -8529,18 +8659,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "fdea86ddd5568519879b8187e1cf04e24fce28f7fe046ceecbce472ff19a2572" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "0c15e1b46eff7c6c91195752e0eeed8ef040e391cdece7c25376957d5f15df22" dependencies = [ "proc-macro2", "quote", @@ -8624,7 +8754,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "anyhow", "chrono", @@ -8634,14 +8764,14 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.14" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#acfc71a42304a19d1c0b5753d3513c0ec0fa1547" +source = "git+https://github.com/zed-industries/zed#1870425b2b262e3f28c90931782aba435afd5b99" dependencies = [ "tracing", "tracing-subscriber", @@ -8652,7 +8782,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#1870425b2b262e3f28c90931782aba435afd5b99" [[package]] name = "zune-core" @@ -8662,9 +8792,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 +8816,18 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.8" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" 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,9 +8840,9 @@ 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", 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/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/assets/themes/.keep b/assets/themes/.keep new file mode 100644 index 0000000..e69de29 diff --git a/assets/themes/catppuccin-frappe.json b/assets/themes/catppuccin-frappe.json deleted file mode 100644 index b9b1f49..0000000 --- a/assets/themes/catppuccin-frappe.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "catppuccin-frappe", - "name": "Catppuccin Frappé", - "author": "Catppuccin", - "url": "https://github.com/catppuccin/catppuccin", - "light": { - "background": "#303446", - "surface_background": "#292c3c", - "elevated_surface_background": "#232634", - "panel_background": "#303446", - "overlay": "#c6d0f51a", - "title_bar": "#00000000", - "title_bar_inactive": "#303446", - "window_border": "#626880", - "border": "#626880", - "border_variant": "#51576d", - "border_focused": "#8caaee", - "border_selected": "#8caaee", - "border_transparent": "#00000000", - "border_disabled": "#414559", - "ring": "#8caaee", - "text": "#c6d0f5", - "text_muted": "#b5bfe2", - "text_placeholder": "#a5adce", - "text_accent": "#8caaee", - "icon": "#c6d0f5", - "icon_muted": "#b5bfe2", - "icon_accent": "#8caaee", - "element_foreground": "#303446", - "element_background": "#8caaee", - "element_hover": "#8caaeee6", - "element_active": "#7e99d6", - "element_selected": "#7088be", - "element_disabled": "#8caaee4d", - "secondary_foreground": "#8caaee", - "secondary_background": "#414559", - "secondary_hover": "#8caaee1a", - "secondary_active": "#51576d", - "secondary_selected": "#51576d", - "secondary_disabled": "#8caaee4d", - "danger_foreground": "#303446", - "danger_background": "#e78284", - "danger_hover": "#e78284e6", - "danger_active": "#d07576", - "danger_selected": "#b96869", - "danger_disabled": "#e782844d", - "warning_foreground": "#303446", - "warning_background": "#e5c890", - "warning_hover": "#e5c890e6", - "warning_active": "#ceb481", - "warning_selected": "#b7a072", - "warning_disabled": "#e5c8904d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#414559", - "ghost_element_hover": "#c6d0f51a", - "ghost_element_active": "#51576d", - "ghost_element_selected": "#51576d", - "ghost_element_disabled": "#c6d0f50d", - "tab_inactive_background": "#414559", - "tab_hover_background": "#51576d", - "tab_active_background": "#626880", - "scrollbar_thumb_background": "#c6d0f533", - "scrollbar_thumb_hover_background": "#c6d0f54d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#51576d", - "drop_target_background": "#8caaee1a", - "cursor": "#99d1db", - "selection": "#99d1db40" - }, - "dark": { - "background": "#303446", - "surface_background": "#292c3c", - "elevated_surface_background": "#232634", - "panel_background": "#303446", - "overlay": "#c6d0f51a", - "title_bar": "#00000000", - "title_bar_inactive": "#303446", - "window_border": "#626880", - "border": "#626880", - "border_variant": "#51576d", - "border_focused": "#8caaee", - "border_selected": "#8caaee", - "border_transparent": "#00000000", - "border_disabled": "#414559", - "ring": "#8caaee", - "text": "#c6d0f5", - "text_muted": "#b5bfe2", - "text_placeholder": "#a5adce", - "text_accent": "#8caaee", - "icon": "#c6d0f5", - "icon_muted": "#b5bfe2", - "icon_accent": "#8caaee", - "element_foreground": "#303446", - "element_background": "#8caaee", - "element_hover": "#8caaeee6", - "element_active": "#7e99d6", - "element_selected": "#7088be", - "element_disabled": "#8caaee4d", - "secondary_foreground": "#8caaee", - "secondary_background": "#414559", - "secondary_hover": "#8caaee1a", - "secondary_active": "#51576d", - "secondary_selected": "#51576d", - "secondary_disabled": "#8caaee4d", - "danger_foreground": "#303446", - "danger_background": "#e78284", - "danger_hover": "#e78284e6", - "danger_active": "#d07576", - "danger_selected": "#b96869", - "danger_disabled": "#e782844d", - "warning_foreground": "#303446", - "warning_background": "#e5c890", - "warning_hover": "#e5c890e6", - "warning_active": "#ceb481", - "warning_selected": "#b7a072", - "warning_disabled": "#e5c8904d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#414559", - "ghost_element_hover": "#c6d0f51a", - "ghost_element_active": "#51576d", - "ghost_element_selected": "#51576d", - "ghost_element_disabled": "#c6d0f50d", - "tab_inactive_background": "#414559", - "tab_hover_background": "#51576d", - "tab_active_background": "#626880", - "scrollbar_thumb_background": "#c6d0f533", - "scrollbar_thumb_hover_background": "#c6d0f54d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#51576d", - "drop_target_background": "#8caaee1a", - "cursor": "#99d1db", - "selection": "#99d1db40" - } -} diff --git a/assets/themes/catppuccin-latte.json b/assets/themes/catppuccin-latte.json deleted file mode 100644 index b0c074b..0000000 --- a/assets/themes/catppuccin-latte.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "catppuccin-latte", - "name": "Catppuccin Latte", - "author": "Catppuccin", - "url": "https://github.com/catppuccin/catppuccin", - "light": { - "background": "#eff1f5", - "surface_background": "#e6e9ef", - "elevated_surface_background": "#dce0e8", - "panel_background": "#eff1f5", - "overlay": "#4c4f691a", - "title_bar": "#00000000", - "title_bar_inactive": "#eff1f5", - "window_border": "#acb0be", - "border": "#acb0be", - "border_variant": "#bcc0cc", - "border_focused": "#1e66f5", - "border_selected": "#1e66f5", - "border_transparent": "#00000000", - "border_disabled": "#ccd0da", - "ring": "#1e66f5", - "text": "#4c4f69", - "text_muted": "#5c5f77", - "text_placeholder": "#6c6f85", - "text_accent": "#1e66f5", - "icon": "#4c4f69", - "icon_muted": "#5c5f77", - "icon_accent": "#1e66f5", - "element_foreground": "#eff1f5", - "element_background": "#1e66f5", - "element_hover": "#1e66f5e6", - "element_active": "#1b5cdc", - "element_selected": "#1852c3", - "element_disabled": "#1e66f54d", - "secondary_foreground": "#1e66f5", - "secondary_background": "#e6e9ef", - "secondary_hover": "#1e66f51a", - "secondary_active": "#dce0e8", - "secondary_selected": "#dce0e8", - "secondary_disabled": "#1e66f54d", - "danger_foreground": "#eff1f5", - "danger_background": "#d20f39", - "danger_hover": "#d20f39e6", - "danger_active": "#bc0e33", - "danger_selected": "#a60c2d", - "danger_disabled": "#d20f394d", - "warning_foreground": "#4c4f69", - "warning_background": "#df8e1d", - "warning_hover": "#df8e1de6", - "warning_active": "#c9801a", - "warning_selected": "#b47217", - "warning_disabled": "#df8e1d4d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#e6e9ef", - "ghost_element_hover": "#4c4f691a", - "ghost_element_active": "#dce0e8", - "ghost_element_selected": "#dce0e8", - "ghost_element_disabled": "#4c4f690d", - "tab_inactive_background": "#e6e9ef", - "tab_hover_background": "#dce0e8", - "tab_active_background": "#ccd0da", - "scrollbar_thumb_background": "#4c4f6933", - "scrollbar_thumb_hover_background": "#4c4f694d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#dce0e8", - "drop_target_background": "#1e66f51a", - "cursor": "#04a5e5", - "selection": "#04a5e540" - }, - "dark": { - "background": "#eff1f5", - "surface_background": "#e6e9ef", - "elevated_surface_background": "#dce0e8", - "panel_background": "#eff1f5", - "overlay": "#4c4f691a", - "title_bar": "#00000000", - "title_bar_inactive": "#eff1f5", - "window_border": "#acb0be", - "border": "#acb0be", - "border_variant": "#bcc0cc", - "border_focused": "#1e66f5", - "border_selected": "#1e66f5", - "border_transparent": "#00000000", - "border_disabled": "#ccd0da", - "ring": "#1e66f5", - "text": "#4c4f69", - "text_muted": "#5c5f77", - "text_placeholder": "#6c6f85", - "text_accent": "#1e66f5", - "icon": "#4c4f69", - "icon_muted": "#5c5f77", - "icon_accent": "#1e66f5", - "element_foreground": "#eff1f5", - "element_background": "#1e66f5", - "element_hover": "#1e66f5e6", - "element_active": "#1b5cdc", - "element_selected": "#1852c3", - "element_disabled": "#1e66f54d", - "secondary_foreground": "#1e66f5", - "secondary_background": "#e6e9ef", - "secondary_hover": "#1e66f51a", - "secondary_active": "#dce0e8", - "secondary_selected": "#dce0e8", - "secondary_disabled": "#1e66f54d", - "danger_foreground": "#eff1f5", - "danger_background": "#d20f39", - "danger_hover": "#d20f39e6", - "danger_active": "#bc0e33", - "danger_selected": "#a60c2d", - "danger_disabled": "#d20f394d", - "warning_foreground": "#4c4f69", - "warning_background": "#df8e1d", - "warning_hover": "#df8e1de6", - "warning_active": "#c9801a", - "warning_selected": "#b47217", - "warning_disabled": "#df8e1d4d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#e6e9ef", - "ghost_element_hover": "#4c4f691a", - "ghost_element_active": "#dce0e8", - "ghost_element_selected": "#dce0e8", - "ghost_element_disabled": "#4c4f690d", - "tab_inactive_background": "#e6e9ef", - "tab_hover_background": "#dce0e8", - "tab_active_background": "#ccd0da", - "scrollbar_thumb_background": "#4c4f6933", - "scrollbar_thumb_hover_background": "#4c4f694d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#dce0e8", - "drop_target_background": "#1e66f51a", - "cursor": "#04a5e5", - "selection": "#04a5e540" - } -} diff --git a/assets/themes/catppuccin-macchiato.json b/assets/themes/catppuccin-macchiato.json deleted file mode 100644 index 09cb345..0000000 --- a/assets/themes/catppuccin-macchiato.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "catppuccin-macchiato", - "name": "Catppuccin Macchiato", - "author": "Catppuccin", - "url": "https://github.com/catppuccin/catppuccin", - "light": { - "background": "#24273a", - "surface_background": "#1e2030", - "elevated_surface_background": "#181926", - "panel_background": "#24273a", - "overlay": "#cad3f51a", - "title_bar": "#00000000", - "title_bar_inactive": "#24273a", - "window_border": "#5b6078", - "border": "#5b6078", - "border_variant": "#494d64", - "border_focused": "#8aadf4", - "border_selected": "#8aadf4", - "border_transparent": "#00000000", - "border_disabled": "#363a4f", - "ring": "#8aadf4", - "text": "#cad3f5", - "text_muted": "#b8c0e0", - "text_placeholder": "#a5adcb", - "text_accent": "#8aadf4", - "icon": "#cad3f5", - "icon_muted": "#b8c0e0", - "icon_accent": "#8aadf4", - "element_foreground": "#24273a", - "element_background": "#8aadf4", - "element_hover": "#8aadf4e6", - "element_active": "#7c9cdc", - "element_selected": "#6e8bc4", - "element_disabled": "#8aadf44d", - "secondary_foreground": "#8aadf4", - "secondary_background": "#363a4f", - "secondary_hover": "#8aadf41a", - "secondary_active": "#494d64", - "secondary_selected": "#494d64", - "secondary_disabled": "#8aadf44d", - "danger_foreground": "#24273a", - "danger_background": "#ed8796", - "danger_hover": "#ed8796e6", - "danger_active": "#d57a87", - "danger_selected": "#bd6d78", - "danger_disabled": "#ed87964d", - "warning_foreground": "#24273a", - "warning_background": "#eed49f", - "warning_hover": "#eed49fe6", - "warning_active": "#d6bf8f", - "warning_selected": "#beaa7f", - "warning_disabled": "#eed49f4d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#363a4f", - "ghost_element_hover": "#cad3f51a", - "ghost_element_active": "#494d64", - "ghost_element_selected": "#494d64", - "ghost_element_disabled": "#cad3f50d", - "tab_inactive_background": "#363a4f", - "tab_hover_background": "#494d64", - "tab_active_background": "#5b6078", - "scrollbar_thumb_background": "#cad3f533", - "scrollbar_thumb_hover_background": "#cad3f54d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#494d64", - "drop_target_background": "#8aadf41a", - "cursor": "#91d7e3", - "selection": "#91d7e340" - }, - "dark": { - "background": "#24273a", - "surface_background": "#1e2030", - "elevated_surface_background": "#181926", - "panel_background": "#24273a", - "overlay": "#cad3f51a", - "title_bar": "#00000000", - "title_bar_inactive": "#24273a", - "window_border": "#5b6078", - "border": "#5b6078", - "border_variant": "#494d64", - "border_focused": "#8aadf4", - "border_selected": "#8aadf4", - "border_transparent": "#00000000", - "border_disabled": "#363a4f", - "ring": "#8aadf4", - "text": "#cad3f5", - "text_muted": "#b8c0e0", - "text_placeholder": "#a5adcb", - "text_accent": "#8aadf4", - "icon": "#cad3f5", - "icon_muted": "#b8c0e0", - "icon_accent": "#8aadf4", - "element_foreground": "#24273a", - "element_background": "#8aadf4", - "element_hover": "#8aadf4e6", - "element_active": "#7c9cdc", - "element_selected": "#6e8bc4", - "element_disabled": "#8aadf44d", - "secondary_foreground": "#8aadf4", - "secondary_background": "#363a4f", - "secondary_hover": "#8aadf41a", - "secondary_active": "#494d64", - "secondary_selected": "#494d64", - "secondary_disabled": "#8aadf44d", - "danger_foreground": "#24273a", - "danger_background": "#ed8796", - "danger_hover": "#ed8796e6", - "danger_active": "#d57a87", - "danger_selected": "#bd6d78", - "danger_disabled": "#ed87964d", - "warning_foreground": "#24273a", - "warning_background": "#eed49f", - "warning_hover": "#eed49fe6", - "warning_active": "#d6bf8f", - "warning_selected": "#beaa7f", - "warning_disabled": "#eed49f4d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#363a4f", - "ghost_element_hover": "#cad3f51a", - "ghost_element_active": "#494d64", - "ghost_element_selected": "#494d64", - "ghost_element_disabled": "#cad3f50d", - "tab_inactive_background": "#363a4f", - "tab_hover_background": "#494d64", - "tab_active_background": "#5b6078", - "scrollbar_thumb_background": "#cad3f533", - "scrollbar_thumb_hover_background": "#cad3f54d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#494d64", - "drop_target_background": "#8aadf41a", - "cursor": "#91d7e3", - "selection": "#91d7e340" - } -} diff --git a/assets/themes/catppuccin-mocha.json b/assets/themes/catppuccin-mocha.json deleted file mode 100644 index 292051d..0000000 --- a/assets/themes/catppuccin-mocha.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "catppuccin-mocha", - "name": "Catppuccin Mocha", - "author": "Catppuccin", - "url": "https://github.com/catppuccin/catppuccin", - "light": { - "background": "#1e1e2e", - "surface_background": "#181825", - "elevated_surface_background": "#11111b", - "panel_background": "#1e1e2e", - "overlay": "#cdd6f41a", - "title_bar": "#00000000", - "title_bar_inactive": "#1e1e2e", - "window_border": "#585b70", - "border": "#585b70", - "border_variant": "#45475a", - "border_focused": "#89b4fa", - "border_selected": "#89b4fa", - "border_transparent": "#00000000", - "border_disabled": "#313244", - "ring": "#89b4fa", - "text": "#cdd6f4", - "text_muted": "#bac2de", - "text_placeholder": "#a6adc8", - "text_accent": "#89b4fa", - "icon": "#cdd6f4", - "icon_muted": "#bac2de", - "icon_accent": "#89b4fa", - "element_foreground": "#1e1e2e", - "element_background": "#89b4fa", - "element_hover": "#89b4fae6", - "element_active": "#7ba2e1", - "element_selected": "#6d90c8", - "element_disabled": "#89b4fa4d", - "secondary_foreground": "#89b4fa", - "secondary_background": "#313244", - "secondary_hover": "#89b4fa1a", - "secondary_active": "#45475a", - "secondary_selected": "#45475a", - "secondary_disabled": "#89b4fa4d", - "danger_foreground": "#1e1e2e", - "danger_background": "#f38ba8", - "danger_hover": "#f38ba8e6", - "danger_active": "#db7d97", - "danger_selected": "#c36f86", - "danger_disabled": "#f38ba84d", - "warning_foreground": "#1e1e2e", - "warning_background": "#f9e2af", - "warning_hover": "#f9e2afe6", - "warning_active": "#e0cb9e", - "warning_selected": "#c7b48d", - "warning_disabled": "#f9e2af4d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#313244", - "ghost_element_hover": "#cdd6f41a", - "ghost_element_active": "#45475a", - "ghost_element_selected": "#45475a", - "ghost_element_disabled": "#cdd6f50d", - "tab_inactive_background": "#313244", - "tab_hover_background": "#45475a", - "tab_active_background": "#585b70", - "scrollbar_thumb_background": "#cdd6f533", - "scrollbar_thumb_hover_background": "#cdd6f54d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#45475a", - "drop_target_background": "#89b4fa1a", - "cursor": "#89dceb", - "selection": "#89dceb40" - }, - "dark": { - "background": "#1e1e2e", - "surface_background": "#181825", - "elevated_surface_background": "#11111b", - "panel_background": "#1e1e2e", - "overlay": "#cdd6f41a", - "title_bar": "#00000000", - "title_bar_inactive": "#1e1e2e", - "window_border": "#585b70", - "border": "#585b70", - "border_variant": "#45475a", - "border_focused": "#89b4fa", - "border_selected": "#89b4fa", - "border_transparent": "#00000000", - "border_disabled": "#313244", - "ring": "#89b4fa", - "text": "#cdd6f4", - "text_muted": "#bac2de", - "text_placeholder": "#a6adc8", - "text_accent": "#89b4fa", - "icon": "#cdd6f4", - "icon_muted": "#bac2de", - "icon_accent": "#89b4fa", - "element_foreground": "#1e1e2e", - "element_background": "#89b4fa", - "element_hover": "#89b4fae6", - "element_active": "#7ba2e1", - "element_selected": "#6d90c8", - "element_disabled": "#89b4fa4d", - "secondary_foreground": "#89b4fa", - "secondary_background": "#313244", - "secondary_hover": "#89b4fa1a", - "secondary_active": "#45475a", - "secondary_selected": "#45475a", - "secondary_disabled": "#89b4fa4d", - "danger_foreground": "#1e1e2e", - "danger_background": "#f38ba8", - "danger_hover": "#f38ba8e6", - "danger_active": "#db7d97", - "danger_selected": "#c36f86", - "danger_disabled": "#f38ba84d", - "warning_foreground": "#1e1e2e", - "warning_background": "#f9e2af", - "warning_hover": "#f9e2afe6", - "warning_active": "#e0cb9e", - "warning_selected": "#c7b48d", - "warning_disabled": "#f9e2af4d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#313244", - "ghost_element_hover": "#cdd6f41a", - "ghost_element_active": "#45475a", - "ghost_element_selected": "#45475a", - "ghost_element_disabled": "#cdd6f50d", - "tab_inactive_background": "#313244", - "tab_hover_background": "#45475a", - "tab_active_background": "#585b70", - "scrollbar_thumb_background": "#cdd6f533", - "scrollbar_thumb_hover_background": "#cdd6f54d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#45475a", - "drop_target_background": "#89b4fa1a", - "cursor": "#89dceb", - "selection": "#89dceb40" - } -} diff --git a/assets/themes/flexoki.json b/assets/themes/flexoki.json deleted file mode 100644 index 374c889..0000000 --- a/assets/themes/flexoki.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "flexoki", - "name": "Flexoki", - "author": "Steph Ango", - "url": "https://stephango.com/flexoki", - "light": { - "background": "#FFFCF0", - "surface_background": "#F2F0E5", - "elevated_surface_background": "#E6E4D9", - "panel_background": "#FFFCF0", - "overlay": "#100F0F1a", - "title_bar": "#00000000", - "title_bar_inactive": "#FFFCF0", - "window_border": "#CECDC3", - "border": "#CECDC3", - "border_variant": "#DAD8CE", - "border_focused": "#24837B", - "border_selected": "#24837B", - "border_transparent": "#00000000", - "border_disabled": "#E6E4D9", - "ring": "#24837B", - "text": "#100F0F", - "text_muted": "#6F6E69", - "text_placeholder": "#878580", - "text_accent": "#24837B", - "icon": "#100F0F", - "icon_muted": "#6F6E69", - "icon_accent": "#24837B", - "element_foreground": "#DDF1E4", - "element_background": "#24837B", - "element_hover": "#24837Be5", - "element_active": "#20756E", - "element_selected": "#1C6861", - "element_disabled": "#24837B4c", - "secondary_foreground": "#24837B", - "secondary_background": "#E6E4D9", - "secondary_hover": "#24837B1a", - "secondary_active": "#DAD8CE", - "secondary_selected": "#DAD8CE", - "secondary_disabled": "#24837B4c", - "danger_foreground": "#FFE1D5", - "danger_background": "#AF3029", - "danger_hover": "#AF3029e5", - "danger_active": "#9E2B25", - "danger_selected": "#8D2620", - "danger_disabled": "#AF30294c", - "warning_foreground": "#FFE7CE", - "warning_background": "#BC5215", - "warning_hover": "#BC5215e5", - "warning_active": "#A94913", - "warning_selected": "#964011", - "warning_disabled": "#BC52154c", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#E6E4D9", - "ghost_element_hover": "#100F0F1a", - "ghost_element_active": "#DAD8CE", - "ghost_element_selected": "#DAD8CE", - "ghost_element_disabled": "#100F0F0d", - "tab_inactive_background": "#E6E4D9", - "tab_hover_background": "#DAD8CE", - "tab_active_background": "#CECDC3", - "scrollbar_thumb_background": "#100F0F33", - "scrollbar_thumb_hover_background": "#100F0F4d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#DAD8CE", - "drop_target_background": "#24837B1a", - "cursor": "#205EA6", - "selection": "#24837B40" - }, - "dark": { - "background": "#100F0F", - "surface_background": "#1C1B1A", - "elevated_surface_background": "#282726", - "panel_background": "#100F0F", - "overlay": "#FFFCF01a", - "title_bar": "#00000000", - "title_bar_inactive": "#100F0F", - "window_border": "#403E3C", - "border": "#403E3C", - "border_variant": "#343331", - "border_focused": "#3AA99F", - "border_selected": "#3AA99F", - "border_transparent": "#00000000", - "border_disabled": "#282726", - "ring": "#3AA99F", - "text": "#FFFCF0", - "text_muted": "#878580", - "text_placeholder": "#575653", - "text_accent": "#3AA99F", - "icon": "#FFFCF0", - "icon_muted": "#878580", - "icon_accent": "#3AA99F", - "element_foreground": "#101F1D", - "element_background": "#3AA99F", - "element_hover": "#3AA99Fe5", - "element_active": "#34988F", - "element_selected": "#2F877F", - "element_disabled": "#3AA99F4c", - "secondary_foreground": "#3AA99F", - "secondary_background": "#282726", - "secondary_hover": "#3AA99F1a", - "secondary_active": "#343331", - "secondary_selected": "#343331", - "secondary_disabled": "#3AA99F4c", - "danger_foreground": "#261312", - "danger_background": "#D14D41", - "danger_hover": "#D14D41e5", - "danger_active": "#BC453A", - "danger_selected": "#A73D33", - "danger_disabled": "#D14D414c", - "warning_foreground": "#27180E", - "warning_background": "#DA702C", - "warning_hover": "#DA702Ce5", - "warning_active": "#C46527", - "warning_selected": "#AF5A22", - "warning_disabled": "#DA702C4c", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#282726", - "ghost_element_hover": "#FFFCF01a", - "ghost_element_active": "#343331", - "ghost_element_selected": "#343331", - "ghost_element_disabled": "#FFFCF00d", - "tab_inactive_background": "#282726", - "tab_hover_background": "#343331", - "tab_active_background": "#403E3C", - "scrollbar_thumb_background": "#FFFCF033", - "scrollbar_thumb_hover_background": "#FFFCF04d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#343331", - "drop_target_background": "#3AA99F1a", - "cursor": "#4385BE", - "selection": "#3AA99F40" - } -} diff --git a/assets/themes/rose-pine-dawn.json b/assets/themes/rose-pine-dawn.json deleted file mode 100644 index 533fb54..0000000 --- a/assets/themes/rose-pine-dawn.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "rose-pine-dawn", - "name": "Rosé Pine Dawn", - "author": "Rosé Pine", - "url": "https://rosepinetheme.com/", - "light": { - "background": "#faf4ed", - "surface_background": "#fffaf3", - "elevated_surface_background": "#f2e9e1", - "panel_background": "#fffaf3", - "overlay": "#5752791a", - "title_bar": "#00000000", - "title_bar_inactive": "#faf4ed", - "window_border": "#cecacd", - "border": "#cecacd", - "border_variant": "#dfdad9", - "border_focused": "#286983", - "border_selected": "#286983", - "border_transparent": "#00000000", - "border_disabled": "#f4ede8", - "ring": "#286983", - "text": "#575279", - "text_muted": "#797593", - "text_placeholder": "#9893a5", - "text_accent": "#907aa9", - "icon": "#575279", - "icon_muted": "#797593", - "icon_accent": "#907aa9", - "element_foreground": "#faf4ed", - "element_background": "#286983", - "element_hover": "#286983e6", - "element_active": "#245f76", - "element_selected": "#205569", - "element_disabled": "#2869834d", - "secondary_foreground": "#286983", - "secondary_background": "#f4ede8", - "secondary_hover": "#2869831a", - "secondary_active": "#dfdad9", - "secondary_selected": "#dfdad9", - "secondary_disabled": "#2869834d", - "danger_foreground": "#faf4ed", - "danger_background": "#b4637a", - "danger_hover": "#b4637ae6", - "danger_active": "#a2596e", - "danger_selected": "#904f62", - "danger_disabled": "#b4637a4d", - "warning_foreground": "#faf4ed", - "warning_background": "#ea9d34", - "warning_hover": "#ea9d34e6", - "warning_active": "#d38d2f", - "warning_selected": "#bc7d2a", - "warning_disabled": "#ea9d344d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#f4ede8", - "ghost_element_hover": "#5752791a", - "ghost_element_active": "#dfdad9", - "ghost_element_selected": "#dfdad9", - "ghost_element_disabled": "#5752790d", - "tab_inactive_background": "#f4ede8", - "tab_hover_background": "#dfdad9", - "tab_active_background": "#cecacd", - "scrollbar_thumb_background": "#57527933", - "scrollbar_thumb_hover_background": "#5752794d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#dfdad9", - "drop_target_background": "#2869831a", - "cursor": "#56949f", - "selection": "#56949f40" - }, - "dark": { - "background": "#faf4ed", - "surface_background": "#fffaf3", - "elevated_surface_background": "#f2e9e1", - "panel_background": "#fffaf3", - "overlay": "#5752791a", - "title_bar": "#00000000", - "title_bar_inactive": "#faf4ed", - "window_border": "#cecacd", - "border": "#cecacd", - "border_variant": "#dfdad9", - "border_focused": "#286983", - "border_selected": "#286983", - "border_transparent": "#00000000", - "border_disabled": "#f4ede8", - "ring": "#286983", - "text": "#575279", - "text_muted": "#797593", - "text_placeholder": "#9893a5", - "text_accent": "#907aa9", - "icon": "#575279", - "icon_muted": "#797593", - "icon_accent": "#907aa9", - "element_foreground": "#faf4ed", - "element_background": "#286983", - "element_hover": "#286983e6", - "element_active": "#245f76", - "element_selected": "#205569", - "element_disabled": "#2869834d", - "secondary_foreground": "#286983", - "secondary_background": "#f4ede8", - "secondary_hover": "#2869831a", - "secondary_active": "#dfdad9", - "secondary_selected": "#dfdad9", - "secondary_disabled": "#2869834d", - "danger_foreground": "#faf4ed", - "danger_background": "#b4637a", - "danger_hover": "#b4637ae6", - "danger_active": "#a2596e", - "danger_selected": "#904f62", - "danger_disabled": "#b4637a4d", - "warning_foreground": "#faf4ed", - "warning_background": "#ea9d34", - "warning_hover": "#ea9d34e6", - "warning_active": "#d38d2f", - "warning_selected": "#bc7d2a", - "warning_disabled": "#ea9d344d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#f4ede8", - "ghost_element_hover": "#5752791a", - "ghost_element_active": "#dfdad9", - "ghost_element_selected": "#dfdad9", - "ghost_element_disabled": "#5752790d", - "tab_inactive_background": "#f4ede8", - "tab_hover_background": "#dfdad9", - "tab_active_background": "#cecacd", - "scrollbar_thumb_background": "#57527933", - "scrollbar_thumb_hover_background": "#5752794d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#dfdad9", - "drop_target_background": "#2869831a", - "cursor": "#56949f", - "selection": "#56949f40" - } -} diff --git a/assets/themes/rose-pine-moon.json b/assets/themes/rose-pine-moon.json deleted file mode 100644 index 102299b..0000000 --- a/assets/themes/rose-pine-moon.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "rose-pine-moon", - "name": "Rosé Pine Moon", - "author": "Rosé Pine", - "url": "https://rosepinetheme.com/", - "light": { - "background": "#232136", - "surface_background": "#2a273f", - "elevated_surface_background": "#393552", - "panel_background": "#2a273f", - "overlay": "#e0def41a", - "title_bar": "#00000000", - "title_bar_inactive": "#232136", - "window_border": "#56526e", - "border": "#56526e", - "border_variant": "#44415a", - "border_focused": "#3e8fb0", - "border_selected": "#3e8fb0", - "border_transparent": "#00000000", - "border_disabled": "#2a283e", - "ring": "#3e8fb0", - "text": "#e0def4", - "text_muted": "#908caa", - "text_placeholder": "#6e6a86", - "text_accent": "#c4a7e7", - "icon": "#e0def4", - "icon_muted": "#908caa", - "icon_accent": "#c4a7e7", - "element_foreground": "#232136", - "element_background": "#3e8fb0", - "element_hover": "#3e8fb0e6", - "element_active": "#38809d", - "element_selected": "#32718a", - "element_disabled": "#3e8fb04d", - "secondary_foreground": "#3e8fb0", - "secondary_background": "#2a283e", - "secondary_hover": "#3e8fb01a", - "secondary_active": "#44415a", - "secondary_selected": "#44415a", - "secondary_disabled": "#3e8fb04d", - "danger_foreground": "#232136", - "danger_background": "#eb6f92", - "danger_hover": "#eb6f92e6", - "danger_active": "#d46483", - "danger_selected": "#bd5974", - "danger_disabled": "#eb6f924d", - "warning_foreground": "#232136", - "warning_background": "#f6c177", - "warning_hover": "#f6c177e6", - "warning_active": "#ddae6b", - "warning_selected": "#c49b5f", - "warning_disabled": "#f6c1774d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#2a283e", - "ghost_element_hover": "#e0def41a", - "ghost_element_active": "#44415a", - "ghost_element_selected": "#44415a", - "ghost_element_disabled": "#e0def40d", - "tab_inactive_background": "#2a283e", - "tab_hover_background": "#44415a", - "tab_active_background": "#56526e", - "scrollbar_thumb_background": "#e0def433", - "scrollbar_thumb_hover_background": "#e0def44d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#44415a", - "drop_target_background": "#3e8fb01a", - "cursor": "#9ccfd8", - "selection": "#9ccfd840" - }, - "dark": { - "background": "#232136", - "surface_background": "#2a273f", - "elevated_surface_background": "#393552", - "panel_background": "#2a273f", - "overlay": "#e0def41a", - "title_bar": "#00000000", - "title_bar_inactive": "#232136", - "window_border": "#56526e", - "border": "#56526e", - "border_variant": "#44415a", - "border_focused": "#3e8fb0", - "border_selected": "#3e8fb0", - "border_transparent": "#00000000", - "border_disabled": "#2a283e", - "ring": "#3e8fb0", - "text": "#e0def4", - "text_muted": "#908caa", - "text_placeholder": "#6e6a86", - "text_accent": "#c4a7e7", - "icon": "#e0def4", - "icon_muted": "#908caa", - "icon_accent": "#c4a7e7", - "element_foreground": "#232136", - "element_background": "#3e8fb0", - "element_hover": "#3e8fb0e6", - "element_active": "#38809d", - "element_selected": "#32718a", - "element_disabled": "#3e8fb04d", - "secondary_foreground": "#3e8fb0", - "secondary_background": "#2a283e", - "secondary_hover": "#3e8fb01a", - "secondary_active": "#44415a", - "secondary_selected": "#44415a", - "secondary_disabled": "#3e8fb04d", - "danger_foreground": "#232136", - "danger_background": "#eb6f92", - "danger_hover": "#eb6f92e6", - "danger_active": "#d46483", - "danger_selected": "#bd5974", - "danger_disabled": "#eb6f924d", - "warning_foreground": "#232136", - "warning_background": "#f6c177", - "warning_hover": "#f6c177e6", - "warning_active": "#ddae6b", - "warning_selected": "#c49b5f", - "warning_disabled": "#f6c1774d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#2a283e", - "ghost_element_hover": "#e0def41a", - "ghost_element_active": "#44415a", - "ghost_element_selected": "#44415a", - "ghost_element_disabled": "#e0def40d", - "tab_inactive_background": "#2a283e", - "tab_hover_background": "#44415a", - "tab_active_background": "#56526e", - "scrollbar_thumb_background": "#e0def433", - "scrollbar_thumb_hover_background": "#e0def44d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#44415a", - "drop_target_background": "#3e8fb01a", - "cursor": "#9ccfd8", - "selection": "#9ccfd840" - } -} diff --git a/assets/themes/rose-pine.json b/assets/themes/rose-pine.json deleted file mode 100644 index 7715730..0000000 --- a/assets/themes/rose-pine.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "id": "rose-pine", - "name": "Rosé Pine", - "author": "Rosé Pine", - "url": "https://rosepinetheme.com/", - "light": { - "background": "#191724", - "surface_background": "#1f1d2e", - "elevated_surface_background": "#26233a", - "panel_background": "#1f1d2e", - "overlay": "#e0def41a", - "title_bar": "#00000000", - "title_bar_inactive": "#191724", - "window_border": "#524f67", - "border": "#524f67", - "border_variant": "#403d52", - "border_focused": "#31748f", - "border_selected": "#31748f", - "border_transparent": "#00000000", - "border_disabled": "#21202e", - "ring": "#31748f", - "text": "#e0def4", - "text_muted": "#908caa", - "text_placeholder": "#6e6a86", - "text_accent": "#c4a7e7", - "icon": "#e0def4", - "icon_muted": "#908caa", - "icon_accent": "#c4a7e7", - "element_foreground": "#191724", - "element_background": "#31748f", - "element_hover": "#31748fe6", - "element_active": "#2c6980", - "element_selected": "#275e71", - "element_disabled": "#31748f4d", - "secondary_foreground": "#31748f", - "secondary_background": "#21202e", - "secondary_hover": "#31748f1a", - "secondary_active": "#403d52", - "secondary_selected": "#403d52", - "secondary_disabled": "#31748f4d", - "danger_foreground": "#191724", - "danger_background": "#eb6f92", - "danger_hover": "#eb6f92e6", - "danger_active": "#d46483", - "danger_selected": "#bd5974", - "danger_disabled": "#eb6f924d", - "warning_foreground": "#191724", - "warning_background": "#f6c177", - "warning_hover": "#f6c177e6", - "warning_active": "#ddae6b", - "warning_selected": "#c49b5f", - "warning_disabled": "#f6c1774d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#21202e", - "ghost_element_hover": "#e0def41a", - "ghost_element_active": "#403d52", - "ghost_element_selected": "#403d52", - "ghost_element_disabled": "#e0def40d", - "tab_inactive_background": "#21202e", - "tab_hover_background": "#403d52", - "tab_active_background": "#524f67", - "scrollbar_thumb_background": "#e0def433", - "scrollbar_thumb_hover_background": "#e0def44d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#403d52", - "drop_target_background": "#31748f1a", - "cursor": "#9ccfd8", - "selection": "#9ccfd840" - }, - "dark": { - "background": "#191724", - "surface_background": "#1f1d2e", - "elevated_surface_background": "#26233a", - "panel_background": "#1f1d2e", - "overlay": "#e0def41a", - "title_bar": "#00000000", - "title_bar_inactive": "#191724", - "window_border": "#524f67", - "border": "#524f67", - "border_variant": "#403d52", - "border_focused": "#31748f", - "border_selected": "#31748f", - "border_transparent": "#00000000", - "border_disabled": "#21202e", - "ring": "#31748f", - "text": "#e0def4", - "text_muted": "#908caa", - "text_placeholder": "#6e6a86", - "text_accent": "#c4a7e7", - "icon": "#e0def4", - "icon_muted": "#908caa", - "icon_accent": "#c4a7e7", - "element_foreground": "#191724", - "element_background": "#31748f", - "element_hover": "#31748fe6", - "element_active": "#2c6980", - "element_selected": "#275e71", - "element_disabled": "#31748f4d", - "secondary_foreground": "#31748f", - "secondary_background": "#21202e", - "secondary_hover": "#31748f1a", - "secondary_active": "#403d52", - "secondary_selected": "#403d52", - "secondary_disabled": "#31748f4d", - "danger_foreground": "#191724", - "danger_background": "#eb6f92", - "danger_hover": "#eb6f92e6", - "danger_active": "#d46483", - "danger_selected": "#bd5974", - "danger_disabled": "#eb6f924d", - "warning_foreground": "#191724", - "warning_background": "#f6c177", - "warning_hover": "#f6c177e6", - "warning_active": "#ddae6b", - "warning_selected": "#c49b5f", - "warning_disabled": "#f6c1774d", - "ghost_element_background": "#00000000", - "ghost_element_background_alt": "#21202e", - "ghost_element_hover": "#e0def41a", - "ghost_element_active": "#403d52", - "ghost_element_selected": "#403d52", - "ghost_element_disabled": "#e0def40d", - "tab_inactive_background": "#21202e", - "tab_hover_background": "#403d52", - "tab_active_background": "#524f67", - "scrollbar_thumb_background": "#e0def433", - "scrollbar_thumb_hover_background": "#e0def44d", - "scrollbar_thumb_border": "#00000000", - "scrollbar_track_background": "#00000000", - "scrollbar_track_border": "#403d52", - "drop_target_background": "#31748f1a", - "cursor": "#9ccfd8", - "selection": "#9ccfd840" - } -} diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 6080a97..ea02060 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -16,7 +16,7 @@ use gpui::{ }; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION}; +use state::{tracker, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP}; mod message; mod room; @@ -63,14 +63,17 @@ pub struct ChatRegistry { /// 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, + /// Tracking the status of unwrapping gift wrap events. + tracking_flag: Arc, + + /// Handle tracking asynchronous task + tracking: Option>>, + /// Handle notifications asynchronous task - notifications: Option>>, + notifications: Option>, /// Tasks for asynchronous operations tasks: Vec>, @@ -101,7 +104,7 @@ impl ChatRegistry { 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)); + let tracking_flag = Arc::new(AtomicBool::new(false)); // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(2048); @@ -112,12 +115,14 @@ impl ChatRegistry { subscriptions.push( // Observe the identity cx.observe(&identity, |this, state, cx| { - if state.read(cx).has_public_key() { + if state.read(cx).messaging_relays_state() == RelayState::Set { // Handle nostr notifications this.handle_notifications(cx); // Track unwrapping progress this.tracking(cx); } + // Get chat rooms from the database on every identity change + this.get_rooms(cx); }), ); @@ -161,9 +166,10 @@ impl ChatRegistry { Self { rooms: vec![], - loading: true, - tracking_flag, + loading: false, sender: tx.clone(), + tracking_flag, + tracking: None, notifications: None, tasks, _subscriptions: subscriptions, @@ -181,9 +187,10 @@ impl ChatRegistry { let status = self.tracking_flag.clone(); let tx = self.sender.clone(); - self.tasks.push(cx.background_spawn(async move { + self.notifications = Some(cx.background_spawn(async move { let initialized_at = Timestamp::now(); - let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); + let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP); + let sub_id2 = SubscriptionId::new(USER_GIFTWRAP); let mut notifications = client.notifications(); let mut processed_events = HashSet::new(); @@ -229,12 +236,12 @@ impl ChatRegistry { } }, 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 { + if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 { tx.send_async(NostrEvent::Eose).await.ok(); } } @@ -246,44 +253,18 @@ impl ChatRegistry { /// 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 { + self.tracking = Some(cx.background_spawn(async move { let loop_duration = Duration::from_secs(12); - let mut is_start_processing = false; - let mut total_loops = 0; - 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); + tx.send_async(NostrEvent::Unwrapping(true)).await.ok(); + } else { + tx.send_async(NostrEvent::Unwrapping(false)).await.ok(); } smol::Timer::after(loop_duration).await; } @@ -309,22 +290,21 @@ 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. @@ -337,6 +317,7 @@ impl ChatRegistry { } /// 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() { @@ -365,28 +346,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. @@ -532,7 +512,7 @@ impl ChatRegistry { } } - /// Parse a Nostr event into a Coop Message and push it to the belonging room + /// 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. @@ -579,7 +559,7 @@ impl ChatRegistry { } } - // 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 +583,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().await?; + 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/room.rs b/crates/chat/src/room.rs index 192c0d9..e92d4be 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -167,22 +167,11 @@ impl From<&UnsignedEvent> for Room { 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, + { + let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect()); let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "") .tags(tags) .build(author); diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index bff3e8d..9f99fc0 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] state = { path = "../state" } ui = { path = "../ui" } +dock = { path = "../dock" } theme = { path = "../theme" } common = { path = "../common" } person = { path = "../person" } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 109182f..791beee 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -4,6 +4,7 @@ use std::time::Duration; pub use actions::*; use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport}; use common::{nip96_upload, RenderedTimestamp}; +use dock::panel::{Panel, PanelEvent}; use gpui::prelude::FluentBuilder; use gpui::{ div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, @@ -25,13 +26,12 @@ 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::input::{InputEvent, InputState, TextInput}; use ui::notification::Notification; use ui::popup_menu::PopupMenuExt; 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; @@ -1199,7 +1199,7 @@ impl Render for ChatPanel { .child( EmojiPicker::new() .target(self.input.downgrade()) - .icon(IconName::EmojiFill) + .icon(IconName::Emoji) .large(), ), ) diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index bfa6c11..9cad0a5 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -26,6 +26,3 @@ 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/display.rs b/crates/common/src/display.rs index 3f967ff..ab7c215 100644 --- a/crates/common/src/display.rs +++ b/crates/common/src/display.rs @@ -12,6 +12,7 @@ const SECONDS_IN_MINUTE: i64 = 60; const MINUTES_IN_HOUR: i64 = 60; const HOURS_IN_DAY: i64 = 24; const DAYS_IN_MONTH: i64 = 30; +const IMAGE_RESIZER: &str = "https://wsrv.nl"; pub trait RenderedProfile { fn avatar(&self) -> SharedString; @@ -24,7 +25,12 @@ impl RenderedProfile for Profile { .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/common/src/lib.rs b/crates/common/src/lib.rs index bbfb448..653d1dc 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -4,7 +4,6 @@ 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::*; @@ -13,7 +12,6 @@ mod constants; mod debounced_delay; mod display; mod event; -mod nip05; mod nip96; mod paths; diff --git a/crates/common/src/nip05.rs b/crates/common/src/nip05.rs deleted file mode 100644 index 1286033..0000000 --- a/crates/common/src/nip05.rs +++ /dev/null @@ -1,31 +0,0 @@ -use anyhow::anyhow; -use nostr::prelude::*; -use reqwest::Client as ReqClient; - -pub async fn nip05_verify(public_key: PublicKey, address: &str) -> Result { - let req_client = ReqClient::new(); - let address = Nip05Address::parse(address)?; - - // Get NIP-05 response - let res = req_client.get(address.url().to_string()).send().await?; - let json: Value = res.json().await?; - - let verify = nip05::verify_from_json(&public_key, &address, &json); - - Ok(verify) -} - -pub async fn nip05_profile(address: &str) -> Result { - let req_client = ReqClient::new(); - let address = Nip05Address::parse(address)?; - - // Get NIP-05 response - let res = req_client.get(address.url().to_string()).send().await?; - let json: Value = res.json().await?; - - if let Ok(profile) = Nip05Profile::from_json(&address, &json) { - Ok(profile) - } else { - Err(anyhow!("Failed to get NIP-05 profile")) - } -} diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index a437497..94a62ac 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -29,12 +29,12 @@ icons = [ [dependencies] assets = { path = "../assets" } ui = { path = "../ui" } -title_bar = { path = "../title_bar" } +titlebar = { path = "../titlebar" } +dock = { path = "../dock" } theme = { path = "../theme" } common = { path = "../common" } state = { path = "../state" } device = { path = "../device" } -key_store = { path = "../key_store" } chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } settings = { path = "../settings" } @@ -58,7 +58,6 @@ smallvec.workspace = true smol.workspace = true futures.workspace = true oneshot.workspace = true -webbrowser.workspace = true indexset = "0.12.3" tracing-subscriber = { version = "0.3.18", features = ["fmt"] } diff --git a/crates/coop/src/actions.rs b/crates/coop/src/actions.rs index 8a5798b..37c2cb9 100644 --- a/crates/coop/src/actions.rs +++ b/crates/coop/src/actions.rs @@ -1,9 +1,4 @@ -use std::sync::Mutex; - -use gpui::{actions, App}; -use key_store::{KeyItem, KeyStore}; -use nostr_connect::prelude::*; -use state::NostrRegistry; +use gpui::actions; // Sidebar actions actions!(sidebar, [Reload, RelayStatus]); @@ -22,73 +17,3 @@ actions!( Quit ] ); - -#[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 { - log::info!("Received Auth URL: {auth_url}"); - webbrowser::open(auth_url.as_str())?; - Ok(()) - }) - } -} - -pub 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::new()); - 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).unwrap().unwrap(); - embedded_fonts.lock().unwrap().push(font_bytes); - }); - } - })); - - cx.text_system() - .add_fonts(embedded_fonts.into_inner().unwrap()) - .unwrap(); -} - -pub fn reset(cx: &mut App) { - let backend = KeyStore::global(cx).read(cx).backend(); - let client = NostrRegistry::global(cx).read(cx).client(); - - cx.spawn(async move |cx| { - // Remove the signer - client.unset_signer().await; - - // Delete user's credentials - backend - .delete_credentials(&KeyItem::User.to_string(), cx) - .await - .ok(); - - // Remove bunker's credentials if available - backend - .delete_credentials(&KeyItem::Bunker.to_string(), cx) - .await - .ok(); - - cx.update(|cx| { - cx.restart(); - }); - }) - .detach(); -} - -pub fn quit(_: &Quit, cx: &mut App) { - log::info!("Gracefully quitting the application . . ."); - cx.quit(); -} 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/command_bar.rs b/crates/coop/src/command_bar.rs new file mode 100644 index 0000000..dbf4532 --- /dev/null +++ b/crates/coop/src/command_bar.rs @@ -0,0 +1,580 @@ +use std::collections::HashSet; +use std::ops::Range; +use std::time::Duration; + +use anyhow::Error; +use chat::{ChatRegistry, Room}; +use common::DebouncedDelay; +use gpui::prelude::FluentBuilder; +use gpui::{ + anchored, deferred, div, point, px, rems, uniform_list, App, AppContext, Bounds, Context, + Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, + Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, + Task, Window, +}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; +use smallvec::{smallvec, SmallVec}; +use state::{NostrRegistry, FIND_DELAY}; +use theme::{ActiveTheme, TITLEBAR_HEIGHT}; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::notification::Notification; +use ui::{h_flex, v_flex, window_paddings, Icon, IconName, Sizable, WindowExtension}; + +const WIDTH: Pixels = px(425.); + +/// Command bar for searching conversations. +pub struct CommandBar { + /// Selected public keys + selected_pkeys: Entity>, + + /// User's contacts + contact_list: Entity>, + + /// Whether to show the contact list + show_contact_list: bool, + + /// Find input state + find_input: Entity, + + /// Debounced delay for find input + find_debouncer: DebouncedDelay, + + /// Whether a search is in progress + finding: bool, + + /// Find results + find_results: Entity>>, + + /// Async find operation + find_task: Option>>, + + /// Image cache for avatars + image_cache: Entity, + + /// Async tasks + tasks: SmallVec<[Task<()>; 1]>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl CommandBar { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let selected_pkeys = cx.new(|_| HashSet::new()); + let contact_list = cx.new(|_| vec![]); + let find_results = cx.new(|_| None); + let find_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Find or start a conversation") + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Subscribe to find input events + cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { + let delay = Duration::from_millis(FIND_DELAY); + + match event { + InputEvent::PressEnter { .. } => { + this.search(window, cx); + } + InputEvent::Change => { + if state.read(cx).value().is_empty() { + // Clear results when input is empty + this.reset(window, cx); + } else { + // Run debounced search + this.find_debouncer + .fire_new(delay, window, cx, |this, window, cx| { + this.debounced_search(window, cx) + }); + } + } + InputEvent::Focus => { + this.get_contact_list(window, cx); + } + _ => {} + }; + }), + ); + + Self { + selected_pkeys, + contact_list, + show_contact_list: false, + find_debouncer: DebouncedDelay::new(), + finding: false, + find_input, + find_results, + find_task: None, + image_cache: RetainAllImageCache::new(cx), + tasks: smallvec![], + _subscriptions: subscriptions, + } + } + + fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let task = nostr.read(cx).get_contact_list(cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.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(); + } + }; + })); + } + + /// Extend the contact list with new contacts. + fn extend_contacts(&mut self, contacts: I, cx: &mut Context) + where + I: IntoIterator, + { + self.contact_list.update(cx, |this, cx| { + this.extend(contacts); + cx.notify(); + }); + } + + /// Toggle the visibility of the contact list. + fn toggle_contact_list(&mut self, cx: &mut Context) { + self.show_contact_list = !self.show_contact_list; + cx.notify(); + } + + 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| { + this.search(window, cx); + }) + .ok(); + }) + } + + fn search(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let identity = nostr.read(cx).identity(); + let query = self.find_input.read(cx).value(); + + // Return if the query is empty + if query.is_empty() { + return; + } + + // Return if a search is already in progress + if self.finding { + if self.find_task.is_none() { + window.push_notification("There is another search in progress", cx); + return; + } else { + // Cancel the ongoing search request + self.find_task = None; + } + } + + // Block the input until the search completes + self.set_finding(true, window, cx); + + let find_users = if identity.read(cx).owned { + nostr.read(cx).wot_search(&query, cx) + } else { + nostr.read(cx).search(&query, cx) + }; + + // Run task in the main thread + self.find_task = Some(cx.spawn_in(window, async move |this, cx| { + let rooms = find_users.await?; + // 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); + })?; + + Ok(()) + })); + } + + fn set_results(&mut self, results: Vec, cx: &mut Context) { + self.find_results.update(cx, |this, cx| { + *this = Some(results); + cx.notify(); + }); + } + + fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { + // Disable the input to prevent duplicate requests + self.find_input.update(cx, |this, cx| { + this.set_disabled(status, cx); + this.set_loading(status, cx); + }); + // Set the search status + self.finding = status; + cx.notify(); + } + + 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(); + }); + + // Reset the search status + self.set_finding(false, window, cx); + + // Cancel the current search task + self.find_task = None; + cx.notify(); + } + + fn create(&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 = self.selected(cx); + + chat.update(cx, |this, cx| { + let room = cx.new(|_| Room::new(public_key, receivers)); + this.emit_room(room.downgrade(), cx); + }); + + window.close_modal(cx); + } + + fn select(&mut self, pkey: PublicKey, cx: &mut Context) { + self.selected_pkeys.update(cx, |this, cx| { + if this.contains(&pkey) { + this.remove(&pkey); + } else { + this.insert(pkey); + } + cx.notify(); + }); + } + + fn is_selected(&self, pkey: PublicKey, cx: &App) -> bool { + self.selected_pkeys.read(cx).contains(&pkey) + } + + fn selected(&self, cx: &Context) -> HashSet { + self.selected_pkeys.read(cx).clone() + } + + fn render_results(&self, range: Range, cx: &Context) -> Vec { + let persons = PersonRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + + let Some(rooms) = self.find_results.read(cx) else { + return vec![]; + }; + + rooms + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| { + let profile = persons.read(cx).get(item, cx); + let pkey = item.to_owned(); + let id = range.start + ix; + + h_flex() + .id(id) + .h_8() + .w_full() + .px_1() + .gap_2() + .rounded(cx.theme().radius) + .when(!hide_avatar, |this| { + this.child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .child(Avatar::new(profile.avatar()).size(rems(1.5))), + ) + }) + .child( + h_flex() + .flex_1() + .justify_between() + .line_clamp(1) + .text_ellipsis() + .truncate() + .text_sm() + .child(profile.name()) + .when(self.is_selected(pkey, cx), |this| { + this.child( + Icon::new(IconName::CheckCircle) + .small() + .text_color(cx.theme().icon_accent), + ) + }), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.select(pkey, cx); + })) + .into_any_element() + }) + .collect() + } + + fn render_contacts(&self, range: Range, cx: &Context) -> Vec { + let persons = PersonRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + let contacts = self.contact_list.read(cx); + + contacts + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| { + let profile = persons.read(cx).get(item, cx); + let pkey = item.to_owned(); + let id = range.start + ix; + + h_flex() + .id(id) + .h_8() + .w_full() + .px_1() + .gap_2() + .rounded(cx.theme().radius) + .when(!hide_avatar, |this| { + this.child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .child(Avatar::new(profile.avatar()).size(rems(1.5))), + ) + }) + .child( + h_flex() + .flex_1() + .justify_between() + .line_clamp(1) + .text_ellipsis() + .truncate() + .text_sm() + .child(profile.name()) + .when(self.is_selected(pkey, cx), |this| { + this.child( + Icon::new(IconName::CheckCircle) + .small() + .text_color(cx.theme().icon_accent), + ) + }), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.select(pkey, cx); + })) + .into_any_element() + }) + .collect() + } +} + +impl Render for CommandBar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let window_paddings = window_paddings(window, cx); + let view_size = window.viewport_size() + - gpui::size( + window_paddings.left + window_paddings.right, + window_paddings.top + window_paddings.bottom, + ); + + let bounds = Bounds { + origin: Point::default(), + size: view_size, + }; + + let x = bounds.center().x - WIDTH / 2.; + let y = TITLEBAR_HEIGHT; + + let input_focus_handle = self.find_input.read(cx).focus_handle(cx); + let input_focused = input_focus_handle.is_focused(window); + + let results = self.find_results.read(cx).as_ref(); + let total_results = results.map_or(0, |r| r.len()); + + let contacts = self.contact_list.read(cx); + let button_label = if self.selected_pkeys.read(cx).len() > 1 { + "Create Group DM" + } else { + "Create DM" + }; + + div() + .image_cache(self.image_cache.clone()) + .w_full() + .child( + TextInput::new(&self.find_input) + .appearance(true) + .bordered(false) + .xsmall() + .text_xs() + .when(!self.find_input.read(cx).loading, |this| { + this.suffix( + Button::new("find-icon") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + }), + ) + .when(input_focused, |this| { + this.child(deferred( + anchored() + .position(point(window_paddings.left, window_paddings.top)) + .snap_to_window() + .child( + div() + .occlude() + .w(view_size.width) + .h(view_size.height) + .on_mouse_down(MouseButton::Left, move |_ev, window, cx| { + window.focus_prev(cx); + }) + .child( + v_flex() + .absolute() + .occlude() + .relative() + .left(x) + .top(y) + .w(WIDTH) + .min_h_24() + .overflow_y_hidden() + .p_1() + .gap_1() + .justify_between() + .border_1() + .border_color(cx.theme().border.alpha(0.4)) + .bg(cx.theme().surface_background) + .shadow_md() + .rounded(cx.theme().radius_lg) + .map(|this| { + if self.show_contact_list { + this.child( + uniform_list( + "contacts", + contacts.len(), + cx.processor(|this, range, _window, cx| { + this.render_contacts(range, cx) + }), + ) + .when(!contacts.is_empty(), |this| this.h_40()), + ) + .when(contacts.is_empty(), |this| { + this.child( + h_flex() + .h_10() + .w_full() + .items_center() + .justify_center() + .text_center() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from( + "Your contact list is empty", + )), + ) + }) + } else { + this.child( + uniform_list( + "rooms", + total_results, + cx.processor(|this, range, _window, cx| { + this.render_results(range, cx) + }), + ) + .when(total_results > 0, |this| this.h_40()), + ) + .when(total_results == 0, |this| { + this.child( + h_flex() + .h_10() + .w_full() + .items_center() + .justify_center() + .text_center() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from( + "Search results appear here", + )), + ) + }) + } + }) + .child( + h_flex() + .pt_1() + .border_t_1() + .border_color(cx.theme().border_variant) + .justify_end() + .child( + Button::new("show-contacts") + .label({ + if self.show_contact_list { + "Hide contact list" + } else { + "Show contact list" + } + }) + .ghost() + .xsmall() + .on_click(cx.listener( + move |this, _ev, _window, cx| { + this.toggle_contact_list(cx); + }, + )), + ) + .when( + !self.selected_pkeys.read(cx).is_empty(), + |this| { + this.child( + Button::new("create") + .label(button_label) + .primary() + .xsmall() + .on_click(cx.listener( + move |this, _ev, window, cx| { + this.create(window, cx); + }, + )), + ) + }, + ), + ), + ), + ), + )) + }) + } +} 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/user/viewer.rs b/crates/coop/src/dialogs/profile.rs similarity index 82% rename from crates/coop/src/user/viewer.rs rename to crates/coop/src/dialogs/profile.rs index e710dba..4cace7a 100644 --- a/crates/coop/src/user/viewer.rs +++ b/crates/coop/src/dialogs/profile.rs @@ -1,28 +1,28 @@ use std::time::Duration; -use common::{nip05_verify, shorten_pubkey}; +use anyhow::Error; +use common::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 person::PersonRegistry; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; +use state::{NostrAddress, 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)) +pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| ProfileDialog::new(public_key, window, cx)) } #[derive(Debug)] -pub struct ProfileViewer { - profile: Person, +pub struct ProfileDialog { + public_key: PublicKey, /// Follow status followed: bool, @@ -37,31 +37,32 @@ pub struct ProfileViewer { _tasks: SmallVec<[Task<()>; 1]>, } -impl ProfileViewer { - pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context) -> Self { +impl ProfileDialog { + pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let http_client = cx.http_client(); 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 profile = persons.read(cx).get(&public_key, cx); let mut tasks = smallvec![]; + // Check if the user is following 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)) + Ok(contact_list.contains(&public_key)) }); - 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 - }; + // Verify the NIP05 address if available + let verify_nip05 = profile.metadata().nip05.and_then(|address| { + Nip05Address::parse(&address).ok().map(|addr| { + cx.background_spawn(async move { addr.verify(&http_client, &public_key).await }) + }) + }); tasks.push( // Load user profile data @@ -89,7 +90,7 @@ impl ProfileViewer { ); Self { - profile, + public_key, followed: false, verified: false, copied: false, @@ -97,12 +98,18 @@ impl ProfileViewer { } } - fn address(&self, _cx: &Context) -> Option { - self.profile.metadata().nip05 + fn address(&self, cx: &Context) -> Option { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(&self.public_key, cx); + + profile.metadata().nip05 } fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context) { - let Ok(bech32) = self.profile.public_key().to_bech32(); + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(&self.public_key, cx); + + let Ok(bech32) = profile.public_key().to_bech32(); let item = ClipboardItem::new_string(bech32); cx.write_to_clipboard(item); @@ -131,9 +138,11 @@ impl ProfileViewer { } } -impl Render for ProfileViewer { +impl Render for ProfileDialog { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let bech32 = shorten_pubkey(self.profile.public_key(), 16); + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(&self.public_key, cx); + let bech32 = shorten_pubkey(profile.public_key(), 16); let shared_bech32 = SharedString::from(bech32); v_flex() @@ -145,14 +154,14 @@ impl Render for ProfileViewer { .items_center() .justify_center() .text_center() - .child(Avatar::new(self.profile.avatar()).size(rems(4.))) + .child(Avatar::new(profile.avatar()).size(rems(4.))) .child( v_flex() .child( div() .font_semibold() .line_height(relative(1.25)) - .child(self.profile.name()), + .child(profile.name()), ) .when_some(self.address(cx), |this, address| { this.child( @@ -168,7 +177,7 @@ impl Render for ProfileViewer { .relative() .text_color(cx.theme().text_accent) .child( - Icon::new(IconName::CheckCircleFill) + Icon::new(IconName::CheckCircle) .small() .block(), ), @@ -207,7 +216,7 @@ impl Render for ProfileViewer { .rounded(cx.theme().radius) .bg(cx.theme().elevated_surface_background) .child( - self.profile + profile .metadata() .about .map(SharedString::from) @@ -240,7 +249,7 @@ impl Render for ProfileViewer { Button::new("copy") .icon({ if self.copied { - IconName::CheckCircleFill + IconName::CheckCircle } else { IconName::Copy } diff --git a/crates/coop/src/views/screening.rs b/crates/coop/src/dialogs/screening.rs similarity index 93% rename from crates/coop/src/views/screening.rs rename to crates/coop/src/dialogs/screening.rs index bc0705f..31b7629 100644 --- a/crates/coop/src/views/screening.rs +++ b/crates/coop/src/dialogs/screening.rs @@ -1,21 +1,21 @@ use std::time::Duration; -use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS}; +use anyhow::Error; +use common::{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 state::{NostrAddress, 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}; +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)) @@ -32,6 +32,7 @@ pub struct Screening { impl Screening { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let http_client = cx.http_client(); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -40,6 +41,7 @@ impl Screening { let mut tasks = smallvec![]; + // Check WOT let contact_check: Task), Error>> = cx.background_spawn({ let client = nostr.read(cx).client(); async move { @@ -67,6 +69,7 @@ impl Screening { } }); + // Check the last activity let activity_check = cx.background_spawn(async move { let filter = Filter::new().author(public_key).limit(1); let mut activity: Option = None; @@ -85,13 +88,12 @@ impl Screening { 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 - }; + // Verify the NIP05 address if available + let addr_check = profile.metadata().nip05.and_then(|address| { + Nip05Address::parse(&address).ok().map(|addr| { + cx.background_spawn(async move { addr.verify(&http_client, &public_key).await }) + }) + }); tasks.push( // Run the contact check in the background @@ -278,7 +280,7 @@ impl Render for Screening { .child( Button::new("report") .tooltip("Report as a scam or impostor") - .icon(IconName::Report) + .icon(IconName::Boom) .danger() .rounded() .on_click(cx.listener(move |this, _e, window, cx| { @@ -440,7 +442,7 @@ fn status_badge(status: Option, cx: &App) -> Div { .flex_shrink_0() .map(|this| { if let Some(status) = status { - this.child(Icon::new(IconName::CheckCircleFill).small().text_color({ + this.child(Icon::new(IconName::CheckCircle).small().text_color({ if status { cx.theme().icon_accent } else { 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..6a40e92 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -1,23 +1,22 @@ -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, - TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, - WindowOptions, + point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, + SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, + WindowDecorations, WindowKind, WindowOptions, }; use ui::Root; -use crate::actions::{load_embedded_fonts, quit, Quit}; +use crate::actions::Quit; mod actions; -mod chatspace; -mod login; -mod new_identity; +mod command_bar; +mod dialogs; +mod panels; mod sidebar; -mod user; -mod views; +mod workspace; fn main() { // Initialize logging @@ -58,6 +57,7 @@ fn main() { window_background: WindowBackgroundAppearance::Opaque, window_decorations: Some(WindowDecorations::Client), window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(Size::new(px(640.), px(480.))), kind: WindowKind::Normal, app_id: Some(APP_ID.to_owned()), titlebar: Some(TitlebarOptions { @@ -74,18 +74,12 @@ fn main() { cx.activate(true); cx.new(|cx| { - // Initialize the tokio runtime - gpui_tokio::init(cx); - // Initialize components ui::init(cx); // Initialize theme registry theme::init(cx); - // Initialize backend for keys storage - key_store::init(cx); - // Initialize the nostr client state::init(cx); @@ -110,9 +104,38 @@ fn main() { auto_update::init(cx); // Root Entity - Root::new(chatspace::init(window, cx).into(), window, cx) + Root::new(workspace::init(window, cx).into(), window, cx) }) }) .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).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..efd98e9 --- /dev/null +++ b/crates/coop/src/panels/connect.rs @@ -0,0 +1,127 @@ +use std::sync::Arc; + +use common::TextUtils; +use dock::panel::{Panel, PanelEvent}; +use dock::ClosePanel; +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::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..04541c9 --- /dev/null +++ b/crates/coop/src/panels/greeter.rs @@ -0,0 +1,281 @@ +use dock::dock::DockPlacement; +use dock::panel::{Panel, PanelEvent}; +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::{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(), + } + } +} + +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 nostr = NostrRegistry::global(cx); + let identity = nostr.read(cx).identity(); + + let relay_list_state = identity.read(cx).relay_list_state(); + let messaging_relay_state = identity.read(cx).messaging_relays_state(); + let required_actions = + relay_list_state == RelayState::NotSet || messaging_relay_state == RelayState::NotSet; + + 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(relay_list_state == RelayState::NotSet, |this| { + this.child( + Button::new("relaylist") + .icon(Icon::new(IconName::Relay)) + .label("Set up relay list") + .ghost() + .small() + .no_center() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + relay_list::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + }), + ) + }) + .when( + messaging_relay_state == RelayState::NotSet, + |this| { + this.child( + Button::new("import") + .icon(Icon::new(IconName::Relay)) + .label("Set up messaging relays") + .ghost() + .small() + .no_center() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + messaging_relays::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + }), + ) + }, + ), + ), + ) + }) + .when(!identity.read(cx).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() + .no_center() + .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() + .no_center() + .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() + .no_center(), + ) + .child( + Button::new("profile") + .icon(Icon::new(IconName::Profile)) + .label("Update profile") + .ghost() + .small() + .no_center() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + profile::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + }), + ) + .child( + Button::new("invite") + .icon(Icon::new(IconName::Invite)) + .label("Invite friends") + .ghost() + .small() + .no_center(), + ), + ), + ), + ) + } +} diff --git a/crates/coop/src/panels/import.rs b/crates/coop/src/panels/import.rs new file mode 100644 index 0000000..ea1281f --- /dev/null +++ b/crates/coop/src/panels/import.rs @@ -0,0 +1,371 @@ +use std::time::Duration; + +use anyhow::anyhow; +use dock::panel::{Panel, PanelEvent}; +use dock::ClosePanel; +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::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/views/setup_relay.rs b/crates/coop/src/panels/messaging_relays.rs similarity index 51% rename from crates/coop/src/views/setup_relay.rs rename to crates/coop/src/panels/messaging_relays.rs index cbc2809..d1a5f4a 100644 --- a/crates/coop/src/views/setup_relay.rs +++ b/crates/coop/src/panels/messaging_relays.rs @@ -2,11 +2,12 @@ use std::collections::HashSet; use std::time::Duration; use anyhow::{anyhow, Error}; +use dock::panel::{Panel, PanelEvent}; 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, + 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}; @@ -14,15 +15,21 @@ 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}; +use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt}; -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| SetupRelay::new(window, cx)) +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| MessagingRelayPanel::new(window, cx)) } #[derive(Debug)] -pub struct SetupRelay { +pub struct MessagingRelayPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Relay URL input input: Entity, + + /// Error message error: Option, // All relays @@ -35,13 +42,12 @@ pub struct SetupRelay { _tasks: SmallVec<[Task<()>; 1]>, } -impl SetupRelay { +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 input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); - let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; @@ -64,18 +70,16 @@ impl SetupRelay { 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); - } - }, - ), + 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, @@ -94,8 +98,7 @@ impl SetupRelay { .limit(1); if let Some(event) = client.database().query(filter).await?.first_owned() { - let urls = nip17::extract_owned_relay_list(event).collect(); - Ok(urls) + Ok(nip17::extract_owned_relay_list(event).collect()) } else { Err(anyhow!("Not found.")) } @@ -133,10 +136,9 @@ impl SetupRelay { 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; - + // Clear the error message after a delay this.update(cx, |this, cx| { this.error = None; cx.notify(); @@ -148,11 +150,7 @@ impl SetupRelay { 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, - ); + self.set_error("You need to add at least 1 relay", window, cx); return; }; @@ -160,7 +158,6 @@ impl SetupRelay { 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 { @@ -192,10 +189,7 @@ impl SetupRelay { cx.spawn_in(window, async move |this, cx| { match task.await { Ok(_) => { - cx.update(|window, cx| { - window.close_modal(cx); - }) - .ok(); + // TODO } Err(e) => { this.update_in(cx, |this, window, cx| { @@ -219,107 +213,148 @@ impl SetupRelay { 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); - }) - }), - ), - ), - ) - } + 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 }), ) - .w_full() - .min_h(px(200.)) + .h_full() } - fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render_empty(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() + .mt_2() .h_20() - .mb_2() .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 Render for SetupRelay { +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() - .gap_3() - .text_sm() + .size_full() + .items_center() + .justify_center() + .p_2() + .gap_10() .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.")), + .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( - h_flex() - .gap_1() - .w_full() - .child(TextInput::new(&self.input).small()) + v_flex() + .gap_1p5() .child( - Button::new("add") - .icon(IconName::PlusFill) - .label("Add") - .ghost() - .on_click(cx.listener(move |this, _, window, cx| { - this.add(window, cx); - })), - ), + 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()), + ) + }), ) - .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); + })), + ), ) - .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/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 53% rename from crates/coop/src/user/mod.rs rename to crates/coop/src/panels/profile.rs index cfee59a..8d48095 100644 --- a/crates/coop/src/user/mod.rs +++ b/crates/coop/src/panels/profile.rs @@ -1,35 +1,35 @@ use std::str::FromStr; use std::time::Duration; -use anyhow::{anyhow, Error}; +use anyhow::anyhow; use common::{nip96_upload, shorten_pubkey}; -use gpui::prelude::FluentBuilder; +use dock::panel::{Panel, PanelEvent}; 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, 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::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(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| ProfilePanel::new(window, cx)) } #[derive(Debug)] -pub struct UserProfile { - /// User profile - profile: Option, +pub struct ProfilePanel { + name: SharedString, + focus_handle: FocusHandle, /// User's name text input name_input: Entity, @@ -48,17 +48,16 @@ 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(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")); + // Hidden input for avatar url + 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,31 @@ impl UserProfile { .placeholder("A short introduce about you.") }); - let get_profile = Self::get_profile(cx); - let mut tasks = smallvec![]; + cx.defer_in(window, move |this, window, cx| { + let nostr = NostrRegistry::global(cx); + let public_key = nostr.read(cx).identity().read(cx).public_key(); - 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(); - } - }), - ); + 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(), 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 +115,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 +129,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,147 +207,188 @@ 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(); + fn set_metadata(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let public_key = nostr.read(cx).identity().read(cx).public_key(); - // Get the current profile metadata - let old_metadata = self - .profile - .as_ref() - .map(|profile| profile.metadata()) - .unwrap_or_default(); + // Get the old metadata + let persons = PersonRegistry::global(cx); + 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 = nostr.read(cx).set_metadata(&new_metadata, cx); - cx.background_spawn(async move { - let urls = write_relays.await; - let signer = client.signer().await?; + 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); + }); - // Sign the new metadata event - let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?; + this.update(cx, |this, cx| { + this.set_metadata(window, cx); + }) + .ok(); - // 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) + 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 nostr = NostrRegistry::global(cx); + let public_key = nostr.read(cx).identity().read(cx).public_key(); + let shorten_pkey = SharedString::from(shorten_pubkey(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(), window, @@ -383,6 +398,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.set_metadata(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..f7aef9c --- /dev/null +++ b/crates/coop/src/panels/relay_list.rs @@ -0,0 +1,366 @@ +use std::collections::HashSet; +use std::time::Duration; + +use anyhow::{anyhow, Error}; +use common::BOOTSTRAP_RELAYS; +use dock::panel::{Panel, PanelEvent}; +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::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().await?; + 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 signer = client.signer().await?; + let event = EventBuilder::relay_list(relays).sign(&signer).await?; + + // Set relay list for current user + client.send_event_to(BOOTSTRAP_RELAYS, &event).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/list_item.rs b/crates/coop/src/sidebar/list_item.rs index a018f38..a49f78f 100644 --- a/crates/coop/src/sidebar/list_item.rs +++ b/crates/coop/src/sidebar/list_item.rs @@ -1,7 +1,8 @@ use std::rc::Rc; -use chat::{ChatRegistry, RoomKind}; +use chat::RoomKind; use chat_ui::{CopyPublicKey, OpenPublicKey}; +use dock::ClosePanel; use gpui::prelude::FluentBuilder; use gpui::{ div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, @@ -13,15 +14,13 @@ 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 ui::{h_flex, StyledExt, WindowExtension}; -use crate::views::screening; +use crate::dialogs::screening; #[derive(IntoElement)] pub struct RoomListItem { ix: usize, - room_id: Option, public_key: Option, name: Option, avatar: Option, @@ -35,7 +34,6 @@ impl RoomListItem { pub fn new(ix: usize) -> Self { Self { ix, - room_id: None, public_key: None, name: None, avatar: None, @@ -45,11 +43,6 @@ impl RoomListItem { } } - 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 @@ -89,41 +82,6 @@ impl RenderOnce for RoomListItem { 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() @@ -133,14 +91,16 @@ impl RenderOnce for RoomListItem { .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))), - ) + 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() @@ -148,52 +108,57 @@ impl RenderOnce for RoomListItem { .flex() .items_center() .justify_between() + .when_some(self.name, |this, name| { + this.child( + div() + .flex_1() + .line_clamp(1) + .text_ellipsis() + .truncate() + .font_medium() + .child(name), + ) + }) .child( - div() - .flex_1() - .line_clamp(1) - .text_ellipsis() - .truncate() - .font_medium() - .child(name), - ) - .child( - div() + h_flex() + .gap_1p5() .flex_shrink_0() .text_xs() .text_color(cx.theme().text_placeholder) - .child(created_at), + .when_some(self.created_at, |this, created_at| this.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); + .when_some(self.public_key, |this, public_key| { + this.context_menu(move |this, _window, _cx| { + this.menu("View Profile", Box::new(OpenPublicKey(public_key))) + .menu("Copy Public Key", Box::new(CopyPublicKey(public_key))) + }) + .when_some(self.handler, |this, handler| { + this.on_click(move |event, window, cx| { + handler(event, window, cx); - if kind != RoomKind::Ongoing && screening { - let screening = screening::init(public_key, window, cx); + 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| { - 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 - }) - }); - } + 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/mod.rs b/crates/coop/src/sidebar/mod.rs index bfb823b..072b0b6 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -1,34 +1,23 @@ use std::ops::Range; -use std::time::Duration; -use anyhow::{anyhow, Error}; -use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; +use chat::{ChatEvent, ChatRegistry, RoomKind}; +use common::RenderedTimestamp; +use dock::panel::{Panel, PanelEvent}; 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, + deferred, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, + Subscription, Window, }; -use gpui_tokio::Tokio; use list_item::RoomListItem; -use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION}; -use theme::ActiveTheme; +use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::popup_menu::PopupMenuExt; -use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Selectable, Sizable, StyledExt}; - -use crate::actions::{RelayStatus, Reload}; +use ui::indicator::Indicator; +use ui::{h_flex, v_flex, IconName, Selectable, Sizable, StyledExt}; mod list_item; -const FIND_DELAY: u64 = 600; -const FIND_LIMIT: usize = 20; - pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Sidebar::new(window, cx)) } @@ -36,52 +25,25 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { /// Sidebar. pub struct Sidebar { name: SharedString, - - /// Focus handle for the sidebar focus_handle: FocusHandle, /// Image cache image_cache: Entity, - /// Search results - search_results: Entity>>>, + /// Whether there are new chat requests + new_requests: bool, - /// Async search operation - search_task: Option>, - - /// Search input state - find_input: Entity, - - /// Debounced delay for search input - find_debouncer: DebouncedDelay, - - /// Whether searching is in progress - finding: bool, - - /// New request flag - new_request: bool, - - /// Current chat room filter - active_filter: Entity, + /// Chatroom filter + filter: Entity, /// 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 find_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Find or start a conversation") - .clean_on_escape() - }); - - // Get the chat registry let chat = ChatRegistry::global(cx); + let filter = cx.new(|_| RoomKind::Ongoing); let mut subscriptions = smallvec![]; @@ -89,487 +51,65 @@ impl Sidebar { // Subscribe for registry new events cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { if event == &ChatEvent::Ping { - this.new_request = true; + this.new_requests = true; cx.notify(); }; }), ); - subscriptions.push( - // Subscribe for find input events - cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { - let delay = Duration::from_millis(FIND_DELAY); - - match event { - InputEvent::PressEnter { .. } => { - this.search(window, cx); - } - InputEvent::Change => { - if state.read(cx).value().is_empty() { - // Clear the result when input is empty - this.clear(window, cx); - } else { - // Run debounced search - this.find_debouncer - .fire_new(delay, window, cx, |this, window, cx| { - this.debounced_search(window, cx) - }); - } - } - _ => {} - }; - }), - ); - Self { name: "Sidebar".into(), focus_handle: cx.focus_handle(), image_cache: RetainAllImageCache::new(cx), - find_debouncer: DebouncedDelay::new(), - finding: false, - new_request: false, - active_filter, - find_input, - search_results, - search_task: None, + new_requests: false, + filter, _subscriptions: subscriptions, } } - async fn nip50(client: &Client, query: &str) -> Result, Error> { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::Metadata) - .search(query.to_lowercase()) - .limit(FIND_LIMIT); - - let mut stream = client - .stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3)) - .await?; - - 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; - } - - // Skip if the event has already been added - if results.iter().any(|this| this.pubkey == event.pubkey) { - continue; - } - - 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) - } - - 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| { - this.search(window, cx); - }) - .ok(); - }) - } - - 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(); - })); - } - - fn search(&mut self, window: &mut Window, cx: &mut Context) { - // Return if the query is empty - if self.find_input.read(cx).value().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 - 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; - }; - - // 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; - } - } - - // Get all local results with current query - let chat = ChatRegistry::global(cx); - let local_results = chat.read(cx).search(&query, 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); - } - - fn set_results(&mut self, rooms: Vec>, cx: &mut Context) { - self.search_results.update(cx, |this, cx| { - *this = Some(rooms); - cx.notify(); - }); - } - - fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { - // Disable the input to prevent duplicate requests - self.find_input.update(cx, |this, cx| { - this.set_disabled(status, cx); - this.set_loading(status, cx); - }); - // Set the finding 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); - } - - // Clear all local results - self.search_results.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - } - - fn filter(&self, kind: &RoomKind, cx: &Context) -> bool { - self.active_filter.read(cx) == kind + /// Get the active filter. + fn current_filter(&self, kind: &RoomKind, cx: &Context) -> bool { + self.filter.read(cx) == kind } + /// Set the active filter for the sidebar. fn set_filter(&mut self, kind: RoomKind, cx: &mut Context) { - self.active_filter.update(cx, |this, cx| { + self.filter.update(cx, |this, cx| { *this = kind; cx.notify(); }); - self.new_request = false; - cx.notify(); + self.new_requests = false; } - fn open(&mut self, id: u64, window: &mut Window, cx: &mut Context) { + 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); - 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); + rooms + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| { + let room = item.read(cx); + let weak_room = item.downgrade(); + 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(weak_room.clone(), 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 task: Task, Error>> = cx.background_spawn(async move { - let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); - let subscription = client.subscription(&id).await; - - let mut relays: Vec = vec![]; - - for (url, _filter) in subscription.into_iter() { - relays.push(client.pool().relay(url).await?); - } - - Ok(relays) - }); - - 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(); - } - - 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 - })) - }); - } - - fn list_items( - &self, - rooms: &[Entity], - range: Range, - cx: &Context, - ) -> Vec { - let mut items = Vec::with_capacity(range.end - range.start); - - for ix in range { - let Some(room) = rooms.get(ix) else { - items.push(RoomListItem::new(ix)); - continue; - }; - - let this = room.read(cx); - let room_id = this.id; - let member = this.display_member(cx); - - let handler = cx.listener({ - move |this, _, window, cx| { - this.open(room_id, window, cx); - } - }); - - 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), - ) - } - - items + RoomListItem::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() } } @@ -591,201 +131,154 @@ 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(); - - // 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() - } 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) - } - }; - - // Get total rooms count - let mut total_rooms = rooms.len(); - - // Add 3 dummy rooms to display as skeletons - if loading { - total_rooms += 3 - } + let total_rooms = chat.read(cx).count(self.filter.read(cx), cx); 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 + .gap_2() .child( - div() - .relative() - .mt_3() - .px_2p5() + h_flex() + .h(TABBAR_HEIGHT) .w_full() - .h_7() - .flex_none() - .flex() + .border_b_1() + .border_color(cx.theme().border) .child( - TextInput::new(&self.find_input) - .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 - } - }), - ), - ) - // Chat Rooms - .child( - v_flex() - .gap_1() - .flex_1() - .px_1p5() - .w_full() - .overflow_y_hidden() - .child( - div() - .px_1() - .h_flex() + h_flex() + .flex_1() + .h_full() .gap_2() - .flex_none() + .p_2() + .justify_center() .child( Button::new("all") - .label("All") + .map(|this| { + if self.current_filter(&RoomKind::Ongoing, cx) { + this.icon(IconName::InboxFill) + } else { + this.icon(IconName::Inbox) + } + }) + .label("Inbox") .tooltip("All ongoing conversations") - .small() - .cta() + .xsmall() .bold() - .secondary() - .rounded() - .selected(self.filter(&RoomKind::Ongoing, cx)) + .ghost() + .flex_1() + .rounded_none() + .selected(self.current_filter(&RoomKind::Ongoing, cx)) .on_click(cx.listener(|this, _, _, cx| { this.set_filter(RoomKind::Ongoing, cx); })), ) .child( Button::new("requests") + .map(|this| { + if self.current_filter(&RoomKind::Request, cx) { + this.icon(IconName::FistbumpFill) + } else { + this.icon(IconName::Fistbump) + } + }) .label("Requests") .tooltip("Incoming new conversations") - .when(self.new_request, |this| { + .xsmall() + .bold() + .ghost() + .flex_1() + .rounded_none() + .selected(!self.current_filter(&RoomKind::Ongoing, cx)) + .when(self.new_requests, |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.")), - ), - )) - } - }) - }) + .child( + h_flex() + .h_full() + .px_2() + .border_l_1() + .border_color(cx.theme().border) + .child( + Button::new("option") + .icon(IconName::Ellipsis) + .small() + .ghost(), + ), + ), + ) + .when(!loading && total_rooms == 0, |this| { + this.child( + div().px_2p5().child(deferred( + 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() + .px_1p5() + .w_full() + .flex_1() + .gap_1() + .overflow_y_hidden() .child( uniform_list( "rooms", total_rooms, - cx.processor(move |this, range, _window, cx| { - this.list_items(&rooms, range, cx) + cx.processor(|this, range, _window, cx| { + this.render_list_items(range, cx) }), ) .h_full(), - ), + ) + .when(loading, |this| { + this.child( + div().absolute().top_2().left_0().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/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/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..2d3cc0b --- /dev/null +++ b/crates/coop/src/workspace.rs @@ -0,0 +1,244 @@ +use std::sync::Arc; + +use chat::{ChatEvent, ChatRegistry}; +use dock::dock::DockPlacement; +use dock::panel::PanelView; +use dock::{ClosePanel, DockArea, DockItem}; +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; +use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; +use titlebar::TitleBar; +use ui::avatar::Avatar; +use ui::{h_flex, v_flex, Icon, IconName, Root, Sizable, WindowExtension}; + +use crate::command_bar::CommandBar; +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, + + /// App's Command Bar + command_bar: 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 command_bar = cx.new(|cx| CommandBar::new(window, cx)); + let dock = + cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar)); + + 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 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, + command_bar, + _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 nostr = NostrRegistry::global(cx); + let identity = nostr.read(cx).identity(); + + h_flex() + .h(TITLEBAR_HEIGHT) + .flex_1() + .justify_between() + .gap_2() + .when_some(identity.read(cx).public_key, |this, public_key| { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(&public_key, cx); + + this.child( + h_flex() + .gap_0p5() + .child(Avatar::new(profile.avatar()).size(rems(1.25))) + .child( + Icon::new(IconName::ChevronDown) + .small() + .text_color(cx.theme().text_muted), + ), + ) + }) + } + + fn titlebar_center(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { + h_flex().flex_1().w_full().child(self.command_bar.clone()) + } + + fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { + h_flex().flex_1() + } +} + +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 center = self.titlebar_center(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, center, 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..f7ac1ee 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -8,7 +8,7 @@ pub use device::*; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{NostrRegistry, RelayState, GIFTWRAP_SUBSCRIPTION, TIMEOUT}; +use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP}; mod device; @@ -72,13 +72,18 @@ impl DeviceRegistry { 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 { + match state.read(cx).relay_list_state() { + RelayState::Initial => { + this.reset(cx); + } + RelayState::Set => { this.get_announcement(cx); + + if state.read(cx).messaging_relays_state() == RelayState::Set { + this.get_messages(cx); + } } - if state.read(cx).messaging_relays_state() == RelayState::Set { - this.get_messages(cx); - } + _ => {} } }), ); @@ -193,7 +198,9 @@ impl DeviceRegistry { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) - .identifier(IDENTIFIER); + .identifier(IDENTIFIER) + .author(public_key) + .limit(1); if let Some(event) = client.database().query(filter).await?.first() { let content = signer.nip44_decrypt(&public_key, &event.content).await?; @@ -206,6 +213,22 @@ impl DeviceRegistry { } } + /// Reset the device state + pub fn reset(&mut self, cx: &mut Context) { + self.requests.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); + + self.device_signer.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); + + self.state = DeviceState::Initial; + cx.notify(); + } + /// Returns the device signer entity pub fn signer(&self, cx: &App) -> Option> { self.device_signer.read(cx).clone() @@ -248,20 +271,30 @@ impl DeviceRegistry { cx.background_spawn(async move { let urls = messaging_relays.await; - let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); - let mut filters = vec![]; - // Construct a filter to get user messages - filters.push(Filter::new().kind(Kind::GiftWrap).pubkey(public_key)); - - // Construct a filter to get dekey messages if available + // Get messages with dekey 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)); + if let Ok(pkey) = signer.get_public_key().await { + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(pkey); + let id = SubscriptionId::new(DEVICE_GIFTWRAP); + + if let Err(e) = client + .subscribe_with_id_to(&urls, id, vec![filter], None) + .await + { + log::error!("Failed to subscribe to gift wrap events: {e}"); + } } } - if let Err(e) = client.subscribe_with_id_to(urls, id, filters, None).await { + // Get messages with user key + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let id = SubscriptionId::new(USER_GIFTWRAP); + + if let Err(e) = client + .subscribe_with_id_to(urls, id, vec![filter], None) + .await + { log::error!("Failed to subscribe to gift wrap events: {e}"); } }) diff --git a/crates/key_store/Cargo.toml b/crates/dock/Cargo.toml similarity index 61% rename from crates/key_store/Cargo.toml rename to crates/dock/Cargo.toml index 616a7f9..0697dfb 100644 --- a/crates/key_store/Cargo.toml +++ b/crates/dock/Cargo.toml @@ -1,19 +1,18 @@ [package] -name = "key_store" +name = "dock" version.workspace = true edition.workspace = true publish.workspace = true [dependencies] common = { path = "../common" } +theme = { path = "../theme" } +ui = { path = "../ui" } gpui.workspace = true -nostr-sdk.workspace = true - -anyhow.workspace = true smallvec.workspace = true -smol.workspace = true +anyhow.workspace = true log.workspace = true -futures.workspace = true -serde.workspace = true -serde_json.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +linicon = "2.3.0" diff --git a/crates/ui/src/dock_area/dock.rs b/crates/dock/src/dock.rs similarity index 83% rename from crates/ui/src/dock_area/dock.rs rename to crates/dock/src/dock.rs index 3c427a6..a535304 100644 --- a/crates/ui/src/dock_area/dock.rs +++ b/crates/dock/src/dock.rs @@ -1,32 +1,27 @@ +use std::ops::Deref; use std::sync::Arc; use gpui::prelude::FluentBuilder as _; use gpui::{ - div, px, App, AppContext, Axis, Context, Element, Entity, InteractiveElement as _, IntoElement, - MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, - StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window, + div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent, + MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity, + Window, }; -use serde::{Deserialize, Serialize}; -use theme::ActiveTheme; +use ui::StyledExt; use super::{DockArea, DockItem}; -use crate::dock_area::panel::PanelView; -use crate::dock_area::tab_panel::TabPanel; -use crate::resizable::{HANDLE_PADDING, HANDLE_SIZE, PANEL_MIN_SIZE}; -use crate::{AxisExt as _, StyledExt}; +use crate::panel::PanelView; +use crate::resizable::{resize_handle, PANEL_MIN_SIZE}; +use crate::tab_panel::TabPanel; #[derive(Clone, Render)] struct ResizePanel; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DockPlacement { - #[serde(rename = "center")] Center, - #[serde(rename = "left")] Left, - #[serde(rename = "bottom")] Bottom, - #[serde(rename = "right")] Right, } @@ -58,16 +53,21 @@ impl DockPlacement { pub struct Dock { pub(super) placement: DockPlacement, dock_area: WeakEntity, + + /// Dock layout pub(crate) panel: DockItem, + /// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height. pub(super) size: Pixels, + + /// Whether the Dock is open pub(super) open: bool, + /// Whether the Dock is collapsible, default: true pub(super) collapsible: bool, - // Runtime state /// Whether the Dock is resizing - is_resizing: bool, + resizing: bool, } impl Dock { @@ -98,7 +98,7 @@ impl Dock { open: true, collapsible: true, size: px(200.0), - is_resizing: false, + resizing: false, } } @@ -231,54 +231,16 @@ impl Dock { cx: &mut Context, ) -> impl IntoElement { let axis = self.placement.axis(); - let neg_offset = -HANDLE_PADDING; let view = cx.entity().clone(); - div() - .id("resize-handle") - .occlude() - .absolute() - .flex_shrink_0() - .when(self.placement.is_left(), |this| { - // FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING) - this.cursor_col_resize() - .top_0() - .right(px(1.)) - .h_full() - .w(HANDLE_SIZE) - .pt_12() - .pb_4() - }) - .when(self.placement.is_right(), |this| { - this.cursor_col_resize() - .top_0() - .left(px(-0.5)) - .h_full() - .w(HANDLE_SIZE) - .pt_12() - .pb_4() - }) - .when(self.placement.is_bottom(), |this| { - this.cursor_row_resize() - .top(neg_offset) - .left_0() - .w_full() - .h(HANDLE_SIZE) - .py(HANDLE_PADDING) - }) - .child( - div() - .rounded_full() - .hover(|this| this.bg(cx.theme().border_variant)) - .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) - .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), - ) + resize_handle("resize-handle", axis) + .placement(self.placement) .on_drag(ResizePanel {}, move |info, _, _, cx| { cx.stop_propagation(); - view.update(cx, |view, _| { - view.is_resizing = true; + view.update(cx, |view, _cx| { + view.resizing = true; }); - cx.new(|_| info.clone()) + cx.new(|_| info.deref().clone()) }) } @@ -288,7 +250,7 @@ impl Dock { _window: &mut Window, cx: &mut Context, ) { - if !self.is_resizing { + if !self.resizing { return; } @@ -349,7 +311,7 @@ impl Dock { } fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context) { - self.is_resizing = false; + self.resizing = false; } } @@ -440,7 +402,7 @@ impl Element for DockElement { ) { window.on_mouse_event({ let view = self.view.clone(); - let is_resizing = view.read(cx).is_resizing; + let is_resizing = view.read(cx).resizing; move |e: &MouseMoveEvent, phase, window, cx| { if !is_resizing { return; diff --git a/crates/ui/src/dock_area/mod.rs b/crates/dock/src/lib.rs similarity index 95% rename from crates/ui/src/dock_area/mod.rs rename to crates/dock/src/lib.rs index a2fb704..972fb27 100644 --- a/crates/ui/src/dock_area/mod.rs +++ b/crates/dock/src/lib.rs @@ -2,30 +2,25 @@ use std::sync::Arc; use gpui::prelude::FluentBuilder; use gpui::{ - actions, canvas, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, - Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, - ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, + actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, + EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, + Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, }; +use ui::ElementExt; -use crate::dock_area::dock::{Dock, DockPlacement}; -use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView}; -use crate::dock_area::stack_panel::StackPanel; -use crate::dock_area::tab_panel::TabPanel; +use crate::dock::{Dock, DockPlacement}; +use crate::panel::{Panel, PanelEvent, PanelStyle, PanelView}; +use crate::stack_panel::StackPanel; +use crate::tab_panel::TabPanel; pub mod dock; pub mod panel; +pub mod resizable; pub mod stack_panel; +pub mod tab; pub mod tab_panel; -actions!( - dock, - [ - /// Zoom the current panel - ToggleZoom, - /// Close the current panel - ClosePanel - ] -); +actions!(dock, [ToggleZoom, ClosePanel]); pub enum DockEvent { /// The layout of the dock has changed, subscribers this to save the layout. @@ -38,20 +33,31 @@ pub enum DockEvent { /// The main area of the dock. pub struct DockArea { pub(crate) bounds: Bounds, + /// The center view of the dockarea. pub items: DockItem, - /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, - toggle_button_panels: Edges>, + /// The left dock of the dock_area. left_dock: Option>, + /// The bottom dock of the dock_area. bottom_dock: Option>, + /// The right dock of the dock_area. right_dock: Option>, + + /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, + toggle_button_panels: Edges>, + + /// Whether to show the toggle button. + toggle_button_visible: bool, + /// The top zoom view of the dock_area, if any. zoom_view: Option, + /// Lock panels layout, but allow to resize. is_locked: bool, + /// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default). pub(crate) panel_style: PanelStyle, subscriptions: Vec, @@ -330,6 +336,7 @@ impl DockArea { items: dock_item, zoom_view: None, toggle_button_panels: Edges::default(), + toggle_button_visible: true, left_dock: None, right_dock: None, bottom_dock: None, @@ -649,31 +656,35 @@ impl DockArea { cx.subscribe_in( view, window, - move |_, panel, event, window, cx| match event { + move |_this, panel, event, window, cx| match event { PanelEvent::ZoomIn => { let panel = panel.clone(); cx.spawn_in(window, async move |view, window| { - _ = view.update_in(window, |view, window, cx| { + view.update_in(window, |view, window, cx| { view.set_zoomed_in(panel, window, cx); cx.notify(); - }); + }) + .ok(); }) .detach(); } - PanelEvent::ZoomOut => cx - .spawn_in(window, async move |view, window| { + PanelEvent::ZoomOut => { + cx.spawn_in(window, async move |view, window| { _ = view.update_in(window, |view, window, cx| { view.set_zoomed_out(window, cx); }); }) - .detach(), + .detach(); + } PanelEvent::LayoutChanged => { cx.spawn_in(window, async move |view, window| { - _ = view.update_in(window, |view, window, cx| { + view.update_in(window, |view, window, cx| { view.update_toggle_button_tab_panels(window, cx) - }); + }) + .ok(); }) .detach(); + // Emit layout changed event for dock cx.emit(DockEvent::LayoutChanged); } }, @@ -746,14 +757,7 @@ impl Render for DockArea { .relative() .size_full() .overflow_hidden() - .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)) .map(|this| { if let Some(zoom_view) = self.zoom_view.clone() { this.child(zoom_view) diff --git a/crates/ui/src/dock_area/panel.rs b/crates/dock/src/panel.rs similarity index 97% rename from crates/ui/src/dock_area/panel.rs rename to crates/dock/src/panel.rs index 8e64dc6..00bec24 100644 --- a/crates/ui/src/dock_area/panel.rs +++ b/crates/dock/src/panel.rs @@ -2,10 +2,10 @@ use gpui::{ AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Render, SharedString, Window, }; +use ui::button::Button; +use ui::popup_menu::PopupMenu; -use crate::button::Button; -use crate::popup_menu::PopupMenu; - +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PanelEvent { ZoomIn, ZoomOut, diff --git a/crates/dock/src/resizable/mod.rs b/crates/dock/src/resizable/mod.rs new file mode 100644 index 0000000..ce66f14 --- /dev/null +++ b/crates/dock/src/resizable/mod.rs @@ -0,0 +1,294 @@ +use std::ops::Range; + +use gpui::{ + px, Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, +}; + +mod panel; +mod resize_handle; +pub use panel::*; +pub(crate) use resize_handle::*; + +pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.); + +/// Create a [`ResizablePanelGroup`] with horizontal resizing +pub fn h_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Horizontal) +} + +/// Create a [`ResizablePanelGroup`] with vertical resizing +pub fn v_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Vertical) +} + +/// Create a [`ResizablePanel`]. +pub fn resizable_panel() -> ResizablePanel { + ResizablePanel::new() +} + +/// State for a [`ResizablePanel`] +#[derive(Debug, Clone)] +pub struct ResizableState { + /// The `axis` will sync to actual axis of the ResizablePanelGroup in use. + axis: Axis, + panels: Vec, + sizes: Vec, + pub(crate) resizing_panel_ix: Option, + bounds: Bounds, +} + +impl Default for ResizableState { + fn default() -> Self { + Self { + axis: Axis::Horizontal, + panels: vec![], + sizes: vec![], + resizing_panel_ix: None, + bounds: Bounds::default(), + } + } +} + +impl ResizableState { + /// Get the size of the panels. + pub fn sizes(&self) -> &Vec { + &self.sizes + } + + pub(crate) fn insert_panel( + &mut self, + size: Option, + ix: Option, + cx: &mut Context, + ) { + let panel_state = ResizablePanelState { + size, + ..Default::default() + }; + + let size = size.unwrap_or(PANEL_MIN_SIZE); + + // We make sure that the size always sums up to the container size + // by reducing the size of all other panels first. + let container_size = self.container_size().max(px(1.)); + let total_leftover_size = (container_size - size).max(px(1.)); + + for (i, panel) in self.panels.iter_mut().enumerate() { + let ratio = self.sizes[i] / container_size; + self.sizes[i] = total_leftover_size * ratio; + panel.size = Some(self.sizes[i]); + } + + if let Some(ix) = ix { + self.panels.insert(ix, panel_state); + self.sizes.insert(ix, size); + } else { + self.panels.push(panel_state); + self.sizes.push(size); + }; + + cx.notify(); + } + + pub(crate) fn sync_panels_count( + &mut self, + axis: Axis, + panels_count: usize, + cx: &mut Context, + ) { + let mut changed = self.axis != axis; + self.axis = axis; + + if panels_count > self.panels.len() { + let diff = panels_count - self.panels.len(); + self.panels + .extend(vec![ResizablePanelState::default(); diff]); + self.sizes.extend(vec![PANEL_MIN_SIZE; diff]); + changed = true; + } + + if panels_count < self.panels.len() { + self.panels.truncate(panels_count); + self.sizes.truncate(panels_count); + changed = true; + } + + if changed { + // We need to make sure the total size is in line with the container size. + self.adjust_to_container_size(cx); + } + } + + pub(crate) fn update_panel_size( + &mut self, + panel_ix: usize, + bounds: Bounds, + size_range: Range, + cx: &mut Context, + ) { + let size = bounds.size.along(self.axis); + // This check is only necessary to stop the very first panel from resizing on its own + // it needs to be passed when the panel is freshly created so we get the initial size, + // but its also fine when it sometimes passes later. + if self.sizes[panel_ix].to_f64() == PANEL_MIN_SIZE.to_f64() { + self.sizes[panel_ix] = size; + self.panels[panel_ix].size = Some(size); + } + self.panels[panel_ix].bounds = bounds; + self.panels[panel_ix].size_range = size_range; + cx.notify(); + } + + pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context) { + self.panels.remove(panel_ix); + self.sizes.remove(panel_ix); + if let Some(resizing_panel_ix) = self.resizing_panel_ix { + if resizing_panel_ix > panel_ix { + self.resizing_panel_ix = Some(resizing_panel_ix - 1); + } + } + self.adjust_to_container_size(cx); + } + + pub(crate) fn replace_panel( + &mut self, + panel_ix: usize, + panel: ResizablePanelState, + cx: &mut Context, + ) { + let old_size = self.sizes[panel_ix]; + + self.panels[panel_ix] = panel; + self.sizes[panel_ix] = old_size; + self.adjust_to_container_size(cx); + } + + pub(crate) fn clear(&mut self) { + self.panels.clear(); + self.sizes.clear(); + } + + #[inline] + pub(crate) fn container_size(&self) -> Pixels { + self.bounds.size.along(self.axis) + } + + pub(crate) fn done_resizing(&mut self, cx: &mut Context) { + self.resizing_panel_ix = None; + cx.emit(ResizablePanelEvent::Resized); + } + + fn panel_size_range(&self, ix: usize) -> Range { + let Some(panel) = self.panels.get(ix) else { + return PANEL_MIN_SIZE..Pixels::MAX; + }; + + panel.size_range.clone() + } + + fn sync_real_panel_sizes(&mut self, _: &App) { + for (i, panel) in self.panels.iter().enumerate() { + self.sizes[i] = panel.bounds.size.along(self.axis); + } + } + + /// The `ix`` is the index of the panel to resize, + /// and the `size` is the new size for the panel. + fn resize_panel(&mut self, ix: usize, size: Pixels, _: &mut Window, cx: &mut Context) { + let old_sizes = self.sizes.clone(); + + let mut ix = ix; + // Only resize the left panels. + if ix >= old_sizes.len() - 1 { + return; + } + let container_size = self.container_size(); + self.sync_real_panel_sizes(cx); + + let move_changed = size - old_sizes[ix]; + if move_changed == px(0.) { + return; + } + + let size_range = self.panel_size_range(ix); + let new_size = size.clamp(size_range.start, size_range.end); + let is_expand = move_changed > px(0.); + + let main_ix = ix; + let mut new_sizes = old_sizes.clone(); + + if is_expand { + let mut changed = new_size - old_sizes[ix]; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix < old_sizes.len() - 1 { + ix += 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + new_sizes[ix] -= to_reduce; + changed -= to_reduce; + } + } else { + let mut changed = new_size - size; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix > 0 { + ix -= 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + changed -= to_reduce; + new_sizes[ix] -= to_reduce; + } + + new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed; + } + + let total_size: Pixels = new_sizes.iter().map(|s| s.to_f64()).sum::().into(); + + // If total size exceeds container size, adjust the main panel + if total_size > container_size { + let overflow = total_size - container_size; + new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start); + } + + for (i, _) in old_sizes.iter().enumerate() { + let size = new_sizes[i]; + self.panels[i].size = Some(size); + } + self.sizes = new_sizes; + cx.notify(); + } + + /// Adjust panel sizes according to the container size. + /// + /// When the container size changes, the panels should take up the same percentage as they did before. + fn adjust_to_container_size(&mut self, cx: &mut Context) { + if self.container_size().is_zero() { + return; + } + + let container_size = self.container_size(); + let total_size = px(self.sizes.iter().map(f32::from).sum::()); + + for i in 0..self.panels.len() { + let size = self.sizes[i]; + let ratio = size / total_size; + let new_size = container_size * ratio; + + self.sizes[i] = new_size; + self.panels[i].size = Some(new_size); + } + cx.notify(); + } +} + +impl EventEmitter for ResizableState {} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResizablePanelState { + pub size: Option, + pub size_range: Range, + bounds: Bounds, +} diff --git a/crates/dock/src/resizable/panel.rs b/crates/dock/src/resizable/panel.rs new file mode 100644 index 0000000..f72269d --- /dev/null +++ b/crates/dock/src/resizable/panel.rs @@ -0,0 +1,405 @@ +use std::ops::{Deref, Range}; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{ + div, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, + Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, + MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, +}; +use ui::{h_flex, v_flex, AxisExt, ElementExt}; + +use super::{resizable_panel, resize_handle, ResizableState}; +use crate::resizable::PANEL_MIN_SIZE; + +pub enum ResizablePanelEvent { + Resized, +} + +#[derive(Clone)] +pub(crate) struct DragPanel; +impl Render for DragPanel { + fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { + Empty + } +} + +/// A group of resizable panels. +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +pub struct ResizablePanelGroup { + id: ElementId, + state: Option>, + axis: Axis, + size: Option, + children: Vec, + on_resize: Rc, &mut Window, &mut App)>, +} + +impl ResizablePanelGroup { + /// Create a new resizable panel group. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + axis: Axis::Horizontal, + children: vec![], + state: None, + size: None, + on_resize: Rc::new(|_, _, _| {}), + } + } + + /// Bind yourself to a resizable state entity. + /// + /// If not provided, it will handle its own state internally. + pub fn with_state(mut self, state: &Entity) -> Self { + self.state = Some(state.clone()); + self + } + + /// Set the axis of the resizable panel group, default is horizontal. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + /// Add a panel to the group. + /// + /// - The `axis` will be set to the same axis as the group. + /// - The `initial_size` will be set to the average size of all panels if not provided. + /// - The `group` will be set to the group entity. + pub fn child(mut self, panel: impl Into) -> Self { + self.children.push(panel.into()); + self + } + + /// Add multiple panels to the group. + pub fn children(mut self, panels: impl IntoIterator) -> Self + where + I: Into, + { + self.children = panels.into_iter().map(|panel| panel.into()).collect(); + self + } + + /// Set size of the resizable panel group + /// + /// - When the axis is horizontal, the size is the height of the group. + /// - When the axis is vertical, the size is the width of the group. + pub fn size(mut self, size: Pixels) -> Self { + self.size = Some(size); + self + } + + /// Set the callback to be called when the panels are resized. + /// + /// ## Callback arguments + /// + /// - Entity: The state of the ResizablePanelGroup. + pub fn on_resize( + mut self, + on_resize: impl Fn(&Entity, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resize = Rc::new(on_resize); + self + } +} + +impl From for ResizablePanel +where + T: Into, +{ + fn from(value: T) -> Self { + resizable_panel().child(value.into()) + } +} + +impl From for ResizablePanel { + fn from(value: ResizablePanelGroup) -> Self { + resizable_panel().child(value) + } +} + +impl EventEmitter for ResizablePanelGroup {} + +impl RenderOnce for ResizablePanelGroup { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.unwrap_or( + window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()), + ); + let container = if self.axis.is_horizontal() { + h_flex() + } else { + v_flex() + }; + + // Sync panels to the state + let panels_count = self.children.len(); + state.update(cx, |state, cx| { + state.sync_panels_count(self.axis, panels_count, cx); + }); + + container + .id(self.id) + .size_full() + .children( + self.children + .into_iter() + .enumerate() + .map(|(ix, mut panel)| { + panel.panel_ix = ix; + panel.axis = self.axis; + panel.state = Some(state.clone()); + panel + }), + ) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + let size_changed = + state.bounds.size.along(self.axis) != bounds.size.along(self.axis); + + state.bounds = bounds; + + if size_changed { + state.adjust_to_container_size(cx); + } + }) + } + }) + .child(ResizePanelGroupElement { + state: state.clone(), + axis: self.axis, + on_resize: self.on_resize.clone(), + }) + } +} + +/// A resizable panel inside a [`ResizablePanelGroup`]. +#[derive(IntoElement)] +pub struct ResizablePanel { + axis: Axis, + panel_ix: usize, + state: Option>, + /// Initial size is the size that the panel has when it is created. + initial_size: Option, + /// size range limit of this panel. + size_range: Range, + children: Vec, + visible: bool, +} + +impl ResizablePanel { + /// Create a new resizable panel. + pub(super) fn new() -> Self { + Self { + panel_ix: 0, + initial_size: None, + state: None, + size_range: (PANEL_MIN_SIZE..Pixels::MAX), + axis: Axis::Horizontal, + children: vec![], + visible: true, + } + } + + /// Set the visibility of the panel, default is true. + pub fn visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Set the initial size of the panel. + pub fn size(mut self, size: impl Into) -> Self { + self.initial_size = Some(size.into()); + self + } + + /// Set the size range to limit panel resize. + /// + /// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`]. + pub fn size_range(mut self, range: impl Into>) -> Self { + self.size_range = range.into(); + self + } +} + +impl ParentElement for ResizablePanel { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for ResizablePanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + if !self.visible { + return div().id(("resizable-panel", self.panel_ix)); + } + + let state = self + .state + .expect("BUG: The `state` in ResizablePanel should be present."); + let panel_state = state + .read(cx) + .panels + .get(self.panel_ix) + .expect("BUG: The `index` of ResizablePanel should be one of in `state`."); + let size_range = self.size_range.clone(); + + div() + .id(("resizable-panel", self.panel_ix)) + .flex() + .flex_grow() + .size_full() + .relative() + .when(self.axis.is_vertical(), |this| { + this.min_h(size_range.start).max_h(size_range.end) + }) + .when(self.axis.is_horizontal(), |this| { + this.min_w(size_range.start).max_w(size_range.end) + }) + // 1. initial_size is None, to use auto size. + // 2. initial_size is Some and size is none, to use the initial size of the panel for first time render. + // 3. initial_size is Some and size is Some, use `size`. + .when(self.initial_size.is_none(), |this| this.flex_shrink()) + .when_some(self.initial_size, |this, initial_size| { + // The `self.size` is None, that mean the initial size for the panel, + // so we need set `flex_shrink_0` To let it keep the initial size. + this.when( + panel_state.size.is_none() && !initial_size.is_zero(), + |this| this.flex_none(), + ) + .flex_basis(initial_size) + }) + .map(|this| match panel_state.size { + Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)), + None => this, + }) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + state.update_panel_size(self.panel_ix, bounds, self.size_range, cx) + }) + } + }) + .children(self.children) + .when(self.panel_ix > 0, |this| { + let ix = self.panel_ix - 1; + this.child(resize_handle(("resizable-handle", ix), self.axis).on_drag( + DragPanel, + move |drag_panel, _, _, cx| { + cx.stop_propagation(); + // Set current resizing panel ix + state.update(cx, |state, _| { + state.resizing_panel_ix = Some(ix); + }); + cx.new(|_| drag_panel.deref().clone()) + }, + )) + }) + } +} + +#[allow(clippy::type_complexity)] +struct ResizePanelGroupElement { + state: Entity, + on_resize: Rc, &mut Window, &mut App)>, + axis: Axis, +} + +impl IntoElement for ResizePanelGroupElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizePanelGroupElement { + type PrepaintState = (); + type RequestLayoutState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + window.on_mouse_event({ + let state = self.state.clone(); + let axis = self.axis; + let current_ix = state.read(cx).resizing_panel_ix; + move |e: &MouseMoveEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + let Some(ix) = current_ix else { return }; + + state.update(cx, |state, cx| { + let panel = state.panels.get(ix).expect("BUG: invalid panel index"); + + match axis { + Axis::Horizontal => { + state.resize_panel(ix, e.position.x - panel.bounds.left(), window, cx) + } + Axis::Vertical => { + state.resize_panel(ix, e.position.y - panel.bounds.top(), window, cx); + } + } + cx.notify(); + }) + } + }); + + // When any mouse up, stop dragging + window.on_mouse_event({ + let state = self.state.clone(); + let current_ix = state.read(cx).resizing_panel_ix; + let on_resize = self.on_resize.clone(); + move |_: &MouseUpEvent, phase, window, cx| { + if current_ix.is_none() { + return; + } + if phase.bubble() { + state.update(cx, |state, cx| state.done_resizing(cx)); + on_resize(&state, window, cx); + } + } + }) + } +} diff --git a/crates/dock/src/resizable/resize_handle.rs b/crates/dock/src/resizable/resize_handle.rs new file mode 100644 index 0000000..55cda5d --- /dev/null +++ b/crates/dock/src/resizable/resize_handle.rs @@ -0,0 +1,227 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + div, px, AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, + InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, + Point, Render, StatefulInteractiveElement, Styled as _, Window, +}; +use theme::ActiveTheme; +use ui::AxisExt; + +use crate::dock::DockPlacement; + +pub(crate) const HANDLE_PADDING: Pixels = px(4.); +pub(crate) const HANDLE_SIZE: Pixels = px(1.); + +/// Create a resize handle for a resizable panel. +pub(crate) fn resize_handle( + id: impl Into, + axis: Axis, +) -> ResizeHandle { + ResizeHandle::new(id, axis) +} + +#[allow(clippy::type_complexity)] +pub(crate) struct ResizeHandle { + id: ElementId, + axis: Axis, + drag_value: Option>, + placement: Option, + on_drag: Option, &mut Window, &mut App) -> Entity>>, +} + +impl ResizeHandle { + fn new(id: impl Into, axis: Axis) -> Self { + let id = id.into(); + Self { + id: id.clone(), + on_drag: None, + drag_value: None, + placement: None, + axis, + } + } + + pub(crate) fn on_drag( + mut self, + value: T, + f: impl Fn(Rc, &Point, &mut Window, &mut App) -> Entity + 'static, + ) -> Self { + let value = Rc::new(value); + self.drag_value = Some(value.clone()); + self.on_drag = Some(Rc::new(move |p, window, cx| { + f(value.clone(), p, window, cx) + })); + self + } + + #[allow(dead_code)] + pub(crate) fn placement(mut self, placement: DockPlacement) -> Self { + self.placement = Some(placement); + self + } +} + +#[derive(Default, Debug, Clone)] +struct ResizeHandleState { + active: Cell, +} + +impl ResizeHandleState { + fn set_active(&self, active: bool) { + self.active.set(active); + } + + fn is_active(&self) -> bool { + self.active.get() + } +} + +impl IntoElement for ResizeHandle { + type Element = ResizeHandle; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizeHandle { + type PrepaintState = (); + type RequestLayoutState = AnyElement; + + 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<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let neg_offset = -HANDLE_PADDING; + let axis = self.axis; + + window.with_element_state(id.unwrap(), |state, window| { + let state = state.unwrap_or(ResizeHandleState::default()); + + let bg_color = if state.is_active() { + cx.theme().border_variant + } else { + cx.theme().border + }; + + let mut el = div() + .id(self.id.clone()) + .occlude() + .absolute() + .flex_shrink_0() + .group("handle") + .when_some(self.on_drag.clone(), |this, on_drag| { + this.on_drag( + self.drag_value.clone().unwrap(), + move |_, position, window, cx| on_drag(&position, window, cx), + ) + }) + .map(|this| match self.placement { + Some(DockPlacement::Left) => { + // Special for Left Dock + // FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING) + this.cursor_col_resize() + .top_0() + .right(px(1.)) + .h_full() + .w(HANDLE_SIZE) + .pl(HANDLE_PADDING) + } + _ => this + .when(axis.is_horizontal(), |this| { + this.cursor_col_resize() + .top_0() + .left(neg_offset) + .h_full() + .w(HANDLE_SIZE) + .px(HANDLE_PADDING) + }) + .when(axis.is_vertical(), |this| { + this.cursor_row_resize() + .top(neg_offset) + .left_0() + .w_full() + .h(HANDLE_SIZE) + .py(HANDLE_PADDING) + }), + }) + .child( + div() + .bg(bg_color) + .group_hover("handle", |this| this.bg(bg_color)) + .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) + .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + ) + .into_any_element(); + + let layout_id = el.request_layout(window, cx); + + ((layout_id, el), state) + }) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + request_layout.prepaint(window, cx); + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + request_layout.paint(window, cx); + + window.with_element_state(id.unwrap(), |state: Option, window| { + let state = state.unwrap_or_default(); + + window.on_mouse_event({ + let state = state.clone(); + move |ev: &MouseDownEvent, phase, window, _| { + if bounds.contains(&ev.position) && phase.bubble() { + state.set_active(true); + window.refresh(); + } + } + }); + + window.on_mouse_event({ + let state = state.clone(); + move |_: &MouseUpEvent, _, window, _| { + if state.is_active() { + state.set_active(false); + window.refresh(); + } + } + }); + + ((), state) + }); + } +} diff --git a/crates/ui/src/dock_area/stack_panel.rs b/crates/dock/src/stack_panel.rs similarity index 77% rename from crates/ui/src/dock_area/stack_panel.rs rename to crates/dock/src/stack_panel.rs index 92fe47a..2a2dd19 100644 --- a/crates/ui/src/dock_area/stack_panel.rs +++ b/crates/dock/src/stack_panel.rs @@ -7,24 +7,24 @@ use gpui::{ Window, }; use smallvec::SmallVec; +use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING}; +use ui::{h_flex, AxisExt as _, Placement}; use super::{DockArea, PanelEvent}; -use crate::dock_area::panel::{Panel, PanelView}; -use crate::dock_area::tab_panel::TabPanel; +use crate::panel::{Panel, PanelView}; use crate::resizable::{ - h_resizable, resizable_panel, v_resizable, ResizablePanel, ResizablePanelEvent, - ResizablePanelGroup, + resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState, + PANEL_MIN_SIZE, }; -use crate::{h_flex, AxisExt as _, Placement}; +use crate::tab_panel::TabPanel; pub struct StackPanel { pub(super) parent: Option>, pub(super) axis: Axis, focus_handle: FocusHandle, pub(crate) panels: SmallVec<[Arc; 2]>, - panel_group: Entity, - #[allow(dead_code)] - subscriptions: Vec, + state: Entity, + _subscriptions: Vec, } impl Panel for StackPanel { @@ -39,28 +39,23 @@ impl Panel for StackPanel { impl StackPanel { pub fn new(axis: Axis, window: &mut Window, cx: &mut Context) -> Self { - let panel_group = cx.new(|cx| { - if axis == Axis::Horizontal { - h_resizable(window, cx) - } else { - v_resizable(window, cx) - } - }); + let state = cx.new(|_| ResizableState::default()); // Bubble up the resize event. - let subscriptions = vec![cx.subscribe_in( - &panel_group, - window, - |_, _, _: &ResizablePanelEvent, _, cx| cx.emit(PanelEvent::LayoutChanged), - )]; + let subscriptions = + vec![ + cx.subscribe_in(&state, window, |_, _, _: &ResizablePanelEvent, _, cx| { + cx.emit(PanelEvent::LayoutChanged) + }), + ]; Self { axis, parent: None, focus_handle: cx.focus_handle(), panels: SmallVec::new(), - panel_group, - subscriptions, + state, + _subscriptions: subscriptions, } } @@ -172,13 +167,6 @@ impl StackPanel { self.insert_panel(panel, ix + 1, size, dock_area, window, cx); } - fn new_resizable_panel(panel: Arc, size: Option) -> ResizablePanel { - resizable_panel() - .content_view(panel.view()) - .content_visible(move |cx| panel.visible(cx)) - .when_some(size, |this, size| this.size(size)) - } - fn insert_panel( &mut self, panel: Arc, @@ -225,14 +213,21 @@ impl StackPanel { ix }; + // Get avg size of all panels to insert new panel, if size is None. + let size = match size { + Some(size) => size, + None => { + let state = self.state.read(cx); + (state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE) + } + }; + + // Insert panel self.panels.insert(ix, panel.clone()); - self.panel_group.update(cx, |view, cx| { - view.insert_child( - Self::new_resizable_panel(panel.clone(), size), - ix, - window, - cx, - ) + + // Update resizable state + self.state.update(cx, |state, cx| { + state.insert_panel(Some(size), Some(ix), cx); }); cx.emit(PanelEvent::LayoutChanged); @@ -240,21 +235,25 @@ impl StackPanel { } /// Remove panel from the stack. + /// + /// If `ix` is not found, do nothing. pub fn remove_panel( &mut self, panel: Arc, window: &mut Window, cx: &mut Context, ) { - if let Some(ix) = self.index_of_panel(panel.clone()) { - self.panels.remove(ix); - self.panel_group.update(cx, |view, cx| { - view.remove_child(ix, window, cx); - }); + let Some(ix) = self.index_of_panel(panel.clone()) else { + return; + }; - cx.emit(PanelEvent::LayoutChanged); - self.remove_self_if_empty(window, cx); - } + self.panels.remove(ix); + self.state.update(cx, |state, cx| { + state.remove_panel(ix, cx); + }); + + cx.emit(PanelEvent::LayoutChanged); + self.remove_self_if_empty(window, cx); } /// Replace the old panel with the new panel at same index. @@ -262,18 +261,14 @@ impl StackPanel { &mut self, old_panel: Arc, new_panel: Entity, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { if let Some(ix) = self.index_of_panel(old_panel.clone()) { self.panels[ix] = Arc::new(new_panel.clone()); - self.panel_group.update(cx, |view, cx| { - view.replace_child( - Self::new_resizable_panel(Arc::new(new_panel.clone()), None), - ix, - window, - cx, - ); + let panel_state = ResizablePanelState::default(); + self.state.update(cx, |state, cx| { + state.replace_panel(ix, panel_state, cx); }); cx.emit(PanelEvent::LayoutChanged); } @@ -362,17 +357,17 @@ impl StackPanel { } /// Remove all panels from the stack. - pub(super) fn remove_all_panels(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context) { self.panels.clear(); - self.panel_group - .update(cx, |view, cx| view.remove_all_children(window, cx)); + self.state.update(cx, |state, cx| { + state.clear(); + cx.notify(); + }); } /// Change the axis of the stack panel. - pub(super) fn set_axis(&mut self, axis: Axis, window: &mut Window, cx: &mut Context) { + pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context) { self.axis = axis; - self.panel_group - .update(cx, |view, cx| view.set_axis(axis, window, cx)); cx.notify(); } } @@ -388,10 +383,23 @@ impl EventEmitter for StackPanel {} impl EventEmitter for StackPanel {} impl Render for StackPanel { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .size_full() .overflow_hidden() - .child(self.panel_group.clone()) + .bg(cx.theme().panel_background) + .when(cx.theme().platform.is_linux(), |this| { + this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .child( + ResizablePanelGroup::new("stack-panel-group") + .with_state(&self.state) + .axis(self.axis) + .children(self.panels.clone().into_iter().map(|panel| { + resizable_panel() + .child(panel.view()) + .visible(panel.visible(cx)) + })), + ) } } diff --git a/crates/dock/src/tab/mod.rs b/crates/dock/src/tab/mod.rs new file mode 100644 index 0000000..3a37ed9 --- /dev/null +++ b/crates/dock/src/tab/mod.rs @@ -0,0 +1,165 @@ +use gpui::prelude::FluentBuilder; +use gpui::{ + div, px, AnyElement, App, Div, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, StatefulInteractiveElement, Styled, Window, +}; +use theme::{ActiveTheme, TABBAR_HEIGHT}; +use ui::{Selectable, Sizable, Size}; + +pub mod tab_bar; + +#[derive(IntoElement)] +pub struct Tab { + ix: usize, + base: Div, + label: Option, + prefix: Option, + suffix: Option, + disabled: bool, + selected: bool, + size: Size, +} + +impl Tab { + pub fn new() -> Self { + Self { + ix: 0, + base: div(), + label: None, + disabled: false, + selected: false, + prefix: None, + suffix: None, + size: Size::default(), + } + } + + /// Set label for the tab. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Set the left side of the tab + pub fn prefix(mut self, prefix: impl Into) -> Self { + self.prefix = Some(prefix.into()); + self + } + + /// Set the right side of the tab + pub fn suffix(mut self, suffix: impl Into) -> Self { + self.suffix = Some(suffix.into()); + self + } + + /// Set disabled state to the tab + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Set index to the tab. + pub fn ix(mut self, ix: usize) -> Self { + self.ix = ix; + self + } +} + +impl Default for Tab { + fn default() -> Self { + Self::new() + } +} + +impl Selectable for Tab { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl InteractiveElement for Tab { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Tab {} + +impl Styled for Tab { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.base.style() + } +} + +impl Sizable for Tab { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl RenderOnce for Tab { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let (text_color, hover_text_color, bg_color, border_color) = + match (self.selected, self.disabled) { + (true, false) => ( + cx.theme().tab_active_foreground, + cx.theme().tab_hover_foreground, + cx.theme().tab_active_background, + cx.theme().border, + ), + (false, false) => ( + cx.theme().tab_inactive_foreground, + cx.theme().tab_hover_foreground, + cx.theme().ghost_element_background, + cx.theme().border_transparent, + ), + (true, true) => ( + cx.theme().tab_inactive_foreground, + cx.theme().tab_hover_foreground, + cx.theme().ghost_element_background, + cx.theme().border_disabled, + ), + (false, true) => ( + cx.theme().tab_inactive_foreground, + cx.theme().tab_hover_foreground, + cx.theme().ghost_element_background, + cx.theme().border_disabled, + ), + }; + + self.base + .id(self.ix) + .h(TABBAR_HEIGHT) + .px_4() + .relative() + .flex() + .items_center() + .flex_shrink_0() + .cursor_pointer() + .overflow_hidden() + .text_xs() + .text_ellipsis() + .text_color(text_color) + .bg(bg_color) + .border_l(px(1.)) + .border_r(px(1.)) + .border_color(border_color) + .when(!self.selected && !self.disabled, |this| { + this.hover(|this| this.text_color(hover_text_color)) + }) + .when_some(self.prefix, |this, prefix| { + this.child(prefix).text_color(text_color) + }) + .when_some(self.label, |this, label| this.child(label)) + .when_some(self.suffix, |this, suffix| this.child(suffix)) + .on_mouse_down(MouseButton::Left, |_ev, _window, cx| { + cx.stop_propagation(); + }) + } +} diff --git a/crates/dock/src/tab/tab_bar.rs b/crates/dock/src/tab/tab_bar.rs new file mode 100644 index 0000000..798093c --- /dev/null +++ b/crates/dock/src/tab/tab_bar.rs @@ -0,0 +1,127 @@ +use gpui::prelude::FluentBuilder as _; +#[cfg(not(target_os = "windows"))] +use gpui::Pixels; +use gpui::{ + div, px, AnyElement, App, Div, InteractiveElement, IntoElement, ParentElement, RenderOnce, + ScrollHandle, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, +}; +use smallvec::SmallVec; +use theme::ActiveTheme; +use ui::{h_flex, Sizable, Size, StyledExt}; + +#[derive(IntoElement)] +pub struct TabBar { + base: Div, + style: StyleRefinement, + scroll_handle: Option, + prefix: Option, + suffix: Option, + last_empty_space: AnyElement, + children: SmallVec<[AnyElement; 2]>, + size: Size, +} + +impl TabBar { + pub fn new() -> Self { + Self { + base: h_flex().px(px(-1.)), + style: StyleRefinement::default(), + scroll_handle: None, + children: SmallVec::new(), + prefix: None, + suffix: None, + size: Size::default(), + last_empty_space: div().w_3().into_any_element(), + } + } + + /// Track the scroll of the TabBar. + pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self { + self.scroll_handle = Some(scroll_handle.clone()); + self + } + + /// Set the prefix element of the TabBar + pub fn prefix(mut self, prefix: impl IntoElement) -> Self { + self.prefix = Some(prefix.into_any_element()); + self + } + + /// Set the suffix element of the TabBar + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + /// Set the last empty space element of the TabBar. + pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self { + self.last_empty_space = last_empty_space.into_any_element(); + self + } + + #[cfg(not(target_os = "windows"))] + pub fn height(window: &mut Window) -> Pixels { + (1.75 * window.rem_size()).max(px(36.)) + } +} + +impl Default for TabBar { + fn default() -> Self { + Self::new() + } +} + +impl ParentElement for TabBar { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Styled for TabBar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for TabBar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl RenderOnce for TabBar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + self.base + .group("tab-bar") + .relative() + .refine_style(&self.style) + .bg(cx.theme().surface_background) + .child( + div() + .id("border-bottom") + .absolute() + .left_0() + .bottom_0() + .size_full() + .border_b_1() + .border_color(cx.theme().border), + ) + .text_color(cx.theme().text) + .when_some(self.prefix, |this, prefix| this.child(prefix)) + .child( + h_flex() + .id("tabs") + .flex_grow() + .overflow_x_scroll() + .when_some(self.scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .children(self.children) + .when(self.suffix.is_some(), |this| { + this.child(self.last_empty_space) + }), + ) + .when_some(self.suffix, |this, suffix| this.child(suffix)) + } +} diff --git a/crates/ui/src/dock_area/tab_panel.rs b/crates/dock/src/tab_panel.rs similarity index 81% rename from crates/ui/src/dock_area/tab_panel.rs rename to crates/dock/src/tab_panel.rs index f92b7d3..94d829c 100644 --- a/crates/ui/src/dock_area/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -7,17 +7,17 @@ use gpui::{ MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, }; -use theme::ActiveTheme; +use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT}; +use ui::button::{Button, ButtonVariants as _}; +use ui::popup_menu::{PopupMenu, PopupMenuExt}; +use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt}; -use super::panel::PanelView; -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::dock::DockPlacement; +use crate::panel::{Panel, PanelView}; +use crate::stack_panel::StackPanel; use crate::tab::tab_bar::TabBar; use crate::tab::Tab; -use crate::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt}; +use crate::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom}; #[derive(Clone)] struct TabState { @@ -65,16 +65,29 @@ impl Render for DragPanel { pub struct TabPanel { focus_handle: FocusHandle, dock_area: WeakEntity, - /// The stock_panel can be None, if is None, that means the panels can't be split or move - stack_panel: Option>, + + /// List of panels in the tab panel pub(crate) panels: Vec>, + + /// Current active panel index pub(crate) active_ix: usize, + /// If this is true, the Panel closeable will follow the active panel's closeable, /// otherwise this TabPanel will not able to close pub(crate) closable: bool, + + /// The stock_panel can be None, if is None, that means the panels can't be split or move + stack_panel: Option>, + + /// Scroll handle for the tab bar tab_bar_scroll_handle: ScrollHandle, - is_zoomed: bool, - is_collapsed: bool, + + /// Whether the tab panel is zoomeds + zoomed: bool, + + /// Whether the tab panel is collapsed + collapsed: bool, + /// When drag move, will get the placement of the panel to be split will_split_placement: Option, } @@ -142,8 +155,8 @@ impl TabPanel { active_ix: 0, tab_bar_scroll_handle: ScrollHandle::new(), will_split_placement: None, - is_zoomed: false, - is_collapsed: false, + zoomed: false, + collapsed: false, closable: true, } } @@ -339,7 +352,7 @@ impl TabPanel { _window: &mut Window, cx: &mut Context, ) { - self.is_collapsed = collapsed; + self.collapsed = collapsed; cx.notify(); } @@ -352,7 +365,7 @@ impl TabPanel { return true; } - if self.is_zoomed { + if self.zoomed { return true; } @@ -408,7 +421,7 @@ impl TabPanel { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let is_zoomed = self.is_zoomed && state.zoomable; + let is_zoomed = self.zoomed && state.zoomable; let view = cx.entity().clone(); let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx); let toolbar = self.toolbar_buttons(window, cx); @@ -420,10 +433,10 @@ impl TabPanel { .occlude() .rounded_full() .children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded())) - .when(self.is_zoomed, |this| { + .when(self.zoomed, |this| { this.child( Button::new("zoom") - .icon(IconName::ArrowIn) + .icon(IconName::Zoom) .small() .ghost() .tooltip("Zoom Out") @@ -433,8 +446,7 @@ impl TabPanel { ) }) .when(has_toolbar, |this| { - this.bg(cx.theme().surface_background) - .child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) + this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) }) .child( Button::new("menu") @@ -461,21 +473,113 @@ impl TabPanel { ) } + fn render_dock_toggle_button( + &self, + placement: DockPlacement, + _window: &mut Window, + cx: &mut Context, + ) -> Option