feat: new search experience (#58)

Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
2026-10-06 03:00:21 +00:00
parent 2a404591d8
commit 2bce050c3b
21 changed files with 2134 additions and 1295 deletions
Generated
+73 -87
View File
@@ -396,13 +396,13 @@ dependencies = [
[[package]] [[package]]
name = "async-recursion" name = "async-recursion"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -839,16 +839,6 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"bytes",
"cfg_aliases",
]
[[package]] [[package]]
name = "browser-signer-proxy" name = "browser-signer-proxy"
version = "0.1.0" version = "0.1.0"
@@ -2690,9 +2680,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-base" name = "gpui-base"
version = "0.7.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64bb52b29a8dcdc8d3f595b8b0839c076f237572ab601ba4577d3da8ae085728" checksum = "3cf698677b253118459f552fe15513858c669d79a25837a94e480db2ae330050"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"anyhow", "anyhow",
@@ -2723,6 +2713,7 @@ dependencies = [
"smallvec", "smallvec",
"smol", "smol",
"tracing", "tracing",
"unicode-linebreak",
"unicode-segmentation", "unicode-segmentation",
"web-time", "web-time",
"windows 0.58.0", "windows 0.58.0",
@@ -2730,14 +2721,15 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre" name = "gpui-pre"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e87a42bb37c7cb4e76dd1ac0ce88851e46e976e0373a47ab3e0757abffee54d" checksum = "d7f0bc3ebba070f8981b3f8e905a12f47d27794a30d029e59cdb9edf7a4b3360"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"anyhow", "anyhow",
"async-channel", "async-channel",
"async-task", "async-task",
"backtrace",
"bindgen", "bindgen",
"bitflags 2.13.2", "bitflags 2.13.2",
"chrono", "chrono",
@@ -2762,6 +2754,7 @@ dependencies = [
"image", "image",
"inventory", "inventory",
"itertools 0.14.0", "itertools 0.14.0",
"libc",
"log", "log",
"lyon", "lyon",
"num_cpus", "num_cpus",
@@ -2800,33 +2793,39 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-apple" name = "gpui-pre-apple"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00053551517815ab169fda28fff78dbebf2712722593c7671c511785cb3f79d5" checksum = "bfdd914311cd12894367c8b9908947bb0c27fd42f8b9a5ff7a0730551a83fa4c"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-task",
"block2 0.6.2", "block2 0.6.2",
"cbindgen", "cbindgen",
"cocoa 0.26.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics 0.24.0",
"core-video", "core-video",
"derive_more", "derive_more",
"dispatch2",
"etagere", "etagere",
"foreign-types", "foreign-types",
"gpui-pre", "gpui-pre",
"gpui-pre-util",
"image", "image",
"libc",
"log", "log",
"mach2",
"metal", "metal",
"objc",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"parking_lot", "parking_lot",
] ]
[[package]] [[package]]
name = "gpui-pre-collections" name = "gpui-pre-collections"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f79760925c31ed06924d28d68981b5b22b2ce25665f58f0a33dfea11170e6e28" checksum = "2c060863025dc3b8e90febad2d1f4ffd23a5d0dcedba39d3f6b7820ea2fdd3a0"
dependencies = [ dependencies = [
"gpui-pre-util", "gpui-pre-util",
"indexmap", "indexmap",
@@ -2835,9 +2834,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-derive-refineable" name = "gpui-pre-derive-refineable"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1aea3f941581a6a9dd545ecd351ef2d336fe6e2a1a4555cb6414944a4984260" checksum = "bc834f30f5f49791e214f1fde01a009c7de91c9cd0a9f31dabfed130ba39e6cf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2846,9 +2845,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-http-client" name = "gpui-pre-http-client"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddce27e989e877a3a1a243f97d7ea4f1ade2532abec418d5a0be9cca802702c7" checksum = "5d2935cf82333da3f41774e5757837756408f01356197b41c817ad2666034453"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-compression", "async-compression",
@@ -2867,9 +2866,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-http-client-tls" name = "gpui-pre-http-client-tls"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0076f6cf87b87b5629d55692638df4edc04fab3da92f897c2294d520efac17d3" checksum = "9bbac58d44e80c2bfdd736cacd334e5c9446d5847cd584193ee3c8634c729c2b"
dependencies = [ dependencies = [
"log", "log",
"rustls", "rustls",
@@ -2879,16 +2878,15 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-linux" name = "gpui-pre-linux"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aac7022e347409454777701082201742710052813964a1e25160f7e22c965ad" checksum = "b1131746fad5c87b5de74dc9c73105655b0c820a2dd4bb247586573e7cf5720e"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_unix", "accesskit_unix",
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
"ashpd", "ashpd",
"bitflags 2.13.2",
"bytemuck", "bytemuck",
"calloop", "calloop",
"calloop-wayland-source", "calloop-wayland-source",
@@ -2917,7 +2915,6 @@ dependencies = [
"wayland-protocols", "wayland-protocols",
"wayland-protocols-plasma", "wayland-protocols-plasma",
"wayland-protocols-wlr", "wayland-protocols-wlr",
"x11-clipboard",
"x11rb", "x11rb",
"xkbcommon", "xkbcommon",
"zed-scap", "zed-scap",
@@ -2926,20 +2923,20 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-macos" name = "gpui-pre-macos"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a43af845b260b09393e923c4f1e1a67a10fcfc847b4815192bbfd02ed9fe725" checksum = "0d756813ff1cd741a502af60fd18869e0677795936d7349642b41c49f43120c0"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_macos", "accesskit_macos",
"anyhow", "anyhow",
"async-task",
"block2 0.6.2", "block2 0.6.2",
"cocoa 0.26.1", "cocoa 0.26.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"core-graphics 0.24.0", "core-graphics 0.24.0",
"core-text", "core-text",
"core-video",
"ctor", "ctor",
"dispatch2", "dispatch2",
"foreign-types", "foreign-types",
@@ -2947,12 +2944,11 @@ dependencies = [
"gpui-pre", "gpui-pre",
"gpui-pre-apple", "gpui-pre-apple",
"gpui-pre-collections", "gpui-pre-collections",
"gpui-pre-scheduler",
"gpui-pre-util", "gpui-pre-util",
"image", "image",
"itertools 0.14.0", "itertools 0.14.0",
"libc",
"log", "log",
"mach2",
"objc", "objc",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
@@ -2973,9 +2969,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-macros" name = "gpui-pre-macros"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1627c5dd3c3351e39e0621d3f3e31dbbe7c7658df90b1d2ec12bfe95d54cd37" checksum = "d2ad26ca759364694cb55487bebff4c1b7dd6accab49990ff3cda6f7a818a9f4"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro-crate", "proc-macro-crate",
@@ -2986,9 +2982,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-perf" name = "gpui-pre-perf"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01954497dd02ba96ab4c252a8668643b0cc3085e7a75dca62e7ba0e3dcd83b02" checksum = "2fcb288a506a476b6cbf97e6ec1cc0118db43f0ca91087588afa1c2d8cd4f842"
dependencies = [ dependencies = [
"gpui-pre-collections", "gpui-pre-collections",
"serde", "serde",
@@ -2997,9 +2993,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-platform" name = "gpui-pre-platform"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0112105c2ac6757fbf527c9c92a753be9bcbce9f4cf26959a11eaa1db6565aa5" checksum = "e200b7b78836f1a0fc70767d971768e88f29d8b91b697285c15fb8e373635a3e"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"console_error_panic_hook", "console_error_panic_hook",
@@ -3012,9 +3008,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-refineable" name = "gpui-pre-refineable"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd14639e5dfab906bbaeabfe0f94289120dcf2e6ed37217deb13964867e89a5" checksum = "4042345bb5bd3ecbc5d15b21ed128154f462a992058d7038cd397631f5462338"
dependencies = [ dependencies = [
"gpui-pre-derive-refineable", "gpui-pre-derive-refineable",
] ]
@@ -3071,9 +3067,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-reqwest-client" name = "gpui-pre-reqwest-client"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53fa47d680f9e186b33af323f97345d67b6150c0e4c0bc894406bff822a4d9b4" checksum = "894b8d8618c92cbd4da28a04dd3fcf3c0e0607074fa99416a864ac9b63b18aae"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -3089,9 +3085,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-scheduler" name = "gpui-pre-scheduler"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8049963b716df01b67b58f86000597f222d5b31b15b35069b23a3b07f3c68352" checksum = "f4acd73345bc3c09f1b98c1b21eb4459ad8336f89bb2edcef619e11c5a08456e"
dependencies = [ dependencies = [
"async-task", "async-task",
"backtrace", "backtrace",
@@ -3106,9 +3102,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-shared-string" name = "gpui-pre-shared-string"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ef6314bdf5ab162c1913712ec4a91b013ce2a9cfb0b557ecdfdd5793c8b61d3" checksum = "477529fd5aa90bff969785ee9a3938484d94aa5101cdf7b84719629c0345f98a"
dependencies = [ dependencies = [
"schemars", "schemars",
"serde", "serde",
@@ -3117,9 +3113,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-sum-tree" name = "gpui-pre-sum-tree"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cca7e5fad9d53b2d265860fefdffc5f171966cb99c3fd99ecac15c033f19209" checksum = "9ec0ec176d576b82aed716cf47185856b54ea9528caaf9d01787024689bfd824"
dependencies = [ dependencies = [
"gpui-pre-ztracing", "gpui-pre-ztracing",
"heapless 0.9.3", "heapless 0.9.3",
@@ -3130,9 +3126,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-util" name = "gpui-pre-util"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d82e06359ae76f8bb07ed713c54adb80cea6a9a2d3dc715f41aec30cd2aadb4d" checksum = "e1635ae7d0b05400bfa211ddef6c67dc8d9f223c463022c2b3bf2c1c14c6044f"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"log", "log",
@@ -3141,9 +3137,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-util-macros" name = "gpui-pre-util-macros"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8a0da4143bb0e7eb926eed7edd3a61c9d17344207543dcc25fb398f0cb96b2f" checksum = "01486fce7722e5d7d7e7bb71c719dfdcc6ed917112bdc7c56d0556fdb60d10ab"
dependencies = [ dependencies = [
"gpui-pre-perf", "gpui-pre-perf",
"quote", "quote",
@@ -3152,9 +3148,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-web" name = "gpui-pre-web"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3870cc909471bb830c3d79496770c754401aad6cfa122147ccb13e8908ad08" checksum = "184776d0763e6889dd2842dcb1204c15055945f2bdfe926b5ef57bdbd1952a65"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"console_error_panic_hook", "console_error_panic_hook",
@@ -3181,9 +3177,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-wgpu" name = "gpui-pre-wgpu"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0b02657b56b09140ce542f5e4434fb96d9fdaba0fd0035ba7dba13c171ddb9b" checksum = "439c5421cafe1a7cf68d0c0ae5cc82b26fad6b0e6345ac98d20acaf1f2463914"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",
@@ -3208,9 +3204,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-windows" name = "gpui-pre-windows"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05592a6f9e3e6bb7a9f2a0d4779271cf747a948db1c07b02448de777ffff8f3" checksum = "371f9bb13f97e3bea2f8b8e5e81232bb74d735153c244ae67f7400e743925b1c"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_windows", "accesskit_windows",
@@ -3220,6 +3216,7 @@ dependencies = [
"futures", "futures",
"gpui-pre", "gpui-pre",
"gpui-pre-collections", "gpui-pre-collections",
"gpui-pre-scheduler",
"gpui-pre-util", "gpui-pre-util",
"image", "image",
"itertools 0.14.0", "itertools 0.14.0",
@@ -3237,9 +3234,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-zlog" name = "gpui-pre-zlog"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7506302091e66d7fb5e04a5b3480e90cff0bbe3157458d7df91a41c59238c48" checksum = "3bbe5b98630ef4bfed5a7105ec1062e78d571912bc491388f07ea22f2457c18b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -3249,9 +3246,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-ztracing" name = "gpui-pre-ztracing"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ed1bc6d53b3ca93785a8573d1066d555b67bc475b344fb28871f9811eb6f218" checksum = "f806400f6bda1fdd9fc627e158c0e44fb0c4b7767582a3621efcf92f4a850db7"
dependencies = [ dependencies = [
"gpui-pre-zlog", "gpui-pre-zlog",
"gpui-pre-ztracing-macro", "gpui-pre-ztracing-macro",
@@ -3261,9 +3258,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-ztracing-macro" name = "gpui-pre-ztracing-macro"
version = "0.3.7" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829258540ea51bb9ea4a71686beffe5c99586901ba9f39944d06e4a180ab63fe" checksum = "d337c9acf058238e45ce0570d8a5958439242889492f91ae2d8e324211a61ccd"
[[package]] [[package]]
name = "gpui-updater-core" name = "gpui-updater-core"
@@ -4442,9 +4439,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.3" version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@@ -5756,9 +5753,9 @@ dependencies = [
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
@@ -7037,7 +7034,6 @@ version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
dependencies = [ dependencies = [
"borsh",
"serde_core", "serde_core",
] ]
@@ -7617,9 +7613,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -9472,16 +9468,6 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11-clipboard"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
dependencies = [
"libc",
"x11rb",
]
[[package]] [[package]]
name = "x11rb" name = "x11rb"
version = "0.13.2" version = "0.13.2"
+1 -3
View File
@@ -1,3 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M9.25 9.75C7.317 9.75 5.75 8.183 5.75 6.25C5.75 4.317 7.317 2.75 9.25 2.75C11.183 2.75 12.75 4.317 12.75 6.25C12.75 8.183 11.183 9.75 9.25 9.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 2.75C16.933 2.75 18.5 4.317 18.5 6.25C18.5 8.183 16.933 9.75 15 9.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.24806 12.75C5.46395 12.75 2.27489 15.1517 1.30259 18.2496C0.971822 19.3035 1.89349 20.2187 2.99806 20.2188H15.4981C16.6026 20.2188 17.5243 19.3035 17.1935 18.2496C16.2212 15.1517 13.0322 12.75 9.24806 12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 20.25H21.25C22.3546 20.25 23.2748 19.3347 22.9405 18.2819C22.2598 16.1389 20.512 14.31 18.25 13.5088" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
<path d="M4.75 20V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H19.25V18.75H6C5.30964 18.75 4.75 19.3096 4.75 20ZM4.75 20C4.75 20.6904 5.30964 21.25 6 21.25H19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 12.25C9.75 12.25 9.75 13.75 9.75 13.75H14.25C14.25 13.75 14.25 12.25 12 12.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 9.25C13 9.80228 12.5523 10.25 12 10.25C11.4477 10.25 11 9.80228 11 9.25C11 8.69772 11.4477 8.25 12 8.25C12.5523 8.25 13 8.69772 13 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 9.25H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 1014 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M12 8.75V15.25M8.75 12H15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>

After

Width:  |  Height:  |  Size: 624 B

+17
View File
@@ -19,11 +19,15 @@ use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
mod message; mod message;
mod room; mod room;
mod search;
pub use message::*; pub use message::*;
pub use room::*; pub use room::*;
pub use search::SearchMessage;
pub use state::FileAttachment; pub use state::FileAttachment;
use crate::search::MessageSearchIndex;
/// A static keypair used only for signing locally-cached rumor events. /// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -82,6 +86,8 @@ pub struct ChatRegistry {
seen: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>, seen: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
/// Mapping of unwrapped event ids to their gift wrap event ids /// Mapping of unwrapped event ids to their gift wrap event ids
event_map: Arc<RwLock<HashMap<EventId, EventId>>>, event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
/// In-memory message content index for search
search: Arc<RwLock<MessageSearchIndex>>,
/// True while the initial event backlog is still loading /// True while the initial event backlog is still loading
tracking: Arc<AtomicBool>, tracking: Arc<AtomicBool>,
/// Channel for sending signals to the UI. /// Channel for sending signals to the UI.
@@ -170,6 +176,7 @@ impl ChatRegistry {
trash: cx.new(|_| BTreeSet::default()), trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())), seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())), event_map: Arc::new(RwLock::new(HashMap::default())),
search: Arc::default(),
tracking: Arc::new(AtomicBool::new(true)), tracking: Arc::new(AtomicBool::new(true)),
matcher: CachedMatcher(SkimMatcherV2::default()), matcher: CachedMatcher(SkimMatcherV2::default()),
signal_rx: rx, signal_rx: rx,
@@ -646,10 +653,16 @@ impl ChatRegistry {
} }
} }
/// Search the content of all indexed messages, newest first.
pub fn search_messages(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
self.search.read().unwrap().search(terms, limit)
}
/// Reset the registry. /// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) { pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear(); self.rooms.clear();
self.room_index.clear(); self.room_index.clear();
self.search.write().unwrap().clear();
self.trash.update(cx, |this, cx| { self.trash.update(cx, |this, cx| {
this.clear(); this.clear();
cx.notify(); cx.notify();
@@ -715,6 +728,7 @@ impl ChatRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let search = self.search.clone();
cx.background_spawn(async move { cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?; let public_key = signer.get_public_key_async().await?;
@@ -746,6 +760,7 @@ impl ChatRegistry {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().next().is_some() && rumor.tags.public_keys().next().is_some()
{ {
search.write().unwrap().insert(&rumor);
if rumor.pubkey != public_key if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key) && !rumor.tags.public_keys().any(|k| k == public_key)
{ {
@@ -782,6 +797,8 @@ impl ChatRegistry {
/// - If the room doesn't exist, it will be created. /// - If the room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages. /// - Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) { pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
self.search.write().unwrap().insert(&message.rumor);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else { let Some(public_key) = nostr.read(cx).current_user() else {
+133
View File
@@ -0,0 +1,133 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::sync::Arc;
use common::EventExt;
use nostr_sdk::prelude::*;
/// A chat message kept in memory for content search.
#[derive(Debug, Clone)]
pub struct SearchMessage {
/// ID of the room the message belongs to
pub room: u64,
/// Author of the message
pub author: PublicKey,
/// The content of the message
pub content: Arc<str>,
/// When the message was created
pub created_at: Timestamp,
}
#[derive(Debug)]
struct IndexedMessage {
message: SearchMessage,
/// Lowercase content so repeated searches don't re-allocate per message
normalized: Arc<str>,
}
/// In-memory index of chat message rumors, keyed by room and rumor id.
#[derive(Debug, Default)]
pub struct MessageSearchIndex(BTreeMap<u64, BTreeMap<EventId, Arc<IndexedMessage>>>);
impl MessageSearchIndex {
/// Index a rumor for search, ignoring reactions and empty content.
pub fn insert(&mut self, rumor: &UnsignedEvent) {
if !is_chat(rumor.kind) || rumor.content.trim().is_empty() {
return;
}
let Some(id) = rumor.id else { return };
let indexed = Arc::new(IndexedMessage {
message: SearchMessage {
room: rumor.uniq_id(),
author: rumor.pubkey,
content: rumor.content.as_str().into(),
created_at: rumor.created_at,
},
normalized: rumor.content.to_lowercase().into(),
});
self.0
.entry(rumor.uniq_id())
.or_default()
.entry(id)
.or_insert(indexed);
}
/// Find messages whose content contains all of the terms, newest first.
pub fn search(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
let mut matches: Vec<&Arc<IndexedMessage>> = self
.0
.values()
.flat_map(|messages| messages.values())
.filter(|indexed| terms.iter().all(|term| indexed.normalized.contains(term)))
.collect();
matches.sort_by_key(|indexed| Reverse(indexed.message.created_at));
matches.truncate(limit);
matches
.into_iter()
.map(|indexed| indexed.message.clone())
.collect()
}
/// Drop all indexed messages.
pub fn clear(&mut self) {
self.0.clear();
}
}
fn is_chat(kind: Kind) -> bool {
kind == Kind::PrivateDirectMessage || kind == Kind::Custom(15)
}
#[cfg(test)]
mod tests {
use super::*;
fn rumor(kind: Kind, content: &str, created_at: u64) -> UnsignedEvent {
let sender = Keys::generate();
let peer = Keys::generate().public_key();
let mut rumor = EventBuilder::new(kind, content)
.tag(Tag::public_key(peer))
.finalize_unsigned(sender.public_key());
rumor.ensure_id();
rumor.created_at = Timestamp::from(created_at);
rumor
}
#[test]
fn search_matches_all_terms_newest_first_and_deduplicates() {
let mut index = MessageSearchIndex::default();
let first = rumor(Kind::PrivateDirectMessage, "Hello ALICE", 10);
let second = rumor(Kind::PrivateDirectMessage, "hello again", 20);
let reaction = rumor(Kind::Reaction, "hello world", 30);
index.insert(&first);
index.insert(&first);
index.insert(&second);
index.insert(&reaction);
let hits = index.search(&["hello".to_owned()], 10);
assert_eq!(hits.len(), 2);
assert_eq!(&*hits[0].content, "hello again");
assert_eq!(hits[0].created_at, Timestamp::from(20));
let hits = index.search(&["hello".to_owned(), "again".to_owned()], 10);
assert_eq!(hits.len(), 1);
let hits = index.search(&["hello".to_owned()], 1);
assert_eq!(hits.len(), 1);
}
#[test]
fn empty_content_is_not_indexed() {
let mut index = MessageSearchIndex::default();
index.insert(&rumor(Kind::PrivateDirectMessage, " ", 10));
assert!(index.search(&["hello".to_owned()], 10).is_empty());
}
}
+21 -2
View File
@@ -88,6 +88,7 @@ pub struct Group {
oldest: Option<Timestamp>, oldest: Option<Timestamp>,
loading: bool, loading: bool,
loading_more: bool, loading_more: bool,
active: bool,
refused: Option<String>, refused: Option<String>,
tasks: SmallVec<[Task<()>; 2]>, tasks: SmallVec<[Task<()>; 2]>,
} }
@@ -115,6 +116,7 @@ impl Group {
oldest: None, oldest: None,
loading: true, loading: true,
loading_more: false, loading_more: false,
active: false,
refused: None, refused: None,
tasks: smallvec![], tasks: smallvec![],
} }
@@ -196,6 +198,14 @@ impl Group {
self.loading_more self.loading_more
} }
pub(crate) fn is_active(&self) -> bool {
self.active
}
pub(crate) fn activate(&mut self) {
self.active = true;
}
pub fn oldest(&self) -> Option<Timestamp> { pub fn oldest(&self) -> Option<Timestamp> {
self.oldest self.oldest
} }
@@ -496,16 +506,25 @@ impl Group {
}) })
} }
pub(crate) fn load_envelopes(&self, cx: &App) -> Task<Result<Vec<Event>>> { pub(crate) fn load_envelopes(&self, cx: &App, state_only: bool) -> Task<Result<Vec<Event>>> {
let cache_tag = self.key.cache_tag(); let cache_tag = self.key.cache_tag();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
cx.background_spawn(async move { cx.background_spawn(async move {
let filter = Filter::new() let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
if state_only {
let kinds = STATE_KINDS
.iter()
.chain(MEMBERSHIP_KINDS.iter())
.map(|kind| kind.to_string());
filter = filter.custom_tags(SingleLetterTag::LOWERCASE_K, kinds);
}
let mut events: Vec<Event> = client let mut events: Vec<Event> = client
.database() .database()
.query(filter) .query(filter)
+77 -4
View File
@@ -549,7 +549,7 @@ impl GroupsRegistry {
); );
self.subscribe(key.clone(), cx); self.subscribe(key.clone(), cx);
self.reload(key, None, cx); self.load_state(key, cx);
} }
fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) { fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
@@ -679,7 +679,7 @@ impl GroupsRegistry {
}; };
let logged = key.clone(); let logged = key.clone();
let load = group.read(cx).load_envelopes(cx); let load = group.read(cx).load_envelopes(cx, false);
self.tasks.push(cx.spawn(async move |_this, cx| { self.tasks.push(cx.spawn(async move |_this, cx| {
if let Some(event) = event if let Some(event) = event
@@ -704,6 +704,71 @@ impl GroupsRegistry {
})); }));
} }
/// Load only the group state (metadata, members, membership) from the cache.
fn load_state(&mut self, key: GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(&key, cx) else {
return;
};
let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else {
return;
};
let logged = key.clone();
let load = group.read(cx).load_envelopes(cx, true);
self.tasks
.push(cx.spawn(async move |_this, cx| match load.await {
Ok(events) => {
group.update(cx, |group, cx| {
group.hydrate(events, me, cx);
});
}
Err(error) => {
log::warn!("nip29: loading {} state failed: {error}", logged.id());
}
}));
}
/// Cache and absorb a single event without rebuilding the group.
fn absorb(&mut self, key: GroupKey, event: Event, cx: &mut Context<Self>) {
let Some(group) = self.group(&key, cx) else {
return;
};
let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else {
return;
};
self.tasks.push(cx.spawn(async move |_this, cx| {
if let Err(error) = group
.read_with(cx, |group, cx| group.save_envelope(event.clone(), cx))
.await
{
log::warn!("nip29: caching failed: {error}");
return;
}
group.update(cx, |group, cx| {
group.hydrate(vec![event], me, cx);
});
}));
}
/// Load the full group content on first open.
pub fn activate(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let Some(group) = self.group(key, cx) else {
return;
};
if group.read(cx).is_active() {
return;
}
group.update(cx, |group, _cx| group.activate());
self.reload(key.clone(), None, cx);
}
fn forget_event(&mut self, group: Entity<Group>, event: Event, cx: &mut Context<Self>) { fn forget_event(&mut self, group: Entity<Group>, event: Event, cx: &mut Context<Self>) {
let Some(target) = event.tags.event_ids().next() else { let Some(target) = event.tags.event_ids().next() else {
return; return;
@@ -761,7 +826,15 @@ impl GroupsRegistry {
return; return;
}; };
self.reload(key, Some(event), cx); let active = self
.group(&key, cx)
.is_some_and(|group| group.read(cx).is_active());
if active {
self.reload(key, Some(event), cx);
} else {
self.absorb(key, event, cx);
}
} }
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) { fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
@@ -819,7 +892,7 @@ impl GroupsRegistry {
} }
if id == fork_subscription_id() { if id == fork_subscription_id() {
log::warn!("nip29: the admins' group lists were refused: {reason}"); log::debug!("nip29: the admins' group lists were refused: {reason}");
return; return;
} }
+7
View File
@@ -135,6 +135,13 @@ fn opens_run(rows: &[Row], index: usize) -> bool {
} }
pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel> { pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel> {
let key = group.read(cx).key().clone();
let groups = GroupsRegistry::global(cx);
groups.update(cx, |registry, cx| {
registry.activate(&key, cx);
});
cx.new(|cx| GroupPanel::new(group, window, cx)) cx.new(|cx| GroupPanel::new(group, window, cx))
} }
+2
View File
@@ -56,6 +56,7 @@ pub enum IconName {
Message, Message,
Moon, Moon,
Plus, Plus,
PlusChat,
PlusCircle, PlusCircle,
Profile, Profile,
Reset, Reset,
@@ -139,6 +140,7 @@ impl IconNamed for IconName {
Self::Message => "icons/message.svg", Self::Message => "icons/message.svg",
Self::Moon => "icons/moon.svg", Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg", Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
Self::PlusCircle => "icons/plus-circle.svg", Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg", Self::Profile => "icons/profile.svg",
Self::Reset => "icons/reset.svg", Self::Reset => "icons/reset.svg",
+3 -3
View File
@@ -990,7 +990,7 @@ impl PopupMenu {
let this = MenuItemElement::new(ix, &group_name) let this = MenuItemElement::new(ix, &group_name)
.relative() .relative()
.text_sm() .text_xs()
.py_0() .py_0()
.px(INNER_PADDING) .px(INNER_PADDING)
.rounded(radius) .rounded(radius)
@@ -1063,7 +1063,7 @@ impl PopupMenu {
}) })
.disabled(*disabled) .disabled(*disabled)
.h(item_height) .h(item_height)
.gap_x_1() .gap_x_1p5()
.children(Self::render_icon( .children(Self::render_icon(
has_left_icon, has_left_icon,
is_left_check, is_left_check,
@@ -1110,7 +1110,7 @@ impl PopupMenu {
.min_h(item_height) .min_h(item_height)
.size_full() .size_full()
.items_center() .items_center()
.gap_x_1() .gap_x_1p5()
.children(Self::render_icon( .children(Self::render_icon(
has_left_icon, has_left_icon,
false, false,
+1 -1
View File
@@ -105,7 +105,7 @@ impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable; let clickable = self.on_click.is_some() || self.clickable;
v_flex().w_full().h_10().child( v_flex().h_10().w_full().items_center().child(
h_flex() h_flex()
.id(self.id) .id(self.id)
.h_9() .h_9()
+310
View File
@@ -0,0 +1,310 @@
use std::ops::Range;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, ClipboardItem, Context, Entity, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, div, px, relative, uniform_list,
};
use nip29::Group;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::nav::Nav;
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use super::Sidebar;
use crate::dialogs::profile;
/// A collapsible section of the group view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupSection {
Admins,
Members,
}
impl GroupSection {
fn label(self) -> &'static str {
match self {
Self::Admins => "Admins",
Self::Members => "Members",
}
}
}
/// A row in the group view.
enum GroupRow {
Section(GroupSection),
Member {
public_key: PublicKey,
roles: Vec<String>,
},
}
impl Sidebar {
/// Show the group view, replacing the main list.
pub(super) fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
self.group = Some(group.downgrade());
self.group_open = true;
cx.notify();
}
/// Leave the group view, returning the sidebar to its main list.
pub(super) fn hide_group(&mut self, cx: &mut Context<Self>) {
if !self.group_open {
return;
}
self.group_open = false;
cx.notify();
}
pub(super) fn render_group(
&mut self,
group: Entity<Group>,
cx: &mut Context<Self>,
) -> AnyElement {
let admin = NostrRegistry::global(cx)
.read(cx)
.current_user()
.is_some_and(|me| group.read(cx).admins().contains(&me));
let (name, seed, picture, total_members, rows) = {
let group = group.read(cx);
let admins = group.admins();
let mut admin_rows = Vec::new();
let mut member_rows = Vec::new();
for (public_key, roles) in admins.entries() {
admin_rows.push(GroupRow::Member {
public_key: *public_key,
roles: roles.clone(),
});
}
for public_key in group.members().iter() {
if admins.contains(public_key) {
continue;
}
member_rows.push(GroupRow::Member {
public_key: *public_key,
roles: Vec::new(),
});
}
let mut rows = Vec::new();
if !admin_rows.is_empty() {
rows.push(GroupRow::Section(GroupSection::Admins));
if self.admins_open {
rows.append(&mut admin_rows);
}
}
if !member_rows.is_empty() {
rows.push(GroupRow::Section(GroupSection::Members));
if self.members_open {
rows.append(&mut member_rows);
}
}
(
group.display_name(),
group.key().cache_tag(),
group.display_image(),
SharedString::from(format!("{} members", group.total_members())),
rows,
)
};
let rows = Rc::new(rows);
let member_group = group.downgrade();
v_flex()
.flex_1()
.min_h_0()
.w_full()
.gap_2()
.child(
h_flex()
.px_2()
.flex_shrink_0()
.justify_between()
.child(
h_flex()
.pl_0p5()
.gap_1()
.flex_shrink_1()
.child(Avatar::from_source(picture).seed(seed).small())
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
div()
.text_xs()
.truncate()
.line_height(relative(1.2))
.font_semibold()
.child(name),
)
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().text_muted)
.child(total_members),
),
),
)
.child(
h_flex()
.gap_1()
.child(nip29_ui::actions(group.downgrade(), cx)),
),
)
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
"group-rows",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_group_rows(
range,
rows.as_slice(),
&member_group,
admin,
cx,
)
}),
)
.track_scroll(&self.group_scroll)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.group_scroll)),
)
.into_any_element()
}
fn render_group_rows(
&self,
range: Range<usize>,
rows: &[GroupRow],
group: &WeakEntity<Group>,
admin: bool,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
rows.get(range)
.into_iter()
.flatten()
.map(|row| match row {
GroupRow::Section(section) => self.section_row(*section, cx),
GroupRow::Member { public_key, roles } => {
self.member_row(public_key, roles, group, admin, cx)
}
})
.collect()
}
fn section_row(&self, section: GroupSection, cx: &mut Context<Self>) -> AnyElement {
let open = match section {
GroupSection::Admins => self.admins_open,
GroupSection::Members => self.members_open,
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
let label = section.label();
Nav::new(label)
.label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
GroupSection::Admins => this.admins_open = !this.admins_open,
GroupSection::Members => this.members_open = !this.members_open,
}
cx.notify();
}))
.into_any_element()
}
fn member_row(
&self,
public_key: &PublicKey,
roles: &[String],
group: &WeakEntity<Group>,
admin: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let public_key = *public_key;
let group = group.clone();
Nav::new(public_key.to_hex())
.label(person.name())
.text_sm()
.font_medium()
.clickable(true)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.when(!roles.is_empty(), |this| {
this.suffix(
div()
.truncate()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from(roles.join(", "))),
)
})
.dropdown_menu(move |menu, _window, _cx| {
let menu = menu
.item(
PopupMenuItem::new("View profile").on_click(move |_, window, cx| {
profile::open(public_key, window, cx);
}),
)
.item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| {
let npub = public_key.to_bech32().unwrap_or_default();
cx.write_to_clipboard(ClipboardItem::new_string(npub));
}));
if !admin {
return menu;
}
menu.separator()
.item(PopupMenuItem::new("Remove").on_click({
let group = group.clone();
move |_event, window, cx| {
nip29_ui::remove_member(group.clone(), public_key, window, cx);
}
}))
})
.into_any_element()
}
}
+156
View File
@@ -0,0 +1,156 @@
use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
Stateful, Styled, div, px,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use theme::TABBAR_HEIGHT;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::menu::DropdownMenu;
use ui::{Disableable, IconName, Selectable, Sizable, TRAFFIC_LIGHT_PADDING, h_flex};
use super::Sidebar;
use crate::Command;
impl Sidebar {
pub(super) fn render_user(
&self,
current_user: &PublicKey,
cx: &mut Context<Self>,
) -> Stateful<Div> {
let updater = AutoUpdater::try_global(cx);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(current_user, cx);
let avatar = profile.avatar();
let avatar_seed = profile.avatar_seed();
h_flex()
.id("sidebar-user")
.w_full()
.h(TABBAR_HEIGHT)
.flex_shrink_0()
.items_center()
.gap_2()
.px_2()
.when(cfg!(target_os = "macos"), |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.child(
Button::new("current-user")
.child(
Avatar::new(avatar.clone())
.seed(avatar_seed.clone())
.small(),
)
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, _cx| {
this.min_w(px(256.))
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Relays",
IconName::Relay,
Box::new(Command::ShowRelayList),
)
.separator()
.menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme))
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
.child(div().flex_1())
.when_some(updater, |this, updater| {
this.child(self.render_updater(updater, cx))
})
.child(
Button::new("sidebar-search")
.icon(IconName::Search)
.tooltip("Search")
.selected(self.search_open)
.ghost()
.small()
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_search(window, cx);
})),
)
.child(
Button::new("sidebar-new")
.icon(IconName::PlusChat)
.tooltip("New")
.ghost()
.small()
.dropdown_menu(|menu, _window, _cx| {
menu.menu_with_icon(
"New Group",
IconName::Group,
Box::new(Command::NewGroup),
)
.menu_with_icon("Join Group", IconName::Door, Box::new(Command::JoinGroup))
.separator()
.menu_with_icon(
"New Chat",
IconName::Message,
Box::new(Command::NewChat),
)
}),
)
.when(self.group_open, |this| {
this.child(
Button::new("sidebar-back")
.icon(IconName::ArrowLeft)
.tooltip("Back")
.ghost()
.small()
.on_click(cx.listener(|this, _event, _window, cx| {
this.hide_group(cx);
})),
)
})
}
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
let status = updater.read(cx).status();
let up_to_date = updater.read(cx).up_to_date();
let staged = updater.read(cx).staged();
h_flex()
.gap_2()
.when(!up_to_date, |this| {
this.child(
Button::new("update")
.icon(IconName::ArrowDownCircle)
.tooltip(status)
.small()
.ghost()
.disabled(true),
)
})
.when(staged, |this| {
this.child(
Button::new("restart")
.icon(IconName::ArrowDownCircle)
.tooltip("Quit and relaunch into the installed update")
.small()
.ghost()
.on_click(move |_, _window, cx| {
updater.update(cx, |this, cx| {
this.restart(cx);
});
}),
)
})
.into_any_element()
}
}
+222
View File
@@ -0,0 +1,222 @@
use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind};
use gpui::{
AnyElement, App, AppContext, Context, IntoElement, ParentElement, SharedString, Styled, Task,
Window, div,
};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockPlacement, PanelHandle};
use ui::notification::Notification;
use ui::{StyledExt, WindowExtension, h_flex, v_flex};
use super::Sidebar;
use super::rows::SidebarRow;
impl Sidebar {
/// 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.
pub(super) 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();
}
/// 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.
pub(super) 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.
pub(super) 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 requests list in the inbox.
fn close_requests(&mut self) {
self.requests_open = false;
}
/// Show or hide the chat requests in the inbox list.
pub(super) 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();
}
/// The signed-in user's contacts shown in the picker.
pub(super) fn contacts_rows(&self) -> Vec<SidebarRow> {
self.contacts
.clone()
.unwrap_or_default()
.into_iter()
.map(|public_key| SidebarRow::Contact { public_key })
.collect()
}
/// The chat requests awaiting a response.
pub(super) fn requests_rows(&self, cx: &App) -> Vec<SidebarRow> {
ChatRegistry::global(cx)
.read(cx)
.rooms(&RoomKind::Request, cx)
.into_iter()
.map(|room| SidebarRow::Room { room })
.collect()
}
/// The overlay with the create button and selection count.
pub(super) fn render_create_overlay(&self, cx: &mut Context<Self>) -> AnyElement {
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()
}
}
+240
View File
@@ -0,0 +1,240 @@
use std::rc::Rc;
use chat::{ChatRegistry, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, px,
uniform_list,
};
use nip29::GroupsRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::nav_item::NavItem;
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use super::Sidebar;
use super::rows::SidebarRow;
use crate::Command;
/// A collapsible section of the sidebar's main list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ListSection {
Pins,
Groups,
Chats,
}
impl ListSection {
pub(super) fn label(self) -> &'static str {
match self {
Self::Pins => "Pinned",
Self::Groups => "Groups",
Self::Chats => "Chats",
}
}
}
impl Sidebar {
pub(super) fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement {
let requests_open = self.requests_open;
let contacts_open = self.contacts_open && !requests_open;
let rows = Rc::new(self.rows_for(cx));
// Whether to show the create button for the contact picker
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, Some("Contacts"))
} else if requests_open {
("sidebar-requests", &self.requests_scroll, Some("Requests"))
} else {
("sidebar-list", &self.list_scroll, None)
};
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(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
)
.child(
NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
.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())
.when(contacts_open, |this| {
this.bg(cx.theme().ghost_element_active)
})
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_contacts(window, cx);
})),
),
)
.when_some(list_title, |this, list_title| {
this.child(
div()
.px_4()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.child(list_title),
)
})
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
list_id,
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.track_scroll(scroll_handle)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(scroll_handle)),
)
.when(create_button, |this| {
this.child(self.render_create_overlay(cx))
})
.into_any_element()
}
fn rows_for(&self, cx: &App) -> Vec<SidebarRow> {
if self.contacts_open {
return self.contacts_rows();
}
if self.requests_open {
return self.requests_rows(cx);
}
let mut rows = Vec::new();
let chat = ChatRegistry::global(cx);
let pinned_rooms = AppSettings::get_pinned_rooms(cx);
let (pinned_rooms, rooms): (Vec<_>, Vec<_>) = chat
.read(cx)
.rooms(&RoomKind::Ongoing, cx)
.into_iter()
.partition(|room| pinned_rooms.contains(&room.read(cx).id));
let registry = GroupsRegistry::global(cx);
let pinned_group_ids = AppSettings::get_pinned_groups(cx);
let (pinned_groups, groups): (Vec<_>, Vec<_>) = registry
.read(cx)
.groups()
.to_vec()
.into_iter()
.partition(|group| {
let tag = group.read(cx).key().cache_tag();
pinned_group_ids.iter().any(|pinned| pinned == &tag)
});
rows.push(SidebarRow::Section(ListSection::Pins));
if self.pins_open {
if pinned_rooms.is_empty() && pinned_groups.is_empty() {
rows.push(SidebarRow::Empty("Nothing pinned yet"));
} else {
rows.extend(
pinned_groups
.into_iter()
.map(|group| SidebarRow::Group { group }),
);
rows.extend(
pinned_rooms
.into_iter()
.map(|room| SidebarRow::Room { room }),
);
}
}
rows.push(SidebarRow::Section(ListSection::Groups));
if self.is_groups_open(cx) {
if groups.is_empty() {
rows.push(SidebarRow::Empty("No groups yet"));
} else {
rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group }));
}
}
rows.push(SidebarRow::Section(ListSection::Chats));
if self.is_chats_open(cx) {
if rooms.is_empty() {
rows.push(SidebarRow::Empty("No chats yet"));
} else {
rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room }));
}
}
rows
}
/// Whether anything is pinned; drives the sections' default expansion.
fn pinned_any(cx: &App) -> bool {
!AppSettings::get_pinned_rooms(cx).is_empty()
|| !AppSettings::get_pinned_groups(cx).is_empty()
}
pub(super) fn is_chats_open(&self, cx: &App) -> bool {
self.chats_open.unwrap_or_else(|| !Self::pinned_any(cx))
}
pub(super) fn is_groups_open(&self, cx: &App) -> bool {
self.groups_open.unwrap_or_else(|| !Self::pinned_any(cx))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img};
use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
use ui::{Sizable, StyledExt, v_flex};
use crate::dialogs::import;
/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
/// Pick one of the signed-out backgrounds at random.
pub(super) fn pick_banner() -> SharedString {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or_default();
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
SIGNED_OUT_BANNERS[index].into()
}
/// The headline and identity import shown while signed out.
pub(super) fn render_signed_out(window: &mut Window, cx: &mut App) -> AnyElement {
v_flex()
.size_full()
.justify_end()
.gap_4()
.p_4()
.child(img("brand/headline.png").max_w_48())
.child(
Button::new("import")
.label("Import Identity")
.custom(
ButtonCustomVariant::new(window, cx)
.color(gpui::white())
.foreground(gpui::black())
.hover(gpui::white().opacity(0.9))
.active(gpui::white().opacity(0.8)),
)
.large()
.font_semibold()
.on_click(|_, window, cx| {
import::open(window, cx);
}),
)
.into_any_element()
}
+392
View File
@@ -0,0 +1,392 @@
use std::ops::Range;
use chat::{Room, RoomKind};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity,
Window, div,
};
use nip29::Group;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::dialog::DialogButtonProps;
use ui::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
use ui::menu::{ContextMenu, PopupMenuItem};
use ui::nav::Nav;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use super::Sidebar;
use super::list::ListSection;
use super::search::MessageHit;
use crate::dialogs::{profile, screening};
/// Open a room in the dock, screening new requests when enabled.
fn open_room(room: &Entity<Room>, dock: &WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
ui::dock::add_panel_to(
dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
window,
cx,
);
if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
let peer = room.read(cx).display_member(cx).public_key();
let view = screening::init(peer, window, cx);
window.open_dialog(cx, move |this, _window, _cx| {
this.confirm()
.child(view.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
})
});
}
}
impl Sidebar {
pub(super) fn render_rows(
&self,
range: Range<usize>,
rows: &[SidebarRow],
cx: &Context<Sidebar>,
) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Section(section) => {
let section = *section;
let label = section.label();
let open = match section {
ListSection::Pins => self.pins_open,
ListSection::Groups => self.is_groups_open(cx),
ListSection::Chats => self.is_chats_open(cx),
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
Nav::new(label)
.label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
ListSection::Pins => this.pins_open = !this.pins_open,
ListSection::Groups => {
this.groups_open = Some(!this.is_groups_open(cx))
}
ListSection::Chats => {
this.chats_open = Some(!this.is_chats_open(cx))
}
}
cx.notify();
}))
.into_any_element()
}
SidebarRow::Heading(label) => {
let label = *label;
Nav::new(label)
.label(label)
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.into_any_element()
}
SidebarRow::Empty(text) => v_flex()
.h_10()
.w_full()
.px_1p5()
.items_center()
.child(
h_flex()
.h_9()
.w_full()
.justify_center()
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(*text),
)
.into_any_element(),
SidebarRow::Room { room } => {
let name = room.read(cx).display_name(cx);
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 id = room.read(cx).id;
let member = room.read(cx).members().first().copied();
let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
let dock = self.dock.clone();
let room = room.clone();
let nav = Nav::new(index)
.label(name)
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
})
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(created_at),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
});
ContextMenu::new(
format!("room-menu-{index}"),
nav,
move |menu, _window, _cx| {
let menu = menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
.on_click(move |_, _window, cx| {
AppSettings::global(cx).update(cx, |settings, cx| {
settings.toggle_pinned_room(id, cx);
});
}),
);
if let Some(member) = member {
menu.item(PopupMenuItem::new("View profile").on_click(
move |_, window, cx| {
profile::open(member, window, cx);
},
))
} else {
menu
}
},
)
.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()
.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()
}
SidebarRow::Profile { public_key } => {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let public_key = public_key.to_owned();
Nav::new(index)
.label(person.name())
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(move |_event, window, cx| {
profile::open(public_key, window, cx);
})
.into_any_element()
}
SidebarRow::Group { group } => {
let dock = self.dock.clone();
let sidebar = cx.entity().downgrade();
let group = group.clone();
let name = group.read(cx).display_name();
let picture = group.read(cx).display_image();
let seed = group.read(cx).key().cache_tag();
let tag = seed.clone();
let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx);
let weak_group = group.downgrade();
let suffix = group
.read(cx)
.membership()
.status()
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
.map(SharedString::from);
let nav = Nav::new(index)
.label(name)
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
})
.when_some(suffix, |this, suffix| {
this.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(suffix),
)
})
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
DockPlacement::Center,
window,
cx,
);
if let Err(error) = sidebar.update(cx, |this, cx| {
this.show_group(&group, cx);
}) {
log::error!("Failed to show the group in the sidebar: {error}");
}
});
ContextMenu::new(
format!("group-menu-{index}"),
nav,
move |menu, _window, _cx| {
menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
.on_click({
let tag = tag.clone();
move |_, _window, cx| {
AppSettings::global(cx).update(
cx,
|settings, cx| {
settings.toggle_pinned_group(&tag, cx);
},
);
}
}),
)
.item(
PopupMenuItem::new("Leave").on_click({
let group = weak_group.clone();
move |_, window, cx| {
nip29_ui::confirm_leave(group.clone(), window, cx);
}
}),
)
},
)
.into_any_element()
}
SidebarRow::Message { hit } => {
let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx);
let name = hit.room.read(cx).display_name(cx);
let detail = format!("{} · {}", name, hit.created_at.to_ago());
let dock = self.dock.clone();
let room = hit.room.clone();
let content = hit.content.clone();
Nav::new(index)
.label(content)
.text_sm()
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(detail),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
})
.into_any_element()
}
}
})
.collect()
}
}
/// A row shown in the sidebar's lists.
pub(super) enum SidebarRow {
Section(ListSection),
Room {
room: Entity<Room>,
},
Group {
group: Entity<Group>,
},
Contact {
public_key: PublicKey,
},
/// A profile found by global search.
Profile {
public_key: PublicKey,
},
Message {
hit: MessageHit,
},
/// A non-interactive label, such as a search results section.
Heading(&'static str),
Empty(&'static str),
}
+315
View File
@@ -0,0 +1,315 @@
use std::collections::HashSet;
use std::rc::Rc;
use chat::{ChatRegistry, Room};
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
Window, div, uniform_list,
};
use nip29::{Group, GroupsRegistry};
use nostr_sdk::prelude::*;
use state::{FIND_LIMIT, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::Input;
use ui::scroll::Scrollbar;
use ui::{IconName, Selectable, Sizable, h_flex, v_flex};
use super::Sidebar;
use super::rows::SidebarRow;
/// An entry in the sidebar's search results.
pub(super) enum SearchResult {
Room(Entity<Room>),
Group(Entity<Group>),
Message(MessageHit),
}
/// A message found by searching the indexed chat history.
#[derive(Clone)]
pub(super) struct MessageHit {
pub(super) room: Entity<Room>,
pub(super) author: PublicKey,
pub(super) content: SharedString,
pub(super) created_at: Timestamp,
}
/// Find groups whose display name contains all of the terms.
fn find_groups(terms: &[String], cx: &App) -> Vec<Entity<Group>> {
GroupsRegistry::global(cx)
.read(cx)
.groups()
.iter()
.filter(|group| {
let name = group.read(cx).display_name().to_lowercase();
terms.iter().all(|term| name.contains(term.as_str()))
})
.cloned()
.collect()
}
impl Sidebar {
/// Show or hide the search bar below the header.
pub(super) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_open = !self.search_open;
if self.search_open {
self.find_input
.update(cx, |input, cx| input.focus(window, cx));
} else {
self.find_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
self.global_open = false;
self.clear_results(cx);
}
cx.notify();
}
/// Search rooms and groups by name, then indexed messages by content.
pub(super) fn search(&mut self, cx: &mut Context<Self>) {
let query = self.find_input.read(cx).value().to_string();
let terms: Vec<String> = query
.to_lowercase()
.split_whitespace()
.map(str::to_owned)
.collect();
if terms.is_empty() {
return;
}
let chat = ChatRegistry::global(cx);
let rooms = chat.read(cx).find(&query, cx);
let groups = find_groups(&terms, cx);
let messages = chat
.read(cx)
.search_messages(&terms, FIND_LIMIT)
.into_iter()
.filter_map(|matched| {
let room = chat.read(cx).room(&matched.room, cx)?.upgrade()?;
Some(MessageHit {
room,
author: matched.author,
content: matched.content.into(),
created_at: matched.created_at,
})
})
.collect::<Vec<_>>();
self.results = rooms
.into_iter()
.map(SearchResult::Room)
.chain(groups.into_iter().map(SearchResult::Group))
.chain(messages.into_iter().map(SearchResult::Message))
.collect();
cx.notify();
}
/// Search profiles on the indexer relays for the current query.
pub(super) fn global_search(&mut self, cx: &mut Context<Self>) {
if !self.global_open {
return;
}
let query = self.find_input.read(cx).value().trim().to_string();
if query.is_empty() {
return;
}
let nostr = NostrRegistry::global(cx);
let find_profiles = nostr.read(cx).search(&query, cx);
self.global_finding = true;
self.global_results = None;
cx.notify();
self.global_task = Some(cx.spawn(async move |this, cx| {
let result = find_profiles.await;
this.update(cx, |this, cx| {
this.global_finding = false;
this.global_results = Some(match result {
Ok(profiles) => {
// Relays may return the same profile more than once
let mut seen = HashSet::new();
profiles
.into_iter()
.filter(|key| seen.insert(*key))
.collect()
}
Err(error) => {
log::debug!("Global search failed: {error}");
Vec::new()
}
});
cx.notify();
})
.ok();
}));
}
/// Trigger a global search once the input debounce delay has passed.
pub(super) fn debounced_global(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, _window, cx| {
this.global_search(cx);
})
.ok();
})
}
/// Toggle whether searches also cover global (relay) profiles.
pub(super) fn toggle_global(&mut self, cx: &mut Context<Self>) {
self.global_open = !self.global_open;
if self.global_open {
self.global_search(cx);
} else {
self.global_finding = false;
self.global_results = None;
self.global_task = None;
}
cx.notify();
}
/// Discard the search results.
pub(super) fn clear_results(&mut self, cx: &mut Context<Self>) {
self.results.clear();
self.global_finding = false;
self.global_results = None;
self.global_task = None;
cx.notify();
}
pub(super) fn render_find(&self, cx: &mut Context<Self>) -> AnyElement {
h_flex()
.px_2()
.flex_shrink_0()
.w_full()
.child(
h_flex()
.px_1p5()
.gap_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
Input::new(&self.find_input)
.appearance(false)
.small()
.text_xs(),
)
.child(
Button::new("sidebar-global")
.icon(IconName::Compass)
.tooltip(if self.global_open {
"Disable global search"
} else {
"Enable global search"
})
.small()
.ghost()
.selected(self.global_open)
.loading(self.global_finding)
.on_click(cx.listener(|this, _event, _window, cx| {
this.toggle_global(cx);
})),
),
)
.into_any_element()
}
pub(super) fn render_results(&self, cx: &mut Context<Self>) -> AnyElement {
let rows = Rc::new(self.results_for());
v_flex()
.flex_1()
.min_h_0()
.relative()
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
"sidebar-search",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.track_scroll(&self.search_scroll)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.search_scroll)),
)
.into_any_element()
}
fn results_for(&self) -> Vec<SidebarRow> {
let mut rooms = Vec::new();
let mut groups = Vec::new();
let mut messages = Vec::new();
for result in &self.results {
match result {
SearchResult::Room(room) => rooms.push(room.clone()),
SearchResult::Group(group) => groups.push(group.clone()),
SearchResult::Message(hit) => messages.push(hit.clone()),
}
}
let mut rows = Vec::new();
rows.push(SidebarRow::Heading("Groups"));
if groups.is_empty() {
rows.push(SidebarRow::Empty("No groups found"));
} else {
rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group }));
}
rows.push(SidebarRow::Heading("Rooms"));
if rooms.is_empty() {
rows.push(SidebarRow::Empty("No rooms found"));
} else {
rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room }));
}
rows.push(SidebarRow::Heading("Messages"));
if messages.is_empty() {
rows.push(SidebarRow::Empty("No messages found"));
} else {
rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit }));
}
// Global profiles only appear once a global search was triggered
if self.global_finding || self.global_results.is_some() {
rows.push(SidebarRow::Heading("Global"));
match self.global_results.as_ref() {
Some(profiles) if profiles.is_empty() => {
rows.push(SidebarRow::Empty("No profiles found"));
}
Some(profiles) => {
rows.extend(profiles.iter().map(|public_key| SidebarRow::Profile {
public_key: *public_key,
}));
}
None => rows.push(SidebarRow::Empty("Searching...")),
}
}
rows
}
}
-16
View File
@@ -1,16 +0,0 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::SharedString;
/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
/// Pick one of the signed-out backgrounds at random.
pub(crate) fn pick_banner() -> SharedString {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or_default();
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
SIGNED_OUT_BANNERS[index].into()
}
+19
View File
@@ -0,0 +1,19 @@
# TODO
Open items from the search and lazy-loading work. Each entry notes the known
tradeoff and where the fix would land.
## nip29
- [ ] **Incremental event handling for open groups.** Live events for an open
(active) group still trigger a full envelope reload and re-hydrate of the
whole group on every event (`GroupsRegistry::reload` in
`crates/nip29/src/lib.rs`). Closed groups already absorb events in O(1);
open groups could use the same incremental path, keeping the full reload
only as an occasional self-healing rebuild.
- [ ] **Fetch admins' group lists from NIP-29 relays.** The fork-detection
query (`GroupsRegistry::sync_forks`) is unscoped, so group relays refuse it
by policy (`must have 'h', 'e' or 'a' tag`). It still works via generic and
gossip relays, but the deeper fix is a per-relay scoped subscription using
an `a` tag with `10009:<admin-pk>` coordinates. The exact coordinate format
each relay accepts needs to be tested first.