feat: add contact-based chat creation and re-add chat screening #54

Merged
reya merged 9 commits from new-panels into master 2026-09-29 06:28:45 +00:00
25 changed files with 1670 additions and 591 deletions
Generated
+98 -88
View File
@@ -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",
+1 -1
View File
@@ -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" }
+2 -2
View File
@@ -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()
}
+4 -4
View File
@@ -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<Vec<SendReport>> {
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<Self>) {
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({
+2 -8
View File
@@ -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()
+13 -9
View File
@@ -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(),
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+24 -132
View File
@@ -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<ImageSource>,
grayscale: bool,
seed: Option<SharedString>,
seed: Option<String>,
style: StyleRefinement,
size: Size,
border_color: Option<Hsla>,
@@ -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<SharedString>) -> 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<ImageSource>) -> 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<impl Into<ImageSource>>) -> Self {
Self::from_picture(picture.map(Into::into))
}
fn from_picture(picture: Option<ImageSource>) -> 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<SharedString>) -> Self {
pub fn seed(mut self, seed: impl Into<String>) -> 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<Hsla>) -> 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<Pixels>]) -> 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}");
}
}
@@ -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<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
type FooterFn =
Box<dyn Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<AnyElement>>;
/// Modal button props.
pub struct ModalButtonProps {
/// Dialog button props.
pub struct DialogButtonProps {
ok_text: Option<SharedString>,
ok_variant: ButtonVariant,
cancel_text: Option<SharedString>,
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<SharedString>) -> 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<AnyElement>,
footer: Option<FooterFn>,
@@ -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<Item = AnyElement>) {
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,20 +395,25 @@ 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)
.absolute()
.top_1p5()
.right_2()
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(cx.theme().icon_muted)
@@ -425,10 +424,12 @@ impl RenderOnce for Modal {
.on_click(move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
window.close_dialog(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)
+8 -6
View File
@@ -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();
+1 -1
View File
@@ -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;
+3 -7
View File
@@ -84,11 +84,7 @@ 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(
v_flex().w_full().h_10().child(
h_flex()
.id(self.id)
.h_9()
@@ -101,8 +97,8 @@ impl RenderOnce for Nav {
h_flex()
.gap_1()
.flex_1()
.child(div().truncate().min_w_0().child(self.label))
.child(div().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))
}),
+43 -43
View File
@@ -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<WeakFocusHandle>,
builder: Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>,
builder: Rc<dyn Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static>,
}
impl ActiveModal {
impl ActiveDialog {
fn new(
focus_handle: FocusHandle,
previous_focused_handle: Option<WeakFocusHandle>,
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<ActiveModal>,
/// All active dialogs
pub(crate) active_dialogs: Vec<ActiveDialog>,
/// Notification layer
pub(crate) notification: Entity<NotificationList>,
@@ -56,7 +56,7 @@ pub struct Root {
impl Root {
pub fn new(view: AnyView, window: &mut Window, cx: &mut Context<Self>) -> 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<impl IntoElement + use<>> {
let root = window.root::<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::<Vec<_>>();
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<F>(&mut self, builder: F, window: &mut Window, cx: &mut Context<'_, Self>)
/// Open a dialog.
pub fn open_dialog<F>(&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<Self>) {
/// Close the topmost dialog.
pub fn close_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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>) {
self.active_modals.clear();
/// Close all dialogs.
pub fn close_all_dialogs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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.
+20 -20
View File
@@ -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<F>(&mut self, cx: &mut App, builder: F)
/// Opens a Dialog.
fn open_dialog<F>(&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<Vec<Entity<Notification>>>;
@@ -46,31 +46,31 @@ pub trait WindowExtension: Sized {
impl WindowExtension for Window {
#[inline]
fn open_modal<F>(&mut self, cx: &mut App, builder: F)
fn open_dialog<F>(&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);
})
}
+2 -2
View File
@@ -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) => {
+1
View File
@@ -2,4 +2,5 @@ pub mod import;
pub mod new_chat;
pub mod new_community;
pub mod restore;
pub mod screening;
pub mod settings;
+362 -39
View File
@@ -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<DockArea>, 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<DockArea>,
/// Public key input
/// Find input, used to filter contacts or enter a new public key
input: Entity<InputState>,
/// Error message
/// Current user's contacts
contacts: Option<Vec<PublicKey>>,
/// Public keys selected as members of the chat
members: Vec<PublicKey>,
/// Whether a NIP-05 address is being resolved
resolving: bool,
error: Option<SharedString>,
/// Input subscription
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Option<Subscription>,
}
impl NewChat {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let contacts: Vec<PublicKey> = 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<Self>) -> Vec<PublicKey> {
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<Self>) {
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<Self>) -> 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<Self>) {
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>) {
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>) {
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<Self>) -> 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(&current_user)
.kind(RoomKind::Ongoing);
@@ -91,43 +319,138 @@ impl NewChat {
cx,
);
window.close_modal(cx);
true
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
fn render_contacts(
&self,
range: Range<usize>,
contacts: &[PublicKey],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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<Self>) -> 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<Self>) -> 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("start-chat")
.label("Start chat")
.primary()
.font_semibold()
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add person")
.ghost()
.large()
.disabled(self.resolving)
.on_click(cx.listener(|this, _event, window, cx| {
this.start_chat(window, cx);
this.submit(window, cx);
})),
),
)
.when_some(self.error.clone(), |this, 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_center()
.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(),
)
}
}
@@ -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
})
+2 -2
View File
@@ -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<S>(&mut self, message: S, cx: &mut Context<Self>)
+545
View File
@@ -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<Screening> {
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<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// 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>) -> 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<Self>) {
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<Result<bool, Error>> = 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<Self>) {
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<Result<Vec<PublicKey>, 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<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = 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<Self>) {
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<Result<bool, Error>> =
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<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
self.profile(cx)
.metadata()
.nip05
.and_then(|addr| Nip05Address::parse(&addr).ok())
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Result<(), Error>> = 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<Self>) {
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<Self>) -> 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<bool>, 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())
}
})
}
+9 -9
View File
@@ -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<Self>) {
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<Self>) {
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<Self>) -> 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)
}
}
+21 -17
View File
@@ -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<DockArea>, window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
@@ -337,6 +337,7 @@ impl SearchPanel {
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx);
// Get the results
@@ -358,17 +359,18 @@ impl SearchPanel {
this.select(&pkey_clone, cx);
});
Nav::new(ElementId::NamedInteger(
"search-result".into(),
(range.start + ix) as u64,
))
Nav::new(range.start + ix)
.label(profile.name())
.text_sm()
.font_medium()
.when_some(
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|this, avatar| this.prefix(avatar),
.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()
@@ -382,6 +384,7 @@ impl SearchPanel {
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx);
// Get the contact list
@@ -403,17 +406,18 @@ impl SearchPanel {
this.select(&pkey_clone, cx);
});
Nav::new(ElementId::NamedInteger(
"contact".into(),
(range.start + ix) as u64,
))
Nav::new(range.start + ix)
.label(profile.name().trim())
.text_sm()
.font_medium()
.when_some(
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|this, avatar| this.prefix(avatar),
.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()
+374 -40
View File
@@ -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<Room> },
Community { community: Entity<Community> },
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<WeakEntity<Community>>,
/// 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<Vec<PublicKey>>,
/// Contacts selected in the contact picker
selected_contacts: HashSet<PublicKey>,
/// 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<Result<(), Error>>; 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>) {
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>) {
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<Self>) {
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<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: Vec<PublicKey> = 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<Self>) {
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<Self>) {
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<PublicKey> = 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<Self>) {
if self.community.take().is_none() {
@@ -293,13 +469,34 @@ impl Sidebar {
fn render_tabs(&mut self, cx: &mut Context<Self>) -> 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(),
)
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))
})
.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(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),
)
}),
)
})
.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<SidebarRow> {
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<Sidebar>,
) -> Vec<AnyElement> {
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()
}
}
+1 -31
View File
@@ -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<impl Into<SharedString>>,
picture: Option<impl Into<ImageSource>>,
cx: &App,
) -> Option<AnyElement> {
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()