diff --git a/Cargo.lock b/Cargo.lock index ff03bcb9..7683cbd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,11 +699,11 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitcoin-consensus-encoding" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.7.0", "hex-conservative 1.3.0", "serde", ] @@ -714,6 +714,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + [[package]] name = "bitcoin-io" version = "0.1.101" @@ -740,7 +746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" dependencies = [ "bitcoin-consensus-encoding", - "bitcoin-internals", + "bitcoin-internals 0.6.0", "hex-conservative 1.3.0", "serde", ] @@ -987,9 +993,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -2229,9 +2235,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixedbitset" @@ -2750,9 +2756,9 @@ dependencies = [ [[package]] name = "gpui-base" -version = "0.6.6" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e904ee0d351f4e85b967e26ae986d3aa195a8d9d35fcd18184a2c40989694e" +checksum = "64bb52b29a8dcdc8d3f595b8b0839c076f237572ab601ba4577d3da8ae085728" dependencies = [ "aho-corasick", "anyhow", @@ -2766,9 +2772,11 @@ dependencies = [ "gpui-pre-sum-tree", "html5ever", "instant", + "itertools 0.13.0", "lsp-types", "markdown", "markup5ever_rcdom", + "num-traits", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", @@ -2788,9 +2796,9 @@ dependencies = [ [[package]] name = "gpui-pre" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0437c0b83e636a92bd1a39fa1d05fb632ae671289537497b35871ffbe231b84" +checksum = "0e87a42bb37c7cb4e76dd1ac0ce88851e46e976e0373a47ab3e0757abffee54d" dependencies = [ "accesskit", "anyhow", @@ -2858,12 +2866,12 @@ dependencies = [ [[package]] name = "gpui-pre-apple" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af534f0746eb9e014114bcc0ee00dc250c4b8d75df2e437180c403aa65649c1" +checksum = "00053551517815ab169fda28fff78dbebf2712722593c7671c511785cb3f79d5" dependencies = [ "anyhow", - "block", + "block2 0.6.2", "cbindgen", "cocoa 0.26.1", "core-foundation 0.10.1", @@ -2882,9 +2890,9 @@ dependencies = [ [[package]] name = "gpui-pre-collections" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15e7a65204039187d1f4b3d4c2e0233eeac54d4acb0b13f9dc1a3b5adad26c1f" +checksum = "f79760925c31ed06924d28d68981b5b22b2ce25665f58f0a33dfea11170e6e28" dependencies = [ "gpui-pre-util", "indexmap", @@ -2893,9 +2901,9 @@ dependencies = [ [[package]] name = "gpui-pre-derive-refineable" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79dfb4a05f3cd9893d7768dbf5dba1bbbe99cab7394e89b360ccb3b2bcb6b34e" +checksum = "d1aea3f941581a6a9dd545ecd351ef2d336fe6e2a1a4555cb6414944a4984260" dependencies = [ "proc-macro2", "quote", @@ -2904,9 +2912,9 @@ dependencies = [ [[package]] name = "gpui-pre-http-client" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd5a7542c77887a5aff997d78c95884119c8463409569b63fcf060956da1682" +checksum = "ddce27e989e877a3a1a243f97d7ea4f1ade2532abec418d5a0be9cca802702c7" dependencies = [ "anyhow", "async-compression", @@ -2925,9 +2933,9 @@ dependencies = [ [[package]] name = "gpui-pre-http-client-tls" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72aed510adb5e7723f8f57b9751007e3451d46a84edb59b92227178cf59cc19c" +checksum = "0076f6cf87b87b5629d55692638df4edc04fab3da92f897c2294d520efac17d3" dependencies = [ "log", "rustls", @@ -2937,9 +2945,9 @@ dependencies = [ [[package]] name = "gpui-pre-linux" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab655cda1d6dcd549ac869e8678babc84cf573ab6794a1fe1a39c6c70c60b48" +checksum = "0aac7022e347409454777701082201742710052813964a1e25160f7e22c965ad" dependencies = [ "accesskit", "accesskit_unix", @@ -2984,9 +2992,9 @@ dependencies = [ [[package]] name = "gpui-pre-macos" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951d17e41a72067ad2d35c60e08a62e8ad7fa9400b41c802e26cfb459f6c05da" +checksum = "5a43af845b260b09393e923c4f1e1a67a10fcfc847b4815192bbfd02ed9fe725" dependencies = [ "accesskit", "accesskit_macos", @@ -3031,9 +3039,9 @@ dependencies = [ [[package]] name = "gpui-pre-macros" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6324092b40ea8ec7c327a4a28427e366a489b4d5d23b5d1bd0243fc2f4ca7a9e" +checksum = "e1627c5dd3c3351e39e0621d3f3e31dbbe7c7658df90b1d2ec12bfe95d54cd37" dependencies = [ "heck 0.5.0", "proc-macro-crate", @@ -3044,9 +3052,9 @@ dependencies = [ [[package]] name = "gpui-pre-perf" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94bff26ccd8105f6206144f8dab16a2dc02e7a68f39dada02cdf492f0afd940" +checksum = "01954497dd02ba96ab4c252a8668643b0cc3085e7a75dca62e7ba0e3dcd83b02" dependencies = [ "gpui-pre-collections", "serde", @@ -3055,10 +3063,11 @@ dependencies = [ [[package]] name = "gpui-pre-platform" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8423d1e0a693d2f9f16c0326bb01b5e81ec0531854d596a18476934ac6880478" +checksum = "0112105c2ac6757fbf527c9c92a753be9bcbce9f4cf26959a11eaa1db6565aa5" dependencies = [ + "anyhow", "console_error_panic_hook", "gpui-pre", "gpui-pre-linux", @@ -3069,9 +3078,9 @@ dependencies = [ [[package]] name = "gpui-pre-refineable" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93b4b0a8d296a3ee5beb1136bc48f8167a6028eb1e243b56851dcd8c9fc6d7a1" +checksum = "ffd14639e5dfab906bbaeabfe0f94289120dcf2e6ed37217deb13964867e89a5" dependencies = [ "gpui-pre-derive-refineable", ] @@ -3128,9 +3137,9 @@ dependencies = [ [[package]] name = "gpui-pre-reqwest-client" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f32ccea5d1a7c8d2868d7b346c2c1f76aa3f86ec9e0575c4e7340af3afd9e9d" +checksum = "53fa47d680f9e186b33af323f97345d67b6150c0e4c0bc894406bff822a4d9b4" dependencies = [ "anyhow", "bytes", @@ -3146,9 +3155,9 @@ dependencies = [ [[package]] name = "gpui-pre-scheduler" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7b10b3d2fc3fda24847a04436eca9531612c8414874a37ddc005f20e521e12" +checksum = "8049963b716df01b67b58f86000597f222d5b31b15b35069b23a3b07f3c68352" dependencies = [ "async-task", "backtrace", @@ -3163,9 +3172,9 @@ dependencies = [ [[package]] name = "gpui-pre-shared-string" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe17f81bd98a0253140ea79f7f76442d7499bd8d17db302861af8e46c11d38df" +checksum = "8ef6314bdf5ab162c1913712ec4a91b013ce2a9cfb0b557ecdfdd5793c8b61d3" dependencies = [ "schemars", "serde", @@ -3174,9 +3183,9 @@ dependencies = [ [[package]] name = "gpui-pre-sum-tree" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6d35e998a51d24248dad731de1f09d386cbbf46a04edde3b67f469b2ce3f16" +checksum = "5cca7e5fad9d53b2d265860fefdffc5f171966cb99c3fd99ecac15c033f19209" dependencies = [ "gpui-pre-ztracing", "heapless 0.9.3", @@ -3187,9 +3196,9 @@ dependencies = [ [[package]] name = "gpui-pre-util" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a026a30c578930d20974fd8c3d77de36223ac7ba193bd4259e7238929bc8806" +checksum = "d82e06359ae76f8bb07ed713c54adb80cea6a9a2d3dc715f41aec30cd2aadb4d" dependencies = [ "anyhow", "log", @@ -3198,9 +3207,9 @@ dependencies = [ [[package]] name = "gpui-pre-util-macros" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbf79627a6afc626d4e887dad9d2d92bb069413e8cc692ba3e90bff219eb7a9" +checksum = "b8a0da4143bb0e7eb926eed7edd3a61c9d17344207543dcc25fb398f0cb96b2f" dependencies = [ "gpui-pre-perf", "quote", @@ -3209,9 +3218,9 @@ dependencies = [ [[package]] name = "gpui-pre-web" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34be433b9fcb370b1fe9a1c7fe911f0eb6058791ef8167dcf6c5a5deb624975c" +checksum = "ab3870cc909471bb830c3d79496770c754401aad6cfa122147ccb13e8908ad08" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3238,9 +3247,9 @@ dependencies = [ [[package]] name = "gpui-pre-wgpu" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05515b32799b1767d69300921c1b562cd53104965211a0e8905acdc7aa18e693" +checksum = "f0b02657b56b09140ce542f5e4434fb96d9fdaba0fd0035ba7dba13c171ddb9b" dependencies = [ "anyhow", "bytemuck", @@ -3265,9 +3274,9 @@ dependencies = [ [[package]] name = "gpui-pre-windows" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27662e65bfcf4445d2cccfad91f1570ec07f4b1b5de98400a9f207fd19c464cf" +checksum = "f05592a6f9e3e6bb7a9f2a0d4779271cf747a948db1c07b02448de777ffff8f3" dependencies = [ "accesskit", "accesskit_windows", @@ -3294,9 +3303,9 @@ dependencies = [ [[package]] name = "gpui-pre-zlog" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a5af0b734a391a84872eb770b3ee9830091ec2487b79d06371fe14eefb2b5d" +checksum = "a7506302091e66d7fb5e04a5b3480e90cff0bbe3157458d7df91a41c59238c48" dependencies = [ "anyhow", "chrono", @@ -3306,9 +3315,9 @@ dependencies = [ [[package]] name = "gpui-pre-ztracing" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a24cb19f78c5bc3aab8ddf1e8e0bd6b402fbcb9c7f112f64ddbedef8f8768a59" +checksum = "4ed1bc6d53b3ca93785a8573d1066d555b67bc475b344fb28871f9811eb6f218" dependencies = [ "gpui-pre-zlog", "gpui-pre-ztracing-macro", @@ -3318,9 +3327,9 @@ dependencies = [ [[package]] name = "gpui-pre-ztracing-macro" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1bb25850ad31b88bbdef454afb93ec4cdb81f5e200ba5b3f1a0368111780d2c" +checksum = "829258540ea51bb9ea4a71686beffe5c99586901ba9f39944d06e4a180ab63fe" [[package]] name = "gpui-updater-core" @@ -4052,9 +4061,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -4753,9 +4762,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.18.0" +version = "4.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46" dependencies = [ "futures-lite", "log", @@ -6996,9 +7005,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skrifa" @@ -7037,9 +7046,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smol" @@ -7441,12 +7450,12 @@ dependencies = [ [[package]] name = "tauri-winrt-notification" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" dependencies = [ "thiserror 2.0.21", - "windows 0.61.3", + "windows 0.62.2", "windows-version", ] @@ -7679,9 +7688,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -8363,9 +8372,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -8376,19 +8385,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8396,9 +8406,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -8409,9 +8419,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -8542,9 +8552,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -9832,18 +9842,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.58" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.58" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index c7968032..50839b5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" } gpui_tokio = { path = "crates/gpui_tokio" } # Unstyled behavior, state, and infrastructure from GPUI Kit -gpui-base = "0.6.1" +gpui-base = "0.7.0" # Nostr nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 8bcb3af9..cea715e7 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -299,9 +299,9 @@ impl Room { } /// A stable seed for the room's generated avatar - pub fn display_image_seed(&self, cx: &App) -> SharedString { + pub fn display_image_seed(&self, cx: &App) -> String { if self.is_group() { - SharedString::from(self.id.to_string()) + self.id.to_string() } else { self.display_member(cx).avatar_seed() } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index a2c876d2..e054c119 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -633,7 +633,7 @@ impl ChatPanel { self.reports_by_id.read().unwrap().contains_key(id) } - /// Clone reports for a message (used for modal display, not called during render) + /// Clone reports for a message (used for dialog display, not called during render) fn sent_reports(&self, id: &EventId) -> Option> { self.reports_by_id.read().unwrap().get(id).cloned() } @@ -978,7 +978,7 @@ impl ChatPanel { let chat = ChatRegistry::global(cx); let seen_on = chat.read(cx).rumor_seen_on(id); - window.open_modal(cx, move |this, _window, cx| { + window.open_dialog(cx, move |this, _window, cx| { this.title("Seen on").show_close(true).child( v_flex() .gap_1() @@ -1021,7 +1021,7 @@ impl ChatPanel { fn open_relays(&mut self, public_key: &PublicKey, window: &mut Window, cx: &mut Context) { let profile = self.profile(public_key, cx); - window.open_modal(cx, move |this, _window, cx| { + window.open_dialog(cx, move |this, _window, cx| { let relays = profile.messaging_relays(); this.title("Messaging Relays") @@ -1422,7 +1422,7 @@ impl ChatPanel { this.on_click(move |_e, window, cx| { let reports = reports.clone(); - window.open_modal(cx, move |this, _window, cx| { + window.open_dialog(cx, move |this, _window, cx| { this.title(SharedString::from("Sent Reports")) .show_close(true) .child(v_flex().gap_4().children({ diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs index 859d0f0c..a2d63602 100644 --- a/crates/community_ui/src/lib.rs +++ b/crates/community_ui/src/lib.rs @@ -641,10 +641,7 @@ impl CommunityPanel { .community .read_with(cx, |community, _cx| { let seed = community.id().to_hex(); - let avatar = match community.icon() { - Some(path) => Avatar::from_source(path).seed(seed).large(), - None => Avatar::new(None).seed(seed).large(), - }; + let avatar = Avatar::from_source(community.icon()).seed(seed).large(); (community.name(), avatar) }) @@ -762,10 +759,7 @@ impl Panel for CommunityPanel { self.community .read_with(cx, |community, _cx| { let seed = community.id().to_hex(); - let avatar = match community.icon() { - Some(path) => Avatar::from_source(path).seed(seed).xsmall(), - None => Avatar::new(None).seed(seed).xsmall(), - }; + let avatar = Avatar::from_source(community.icon()).seed(seed).xsmall(); h_flex() .gap_1() diff --git a/crates/concord/src/state.rs b/crates/concord/src/state.rs index 7bb519b3..aa3f459d 100644 --- a/crates/concord/src/state.rs +++ b/crates/concord/src/state.rs @@ -235,6 +235,17 @@ impl CommunityState { None => BTreeMap::new(), }; + let relays = material + .relays + .iter() + .filter_map(|relay| RelayUrl::parse(relay).ok()) + .collect(); + + let control_root = match &material.control_root { + Some(root) => Some(decode_hex_32(root)?), + None => None, + }; + let mut channels = Vec::with_capacity(material.channels.len()); for grant in &material.channels { @@ -260,17 +271,10 @@ impl CommunityState { owner_salt: decode_hex_32(&material.owner_salt)?, community_root: decode_hex_32(&material.community_root)?, root_epoch: material.root_epoch, - control_root: match &material.control_root { - Some(root) => Some(decode_hex_32(root)?), - None => None, - }, + control_root, control_pks, channels, - relays: material - .relays - .iter() - .filter_map(|relay| RelayUrl::parse(relay).ok()) - .collect(), + relays, heads: Vec::new(), banned: BTreeSet::new(), cursors: BTreeMap::new(), diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index da74ea79..243268b5 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -113,8 +113,8 @@ impl Person { } /// A stable seed for this profile's generated avatar - pub fn avatar_seed(&self) -> SharedString { - SharedString::from(self.public_key().to_hex()) + pub fn avatar_seed(&self) -> String { + self.public_key().to_hex() } /// Get profile name diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index e768504d..e21198f1 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -134,7 +134,7 @@ pub struct Theme { /// Radius for the general elements. pub radius: Pixels, - /// Radius for the large elements, e.g.: modal, notification. + /// Radius for the large elements, e.g.: dialog, notification. pub radius_lg: Pixels, /// Enable shadow for the general elements. default is true diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 1e7c83ee..57909c81 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -10,7 +10,10 @@ use crate::{Selectable, Sizable, Size, StyledExt}; /// Number of rows and columns in the generated pixel grid. const PIXEL_GRID: usize = 8; -/// Probability that a cell in the left half of the grid is filled. +/// Empty cells kept between the pattern and the avatar edge, so the art +/// gathers in the center instead of filling the whole avatar. +const MARGIN: usize = 1; +/// Probability that a cell in the left half of the pattern area is filled. const FILL_PROBABILITY: f32 = 0.42; /// Probability that a filled cell uses the accent shade instead of the main color. const ACCENT_PROBABILITY: f32 = 0.25; @@ -33,19 +36,6 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength { } /// A deterministic, offline pixel-art avatar derived from a seed. -/// -/// Use it for entities that have no profile picture: the same seed always -/// renders the same pattern, so identities stay recognizable without a -/// network round trip. The pattern is painted as geometry and cropped to a -/// circle, at the same sizes as [`Avatar`]. -/// -/// # Examples -/// -/// ``` -/// use ui::avatar::PixelAvatar; -/// -/// PixelAvatar::new("alice"); -/// ``` #[derive(IntoElement)] pub struct PixelAvatar { seed: u64, @@ -87,7 +77,7 @@ impl RenderOnce for PixelAvatar { move |bounds, seed, window, cx| { let theme = cx.theme(); let main = Hsla { - h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1., + h: seed as f32 / u64::MAX as f32, s: 0.6, l: if theme.is_dark() { 0.6 } else { 0.45 }, a: 1., @@ -142,8 +132,11 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID]; let mut filled = 0usize; - for row in 0..PIXEL_GRID { - for col in 0..PIXEL_GRID / 2 { + let art_rows = PIXEL_GRID - 2 * MARGIN; + let art_columns = PIXEL_GRID / 2 - MARGIN; + + for row in MARGIN..PIXEL_GRID - MARGIN { + for col in MARGIN..PIXEL_GRID / 2 { if rng.chance(FILL_PROBABILITY) { let accent = rng.chance(ACCENT_PROBABILITY); set_cell(&mut pattern, row, col, if accent { 2 } else { 1 }); @@ -153,17 +146,17 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { } if filled < MIN_FILLED { - let half = PIXEL_GRID * PIXEL_GRID / 2; - let start = (rng.next() % half as u64) as usize; + let total = art_rows * art_columns; + let start = (rng.next() % total as u64) as usize; - for offset in 0..half { + for offset in 0..total { if filled >= MIN_FILLED { break; } - let ix = (start + offset) % half; - let row = ix / (PIXEL_GRID / 2); - let col = ix % (PIXEL_GRID / 2); + let ix = (start + offset) % total; + let row = MARGIN + ix / art_columns; + let col = MARGIN + ix % art_columns; if pattern[row * PIXEL_GRID + col] == 0 { set_cell(&mut pattern, row, col, 1); @@ -358,24 +351,12 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement { } /// An element that renders a user avatar with customizable appearance options. -/// -/// Entities without a picture still get a stable identity: the avatar falls -/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there -/// is no picture and when the picture fails to load. -/// -/// # Examples -/// -/// ``` -/// use ui::avatar::Avatar; -/// -/// Avatar::new(None).seed("alice"); -/// ``` #[derive(IntoElement)] pub struct Avatar { base: Div, picture: Option, grayscale: bool, - seed: Option, + seed: Option, style: StyleRefinement, size: Size, border_color: Option, @@ -384,16 +365,13 @@ pub struct Avatar { impl Avatar { /// Creates an avatar for an entity whose profile picture may be missing. - /// - /// Use [`Avatar::seed`] to choose the generated - /// pixel avatar rendered when `picture` is `None`. pub fn new(picture: Option) -> Self { - Self::from_picture(picture.map(ImageSource::from)) + Self::from_source(picture) } - /// Creates an avatar from an already-resolved source. - pub fn from_source(picture: impl Into) -> Self { - Self::from_picture(Some(picture.into())) + /// Creates an avatar from an already-resolved source, which may be missing. + pub fn from_source(picture: Option>) -> Self { + Self::from_picture(picture.map(Into::into)) } fn from_picture(picture: Option) -> Self { @@ -410,33 +388,18 @@ impl Avatar { } /// Sets the seed for the generated pixel avatar. - /// - /// The seed should be a stable identifier of the entity the avatar - /// represents, such as a public key. - pub fn seed(mut self, seed: impl Into) -> Self { + pub fn seed(mut self, seed: impl Into) -> Self { self.seed = Some(seed.into()); self } /// Applies a grayscale filter to the avatar image. - /// - /// # Examples - /// - /// ``` - /// use ui::avatar::Avatar; - /// - /// Avatar::new(None).grayscale(true); - /// ``` pub fn grayscale(mut self, grayscale: bool) -> Self { self.grayscale = grayscale; self } /// Sets the border color of the avatar. - /// - /// This might be used to match the border to the background color of - /// the parent element to create the illusion of cropping another - /// shape underneath (for example in face piles.) pub fn border_color(mut self, color: impl Into) -> Self { self.border_color = Some(color.into()); self @@ -480,6 +443,7 @@ impl RenderOnce for Avatar { } else { px(0.) }; + let image_size = avatar_size(self.size).to_pixels(window.rem_size()); let container_size = image_size + border_width * 2.; @@ -487,6 +451,7 @@ impl RenderOnce for Avatar { Some(picture) => { let seed = self.seed; let grayscale = self.grayscale; + img(picture) .size(image_size) .rounded_full() @@ -510,76 +475,3 @@ impl RenderOnce for Avatar { .child(content) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pixel_patterns_are_symmetric_and_stable() { - for seed in 0..50 { - let pattern = pixel_pattern(seed); - let filled = pattern.iter().filter(|&&cell| cell != 0).count(); - - assert!( - filled >= MIN_FILLED * 2, - "pattern too sparse for seed {seed}" - ); - - for row in 0..PIXEL_GRID { - for col in 0..PIXEL_GRID { - assert_eq!( - pattern[row * PIXEL_GRID + col], - pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)], - "asymmetric pattern for seed {seed} at ({row}, {col})" - ); - } - } - } - - for seed in [0, 1, 42, u64::MAX] { - assert_eq!(pixel_pattern(seed), pixel_pattern(seed)); - } - - assert_ne!(pixel_pattern(42), pixel_pattern(43)); - } - - fn area(polygon: &[Point]) -> f32 { - let mut sum: f32 = 0.; - for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) { - sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32(); - } - (sum / 2.).abs() - } - - #[test] - fn clipping_keeps_only_the_part_inside_the_circle() { - let circle = circle_polygon(point(px(10.), px(10.)), 10.); - let square = |left: f32, top: f32| { - [ - point(px(left), px(top)), - point(px(left + 4.), px(top)), - point(px(left + 4.), px(top + 4.)), - point(px(left), px(top + 4.)), - ] - }; - - let inside = clip_polygon(&square(8., 8.), &circle); - assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside)); - - assert!(clip_polygon(&square(20., 20.), &circle).is_empty()); - - let straddling = clip_polygon(&square(0., 0.), &circle); - for vertex in &straddling { - let delta_x = vertex.x.as_f32() - 10.; - let delta_y = vertex.y.as_f32() - 10.; - assert!( - delta_x.hypot(delta_y) <= 10. + 0.1, - "clipped vertex outside the circle" - ); - } - - let area = area(&straddling); - assert!(area > 0. && area < 16., "area {area}"); - } -} diff --git a/crates/ui/src/modal.rs b/crates/ui/src/dialog.rs similarity index 76% rename from crates/ui/src/modal.rs rename to crates/ui/src/dialog.rs index e7715416..f63dffd5 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/dialog.rs @@ -6,7 +6,6 @@ use gpui::{ InteractiveElement as _, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, hsla, point, px, size, }; -use gpui_base::Dialog; use instant::Duration; use theme::ActiveTheme; @@ -22,15 +21,15 @@ type RenderButtonFn = Box AnyElement>; type FooterFn = Box Vec>; -/// Modal button props. -pub struct ModalButtonProps { +/// Dialog button props. +pub struct DialogButtonProps { ok_text: Option, ok_variant: ButtonVariant, cancel_text: Option, cancel_variant: ButtonVariant, } -impl Default for ModalButtonProps { +impl Default for DialogButtonProps { fn default() -> Self { Self { ok_text: None, @@ -41,7 +40,7 @@ impl Default for ModalButtonProps { } } -impl ModalButtonProps { +impl DialogButtonProps { /// Sets the text of the OK button. Default is `OK`. pub fn ok_text(mut self, ok_text: impl Into) -> Self { self.ok_text = Some(ok_text.into()); @@ -68,7 +67,7 @@ impl ModalButtonProps { } #[derive(IntoElement)] -pub struct Modal { +pub struct Dialog { style: StyleRefinement, title: Option, footer: Option, @@ -85,15 +84,15 @@ pub struct Modal { overlay_closable: bool, keyboard: bool, show_close: bool, - button_props: ModalButtonProps, + button_props: DialogButtonProps, - /// This will be change when open the modal, the focus handle is create when open the modal. + /// This will be change when open the dialog, the focus handle is create when open the dialog. pub focus_handle: FocusHandle, pub layer_ix: usize, pub overlay_visible: bool, } -impl Modal { +impl Dialog { pub fn new(_window: &mut Window, cx: &mut App) -> Self { Self { style: StyleRefinement::default(), @@ -111,19 +110,19 @@ impl Modal { on_close: Rc::new(|_, _, _| {}), on_ok: None, on_cancel: Rc::new(|_, _, _| true), - button_props: ModalButtonProps::default(), + button_props: DialogButtonProps::default(), show_close: true, overlay_closable: true, } } - /// Sets the title of the modal. + /// Sets the title of the dialog. pub fn title(mut self, title: impl IntoElement) -> Self { self.title = Some(title.into_any_element()); self } - /// Set the footer of the modal. + /// Set the footer of the dialog. /// /// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`. /// @@ -145,7 +144,7 @@ impl Modal { self } - /// Set to use confirm modal, with OK and Cancel buttons. + /// Set to use confirm dialog, with OK and Cancel buttons. /// /// See also [`Self::alert`] pub fn confirm(self) -> Self { @@ -154,7 +153,7 @@ impl Modal { .show_close(false) } - /// Set to as a alter modal, with OK button. + /// Set to as a alter dialog, with OK button. /// /// See also [`Self::confirm`] pub fn alert(self) -> Self { @@ -163,13 +162,13 @@ impl Modal { .show_close(false) } - /// Set the button props of the modal. - pub fn button_props(mut self, button_props: ModalButtonProps) -> Self { + /// Set the button props of the dialog. + pub fn button_props(mut self, button_props: DialogButtonProps) -> Self { self.button_props = button_props; self } - /// Sets the callback for when the modal is closed. + /// Sets the callback for when the dialog is closed. /// /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback. pub fn on_close( @@ -180,9 +179,9 @@ impl Modal { self } - /// Sets the callback for when the modal is has been confirmed. + /// Sets the callback for when the dialog is has been confirmed. /// - /// The callback should return `true` to close the modal, if return `false` the modal will not be closed. + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. pub fn on_ok( mut self, on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, @@ -191,9 +190,9 @@ impl Modal { self } - /// Sets the callback for when the modal is has been canceled. + /// Sets the callback for when the dialog is has been canceled. /// - /// The callback should return `true` to close the modal, if return `false` the modal will not be closed. + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. pub fn on_cancel( mut self, on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, @@ -208,39 +207,39 @@ impl Modal { self } - /// Set the top offset of the modal, defaults to None, will use the 1/10 of the viewport height. + /// Shift the dialog down from the window centre, defaults to `None`. pub fn margin_top(mut self, margin_top: Pixels) -> Self { self.margin_top = Some(margin_top); self } - /// Sets the width of the modal, defaults to 480px. + /// Sets the width of the dialog, defaults to 480px. pub fn width(mut self, width: Pixels) -> Self { self.width = width; self } - /// Set the maximum width of the modal, defaults to `None`. + /// Set the maximum width of the dialog, defaults to `None`. pub fn max_w(mut self, max_width: Pixels) -> Self { self.max_width = Some(max_width); self } - /// Set the overlay of the modal, defaults to `true`. + /// Set the overlay of the dialog, defaults to `true`. pub fn overlay(mut self, overlay: bool) -> Self { self.overlay = overlay; self } - /// Set the overlay closable of the modal, defaults to `true`. + /// Set the overlay closable of the dialog, defaults to `true`. /// - /// When the overlay is clicked, the modal will be closed. + /// When the overlay is clicked, the dialog will be closed. pub fn overlay_closable(mut self, overlay_closable: bool) -> Self { self.overlay_closable = overlay_closable; self } - /// Set whether to support keyboard esc to close the modal, defaults to `true`. + /// Set whether to support keyboard esc to close the dialog, defaults to `true`. pub fn keyboard(mut self, keyboard: bool) -> Self { self.keyboard = keyboard; self @@ -251,22 +250,22 @@ impl Modal { } } -impl ParentElement for Modal { +impl ParentElement for Dialog { fn extend(&mut self, elements: impl IntoIterator) { self.content.extend(elements); } } -impl Styled for Modal { +impl Styled for Dialog { fn style(&mut self) -> &mut gpui::StyleRefinement { &mut self.style } } -impl RenderOnce for Modal { +impl RenderOnce for Dialog { fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { let layer_ix = self.layer_ix; - let is_topmost = layer_ix + 1 == Root::read(window, cx).active_modals.len(); + let is_topmost = layer_ix + 1 == Root::read(window, cx).active_dialogs.len(); let has_footer = self.footer.is_some(); let on_close = self.on_close.clone(); let on_ok = self.on_ok.clone(); @@ -296,7 +295,7 @@ impl RenderOnce for Modal { } on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); + window.close_dialog(cx); } }) .into_any_element() @@ -327,7 +326,7 @@ impl RenderOnce for Modal { } on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); + window.close_dialog(cx); } }) .into_any_element() @@ -344,10 +343,7 @@ impl RenderOnce for Modal { ); let offset_top = px(layer_ix as f32 * 16.); - let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; - let x = view_size.width / 2. - self.width / 2.; - let card_top = window_paddings.top + y; - let card_left = window_paddings.left + x; + let top_offset = self.margin_top.unwrap_or(px(0.)) + offset_top; let mut padding_right = px(16.); let mut padding_left = px(16.); @@ -386,11 +382,9 @@ impl RenderOnce for Modal { .min_h_24() .refine_style(&self.style) // There style is high priority, can't be overridden. - .absolute() .occlude() .relative() - .left(card_left) - .top(card_top) + .top(top_offset) .w(self.width) .when_some(self.max_width, |this, w| this.max_w(w)) .child( @@ -401,34 +395,41 @@ impl RenderOnce for Modal { .flex() .items_center() .justify_center() + .relative() .when_some(self.title, |this, title| { this.h_10().font_semibold().text_center().child(title) + }) + .when(self.show_close, |this| { + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); + + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .right_2() + .flex() + .items_center() + .child( + Button::new("close") + .icon(IconName::CloseCircleFill) + .custom( + ButtonCustomVariant::new(window, cx) + .foreground(cx.theme().icon_muted) + .color(cx.theme().ghost_element_background) + .hover(cx.theme().ghost_element_background) + .active(cx.theme().ghost_element_background), + ) + .on_click(move |_, window, cx| { + on_cancel(&ClickEvent::default(), window, cx); + on_close(&ClickEvent::default(), window, cx); + window.close_dialog(cx); + }), + ), + ) }), ) - .when(self.show_close, |this| { - let on_cancel = on_cancel.clone(); - let on_close = on_close.clone(); - - this.child( - Button::new("close") - .icon(IconName::CloseCircleFill) - .absolute() - .top_1p5() - .right_2() - .custom( - ButtonCustomVariant::new(window, cx) - .foreground(cx.theme().icon_muted) - .color(cx.theme().ghost_element_background) - .hover(cx.theme().ghost_element_background) - .active(cx.theme().ghost_element_background), - ) - .on_click(move |_, window, cx| { - on_cancel(&ClickEvent::default(), window, cx); - on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); - }), - ) - }) .child( div() .pt_px() @@ -459,7 +460,8 @@ impl RenderOnce for Modal { ) }) .with_animation("slide-down", animation, move |this, delta| { - let y_offset = px(0.) + delta * px(30.); + // Settle at the target offset; only the entrance is animated. + let y_offset = px(30.) * (delta - 1.); // This is equivalent to `shadow_xl` with an extra opacity. let shadow = vec![ BoxShadow { @@ -477,10 +479,10 @@ impl RenderOnce for Modal { inset: false, }, ]; - this.top(card_top + y_offset).shadow(shadow) + this.top(top_offset + y_offset).shadow(shadow) }); - Dialog::new(cx) + gpui_base::Dialog::new(cx) .layer(layer_ix, is_topmost) .focus_handle(self.focus_handle.clone()) .close_on_escape(self.keyboard) @@ -492,7 +494,7 @@ impl RenderOnce for Modal { .on_cancel(move |event, window, cx| on_cancel(event, window, cx)) .on_close(move |event, window, cx| { on_close(event, window, cx); - window.close_modal(cx); + window.close_dialog(cx); }) .backdrop(backdrop) .popup(card) diff --git a/crates/ui/src/dock/mod.rs b/crates/ui/src/dock/mod.rs index 4b6a32ce..f5a1bbdf 100644 --- a/crates/ui/src/dock/mod.rs +++ b/crates/ui/src/dock/mod.rs @@ -16,7 +16,7 @@ use gpui_base::dock::{ DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode, PaneRef, PanelId, TabGroupContext, TabGroupRenderer, }; -use gpui_base::{Placement, ResizeHandleContext, Side}; +use gpui_base::{HandleEdge, Placement, ResizeHandleContext}; use theme::{ActiveTheme, TABBAR_HEIGHT}; use crate::button::{Button, ButtonVariants as _}; @@ -225,12 +225,14 @@ impl DockSkin { DockPlacement::Center => "dock-resize-handle-center", }; + let edge = match placement { + DockPlacement::Left => Some(HandleEdge::Trailing), + DockPlacement::Right | DockPlacement::Bottom => Some(HandleEdge::Leading), + DockPlacement::Center => None, + }; + resize_handle(id, placement.axis()) - .placement(if placement.is_left() { - Side::Left - } else { - Side::Right - }) + .when_some(edge, |this, edge| this.inside(edge)) .with_appearance(resize_handle_appearance()) .on_drag(DockResizeHandle, move |info, _, _, cx| { cx.stop_propagation(); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index d4ac3f1c..1824ad95 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -11,6 +11,7 @@ pub use crate::Disableable; pub mod animation; pub mod avatar; pub mod button; +pub mod dialog; pub mod divider; pub mod dock; pub mod group_box; @@ -19,7 +20,6 @@ pub mod input; pub mod markdown; pub mod menu; pub mod message; -pub mod modal; pub mod nav; pub mod nav_item; pub mod notification; diff --git a/crates/ui/src/nav.rs b/crates/ui/src/nav.rs index 21a17538..efa770f7 100644 --- a/crates/ui/src/nav.rs +++ b/crates/ui/src/nav.rs @@ -84,40 +84,36 @@ impl RenderOnce for Nav { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let clickable = self.on_click.is_some(); - v_flex() - .id(format!("nav-{}", self.id)) - .w_full() - .h_10() - .child( - h_flex() - .id(self.id) - .h_9() - .w_full() - .px_1p5() - .gap_1p5() - .rounded(cx.theme().radius_lg) - .when_some(self.prefix, |this, prefix| this.child(prefix)) - .child( - h_flex() - .gap_1() - .flex_1() - .child(div().truncate().min_w_0().child(self.label)) - .child(div().flex_1()) - .when_some(self.suffix, |this, suffix| { - this.child(div().flex_shrink_0().child(suffix)) - }), - ) - .when(clickable, |this| { - this.cursor_pointer() - .hover(|this| this.bg(cx.theme().ghost_element_hover)) - .when(self.selected, |this| { - this.bg(cx.theme().ghost_element_active) - }) - }) - .when_some(self.on_click, |this, handler| { - this.on_click(move |event, window, cx| handler(event, window, cx)) - }) - .refine_style(&self.style), - ) + v_flex().w_full().h_10().child( + h_flex() + .id(self.id) + .h_9() + .w_full() + .px_1p5() + .gap_1p5() + .rounded(cx.theme().radius_lg) + .when_some(self.prefix, |this, prefix| this.child(prefix)) + .child( + h_flex() + .gap_1() + .flex_1() + .min_w_0() + .child(div().flex_1().min_w_0().truncate().child(self.label)) + .when_some(self.suffix, |this, suffix| { + this.child(div().flex_shrink_0().child(suffix)) + }), + ) + .when(clickable, |this| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .when(self.selected, |this| { + this.bg(cx.theme().ghost_element_active) + }) + }) + .when_some(self.on_click, |this, handler| { + this.on_click(move |event, window, cx| handler(event, window, cx)) + }) + .refine_style(&self.style), + ) } } diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index d1e44182..8da9baa1 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -13,23 +13,23 @@ use theme::{ CLIENT_SIDE_DECORATION_SHADOW, }; -use crate::modal::Modal; +use crate::dialog::Dialog; use crate::notification::{Notification, NotificationList}; #[derive(Clone)] #[allow(clippy::type_complexity)] -pub struct ActiveModal { +pub struct ActiveDialog { focus_handle: FocusHandle, - /// The previous focused handle before opening the modal. + /// The previous focused handle before opening the dialog. previous_focused_handle: Option, - builder: Rc Modal + 'static>, + builder: Rc Dialog + 'static>, } -impl ActiveModal { +impl ActiveDialog { fn new( focus_handle: FocusHandle, previous_focused_handle: Option, - builder: impl Fn(Modal, &mut Window, &mut App) -> Modal + 'static, + builder: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static, ) -> Self { Self { focus_handle, @@ -41,10 +41,10 @@ impl ActiveModal { /// Root is a view for the App window for as the top level view (Must be the first view in the window). /// -/// It is used to manage the Modal, and Notification. +/// It is used to manage the Dialog, and Notification. pub struct Root { - /// All active models - pub(crate) active_modals: Vec, + /// All active dialogs + pub(crate) active_dialogs: Vec, /// Notification layer pub(crate) notification: Entity, @@ -56,7 +56,7 @@ pub struct Root { impl Root { pub fn new(view: AnyView, window: &mut Window, cx: &mut Context) -> Self { Self { - active_modals: Vec::new(), + active_dialogs: Vec::new(), notification: cx.new(|cx| NotificationList::new(window, cx)), view, } @@ -98,63 +98,63 @@ impl Root { ) } - /// Render the modal layer. - pub fn render_modal_layer( + /// Render the dialog layer. + pub fn render_dialog_layer( window: &mut Window, cx: &mut App, ) -> Option> { let root = window.root::()??; - let active_modals = root.read(cx).active_modals.clone(); + let active_dialogs = root.read(cx).active_dialogs.clone(); - if active_modals.is_empty() { + if active_dialogs.is_empty() { return None; } let mut show_overlay_ix = None; - let mut modals = active_modals + let mut dialogs = active_dialogs .iter() .enumerate() - .map(|(i, active_modal)| { - let mut modal = Modal::new(window, cx); + .map(|(i, active_dialog)| { + let mut dialog = Dialog::new(window, cx); - modal = (active_modal.builder)(modal, window, cx); + dialog = (active_dialog.builder)(dialog, window, cx); - // Give the modal the focus handle, because `modal` is a temporary value, is not possible to - // keep the focus handle in the modal. + // Give the dialog the focus handle, because `dialog` is a temporary value, is not possible to + // keep the focus handle in the dialog. // - // So we keep the focus handle in the `active_modal`, this is owned by the `Root`. - modal.focus_handle = active_modal.focus_handle.clone(); + // So we keep the focus handle in the `active_dialog`, this is owned by the `Root`. + dialog.focus_handle = active_dialog.focus_handle.clone(); - modal.layer_ix = i; - // Find the modal which one needs to show overlay. - if modal.has_overlay() { + dialog.layer_ix = i; + // Find the dialog which one needs to show overlay. + if dialog.has_overlay() { show_overlay_ix = Some(i); } - modal + dialog }) .collect::>(); if let Some(ix) = show_overlay_ix - && let Some(modal) = modals.get_mut(ix) + && let Some(dialog) = dialogs.get_mut(ix) { - modal.overlay_visible = true; + dialog.overlay_visible = true; } - Some(div().children(modals)) + Some(div().children(dialogs)) } - /// Open a modal. - pub fn open_modal(&mut self, builder: F, window: &mut Window, cx: &mut Context<'_, Self>) + /// Open a dialog. + pub fn open_dialog(&mut self, builder: F, window: &mut Window, cx: &mut Context<'_, Self>) where - F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static, + F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static, { let previous_focused_handle = window.focused(cx).map(|h| h.downgrade()); let focus_handle = cx.focus_handle(); focus_handle.focus(window, cx); - self.active_modals.push(ActiveModal::new( + self.active_dialogs.push(ActiveDialog::new( focus_handle, previous_focused_handle, builder, @@ -163,10 +163,10 @@ impl Root { cx.notify(); } - /// Close the topmost modal. - pub fn close_modal(&mut self, window: &mut Window, cx: &mut Context) { + /// Close the topmost dialog. + pub fn close_dialog(&mut self, window: &mut Window, cx: &mut Context) { if let Some(handle) = self - .active_modals + .active_dialogs .pop() .and_then(|d| d.previous_focused_handle) .and_then(|h| h.upgrade()) @@ -177,12 +177,12 @@ impl Root { cx.notify(); } - /// Close all modals. - pub fn close_all_modals(&mut self, window: &mut Window, cx: &mut Context) { - self.active_modals.clear(); + /// Close all dialogs. + pub fn close_all_dialogs(&mut self, window: &mut Window, cx: &mut Context) { + self.active_dialogs.clear(); let previous_focused_handle = self - .active_modals + .active_dialogs .first() .and_then(|d| d.previous_focused_handle.clone()); @@ -193,9 +193,9 @@ impl Root { cx.notify(); } - /// Check if there are any active modals. - pub fn has_active_modals(&self) -> bool { - !self.active_modals.is_empty() + /// Check if there are any active dialogs. + pub fn has_active_dialogs(&self) -> bool { + !self.active_dialogs.is_empty() } /// Push a notification to the notification layer. diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs index e042c448..4877d7bd 100644 --- a/crates/ui/src/window_ext.rs +++ b/crates/ui/src/window_ext.rs @@ -3,24 +3,24 @@ use std::rc::Rc; use gpui::{App, ElementId, Entity, Window}; use crate::Root; -use crate::modal::Modal; +use crate::dialog::Dialog; use crate::notification::Notification; -/// Extension trait for [`Window`] to add modal, notification .. functionality. +/// Extension trait for [`Window`] to add dialog, notification .. functionality. pub trait WindowExtension: Sized { - /// Opens a Modal. - fn open_modal(&mut self, cx: &mut App, builder: F) + /// Opens a Dialog. + fn open_dialog(&mut self, cx: &mut App, builder: F) where - F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static; + F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static; - /// Return true, if there is an active Modal. - fn has_active_modal(&mut self, cx: &mut App) -> bool; + /// Return true, if there is an active Dialog. + fn has_active_dialog(&mut self, cx: &mut App) -> bool; - /// Closes the last active Modal. - fn close_modal(&mut self, cx: &mut App); + /// Closes the last active Dialog. + fn close_dialog(&mut self, cx: &mut App); - /// Closes all active Modals. - fn close_all_modals(&mut self, cx: &mut App); + /// Closes all active Dialogs. + fn close_all_dialogs(&mut self, cx: &mut App); /// Returns number of notifications. fn notifications(&mut self, cx: &mut App) -> Rc>>; @@ -46,31 +46,31 @@ pub trait WindowExtension: Sized { impl WindowExtension for Window { #[inline] - fn open_modal(&mut self, cx: &mut App, builder: F) + fn open_dialog(&mut self, cx: &mut App, builder: F) where - F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static, + F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static, { Root::update(self, cx, move |root, window, cx| { - root.open_modal(builder, window, cx); + root.open_dialog(builder, window, cx); }) } #[inline] - fn has_active_modal(&mut self, cx: &mut App) -> bool { - Root::read(self, cx).has_active_modals() + fn has_active_dialog(&mut self, cx: &mut App) -> bool { + Root::read(self, cx).has_active_dialogs() } #[inline] - fn close_modal(&mut self, cx: &mut App) { + fn close_dialog(&mut self, cx: &mut App) { Root::update(self, cx, move |root, window, cx| { - root.close_modal(window, cx); + root.close_dialog(window, cx); }) } #[inline] - fn close_all_modals(&mut self, cx: &mut App) { + fn close_all_dialogs(&mut self, cx: &mut App) { Root::update(self, cx, |root, window, cx| { - root.close_all_modals(window, cx); + root.close_all_dialogs(window, cx); }) } diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index ed118245..20a0963c 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -15,7 +15,7 @@ use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex}; pub fn open(window: &mut Window, cx: &mut App) { let import = cx.new(|cx| ImportIdentity::new(window, cx)); - window.open_modal(cx, move |this, _window, _cx| { + window.open_dialog(cx, move |this, _window, _cx| { this.width(px(450.)) .show_close(false) .overlay_closable(false) @@ -138,7 +138,7 @@ impl ImportIdentity { Ok(keys) => { nostr.update_in(cx, |this, window, cx| { this.set_signer(keys, cx); - window.close_modal(cx); + window.close_dialog(cx); })?; } Err(e) => { diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index 43495b33..11eefa68 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -2,4 +2,5 @@ pub mod import; pub mod new_chat; pub mod new_community; pub mod restore; +pub mod screening; pub mod settings; diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs index 8e766050..8ff55b80 100644 --- a/crates/workspace/src/dialogs/new_chat.rs +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -1,79 +1,307 @@ +use std::ops::Range; +use std::rc::Rc; + +use anyhow::Error; use chat::{ChatRegistry, Room, RoomKind}; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, - Subscription, WeakEntity, Window, div, px, + AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, + Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, Task, WeakEntity, + Window, div, px, uniform_list, }; use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; use state::NostrRegistry; use theme::ActiveTheme; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; +use ui::dialog::DialogButtonProps; use ui::dock::{DockArea, DockPlacement, PanelHandle}; use ui::input::{Input, InputEvent, InputState}; -use ui::{StyledExt, WindowExtension, v_flex}; +use ui::nav::Nav; +use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; + +/// Maximum number of members a chat can have. +const MAX_MEMBERS: usize = 3; pub fn open(dock: WeakEntity, window: &mut Window, cx: &mut App) { let view = cx.new(|cx| NewChat::new(dock, window, cx)); - window.open_modal(cx, move |this, _window, _cx| { - this.width(px(420.)).title("New chat").child(view.clone()) + window.open_dialog(cx, { + let view = view.clone(); + move |this, _window, _cx| { + this.width(px(420.)) + .title("New chat") + .confirm() + .button_props( + DialogButtonProps::default() + .cancel_text("Cancel") + .ok_text("Start"), + ) + .on_ok({ + let view = view.clone(); + move |_, window, cx| view.update(cx, |this, cx| this.start_chat(window, cx)) + }) + .child(view.clone()) + } }); } pub struct NewChat { - /// The dock a started chat opens in dock: WeakEntity, - - /// Public key input + /// Find input, used to filter contacts or enter a new public key input: Entity, - - /// Error message + /// Current user's contacts + contacts: Option>, + /// Public keys selected as members of the chat + members: Vec, + /// Whether a NIP-05 address is being resolved + resolving: bool, error: Option, - - /// Input subscription + tasks: Vec>>, _subscription: Option, } impl NewChat { fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { - let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub")); + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder("Search or enter npub or user@address.me") + }); let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { - this.start_chat(window, cx); + this.submit(window, cx) } }); + // Load the contacts once the view is mounted + cx.defer_in(window, |this, window, cx| { + this.load_contacts(window, cx); + }); + Self { dock, input, + contacts: None, + members: Vec::new(), + resolving: false, error: None, + tasks: Vec::new(), _subscription: Some(subscription), } } - fn start_chat(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.input.read(cx).value().to_string(); + /// Load the signed-in user's contact list. + fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { + if self.contacts.is_some() { + return; + } - let Ok(peer) = PublicKey::parse(&value) else { - self.set_error("Public key is invalid", cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(current_user) = nostr.read(cx).current_user() else { return; }; + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(current_user) + .kind(Kind::ContactList) + .limit(1); + + let contacts: Vec = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.contacts = Some(contacts); + cx.notify(); + })?; + } + Err(error) => { + this.update(cx, |this, cx| { + this.set_error(error.to_string(), cx); + })?; + } + } + + Ok(()) + })); + } + + /// The contacts matching the current input, or every contact when empty. + fn filtered_contacts(&self, cx: &Context) -> Vec { + let Some(contacts) = self.contacts.as_ref() else { + return Vec::new(); + }; + + let persons = PersonRegistry::global(cx); + let query = self.input.read(cx).value().trim().to_lowercase(); + + if query.is_empty() { + return contacts.clone(); + } + + contacts + .iter() + .filter(|public_key| { + if public_key.to_hex().to_lowercase().contains(&query) { + return true; + } + let name = persons.read(cx).get(public_key, cx).name(); + name.to_lowercase().contains(&query) + }) + .cloned() + .collect() + } + + /// Resolve the input into a public key and add it as a member. + fn submit(&mut self, window: &mut Window, cx: &mut Context) { + if self.resolving { + return; + } + + let query = self.input.read(cx).value().trim().to_string(); + + if query.is_empty() { + return; + } + + // A raw public key or an npub can be added directly. + if let Ok(public_key) = PublicKey::parse(&query) { + if self.try_add(public_key, cx) { + self.clear_input(window, cx); + } + return; + } + + // A NIP-05 address is resolved before being added. + if let Ok(address) = Nip05Address::parse(&query) { + let nostr = NostrRegistry::global(cx); + let task = nostr.read(cx).query_address(address, cx); + + self.set_resolving(true, window, cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let result = task.await; + + this.update_in(cx, |this, window, cx| { + this.set_resolving(false, window, cx); + + match result { + Ok(public_key) => { + if this.try_add(public_key, cx) { + this.clear_input(window, cx); + } + } + Err(error) => this.set_error(error.to_string(), cx), + } + })?; + + Ok(()) + })); + return; + } + + // Fall back to a local search by contact name. + let mut matches = self.filtered_contacts(cx); + match matches.len() { + 0 => self.set_error("Enter a valid npub or NIP-05 address", cx), + 1 => { + let public_key = matches.remove(0); + if self.try_add(public_key, cx) { + self.clear_input(window, cx); + } + } + _ => self.set_error("Multiple contacts match, pick one from the list", cx), + } + } + + /// Add a public key to the members if possible, otherwise show an error. + fn try_add(&mut self, public_key: PublicKey, cx: &mut Context) -> bool { + let nostr = NostrRegistry::global(cx); + + let Some(current_user) = nostr.read(cx).current_user() else { + self.set_error("You are not signed in", cx); + return false; + }; + + if public_key == current_user { + self.set_error("You cannot add yourself", cx); + return false; + } + + if self.members.contains(&public_key) { + self.set_error("This person is already added", cx); + return false; + } + + if self.members.len() >= MAX_MEMBERS { + self.set_error(format!("A chat can have up to {MAX_MEMBERS} members"), cx); + return false; + } + + self.members.push(public_key); + self.error = None; + cx.notify(); + + true + } + + /// Toggle a contact's membership. + fn toggle_member(&mut self, public_key: &PublicKey, cx: &mut Context) { + if self.members.iter().any(|member| member == public_key) { + self.members.retain(|member| member != public_key); + self.error = None; + cx.notify(); + } else { + self.try_add(public_key.to_owned(), cx); + } + } + + fn clear_input(&mut self, window: &mut Window, cx: &mut Context) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + } + + /// Toggle the input's loading spinner while a NIP-05 address is resolved. + fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context) { + self.input.update(cx, |this, cx| { + this.set_loading(resolving, window, cx); + }); + self.resolving = resolving; + cx.notify(); + } + + fn start_chat(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.members.is_empty() { + self.set_error("Add at least one person to chat with", cx); + return false; + } + let nostr = NostrRegistry::global(cx); let chat = ChatRegistry::global(cx); let Some(current_user) = nostr.read(cx).current_user() else { self.set_error("You are not signed in", cx); - return; + return false; }; - if peer == current_user { - self.set_error("You cannot chat with yourself", cx); - return; - } - - let room = Room::new(current_user, [peer]) + let room = Room::new(current_user, self.members.clone()) .organize(¤t_user) .kind(RoomKind::Ongoing); @@ -91,43 +319,138 @@ impl NewChat { cx, ); - window.close_modal(cx); + true } fn set_error(&mut self, message: impl Into, cx: &mut Context) { self.error = Some(message.into()); cx.notify(); } + + fn render_contacts( + &self, + range: Range, + contacts: &[PublicKey], + cx: &mut Context, + ) -> Vec { + let persons = PersonRegistry::global(cx); + let start = range.start; + + contacts + .get(range) + .into_iter() + .flatten() + .enumerate() + .map(|(offset, public_key)| { + let profile = persons.read(cx).get(public_key, cx); + let public_key = *public_key; + + Nav::new(start + offset) + .label(profile.name()) + .text_sm() + .when(!AppSettings::get_hide_avatar(cx), |this| { + this.prefix( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_member(&public_key, cx); + })) + .into_any_element() + }) + .collect() + } + + fn render_members(&self, cx: &mut Context) -> impl IntoElement { + let persons = PersonRegistry::global(cx); + + h_flex() + .flex_wrap() + .gap_1() + .children(self.members.iter().map(|public_key| { + let profile = persons.read(cx).get(public_key, cx); + let public_key = *public_key; + let id = SharedString::from(format!("m-{}", public_key.to_hex())); + + h_flex() + .id(id) + .gap_1() + .p_0p5() + .rounded_full() + .bg(cx.theme().ghost_element_background_alt) + .text_size(px(10.)) + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .child( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .xsmall() + .flex_shrink_0(), + ) + .child(div().max_w_32().truncate().child(profile.name())) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_member(&public_key, cx); + })) + })) + } } impl Render for NewChat { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let contacts = Rc::new(self.filtered_contacts(cx)); + let contact_count = contacts.len(); + let notice = SharedString::from(format!("Group Chat can have up to {MAX_MEMBERS} members")); + v_flex() - .gap_4() + .gap_3() .child( - v_flex() + h_flex() .gap_1() - .text_color(cx.theme().text_muted) - .child("Public key of the person you want to chat with") - .child(Input::new(&self.input)), + .w_full() + .child(Input::new(&self.input).flex_1().text_sm().cleanable(true)) + .child( + Button::new("add") + .icon(IconName::Plus) + .tooltip("Add person") + .ghost() + .large() + .disabled(self.resolving) + .on_click(cx.listener(|this, _event, window, cx| { + this.submit(window, cx); + })), + ), ) - .child( - Button::new("start-chat") - .label("Start chat") - .primary() - .font_semibold() - .on_click(cx.listener(|this, _event, window, cx| { - this.start_chat(window, cx); - })), - ) - .when_some(self.error.clone(), |this, error| { - this.child( - div() - .text_xs() - .text_center() - .text_color(cx.theme().text_danger) - .child(error), - ) + .when(!self.members.is_empty(), |this| { + this.child(self.render_members(cx)) }) + .map(|this| { + if let Some(error) = self.error.clone() { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_danger) + .child(error), + ) + } else { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(notice), + ) + } + }) + .child( + uniform_list( + "contacts", + contact_count, + cx.processor(move |this, range, _window, cx| { + this.render_contacts(range, contacts.as_slice(), cx) + }), + ) + .h_80(), + ) } } diff --git a/crates/workspace/src/dialogs/new_community.rs b/crates/workspace/src/dialogs/new_community.rs index 4b4f36ba..dea1a10a 100644 --- a/crates/workspace/src/dialogs/new_community.rs +++ b/crates/workspace/src/dialogs/new_community.rs @@ -6,7 +6,7 @@ use ui::input::{Input, InputState}; pub fn open(window: &mut Window, cx: &mut App) { let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name")); - window.open_modal(cx, move |this, _window, _cx| { + window.open_dialog(cx, move |this, _window, _cx| { let name_input = name_input.clone(); this.width(px(380.)) @@ -14,6 +14,7 @@ pub fn open(window: &mut Window, cx: &mut App) { .title("New community") .child(Input::new(&name_input)) .on_ok(move |_event, _window, cx| { + let community = CommunityRegistry::global(cx); let name = name_input.read(cx).value().trim().to_owned(); if name.is_empty() { @@ -25,8 +26,9 @@ pub fn open(window: &mut Window, cx: &mut App) { ..CommunityMetadata::default() }; - CommunityRegistry::global(cx) - .update(cx, |registry, cx| registry.create(metadata, cx)); + community.update(cx, |registry, cx| { + registry.create(metadata, cx); + }); true }) diff --git a/crates/workspace/src/dialogs/restore.rs b/crates/workspace/src/dialogs/restore.rs index 958d387b..7196bc00 100644 --- a/crates/workspace/src/dialogs/restore.rs +++ b/crates/workspace/src/dialogs/restore.rs @@ -65,8 +65,8 @@ impl RestoreEncryption { this.set_announcement(Keys::new(secret), cx); }); - // Close the current modal - window.close_modal(cx); + // Close the current dialog + window.close_dialog(cx); } fn set_error(&mut self, message: S, cx: &mut Context) diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs new file mode 100644 index 00000000..8a61cfd6 --- /dev/null +++ b/crates/workspace/src/dialogs/screening.rs @@ -0,0 +1,545 @@ +use std::collections::HashMap; + +use anyhow::Error; +use common::TimestampExt; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, + SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list, +}; +use instant::Duration; +use nostr_sdk::prelude::*; +use person::{Person, PersonRegistry, shorten_pubkey}; +use smallvec::{SmallVec, smallvec}; +use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::indicator::Indicator; +use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Screening::new(public_key, window, cx)) +} + +/// Screening +pub struct Screening { + /// Public Key of the person being screened. + public_key: PublicKey, + + /// Whether the person's address is verified. + verified: bool, + + /// Whether the person is followed by current user. + followed: bool, + + /// Last time the person was active. + last_active: Option, + + /// All mutual contacts of the person being screened. + mutual_contacts: Vec, + + /// Async tasks + tasks: SmallVec<[Task<()>; 3]>, + + /// Subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl Screening { + pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.on_release_in(window, move |this, window, cx| { + this.tasks.clear(); + window.close_all_dialogs(cx); + })); + + cx.defer_in(window, |this, _window, cx| { + this.check_contact(cx); + this.check_wot(cx); + this.check_last_activity(cx); + this.verify_identifier(cx); + }); + + Self { + public_key, + verified: false, + followed: false, + last_active: None, + mutual_contacts: vec![], + tasks: smallvec![], + _subscriptions: subscriptions, + } + } + + fn check_contact(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let public_key = self.public_key; + + let Some(current_user) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task> = cx.background_spawn(async move { + // Check if user is in contact list + let filter = Filter::new() + .author(current_user) + .kind(Kind::ContactList) + .limit(1); + + let followed = client + .database() + .query(filter) + .await + .unwrap_or_default() + .into_iter() + .next() + .map(|event| event.tags.public_keys().any(|k| k == public_key)) + .unwrap_or(false); + + Ok(followed) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await.unwrap_or(false); + + this.update(cx, |this, cx| { + this.followed = result; + cx.notify(); + }) + .ok(); + })); + } + + fn check_wot(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let public_key = self.public_key; + + let Some(current_user) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + // Check mutual contacts + let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); + let mut mutual_contacts = vec![]; + + if let Ok(events) = client.database().query(filter).await { + for event in events.into_iter().filter(|ev| ev.pubkey != current_user) { + mutual_contacts.push(event.pubkey); + } + } + + Ok(mutual_contacts) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.mutual_contacts = contacts; + cx.notify(); + }) + .ok(); + } + Err(e) => { + log::error!("Failed to fetch mutual contacts: {}", e); + } + }; + })); + } + + fn check_last_activity(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let public_key = self.public_key; + + let task: Task> = cx.background_spawn(async move { + let filter = Filter::new().author(public_key).limit(1); + let mut activity: Option = None; + + // Construct target for subscription + let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS + .into_iter() + .map(|relay| (relay, vec![filter.clone()])) + .collect(); + + if let Ok(mut stream) = client + .stream_events(target) + .timeout(Duration::from_secs(TIMEOUT)) + .await + { + while let Some((_url, event)) = stream.next().await { + if let Ok(event) = event { + activity = Some(event.created_at); + } + } + } + + activity + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await; + + this.update(cx, |this, cx| { + this.last_active = result; + cx.notify(); + }) + .ok(); + })); + } + + fn verify_identifier(&mut self, cx: &mut Context) { + let http_client = cx.http_client(); + let public_key = self.public_key; + + // Skip if the user doesn't have a NIP-05 identifier + let Some(address) = self.address(cx) else { + return; + }; + + let task: Task> = + cx.background_spawn(async move { address.verify(&http_client, &public_key).await }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await.unwrap_or(false); + + this.update(cx, |this, cx| { + this.verified = result; + cx.notify(); + }) + .ok(); + })); + } + + fn profile(&self, cx: &Context) -> Person { + let persons = PersonRegistry::global(cx); + persons.read(cx).get(&self.public_key, cx) + } + + fn address(&self, cx: &Context) -> Option { + self.profile(cx) + .metadata() + .nip05 + .and_then(|addr| Nip05Address::parse(&addr).ok()) + } + + fn open_njump(&mut self, _window: &mut Window, cx: &mut Context) { + let Ok(bech32) = self.profile(cx).public_key().to_bech32(); + cx.open_url(&format!("https://njump.me/{bech32}")); + } + + fn report(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let public_key = self.public_key; + + let task: Task> = cx.background_spawn(async move { + let tag = Tag::from(Nip56Tag::PublicKey { + public_key, + report: Report::Impersonation, + }); + + let event = EventBuilder::new(Kind::Reporting, "") + .tag(tag) + .finalize_async(&signer) + .await?; + + // Send the report to the public relays + client.send_event(&event).to(BOOTSTRAP_RELAYS).await?; + + Ok(()) + }); + + self.tasks.push(cx.spawn_in(window, async move |_, cx| { + if task.await.is_ok() { + cx.update(|window, cx| { + window.close_dialog(cx); + window.push_notification("Report submitted successfully", cx); + }) + .ok(); + } + })); + } + + fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { + let contacts = self.mutual_contacts.clone(); + + window.open_dialog(cx, move |this, _window, _cx| { + let contacts = contacts.clone(); + let total = contacts.len(); + + this.title("Mutual contacts").child( + v_flex().gap_1().pb_2().child( + uniform_list("contacts", total, move |range, _window, cx| { + let persons = PersonRegistry::global(cx); + let mut items = Vec::with_capacity(total); + + for ix in range { + let Some(contact) = contacts.get(ix) else { + continue; + }; + let profile = persons.read(cx).get(contact, cx); + + items.push( + h_flex() + .h_11() + .w_full() + .px_2() + .gap_1p5() + .rounded(cx.theme().radius) + .text_sm() + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .child(Avatar::new(profile.avatar()).small()) + .child(profile.name()), + ); + } + + items + }) + .h(px(300.)), + ), + ) + }); + } +} + +impl Render for Screening { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const CONTACT: &str = "This person is one of your contacts."; + const NOT_CONTACT: &str = "This person is not one of your contacts."; + const NO_ACTIVITY: &str = "This person hasn't had any activity."; + const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate."; + const NO_MUTUAL: &str = "You don't have any mutual contacts."; + const NIP05_MATCH: &str = "The address matches the user's public key."; + const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; + const NO_NIP05: &str = "This person has not set up their friendly address"; + + let profile = self.profile(cx); + let shorten_pubkey = shorten_pubkey(self.public_key, 8); + + let last_active = self.last_active.map(|_| true); + let mutuals = self.mutual_contacts.len(); + let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals); + + v_flex() + .gap_4() + .child( + v_flex() + .gap_3() + .items_center() + .justify_center() + .text_center() + .child(Avatar::new(profile.avatar()).large()) + .child( + div() + .font_semibold() + .line_height(relative(1.25)) + .child(profile.name()), + ), + ) + .child( + h_flex() + .gap_3() + .child( + h_flex() + .p_1() + .flex_1() + .h_7() + .justify_center() + .rounded_full() + .bg(cx.theme().elevated_surface_background) + .text_sm() + .truncate() + .text_ellipsis() + .text_center() + .line_height(relative(1.)) + .child(shorten_pubkey), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("njump") + .icon(IconName::Link) + .label("njump.me") + .secondary() + .small() + .rounded() + .on_click(cx.listener(move |this, _e, window, cx| { + this.open_njump(window, cx); + })), + ) + .child( + Button::new("report") + .tooltip("Report as a scam or impostor") + .icon(IconName::Warning) + .small() + .warning() + .rounded() + .on_click(cx.listener(move |this, _e, window, cx| { + this.report(window, cx); + })), + ), + ), + ) + .child( + v_flex() + .gap_3() + .child( + h_flex() + .items_start() + .gap_2() + .text_sm() + .child(status_badge(Some(self.followed), cx)) + .child( + v_flex().text_sm().child("Contact").child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if self.followed { + SharedString::from(CONTACT) + } else { + SharedString::from(NOT_CONTACT) + } + }), + ), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .text_sm() + .child(status_badge(last_active, cx)) + .child( + v_flex() + .text_sm() + .child( + h_flex() + .gap_0p5() + .child("Activity on Public Relays") + .child( + Button::new("active") + .icon(IconName::Info) + .xsmall() + .ghost() + .rounded() + .tooltip(RELAY_INFO), + ), + ) + .child( + div() + .w_full() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .map(|this| { + if let Some(t) = self.last_active { + this.child(SharedString::from(format!( + "Last active: {}.", + t.to_human_time() + ))) + } else { + this.child(SharedString::from(NO_ACTIVITY)) + } + }), + ), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .child(status_badge(Some(self.verified), cx)) + .child( + v_flex() + .text_sm() + .child({ + if let Some(addr) = self.address(cx) { + SharedString::from(format!("{} validation", addr)) + } else { + SharedString::from( + "Friendly Address (NIP-05) validation", + ) + } + }) + .child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if self.address(cx).is_some() { + if self.verified { + SharedString::from(NIP05_MATCH) + } else { + SharedString::from(NIP05_NOT_MATCH) + } + } else { + SharedString::from(NO_NIP05) + } + }), + ), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .child(status_badge(Some(mutuals > 0), cx)) + .child( + h_flex() + .text_sm() + .child( + div() + .line_clamp(1) + .text_color(cx.theme().text_muted) + .child({ + if mutuals > 0 { + SharedString::from(mutuals_str) + } else { + SharedString::from(NO_MUTUAL) + } + }), + ) + .child( + Button::new("mutuals") + .icon(IconName::Info) + .xsmall() + .ghost() + .rounded() + .disabled(mutuals == 0) + .on_click(cx.listener(move |this, _, window, cx| { + this.mutual_contacts(window, cx); + })), + ), + ), + ), + ) + } +} + +fn status_badge(status: Option, cx: &App) -> Div { + h_flex() + .size_6() + .justify_center() + .flex_shrink_0() + .map(|this| { + if let Some(status) = status { + this.child(Icon::new(IconName::CheckCircle).small().text_color({ + if status { + cx.theme().icon_accent + } else { + cx.theme().icon_muted + } + })) + } else { + this.child(Indicator::new().small()) + } + }) +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index dff8213a..113974b4 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -93,7 +93,7 @@ impl Workspace { // Subscribe to the nostr events cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| { if let StateEvent::SignerChanged = event { - window.close_all_modals(cx); + window.close_all_dialogs(cx); } }), ); @@ -242,7 +242,7 @@ impl Workspace { Command::ShowSettings => { let view = settings::init(window, cx); - window.open_modal(cx, move |this, _window, _cx| { + window.open_dialog(cx, move |this, _window, _cx| { this.width(px(520.)) .show_close(true) .pb_2() @@ -387,7 +387,7 @@ impl Workspace { let device = DeviceRegistry::global(cx); let ent = device.downgrade(); - window.open_modal(cx, move |this, _window, cx| { + window.open_dialog(cx, move |this, _window, cx| { let ent = ent.clone(); this.confirm() @@ -410,7 +410,7 @@ impl Workspace { this.set_announcement(Keys::generate(), cx); }) .ok(); - // true to close modal + // true to close dialog true }) }); @@ -418,7 +418,7 @@ impl Workspace { fn import_encryption(&mut self, window: &mut Window, cx: &mut Context) { let restore = cx.new(|cx| RestoreEncryption::new(window, cx)); - window.open_modal(cx, move |this, _window, _cx| { + window.open_dialog(cx, move |this, _window, _cx| { this.width(px(420.)) .title("Restore Encryption") .child(restore.clone()) @@ -426,7 +426,7 @@ impl Workspace { } fn theme_selector(&mut self, window: &mut Window, cx: &mut Context) { - window.open_modal(cx, move |this, _window, cx| { + window.open_dialog(cx, move |this, _window, cx| { let registry = ThemeRegistry::global(cx); let themes = registry.read(cx).themes(); @@ -508,7 +508,7 @@ impl Workspace { 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 dialog_layer = Root::render_dialog_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); div() @@ -519,7 +519,7 @@ impl Render for Workspace { .child(self.dock.clone()) // Notifications .children(notification_layer) - // Modals - .children(modal_layer) + // Dialogs + .children(dialog_layer) } } diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 10b6db2e..056fbb0e 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -6,16 +6,18 @@ use chat::{ChatRegistry, Room, RoomKind}; use common::DebouncedDelay; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, uniform_list, }; use instant::Duration; use nostr_sdk::prelude::*; use person::PersonRegistry; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry}; use theme::ActiveTheme; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::input::{Input, InputEvent, InputState}; @@ -23,8 +25,6 @@ use ui::nav::Nav; use ui::notification::Notification; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; -use crate::sidebar::nav_avatar; - const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; pub fn init(dock: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -337,6 +337,7 @@ impl SearchPanel { range: Range, cx: &Context, ) -> Vec> { + let hide_avatar = AppSettings::get_hide_avatar(cx); let persons = PersonRegistry::global(cx); // Get the results @@ -358,20 +359,21 @@ impl SearchPanel { this.select(&pkey_clone, cx); }); - Nav::new(ElementId::NamedInteger( - "search-result".into(), - (range.start + ix) as u64, - )) - .label(profile.name()) - .text_sm() - .font_medium() - .when_some( - nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) - .on_click(handler) - .selected(selected) - .into_any_element() + Nav::new(range.start + ix) + .label(profile.name()) + .text_sm() + .font_medium() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .on_click(handler) + .selected(selected) + .into_any_element() }) .collect() } @@ -382,6 +384,7 @@ impl SearchPanel { range: Range, cx: &Context, ) -> Vec> { + let hide_avatar = AppSettings::get_hide_avatar(cx); let persons = PersonRegistry::global(cx); // Get the contact list @@ -403,20 +406,21 @@ impl SearchPanel { this.select(&pkey_clone, cx); }); - Nav::new(ElementId::NamedInteger( - "contact".into(), - (range.start + ix) as u64, - )) - .label(profile.name().trim()) - .text_sm() - .font_medium() - .when_some( - nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) - .on_click(handler) - .selected(selected) - .into_any_element() + Nav::new(range.start + ix) + .label(profile.name().trim()) + .text_sm() + .font_medium() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .on_click(handler) + .selected(selected) + .into_any_element() }) .collect() } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 4e19ffb8..035e852e 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,25 +1,29 @@ +use std::collections::HashSet; use std::ops::Range; use std::rc::Rc; +use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::TimestampExt; use community::{ChannelId, Community, CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful, - Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px, - retain_all, uniform_list, + Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, + px, retain_all, uniform_list, }; use nostr_sdk::prelude::*; use person::PersonRegistry; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; -use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; +use ui::dialog::DialogButtonProps; +use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::indicator::Indicator; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::nav::Nav; @@ -34,17 +38,18 @@ use ui::{ }; use crate::Command; -use crate::dialogs::import; +use crate::dialogs::{import, screening}; mod tab; mod utils; use tab::SidebarTab; -pub(crate) use utils::{nav_avatar, nav_icon, pick_banner}; +pub(crate) use utils::{nav_icon, pick_banner}; pub enum SidebarRow { Room { room: Entity }, Community { community: Entity }, + Contact { public_key: PublicKey }, } /// A collapsible group of rows in the sidebar's community view. @@ -81,10 +86,23 @@ pub struct Sidebar { active_tab: SidebarTab, /// The community the sidebar is browsing, if any community: Option>, + /// Whether the inbox list is showing contacts to start a chat with + contacts_open: bool, + /// The signed-in user's contacts, loaded when the contact picker opens + contacts: Option>, + /// Contacts selected in the contact picker + selected_contacts: HashSet, + /// Scroll state of the contact picker's list + contacts_scroll: UniformListScrollHandle, + /// Whether the inbox list is showing chat requests instead of rooms + requests_open: bool, + /// Scroll state of the requests list + requests_scroll: UniformListScrollHandle, channels_open: bool, admins_open: bool, members_open: bool, new_requests: bool, + tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, } @@ -105,6 +123,11 @@ impl Sidebar { }), ); + // Keep the room and request counts in sync with the chat registry. + subscriptions.push(cx.observe(&chat, |_this, _chat, cx| { + cx.notify(); + })); + subscriptions.push(cx.subscribe_in( &communities, window, @@ -136,10 +159,17 @@ impl Sidebar { banner: pick_banner(), active_tab: SidebarTab::Inbox, community: None, + contacts_open: false, + contacts: None, + selected_contacts: HashSet::new(), + contacts_scroll: UniformListScrollHandle::new(), + requests_open: false, + requests_scroll: UniformListScrollHandle::new(), channels_open: true, admins_open: true, members_open: true, new_requests: false, + tasks: smallvec![], _subscriptions: subscriptions, } } @@ -149,9 +179,155 @@ impl Sidebar { return; } self.active_tab = tab; + self.close_contacts(); + self.close_requests(); cx.notify(); } + /// Leave the contact picker and clear its selection. + fn close_contacts(&mut self) { + self.contacts_open = false; + self.selected_contacts.clear(); + } + + /// Show or hide the contact picker in the inbox list. + fn toggle_contacts(&mut self, window: &mut Window, cx: &mut Context) { + self.contacts_open = !self.contacts_open; + + if !self.contacts_open { + self.selected_contacts.clear(); + } else { + self.close_requests(); + if self.contacts.is_none() { + self.load_contacts(window, cx); + } + } + + cx.notify(); + } + + /// Leave the requests list in the inbox. + fn close_requests(&mut self) { + self.requests_open = false; + } + + /// Show or hide the chat requests in the inbox list. + fn toggle_requests(&mut self, cx: &mut Context) { + self.requests_open = !self.requests_open; + + if self.requests_open { + self.close_contacts(); + self.new_requests = false; + } + + cx.notify(); + } + + /// Load the signed-in user's contact list into the contact picker. + fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(public_key) + .kind(Kind::ContactList) + .limit(1); + + let contacts: Vec = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.contacts = Some(contacts); + cx.notify(); + })?; + } + Err(error) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + /// Toggle a contact's selection in the contact picker. + fn toggle_contact(&mut self, public_key: &PublicKey, cx: &mut Context) { + if !self.selected_contacts.remove(public_key) { + self.selected_contacts.insert(public_key.to_owned()); + } + cx.notify(); + } + + /// Start a chat with the contacts selected in the picker. + fn create_chat(&mut self, window: &mut Window, cx: &mut Context) { + if self.selected_contacts.is_empty() { + return; + } + + let nostr = NostrRegistry::global(cx); + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); + let dock = self.dock.clone(); + + let receivers: Vec = self.selected_contacts.iter().copied().collect(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let room = async_chat.update_in(cx, |chat, _window, cx| { + let room = cx.new(|_| { + Room::new(public_key, receivers) + .organize(&public_key) + .kind(RoomKind::Ongoing) + }); + chat.track_room(&room, cx); + room + })?; + + cx.update(|window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + })?; + + // Leave the picker now that the chat is open + this.update(cx, |this, cx| { + this.close_contacts(); + cx.notify(); + })?; + + Ok(()) + })); + } + /// Leave the community view, returning the sidebar to its tab list. fn reset_community(&mut self, cx: &mut Context) { if self.community.take().is_none() { @@ -293,13 +469,34 @@ impl Sidebar { fn render_tabs(&mut self, cx: &mut Context) -> AnyElement { let sidebar = cx.entity().downgrade(); let active_tab = self.active_tab; + let requests_open = self.requests_open && active_tab == SidebarTab::Inbox; + let contacts_open = self.contacts_open && active_tab == SidebarTab::Inbox && !requests_open; let rows = Rc::new(self.rows_for(active_tab, cx)); - let scroll_handle = &self.scroll_handles[active_tab.index()]; + + // Whether to show the create button for the active tab + let create_button = contacts_open && !self.selected_contacts.is_empty(); + + let (list_id, scroll_handle, list_title) = if contacts_open { + ("sidebar-contacts", &self.contacts_scroll, "Contacts") + } else if requests_open { + ("sidebar-requests", &self.requests_scroll, "Requests") + } else { + ( + active_tab.list_id(), + &self.scroll_handles[active_tab.index()], + active_tab.list_title(), + ) + }; + + let request_count = ChatRegistry::global(cx) + .read(cx) + .count(&RoomKind::Request, cx); v_flex() .size_full() .flex_1() .min_h_0() + .relative() .gap_2() .child( div().px_2().child( @@ -329,38 +526,58 @@ impl Sidebar { .px_2() .gap_1() .child( - NavItem::new( - "new-chat", - "New Chat", - Icon::new(IconName::Message).small(), - ) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::NewChat), cx) - }), + NavItem::new("chat", "New Chat", Icon::new(IconName::Message).small()) + .on_click(|_event, window, cx| { + window.dispatch_action(Box::new(Command::NewChat), cx) + }), ) .child( NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small()) - .when(self.new_requests, |this| { - this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) + .when(request_count > 0, |this| { + this.suffix( + h_flex() + .gap_1() + .items_center() + .child( + h_flex() + .py_0p5() + .px_1() + .min_w_6() + .justify_center() + .text_size(px(10.)) + .text_color(cx.theme().text_muted) + .text_center() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child(SharedString::from( + request_count.to_string(), + )), + ) + .when(self.new_requests && !requests_open, |this| { + this.child( + div() + .size_1() + .rounded_full() + .bg(cx.theme().cursor), + ) + }), + ) }) - .on_click({ - let sidebar = sidebar.clone(); - move |_event, window, cx| { - if let Err(error) = sidebar.update(cx, |this, cx| { - this.new_requests = false; - cx.notify(); - }) { - log::error!("Failed to clear new requests: {error}"); - } - window.dispatch_action(Box::new(Command::ShowRequests), cx); - } - }), + .when(requests_open, |this| { + this.bg(cx.theme().ghost_element_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_requests(cx); + })), ) .child( NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::ShowContactList), cx) - }), + .when(contacts_open, |this| { + this.bg(cx.theme().ghost_element_active) + }) + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_contacts(window, cx); + })), ), ), SidebarTab::Communities => this.child( @@ -391,7 +608,7 @@ impl Sidebar { .text_xs() .font_semibold() .text_color(cx.theme().text_placeholder) - .child(active_tab.list_title()), + .child(list_title), ) .child( div() @@ -399,7 +616,7 @@ impl Sidebar { .flex_1() .child( uniform_list( - active_tab.list_id(), + list_id, rows.len(), cx.processor(move |this, range, _window, cx| { this.render_rows(range, rows.as_slice(), cx) @@ -411,6 +628,43 @@ impl Sidebar { ) .child(Scrollbar::vertical(scroll_handle)), ) + .when(create_button, |this| { + this.child( + v_flex() + .absolute() + .bottom_0() + .left_0() + .justify_center() + .h_20() + .w_full() + .px_4() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .child( + Button::new("create-chat") + .label(if self.selected_contacts.len() > 1 { + "Create Group Chat" + } else { + "Create Chat" + }) + .primary() + .on_click(cx.listener(|this, _ev, window, cx| { + this.create_chat(window, cx); + })), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().text_muted) + .child("Selected") + .child(div().font_semibold().child(SharedString::from( + self.selected_contacts.len().to_string(), + ))), + ), + ) + }) .into_any_element() } @@ -537,6 +791,21 @@ impl Sidebar { fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec { match tab { + SidebarTab::Inbox if self.contacts_open => self + .contacts + .clone() + .unwrap_or_default() + .into_iter() + .map(|public_key| SidebarRow::Contact { public_key }) + .collect(), + SidebarTab::Inbox if self.requests_open => { + let chat = ChatRegistry::global(cx); + chat.read(cx) + .rooms(&RoomKind::Request, cx) + .into_iter() + .map(|room| SidebarRow::Room { room }) + .collect() + } SidebarTab::Inbox => { let chat = ChatRegistry::global(cx); chat.read(cx) @@ -564,6 +833,8 @@ impl Sidebar { rows: &[SidebarRow], cx: &Context, ) -> Vec { + let hide_avatar = AppSettings::get_hide_avatar(cx); + rows.get(range.clone()) .into_iter() .flatten() @@ -577,15 +848,22 @@ impl Sidebar { let picture = room.read(cx).display_image(cx); let seed = room.read(cx).display_image_seed(cx); let created_at = room.read(cx).created_at.to_ago(); + let kind = room.read(cx).kind; + let peer = room.read(cx).display_member(cx).public_key(); let dock = self.dock.clone(); let room = room.clone(); - Nav::new(SharedString::from(format!("room-{index}"))) + Nav::new(index) .label(name) .text_sm() .font_medium() - .when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| { - this.prefix(avatar) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) }) .suffix( div() @@ -602,6 +880,52 @@ impl Sidebar { window, cx, ); + if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { + let screening = screening::init(peer, window, cx); + + window.open_dialog(cx, move |this, _window, _cx| { + this.confirm() + .child(screening.clone()) + .button_props( + DialogButtonProps::default() + .cancel_text("Ignore") + .ok_text("Response"), + ) + .on_cancel(move |_event, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + true + }) + }); + } + }) + .into_any_element() + } + SidebarRow::Contact { public_key } => { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let selected = self.selected_contacts.contains(public_key); + let sidebar = cx.entity().downgrade(); + let public_key = public_key.to_owned(); + + Nav::new(index) + .label(person.name()) + .text_sm() + .font_medium() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .selected(selected) + .on_click(move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| { + this.toggle_contact(&public_key, cx); + }) { + log::error!("Failed to select contact: {error}"); + } }) .into_any_element() } @@ -613,11 +937,16 @@ impl Sidebar { let sidebar = cx.entity().downgrade(); let community = community.clone(); - Nav::new(SharedString::from(format!("com-{index}"))) + Nav::new(index) .label(name) .text_sm() - .when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| { - this.prefix(avatar) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) }) .on_click(move |_event, window, cx| { ui::dock::add_panel_to( @@ -708,6 +1037,7 @@ impl Sidebar { } fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement { + let hide_avatar = AppSettings::get_hide_avatar(cx); let persons = PersonRegistry::global(cx); let person = persons.read(cx).get(public_key, cx); @@ -715,10 +1045,14 @@ impl Sidebar { .label(person.name()) .text_sm() .font_medium() - .when_some( - nav_avatar(Some(person.avatar_seed()), person.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) .into_any_element() } } diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs index b01f3520..97386c0d 100644 --- a/crates/workspace/src/sidebar/utils.rs +++ b/crates/workspace/src/sidebar/utils.rs @@ -1,39 +1,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use gpui::prelude::FluentBuilder as _; -use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px}; -use settings::AppSettings; +use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px}; use theme::ActiveTheme; -use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Sizable, h_flex}; -pub(crate) fn nav_avatar( - seed: Option>, - picture: Option>, - cx: &App, -) -> Option { - if AppSettings::get_hide_avatar(cx) { - return None; - } - - match (seed.map(Into::into), picture.map(Into::into)) { - (None, None) => None, - (seed, Some(picture)) => Some( - Avatar::from_source(picture) - .when_some(seed, |avatar, seed| avatar.seed(seed)) - .small() - .flex_shrink_0() - .into_any_element(), - ), - (Some(seed), None) => Some( - PixelAvatar::new(seed) - .small() - .flex_shrink_0() - .into_any_element(), - ), - } -} - pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement { h_flex() .flex_shrink_0()