5 Commits
Author SHA1 Message Date
reya fd802a8870 harden proxy auth and event ordering 2026-11-12 17:59:28 +07:00
reya fe0ca04817 add plan 2026-11-12 15:41:15 +07:00
reya 8fa79aef33 nip4e: drop processed-requests persistence 2026-11-12 14:39:28 +07:00
reya 8acbbaef5f feat: add onboarding dialog 2026-11-12 13:48:51 +07:00
reya 85f2bb9885 chore: update deps 2026-11-12 09:18:35 +07:00
74 changed files with 3579 additions and 2017 deletions
Generated
+144 -101
View File
@@ -226,9 +226,9 @@ dependencies = [
[[package]]
name = "annotate-snippets"
version = "0.12.17"
version = "0.12.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176"
checksum = "0258346f92c78dbc5b336b9a5b27c64447aa10643bc64e651db2edb145361564"
dependencies = [
"anstyle",
"memchr",
@@ -1152,7 +1152,7 @@ dependencies = [
[[package]]
name = "cashu"
version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7"
source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [
"bitcoin",
"cbor-diag",
@@ -1243,7 +1243,7 @@ dependencies = [
[[package]]
name = "cdk"
version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7"
source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [
"anyhow",
"arc-swap",
@@ -1275,7 +1275,7 @@ dependencies = [
[[package]]
name = "cdk-common"
version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7"
source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [
"anyhow",
"async-trait",
@@ -1306,7 +1306,7 @@ dependencies = [
[[package]]
name = "cdk-http-client"
version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7"
source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [
"async-trait",
"bitreq",
@@ -1333,7 +1333,7 @@ dependencies = [
[[package]]
name = "cdk-redb"
version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7"
source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [
"async-trait",
"cdk-common",
@@ -1348,7 +1348,7 @@ dependencies = [
[[package]]
name = "cdk-signatory"
version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7"
source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [
"anyhow",
"arc-swap",
@@ -1463,7 +1463,6 @@ dependencies = [
"gpui-pre",
"instant",
"itertools 0.13.0",
"log",
"nip60_ui",
"nostr-sdk",
"person",
@@ -1809,11 +1808,8 @@ dependencies = [
"chat_ui",
"gpui-kit",
"gpui-pre",
"gpui-pre-linux",
"gpui-pre-macos",
"gpui-pre-platform",
"gpui-pre-reqwest-client",
"gpui-pre-windows",
"log",
"nip29",
"nip4e",
@@ -1842,7 +1838,6 @@ dependencies = [
"gpui-kit",
"gpui-pre",
"gpui-pre-platform",
"gpui-pre-web",
"instant",
"js-sys",
"log",
@@ -3497,9 +3492,9 @@ dependencies = [
[[package]]
name = "gpui-base"
version = "0.8.1"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d862bc8f2e27710039013d075ac4995892e9687ff207fa530f880cbcd513936f"
checksum = "f3e9ae5237737cfd110d0ff513fcd9c20f97d98ebabd0a73164068177f0aca4f"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3538,9 +3533,9 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.8.1"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46ce53430332e30f303364eaabfa10ed2aa23dfaf2a147e04370e0e9c423578b"
checksum = "e155c4914bd809d966660610d596f7b57c5cd38979ca2c132215057a91f23f03"
dependencies = [
"anyhow",
"block2 0.6.2",
@@ -3590,9 +3585,9 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.8.1"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a512c830553e7dddc43c3f8faffc2d2d7bd24db21fe3dc1326dde9c470013768"
checksum = "9676a5d12c59ce295b5c82cc18365ad8b96cb683682e2e74668a56fb92544300"
dependencies = [
"proc-macro-crate",
"proc-macro2",
@@ -3602,9 +3597,9 @@ dependencies = [
[[package]]
name = "gpui-kit"
version = "0.8.1"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25210f6e59ea9ffac3a033dbce9edafe5ff0dd2900f894fd1316f54da2fe2b89"
checksum = "aca1dedfea73645b60b5893c9c16de0b1396f2cf4251d19e5f03a295c1b61879"
dependencies = [
"gpui-base",
"gpui-component",
@@ -3617,9 +3612,9 @@ dependencies = [
[[package]]
name = "gpui-kit-assets"
version = "0.8.1"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c214017b45e5654665f18faa551f58a2c2c8d25efe6632b1a443ba8edf365e96"
checksum = "cb5abe0bbd8f9226170c8726e529f6574519e297e9ed1bde86d5fd4d3536079a"
dependencies = [
"anyhow",
"gpui-pre",
@@ -3632,9 +3627,9 @@ dependencies = [
[[package]]
name = "gpui-pre"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df4b5f8d0c278308df2f11e0bfe0212dd77d7848ce1bdc4b9d513334414396f6"
checksum = "29db89613527079737f53752eab27fb65c9a7dd94979256c65a0e590c9fd34d8"
dependencies = [
"accesskit",
"anyhow",
@@ -3643,7 +3638,6 @@ dependencies = [
"backtrace",
"bitflags 2.13.2",
"chrono",
"ctor",
"derive_more",
"embed-resource",
"etagere",
@@ -3658,6 +3652,7 @@ dependencies = [
"gpui-pre-sum-tree",
"gpui-pre-util",
"heapless 0.9.3",
"icu_segmenter",
"image",
"inventory",
"itertools 0.14.0",
@@ -3687,6 +3682,7 @@ dependencies = [
"taffy",
"thiserror 2.0.21",
"ttf-parser",
"unicode-segmentation",
"url",
"usvg 0.46.0",
"uuid",
@@ -3697,9 +3693,9 @@ dependencies = [
[[package]]
name = "gpui-pre-apple"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e97cd068d8134b4e073321b946491760aa1e4511e486084a92e73b9174bff78a"
checksum = "a460605ec4e8cbcc9b213462a84b1d6cc3efe4ed8f0e18669659f07b2d5c6aec"
dependencies = [
"anyhow",
"async-task",
@@ -3709,10 +3705,8 @@ dependencies = [
"core-foundation-sys",
"core-graphics 0.24.0",
"core-text",
"derive_more",
"dispatch2",
"etagere",
"foreign-types 0.5.0",
"futures",
"gpui-pre",
"gpui-pre-collections",
@@ -3721,9 +3715,9 @@ dependencies = [
"libc",
"log",
"mach2 0.6.0",
"metal",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
@@ -3739,9 +3733,9 @@ dependencies = [
[[package]]
name = "gpui-pre-collections"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f89bf8941a7437dd761161c552543fb8268b9633fe74e29eafbe0014cd2164b"
checksum = "cb60ece401b399fd5ec13911915b407b0c6efa1f6c9a9f66201aaac56c7ec39d"
dependencies = [
"gpui-pre-util",
"indexmap 2.14.2",
@@ -3750,9 +3744,9 @@ dependencies = [
[[package]]
name = "gpui-pre-derive-refineable"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2998b1cebbaa0fa004fe4939a513bbf4103c7fb69ae2e84ee73634554e3f7edc"
checksum = "aeb2a3f5e6c9f300f432ae7989a81b262953ccfb3f9385be51cdc3303aef8858"
dependencies = [
"proc-macro2",
"quote",
@@ -3761,16 +3755,15 @@ dependencies = [
[[package]]
name = "gpui-pre-http-client"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e516820ee56ac357a87ff97b1a2c5f22c8d5dcd2a50cdd15c16949f24464d98e"
checksum = "55245de7e3b6e219596fd98bb29555d6e3fe44c055d025960affeb11b894c048"
dependencies = [
"anyhow",
"bytes",
"futures",
"http",
"http-body",
"log",
"parking_lot",
"serde",
"serde_json",
@@ -3780,9 +3773,9 @@ dependencies = [
[[package]]
name = "gpui-pre-http-client-tls"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a487328e5cf704b44f3e6ca0b86b7688f2c488ce66cace071e713df94810d7"
checksum = "8198d884ff64f2ae87bce95dfee1d9deeca11f3b8201803e56111015d47b2455"
dependencies = [
"log",
"rustls",
@@ -3792,9 +3785,9 @@ dependencies = [
[[package]]
name = "gpui-pre-ios"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a1e3a399df64f3ecc5538727fd0575279bd190959cf97a9e2b698c0310b8"
checksum = "10656f6db4bbedb60bd7fcbb8a70f7c7eeee45bc4582fb8933ca807d2ddf52ad"
dependencies = [
"anyhow",
"block2 0.6.2",
@@ -3814,9 +3807,9 @@ dependencies = [
[[package]]
name = "gpui-pre-linux"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c6ffd2dce7b75cce0bb3382994912f84c2a05389a4a236bb8bd72cb098fc083"
checksum = "54bab418d0b7c027af9495f278619b46887362b3bd6446e246c87c5b0fa167df"
dependencies = [
"accesskit",
"accesskit_unix",
@@ -3859,9 +3852,9 @@ dependencies = [
[[package]]
name = "gpui-pre-macos"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e07a5e5c84f0b2160c194a1c852cc89d1942db66867938ceabcd7f763cb4386"
checksum = "2de7d3f4ad51ac387e822495db363fcc1188d349793365731d054444c4cb06ee"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -3887,6 +3880,8 @@ dependencies = [
"objc2-core-graphics",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"objc2-uniform-type-identifiers",
"objc2-user-notifications",
"parking_lot",
"raw-window-handle",
@@ -3898,9 +3893,9 @@ dependencies = [
[[package]]
name = "gpui-pre-macros"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c95d70a9fe10701533606c6135f9a1d4cd96e87e4e6062b3d426eb57b415daf"
checksum = "1c7f67904eced61ed42ae3320a9b4b388aed893f8547fffe9161ec425ad162eb"
dependencies = [
"heck 0.5.0",
"proc-macro-crate",
@@ -3911,9 +3906,9 @@ dependencies = [
[[package]]
name = "gpui-pre-platform"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a7f07a15187e82ec76491492c7e3245db1b59633d3bedb00b828ffaf67c4b8"
checksum = "d2b785bdcbd229f762096aebf4f9ae913bdaa9db49794907cabaf516eb55227b"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -3927,9 +3922,9 @@ dependencies = [
[[package]]
name = "gpui-pre-refineable"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ce7e83592bc6c1ead2affd91b333f11b60c3eb7d06cae1605dd56f149fe8674"
checksum = "4d197569e3b1fd84170f574bb866e95ecbc2625e7ae9443c877150eaf7d53374"
dependencies = [
"gpui-pre-derive-refineable",
]
@@ -3986,9 +3981,9 @@ dependencies = [
[[package]]
name = "gpui-pre-reqwest-client"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca7eada23ad3c3cff486fd7ab5d92abb82236188cca61cde708adf54cb436226"
checksum = "86152c439d502ac61cf3c42483bd41093bdaf9c1240b42367517218d47481866"
dependencies = [
"anyhow",
"bytes",
@@ -4004,9 +3999,9 @@ dependencies = [
[[package]]
name = "gpui-pre-scheduler"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87c2c6b0fabcd20e6773a0be30afa1f25637c53a06201bb80ab24a21fe198865"
checksum = "b1d3fa97eeb4ea233635b756684affe96db9e29fc2af8cdc269268bd7ff649fc"
dependencies = [
"async-task",
"backtrace",
@@ -4021,9 +4016,9 @@ dependencies = [
[[package]]
name = "gpui-pre-shared-string"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8068f94355f173558a51998a32985a434af54cc6c7262a060fffc369a505998f"
checksum = "63c2ed1e44195079d4461b83a99845aa9a52ce8fcf1516410816670a03d32d94"
dependencies = [
"schemars 1.2.3",
"serde",
@@ -4032,9 +4027,9 @@ dependencies = [
[[package]]
name = "gpui-pre-sum-tree"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ce66c57e9b5fd6c7a90fa9f9de2f9bb342127d670ca3c8fe5885270079eefa"
checksum = "6085e61458366decd21491396b0b90b3adf928f2ec27e170c4d3c9fba455d536"
dependencies = [
"gpui-pre-ztracing",
"heapless 0.9.3",
@@ -4044,9 +4039,9 @@ dependencies = [
[[package]]
name = "gpui-pre-util"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162dc29b2d2c0223ee4bb4e9f5e913eab4f62cad0943eb0c1122a74ac82b0768"
checksum = "52ead57ff96b5a34e71d64758080347066d667e0c4bd4ff71e85f3a50643fa3e"
dependencies = [
"anyhow",
"log",
@@ -4055,9 +4050,9 @@ dependencies = [
[[package]]
name = "gpui-pre-web"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1acd86af2347f0dd8b63c441a5dbd43b3fcd7dac69b340827c3497da5a13d6fb"
checksum = "a85e775c6874257c63f9cf51d99f0aba13b3bc615ed8d47e7d5338e3b5941f6a"
dependencies = [
"anyhow",
"async-channel",
@@ -4085,9 +4080,9 @@ dependencies = [
[[package]]
name = "gpui-pre-wgpu"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce693915768aae38bf788f245af75aae78e3e1179245860fc80ce831d0a8f74e"
checksum = "dc76608d385f6d0dcbad44504b1bf669dac3b7d7ffa2885acdb7c1c358caf852"
dependencies = [
"anyhow",
"bytemuck",
@@ -4112,9 +4107,9 @@ dependencies = [
[[package]]
name = "gpui-pre-windows"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80670f987b920fba019917b2e222ecf2b377f2a6d77872010c7d284c235837b3"
checksum = "7201762420222d5b5ce5bdf0056996199212a3349bfb2457a3a0238c688681f3"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -4142,9 +4137,9 @@ dependencies = [
[[package]]
name = "gpui-pre-ztracing"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "814cc08c526961c88703806a68ba4cf216a1d988ffbbdc673304f4dd25858952"
checksum = "1043bbe8d711baf03e774b03bae6521155f017450b866dcaa987a07d978d660a"
dependencies = [
"gpui-pre-ztracing-macro",
"tracing",
@@ -4152,9 +4147,9 @@ dependencies = [
[[package]]
name = "gpui-pre-ztracing-macro"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f9dae92b9bc15baa6e530c90bdd4c689d3710bd4d18c3566deb1d6ae182daf"
checksum = "85701631a9bbecf47e6d19f145e85e57767e7c50f7192d5b1bf65b5ef43cb373"
[[package]]
name = "gpui-updater-core"
@@ -4628,11 +4623,32 @@ checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locale_fallback"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c"
dependencies = [
"icu_locale_core",
"icu_locale_fallback_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_fallback_data"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c"
[[package]]
name = "icu_normalizer"
version = "2.4.0"
@@ -4682,6 +4698,8 @@ checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
dependencies = [
"displaydoc",
"icu_locale_core",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -4689,6 +4707,29 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710"
dependencies = [
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3"
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -4994,9 +5035,9 @@ checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
[[package]]
name = "jiff"
version = "0.2.42"
version = "0.2.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fefca06093eeaed707a905795e68206da0edca4edef23d2a3997a3ddbee462aa"
checksum = "26699905cc795a23e3949585cc7ba3ee8ade090872a27e4316304a777297894a"
dependencies = [
"jiff-core",
"jiff-static",
@@ -5010,18 +5051,18 @@ dependencies = [
[[package]]
name = "jiff-core"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a24020db0ee88d1c681d677681bd31f9f4ea189a455425223d52ce4f1508aa"
checksum = "ffe6854081f5ac0a3046a9b223729ccd7a20b1e0e8277e44dc0660eedf67dfe2"
dependencies = [
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.42"
version = "0.2.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d3213549742662eea1a18e6c5af9be5dfbabeb7611ab71356a54928ec398f0"
checksum = "4b4ff49b2887190c760b86207b55cdcaabbdecaf7a0660d68aa2ed6f652abf0a"
dependencies = [
"jiff-core",
"proc-macro2",
@@ -5751,21 +5792,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "metal"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
dependencies = [
"bitflags 2.13.2",
"block",
"core-graphics-types 0.2.0",
"foreign-types 0.5.0",
"log",
"objc",
"paste",
]
[[package]]
name = "mime"
version = "0.3.18"
@@ -6612,6 +6638,7 @@ dependencies = [
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"objc2-uniform-type-identifiers",
]
[[package]]
@@ -6817,6 +6844,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.5",
"objc2-foundation 0.3.2",
]
@@ -6883,6 +6911,16 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-uniform-type-identifiers"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4"
dependencies = [
"objc2 0.6.5",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
@@ -7602,6 +7640,8 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -8308,9 +8348,9 @@ dependencies = [
[[package]]
name = "rust-embed"
version = "8.13.0"
version = "8.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23"
checksum = "e98f580cf7a2863aeb6827800283f52cbe6fb3f3bb7c1843783f903c16dcb8ad"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
@@ -8319,9 +8359,9 @@ dependencies = [
[[package]]
name = "rust-embed-impl"
version = "8.13.0"
version = "8.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053"
checksum = "a93e2f69cf224a6c7df7509aa5ba4a23f56828ee41b040e92ac8762371eb9ba7"
dependencies = [
"mime_guess",
"proc-macro2",
@@ -8334,9 +8374,9 @@ dependencies = [
[[package]]
name = "rust-embed-utils"
version = "8.13.0"
version = "8.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a"
checksum = "915e33dccbe7deb2b8a342dc4c517503fe85e0230ddbae40a4fb0220493dad9b"
dependencies = [
"globset",
"sha2 0.11.1",
@@ -9785,6 +9825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -10888,9 +10929,9 @@ dependencies = [
[[package]]
name = "webbrowser"
version = "1.2.6"
version = "1.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe8d20d0214027baf11b8b0cc307c1da7657d8f4db9aa956f7de27a6fb1acbd5"
checksum = "73e91ccd4698f2647d6137e8eb17a3f1dcfd63810be9029fb2398f507279b95b"
dependencies = [
"jni 0.22.4",
"log",
@@ -12354,6 +12395,7 @@ dependencies = [
"displaydoc",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
@@ -12362,6 +12404,7 @@ version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+6 -10
View File
@@ -9,18 +9,14 @@ edition = "2024"
publish = false
[workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.10" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
gpui = { package = "gpui-pre", version = "0.3.12" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.12", features = ["font-kit", "x11", "wayland"] }
gpui_util = { package = "gpui-pre-util", version = "0.3.12" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.12" }
gpui_tokio = { path = "crates/gpui_tokio" }
gpui-base = "0.8.1"
gpui-kit = "0.8.1"
gpui-base = "0.9.1"
gpui-kit = "0.9.1"
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.25 13L12 16.25L8.75 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.75V15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.5 18.25L15.75 12L9.5 5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.9231L10.5625 15.75L15.25 8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 21.25L21.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.75 21.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 522 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M19 1.75V8.25M15.75 5H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.75 9.9C10.75 11.0046 10.0784 11.75 9.25 11.75C8.42157 11.75 7.75 11.0046 7.75 9.9C7.75 8.79543 8.42157 8 9.25 8C10.0784 8 10.75 8.79543 10.75 9.9Z" fill="currentColor"/><path d="M16.25 9.9C16.25 11.0046 15.5784 11.75 14.75 11.75C13.9216 11.75 13.25 11.0046 13.25 9.9C13.25 8.79543 13.9216 8 14.75 8C15.5784 8 16.25 8.79543 16.25 9.9Z" fill="currentColor"/><path d="M16.1123 14.8493C16.1942 14.7105 16.2249 14.545 16.192 14.3857C16.1592 14.2263 16.0665 14.0867 15.933 13.9968C15.7996 13.9069 15.6354 13.8733 15.4754 13.9028C15.3154 13.9321 15.1736 14.0226 15.0757 14.1507C15.0008 14.2469 14.9237 14.3367 14.8415 14.4241C14.1096 15.2083 13.061 15.628 12.0035 15.625C10.946 15.6265 9.8972 15.2055 9.16254 14.4222C9.08002 14.3348 9.00261 14.2451 8.92738 14.1491C8.8291 14.0214 8.68699 13.9313 8.52686 13.9024C8.36679 13.8735 8.20268 13.9076 8.06954 13.9979C7.9364 14.0882 7.84406 14.2281 7.81174 14.3875C7.77938 14.547 7.81054 14.7123 7.89293 14.8509C7.97731 14.99 8.06686 15.1223 8.16553 15.2526C9.04297 16.4311 10.5292 17.1343 12.0024 17.125C13.4754 17.1367 14.965 16.4342 15.8405 15.2521C15.939 15.1215 16.0282 14.9888 16.1123 14.8493Z" fill="currentColor"/><path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

-16
View File
@@ -1,16 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 10V9" />
<path d="M7.084 14.302a5.12 5.12 0 009.833 0 .24.24 0 00-.235-.302H7.32a.24.24 0 00-.235.302" />
<path d="M9 10V9" />
<circle cx="12" cy="12" r="10" />
</svg>

Before

Width:  |  Height:  |  Size: 390 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M11.75 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V15.25C21.25 16.3546 20.3546 17.25 19.25 17.25H11.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 6.75H4.75C3.64543 6.75 2.75 7.64543 2.75 8.75V15.25C2.75 16.3546 3.64543 17.25 4.75 17.25H5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.75 3.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4.75 10.9853V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V10.9853M9.75 7.75H14.25M12.617 13.5499L19.9415 11.1744C20.5875 10.9649 21.25 11.4465 21.25 12.1256V18.25C21.25 19.3546 20.3546 20.25 19.25 20.25H4.75C3.64543 20.25 2.75 19.3546 2.75 18.25V12.1256C2.75 11.4465 3.41249 10.9649 4.0585 11.1744L11.383 13.5499C11.784 13.68 12.216 13.68 12.617 13.5499Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

-14
View File
@@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
</svg>

Before

Width:  |  Height:  |  Size: 308 B

+3
View File
@@ -0,0 +1,3 @@
<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"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 435 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

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M17.75 2.75H6.25C5.14543 2.75 4.25 3.64543 4.25 4.75V19.25C4.25 20.3546 5.14543 21.25 6.25 21.25H17.75C18.8546 21.25 19.75 20.3546 19.75 19.25V4.75C19.75 3.64543 18.8546 2.75 17.75 2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12.25" r="2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M16 21C16 18.7909 14.2091 17 12 17C9.79086 17 8 18.7909 8 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.75 6.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="9.25" r="1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 21.25L11.75 9.25H12.25L16.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.5 17.75H14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75693 12.7501C6.08102 10.7234 6.08103 7.77679 7.75693 5.75M16.2431 5.75C17.919 7.77679 17.919 10.7234 16.2431 12.7501" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.06494 2.7574C1.64285 6.40823 1.64502 12.1018 5.07145 15.75M18.9281 2.75C22.3572 6.40053 22.3573 12.0993 18.9285 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

-16
View File
@@ -1,16 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" x2="19" y1="8" y2="14" />
<line x1="22" x2="16" y1="11" y2="11" />
</svg>

Before

Width:  |  Height:  |  Size: 383 B

+2 -2
View File
@@ -155,7 +155,7 @@ impl AutoUpdater {
pub fn status(&self) -> SharedString {
match &self.status {
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates".into(),
UpdateStatus::Checking => "Checking for updates…".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
@@ -165,7 +165,7 @@ impl AutoUpdater {
None => format!("Downloading {downloaded_mb:.1} MB").into(),
}
}
UpdateStatus::Installing => "Installing update".into(),
UpdateStatus::Installing => "Installing update…".into(),
UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into()
}
+1 -1
View File
@@ -172,7 +172,7 @@
<div id="nip07-status" class="status status--checking">
<div class="status__dot"></div>
<span id="nip07-status-text">Checking extension</span>
<span id="nip07-status-text">Checking extension…</span>
</div>
<div class="hint">
+3 -3
View File
@@ -5,7 +5,7 @@ async function pollForRequests() {
isPolling = true;
try {
const response = await fetch('/api/pending');
const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
const data = await response.json();
console.log('Polled for requests, got:', data);
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+97 -77
View File
@@ -265,6 +265,10 @@ impl BrowserSignerProxy {
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
// Regenerated on every startup, never part of the opened URL
let session_token: String = Uuid::new_v4().simple().to_string();
// Only the exact host:port the server binds may appear in the Host header
let expected_host: String = addr.to_string();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
@@ -291,13 +295,22 @@ impl BrowserSignerProxy {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
let expected_host: String = expected_host.clone();
let session_token: String = session_token.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
handle_connection(
stream,
state,
custom_html,
expected_host,
session_token,
)
.await;
},
async {
shutdown_listener.await;
@@ -336,6 +349,18 @@ impl BrowserSignerProxy {
outgoing_requests.push(request);
}
/// Drop a request from both queues so timed-out request data is not retained.
#[inline]
async fn cancel_request(&self, id: Uuid) {
self.inner.state.pending_responses.lock().await.remove(&id);
self.inner
.state
.outgoing_requests
.lock()
.await
.retain(|request| request.id != id);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
@@ -345,6 +370,7 @@ impl BrowserSignerProxy {
// Construct the request
let request: RequestData = RequestData::new(method, params);
let request_id = request.id;
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
@@ -361,7 +387,10 @@ impl BrowserSignerProxy {
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
Err(TimeoutError) => {
self.cancel_request(request_id).await;
Err(Error::timeout())
}
}
}
@@ -437,10 +466,14 @@ impl AsyncNip44 for BrowserSignerProxy {
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
async fn handle_connection(
stream: TcpStream,
state: Arc<ProxyState>,
custom_html: &'static str,
expected_host: String,
session_token: String,
) {
let mut reader = BufReader::new(stream);
// Read the request line
@@ -462,24 +495,49 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
let mut host: Option<String> = None;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("host:") {
host = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("Host:") {
host = Some(value.trim().to_string());
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// DNS rebinding defense: only the exact bound host:port is accepted
if host.as_deref() != Some(expected_host.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
let (route, query) = match path.split_once('?') {
Some((route, query)) => (route, Some(query)),
None => (path.as_str(), None),
};
if route.starts_with("/api/") && query_param(query, "token") != Some(session_token.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
match (method.as_str(), route) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
@@ -487,7 +545,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
let html = inject_session_token(html, &session_token);
send_response(&mut reader, 200, "OK", "text/html", &html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
@@ -516,7 +575,7 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
send_response(&mut reader, 200, "OK", "application/json", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
@@ -541,27 +600,16 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
Some(sender) => match sender.try_send(message.into_result()) {
Ok(()) => tracing::info!("Forwarded response for request {id}"),
Err(e) => {
tracing::warn!("Failed to forward response for request {id}: {e}")
}
},
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
send_response(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// 404 - not found
_ => {
@@ -585,68 +633,44 @@ async fn send_response(
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
write_response(stream, &response).await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
if let Err(e) = stream.write_all(response.as_bytes()).await {
tracing::debug!("Failed to write response to peer: {e}");
}
if let Err(e) = stream.flush().await {
tracing::debug!("Failed to flush response to peer: {e}");
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// Get a parameter value from an optional query string.
fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
let query = query?;
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then_some(value)
})
}
fn inject_session_token(html: &str, token: &str) -> String {
let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
match html.rfind("</body>") {
Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
None => format!("{html}{script}"),
}
}
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
@@ -677,10 +701,6 @@ async fn race_timeout<T>(
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
-1
View File
@@ -10,7 +10,6 @@ speech = { path = "../speech" }
ui = { path = "../ui" }
gpui-kit = { workspace = true, features = ["speech"] }
common = { path = "../common" }
log.workspace = true
person = { path = "../person" }
chat = { path = "../chat" }
settings = { path = "../settings" }
+119 -169
View File
@@ -12,14 +12,13 @@ use gpui::{
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled,
StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity,
Window, div, img, list, px, relative, rems, retain_all, svg,
Window, div, img, list, px, red, relative, retain_all, svg, white,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_kit::component::menu::{ContextMenuExt as _, DropdownMenu, PopupMenuItem};
use gpui_kit::component::menu::DropdownMenu;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
@@ -36,9 +35,9 @@ use state::{
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage, group_reactions,
opens_run, react_menu, rendered_text,
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage,
group_reactions, opens_run, rendered_text,
};
use crate::file::*;
@@ -599,16 +598,23 @@ impl ChatPanel {
return;
}
let content = SharedString::from(&event.content);
let emojis = emojis_from_tags(&event.tags);
let image = Message::reaction_image(&event.content, &emojis)
.map(|url| SharedUri::from(url.to_string()));
for id in event.tags.event_ids() {
self.reactions.entry(id).or_default().push((
SharedString::from(&event.content),
image.clone(),
event.pubkey,
));
let entries = self.reactions.entry(id).or_default();
// NIP-25: a newer reaction by the same author replaces their previous one
if let Some(existing) = entries
.iter_mut()
.find(|(_, _, author)| *author == event.pubkey)
{
*existing = (content.clone(), image.clone(), event.pubkey);
} else {
entries.push((content.clone(), image.clone(), event.pubkey));
}
}
cx.notify();
@@ -675,36 +681,6 @@ impl ChatPanel {
}
}
/// Read a message aloud, tracking its reveal state until utterance end.
fn speak_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
let Some(text) = self.message(id).map(|message| message.preview()) else {
return;
};
match self.speaker.speak(&text) {
Ok(duration) => {
self.speech_reveal = Some((*id, duration));
// Re-enable speaking when no end event ever arrives
let session = (*id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
self.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => window.push_notification(Notification::error(error.to_string()), cx),
}
}
fn remove_reply(&mut self, id: &EventId, cx: &mut Context<Self>) {
self.replies_to.update(cx, |this, cx| {
this.remove(id);
@@ -1057,9 +1033,7 @@ impl ChatPanel {
.bg(cx.theme().secondary)
.rounded(cx.theme().radius)
.text_xs()
.child(
div().size_1p5().rounded_full().bg(cx.theme().success),
)
.child(div().size_1p5().rounded_full().bg(gpui::green()))
.child(SharedString::from(url.to_string())),
);
}
@@ -1077,7 +1051,7 @@ impl ChatPanel {
window.open_dialog(cx, move |this, _window, cx| {
let relays = profile.messaging_relays();
this.title("Messaging relays")
this.title("Messaging Relays")
.close_button(true)
.child(v_flex().gap_1().children({
let mut items = vec![];
@@ -1091,7 +1065,7 @@ impl ChatPanel {
.bg(cx.theme().secondary)
.rounded(cx.theme().radius)
.text_xs()
.child(div().size_1p5().rounded_full().bg(cx.theme().success))
.child(div().size_1p5().rounded_full().bg(gpui::green()))
.child(SharedString::from(url.to_string())),
);
}
@@ -1134,7 +1108,7 @@ impl ChatPanel {
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().accent),
.text_color(cx.theme().muted_foreground),
)
.child(PRIVATE_NOTICE)
.into_any_element()
@@ -1159,7 +1133,7 @@ impl ChatPanel {
.rounded_full()
.bg(cx.theme().warning.opacity(0.15))
.text_color(cx.theme().warning)
.child(Icon::new(IconName::TriangleAlert).small()),
.child(Icon::new(IconName::Warning).small()),
)
.child(
div()
@@ -1228,11 +1202,7 @@ impl ChatPanel {
cx,
)
})
.element(
SharedString::from(format!("text-{}", message.id.to_hex())).into(),
window,
cx,
);
.element(ix.into(), window, cx);
self.render_text_message(ix, message, text, show_author, cx)
} else {
@@ -1242,7 +1212,7 @@ impl ChatPanel {
fn render_text_message(
&self,
_ix: usize,
ix: usize,
message: &Message,
rendered_text: AnyElement,
show_author: bool,
@@ -1274,7 +1244,7 @@ impl ChatPanel {
None => rendered_text,
};
let row = MessageRow::new(SharedString::from(format!("message-{}", id.to_hex())))
MessageRow::new(ix)
.show_author(show_author)
.hide_avatar(hide_avatar)
.avatar(
@@ -1304,6 +1274,16 @@ impl ChatPanel {
this.child(self.render_message_file(&id, file, cx))
})
.when(has_reactions, |this| this.child(reactions))
.overlay(
div()
.group_hover("", |this| this.bg(cx.theme().list_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(gpui::transparent_black()),
)
.overlay(self.render_actions(&id, &pk, cx))
.on_mouse_down(
MouseButton::Middle,
@@ -1313,76 +1293,7 @@ impl ChatPanel {
)
.on_double_click(cx.listener(move |this, _, _window, cx| {
this.reply_to(&id, cx);
}));
let panel = cx.weak_entity();
let speaking = self.speech_reveal.is_some();
div()
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
.w_full()
.child(row)
.context_menu(move |menu, window, cx| {
let menu = menu.item({
let panel = panel.clone();
let id = id;
PopupMenuItem::new("Reply").on_click(move |_, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.reply_to(&id, cx);
}) {
log::warn!("starting a reply failed: {error}");
}
})
});
let menu = menu.submenu("React", window, cx, {
let panel = panel.clone();
move |menu, _window, _cx| {
let panel = panel.clone();
react_menu(menu, REACTION_EMOJIS, &[], move |emoji, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.send_reaction(emoji, &id, window, cx);
}) {
log::warn!("sending a reaction failed: {error}");
}
})
}
});
menu.item({
let panel = panel.clone();
let id = id;
PopupMenuItem::new("Copy message").on_click(move |_, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.copy_message(&id, cx);
}) {
log::warn!("copying a message failed: {error}");
}
})
})
.item({
let panel = panel.clone();
let id = id;
let speaking = speaking;
PopupMenuItem::new("Read aloud")
.disabled(speaking)
.on_click(move |_, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.speak_message(&id, window, cx);
}) {
log::warn!("reading the message aloud failed: {error}");
}
})
})
.separator()
.menu("Copy author", Box::new(Command::Copy(pk)))
.menu("Seen on", Box::new(Command::Trace(id)))
})
}))
.into_any_element()
}
@@ -1393,19 +1304,14 @@ impl ChatPanel {
) -> impl IntoIterator<Item = impl IntoElement> {
let mut items = Vec::with_capacity(replies.len());
for id in replies.iter() {
for (ix, id) in replies.iter().enumerate() {
let Some(message) = self.message(id) else {
continue;
};
let author = self.profile(&message.author, cx);
items.push(
ReplyPreview::new(
SharedString::from(format!("reply-{}", id.to_hex())),
author.name(),
message.preview(),
)
.on_click({
ReplyPreview::new(ix, author.name(), message.preview()).on_click({
let id = *id;
cx.listener(move |this, _event, _window, _cx| {
this.scroll_to(&id);
@@ -1436,7 +1342,7 @@ impl ChatPanel {
} else if failed {
SharedString::from("• Error")
} else if pending {
SharedString::from("• Sending")
SharedString::from("• Sending...")
} else {
SharedString::from("• Unknown")
};
@@ -1461,7 +1367,7 @@ impl ChatPanel {
})
.unwrap_or_default();
this.title(SharedString::from("Sent reports"))
this.title(SharedString::from("Sent Reports"))
.close_button(true)
.child(v_flex().gap_4().children(items))
});
@@ -1616,7 +1522,34 @@ impl ChatPanel {
let actions = actions.speech(
speaking,
cx.listener(move |this, _event, window, cx| {
this.speak_message(&speech_id, window, cx);
let text = this.message(&speech_id).map(|message| message.preview());
if let Some(text) = text {
match this.speaker.speak(&text) {
Ok(duration) => {
this.speech_reveal = Some((speech_id, duration));
// Re-enable speaking when no end event ever arrives
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
this.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}
}),
);
@@ -1648,12 +1581,8 @@ impl ChatPanel {
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().danger)
.child(
Icon::new(IconName::Close)
.size_2()
.text_color(cx.theme().danger_foreground),
),
.bg(red())
.child(Icon::new(IconName::Close).size_2().text_color(white())),
)
.on_click({
let url = url.clone();
@@ -1698,7 +1627,7 @@ impl ChatPanel {
img(local.image_source())
.border_1()
.border_color(cx.theme().border)
.h(rems(16.))
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius_lg),
)
@@ -1733,7 +1662,7 @@ impl ChatPanel {
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.child(Icon::new(IconName::Lock).text_color(cx.theme().accent))
.child(Icon::new(IconName::Lock).text_color(cx.theme().primary))
.child(
v_flex()
.min_w_0()
@@ -1805,12 +1734,8 @@ impl ChatPanel {
.rounded_full()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().success)
.child(
Icon::new(IconName::Lock)
.size_2()
.text_color(cx.theme().success_foreground),
),
.bg(gpui::green())
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
)
.on_click({
let url = file.url.clone();
@@ -1838,20 +1763,47 @@ impl ChatPanel {
if let Some(text) = self.message(id) {
let profile = self.directory.read(cx).person(&text.author, cx);
ReplyPreview::new(
SharedString::from(format!("reply-{}", id.to_hex())),
profile.name(),
text.preview(),
)
.on_remove({
let id = text.id;
cx.listener(move |this, _, _, cx| {
this.remove_reply(&id, cx);
})
})
.into_any_element()
div()
.w_full()
.pl_2()
.border_l_2()
.border_color(cx.theme().list_active)
.child(
h_flex()
.justify_between()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Replying to:")
.child(div().text_color(cx.theme().primary).child(profile.name())),
)
.child(
Button::new("remove-reply")
.icon(IconName::Close)
.xsmall()
.ghost()
.on_click({
let id = text.id;
cx.listener(move |this, _, _, cx| {
this.remove_reply(&id, cx);
})
}),
),
)
.child(
div()
.w_full()
.text_xs()
.text_ellipsis()
.line_clamp(1)
.child(text.preview()),
)
} else {
div().into_any_element()
div()
}
}
@@ -1938,8 +1890,7 @@ impl ChatPanel {
.unwrap_or_default();
Button::new("emoji")
.icon(IconName::FaceGrinning)
.tooltip("Emoji")
.icon(IconName::Emoji)
.ghost()
.disabled(disabled)
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
@@ -1987,7 +1938,7 @@ impl Panel for ChatPanel {
Some(vec![
Button::new("subject")
.icon(IconName::TextCursorInput)
.icon(IconName::Input)
.tooltip("Change subject")
.small()
.ghost()
@@ -2034,7 +1985,7 @@ impl Render for ChatPanel {
.child(Input::new(&self.subject_input))
.child(
Button::new("change")
.icon(IconName::CircleCheck)
.icon(IconName::CheckCircle)
.label("Change")
.secondary()
.disabled(self.uploading)
@@ -2149,10 +2100,9 @@ impl Render for ChatPanel {
.child(self.render_config_menu(dictating, window, cx))
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.tooltip("Send")
.icon(IconName::PaperPlaneFill)
.disabled(dictating || self.uploading)
.primary()
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx);
})),
+141 -45
View File
@@ -1,65 +1,161 @@
use std::sync::LazyLock;
use gpui::SharedUri;
use regex::Regex;
/// Extracts media URLs from a string and returns both
/// the extracted URLs and the string with media URLs removed
struct MediaExtractor {
image_regex: Regex,
}
/// Matches image URLs with common image extensions
static IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
)
.unwrap()
});
/// Extracts media URLs from a string and returns the URLs and the cleaned text
struct MediaExtractor;
impl MediaExtractor {
/// Creates a new MediaExtractor with compiled regex patterns
fn new() -> Self {
MediaExtractor {
// Match common image extensions
image_regex: Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
).unwrap(),
}
}
/// Extracts all media URLs from a string
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
let mut urls = Vec::new();
// Extract image URLs
for capture in self.image_regex.find_iter(text) {
urls.push(capture.as_str().to_string().into());
}
urls
fn extract_media_urls(text: &str) -> Vec<SharedUri> {
IMAGE_RE
.find_iter(text)
.map(|capture| capture.as_str().to_string().into())
.collect()
}
/// Removes all media URLs from a string and returns the cleaned text
fn remove_media_urls(&self, text: &str) -> String {
let mut result = text.to_string();
fn remove_media_urls(text: &str) -> String {
let mut cleaned = String::with_capacity(text.len());
let mut last_end = 0;
// Whitespace bordered a removed URL, so one space stands in for it
let mut spaced = false;
// Remove image URLs
result = self.image_regex.replace_all(&result, "").to_string();
for url in IMAGE_RE.find_iter(text) {
let raw_gap = &text[last_end..url.start()];
let surrounded = raw_gap.ends_with(char::is_whitespace)
|| text[url.end()..].starts_with(char::is_whitespace);
// Clean up extra whitespace that might result from removal
self.cleanup_text(&result)
let gap = if last_end == 0 {
raw_gap.trim_end()
} else {
raw_gap.trim()
};
if !gap.is_empty() {
if spaced && !cleaned.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(gap);
}
spaced |= surrounded;
last_end = url.end();
}
let tail = text[last_end..].trim_start();
if spaced && !cleaned.is_empty() && !tail.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(tail);
cleaned.trim().to_string()
}
/// Extracts media URLs and removes them from the string, returning both
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
let urls = self.extract_media_urls(text);
let cleaned_text = self.remove_media_urls(text);
(urls, cleaned_text)
}
/// Helper function to clean up text after URL removal
fn cleanup_text(&self, text: &str) -> String {
let text = text.trim();
// Remove multiple consecutive spaces
let re = Regex::new(r"\s+").unwrap();
re.replace_all(text, " ").trim().to_string()
fn extract_and_remove(text: &str) -> (Vec<SharedUri>, String) {
(
Self::extract_media_urls(text),
Self::remove_media_urls(text),
)
}
}
/// Convenience function for one-time extraction and removal
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
let extractor = MediaExtractor::new();
extractor.extract_and_remove(text)
MediaExtractor::extract_and_remove(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_line_text_without_urls_keeps_line_breaks() {
let text = "first line\n\nsecond line";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn removes_url_and_tidies_around_it() {
let (urls, cleaned) =
extract_and_remove_media_urls("check https://example.com/pic.png here");
assert_eq!(urls, vec![SharedUri::from("https://example.com/pic.png")]);
assert_eq!(cleaned, "check here");
}
#[test]
fn removing_url_keeps_other_line_breaks() {
let (urls, cleaned) = extract_and_remove_media_urls(
"first\nsecond https://example.com/pic.png third\nfourth",
);
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first\nsecond third\nfourth");
}
#[test]
fn url_on_its_own_line_joins_neighbors_with_one_space() {
let (urls, cleaned) =
extract_and_remove_media_urls("first\n\nhttps://example.com/pic.png\n\nlast");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first last");
}
#[test]
fn multiple_urls_collapse_to_single_spaces() {
let (urls, cleaned) = extract_and_remove_media_urls(
"a https://example.com/a.png b https://example.com/b.png c",
);
assert_eq!(urls.len(), 2);
assert_eq!(cleaned, "a b c");
}
#[test]
fn message_with_only_a_url_becomes_empty() {
let (urls, cleaned) = extract_and_remove_media_urls("https://example.com/pic.png");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "");
}
#[test]
fn digits_glued_to_url_scheme_prevent_match() {
let text = "v1.2https://example.com/pic.png";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn digits_around_removed_url_survive() {
let (urls, cleaned) = extract_and_remove_media_urls("123 https://example.com/pic.png 456");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "123 456");
}
#[test]
fn emoji_around_removed_url_survives() {
let (urls, cleaned) = extract_and_remove_media_urls("👍 https://example.com/pic.png 👍");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "👍 👍");
}
}
+16 -3
View File
@@ -13,7 +13,7 @@ use crate::LOCAL_KEYS;
use crate::protocol::{
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
TimelineWindow, tag_value,
TimelineWindow, is_newer_event, tag_value,
};
const REPLAY_LIMIT: usize = 100;
@@ -91,7 +91,8 @@ pub struct Group {
membership: Membership,
membership_events: Vec<Event>,
elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>,
/// The last accepted (created_at, id) per state kind.
state_ids: HashMap<Kind, (Timestamp, EventId)>,
rows: Vec<Row>,
index: HashSet<EventId>,
reactions: HashMap<EventId, Vec<Reaction>>,
@@ -694,6 +695,15 @@ impl Group {
}
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
// Relays deliver same-kind state events in any order.
if !is_newer_event(
self.state_ids.get(&event.kind).copied(),
event.created_at,
event.id,
) {
return false;
}
match event.kind {
Kind::GroupMetadata => match GroupMetadata::parse(event) {
Ok(metadata) => self.metadata = Some(metadata),
@@ -724,7 +734,10 @@ impl Group {
_ => return false,
}
self.state_ids.insert(event.kind, event.id) != Some(event.id)
self.state_ids
.insert(event.kind, (event.created_at, event.id));
true
}
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
+43 -17
View File
@@ -76,6 +76,8 @@ pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
previews: HashMap<GroupKey, Entity<Group>>,
list: GroupList,
/// The (created_at, id) of the last accepted group list event.
list_stamp: Option<(Timestamp, EventId)>,
browse: HashMap<SubscriptionId, RelayUrl>,
routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>,
@@ -132,6 +134,7 @@ impl GroupsRegistry {
groups: Vec::new(),
previews: HashMap::new(),
list: GroupList::default(),
list_stamp: None,
browse: HashMap::new(),
routes: HashMap::new(),
observers: HashMap::new(),
@@ -455,6 +458,8 @@ impl GroupsRegistry {
self.fork_authors.clear();
self.syncing.clear();
self.list = GroupList::default();
// A new signer's list events must not be judged against the old stamp.
self.list_stamp = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -488,24 +493,26 @@ impl GroupsRegistry {
let signer = nostr.read(cx).signer();
// Show the cached groups before the relays answer.
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
let task: Task<Result<(GroupList, Option<Event>), Error>> =
cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
match client.database().query(filter).await?.into_iter().next() {
Some(event) => {
let list = GroupList::parse(&event)?;
Ok(Self::hydrate_list(list, &signer, &me).await)
match client.database().query(filter).await?.into_iter().next() {
Some(event) => {
let list =
Self::hydrate_list(GroupList::parse(&event)?, &signer, &me).await;
Ok((list, Some(event)))
}
None => Ok((GroupList::default(), None)),
}
None => Ok(GroupList::default()),
}
});
});
self.tasks
.push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => {
Ok((list, event)) => {
this.update(cx, |this, cx| {
this.merge(list, cx);
this.merge(list, event.as_ref(), cx);
})
.ok();
}
@@ -538,7 +545,16 @@ impl GroupsRegistry {
self.watch_forks(cx);
}
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
fn merge(&mut self, list: GroupList, event: Option<&Event>, cx: &mut Context<Self>) {
if let Some(event) = event {
// Relays replay list events out of order; only strictly newer applies.
if !is_newer_event(self.list_stamp, event.created_at, event.id) {
return;
}
self.list_stamp = Some((event.created_at, event.id));
}
// Carry the event's private section so a later publish can preserve it.
self.list
.set_content(list.content().map(str::to_owned), list.content_readable());
@@ -589,20 +605,21 @@ impl GroupsRegistry {
};
if list.content().is_none() {
self.merge(list, cx);
self.merge(list, Some(&event), cx);
return;
}
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let Some(me) = nostr.read(cx).current_user() else {
self.merge(list, cx);
self.merge(list, Some(&event), cx);
return;
};
self.tasks.push(cx.spawn(async move |this, cx| {
let list = Self::hydrate_list(list, &signer, &me).await;
this.update(cx, |this, cx| this.merge(list, cx)).ok();
this.update(cx, |this, cx| this.merge(list, Some(&event), cx))
.ok();
}));
}
@@ -864,7 +881,7 @@ impl GroupsRegistry {
let me = nostr.read(cx).current_user();
let list = self.list.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
// Re-encrypt the private section when it carries local entries; a
// section this session could not read is carried verbatim.
let content = match (list.has_private(), list.content_readable()) {
@@ -893,6 +910,15 @@ impl GroupsRegistry {
}
};
// Our own publish is the newest list until a newer event arrives.
if let Err(error) = this.update(cx, |this, _cx| {
if is_newer_event(this.list_stamp, event.created_at, event.id) {
this.list_stamp = Some((event.created_at, event.id));
}
}) {
log::warn!("recording the published list failed: {error}");
}
match client.send_event(&event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => {
+24
View File
@@ -50,6 +50,15 @@ pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
.and_then(|tag| tag.content())
}
/// Whether `(created_at, id)` is strictly newer than the last accepted stamp.
pub(crate) fn is_newer_event(
accepted: Option<(Timestamp, EventId)>,
created_at: Timestamp,
id: EventId,
) -> bool {
accepted.is_none_or(|last| (created_at, id) > last)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GroupId(String);
@@ -1152,4 +1161,19 @@ mod tests {
let malformed = Tag::custom("previous", ["nothex"]);
assert_eq!(window.references_seen(Some(&malformed)), Some(false));
}
#[test]
fn is_newer_event_accepts_only_strictly_newer_events() {
let lower = EventId::from_byte_array([0x00; 32]);
let higher = EventId::from_byte_array([0xff; 32]);
let earlier = Timestamp::from(100);
let later = Timestamp::from(200);
assert!(is_newer_event(None, earlier, lower));
assert!(!is_newer_event(Some((earlier, lower)), earlier, lower));
assert!(is_newer_event(Some((earlier, lower)), later, lower));
assert!(!is_newer_event(Some((later, lower)), earlier, higher));
assert!(is_newer_event(Some((earlier, lower)), earlier, higher));
assert!(!is_newer_event(Some((earlier, higher)), earlier, lower));
}
}
+85 -75
View File
@@ -1,19 +1,17 @@
use gpui::{
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div,
rems,
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
relative,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::description_list::{DescriptionItem, DescriptionList};
use gpui_kit::component::tag::Tag;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupMetadata};
use ui::Avatar;
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |this, _window, cx| {
this.max_h(rems(37.5))
this.max_h(px(600.))
.close_button(true)
.title("About group")
.title("About Group")
.child(body(&group, cx))
});
}
@@ -25,7 +23,6 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
let group = entity.read(cx);
let mut body = v_flex().gap_3().text_xs().child(header(group, cx));
let mut items = Vec::new();
if let Some(metadata) = group.metadata() {
if let Some(about) = metadata.about.as_ref() {
@@ -39,100 +36,86 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
let flags = flags(metadata);
if !flags.is_empty() {
items.push(
DescriptionItem::new("Flags").value(wrap(
flags
.into_iter()
.map(|flag| Tag::secondary().child(flag).into_any_element()),
)),
);
body = body.child(section(
"Flags",
wrap(flags.into_iter().map(|f| chip(f, cx))),
cx,
));
}
if let Some(parent) = metadata.parent.as_ref() {
items.push(
DescriptionItem::new("Parent").value(
div()
.child(SharedString::from(parent.as_str().to_owned()))
.into_any_element(),
),
);
body = body.child(section(
"Parent",
div()
.child(SharedString::from(parent.as_str().to_owned()))
.into_any_element(),
cx,
));
}
if !metadata.children.is_empty() {
items.push(
DescriptionItem::new("Children").value(wrap(metadata.children.iter().map(
|child| {
Tag::secondary()
.child(SharedString::from(child.as_str().to_owned()))
.into_any_element()
},
))),
);
body = body.child(section(
"Children",
wrap(
metadata
.children
.iter()
.map(|child| chip(child.as_str().to_owned(), cx)),
),
cx,
));
}
if let Some(kinds) = metadata.supported_kinds.as_ref() {
let values: Vec<AnyElement> = kinds
let items: Vec<AnyElement> = kinds
.iter()
.map(|kind| {
Tag::secondary()
.child(SharedString::from(kind.to_string()))
.into_any_element()
})
.map(|kind| chip(kind.to_string(), cx))
.collect();
items.push(
DescriptionItem::new("Supported kinds").value(if values.is_empty() {
body = body.child(section(
"Supported kinds",
if items.is_empty() {
div()
.text_color(cx.theme().muted_foreground)
.child("None")
.into_any_element()
} else {
wrap(values)
}),
);
wrap(items)
},
cx,
));
}
}
let roles = group.roles().roles();
if !roles.is_empty() {
items.push(
DescriptionItem::new("Roles").value(wrap(roles.iter().map(|role| {
Tag::secondary()
.child(SharedString::from(role.name.clone()))
.into_any_element()
}))),
);
body = body.child(section(
"Roles",
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
cx,
));
}
let pins = group.pins().pins();
if !pins.is_empty() {
items.push(
DescriptionItem::new("Pinned").value(
v_flex()
.gap_1()
.children(
pins.iter()
.map(|pin| {
div()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(crate::util::pin_label(group, pin))
.into_any_element()
})
.collect::<Vec<_>>(),
)
.into_any_element(),
),
);
}
if !items.is_empty() {
body = body.child(
DescriptionList::vertical()
.bordered(false)
.columns(1)
.children(items),
);
body = body.child(section(
"Pinned",
v_flex()
.gap_1()
.children(
pins.iter()
.map(|pin| {
div()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(crate::util::pin_label(group, pin))
.into_any_element()
})
.collect::<Vec<_>>(),
)
.into_any_element(),
cx,
));
}
body.into_any_element()
@@ -174,6 +157,20 @@ fn header(group: &Group, cx: &App) -> AnyElement {
.into_any_element()
}
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_medium()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(content)
.into_any_element()
}
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
h_flex()
.flex_wrap()
@@ -182,6 +179,19 @@ fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
.into_any_element()
}
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
div()
.px_2()
.py_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.text_color(cx.theme().foreground)
.text_xs()
.line_height(relative(1.))
.child(label.into())
.into_any_element()
}
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
let mut flags = Vec::new();
+7 -41
View File
@@ -3,10 +3,9 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, Window, div, px, rems,
WeakEntity, Window, div, px,
};
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::InputState;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
@@ -25,7 +24,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
});
window.open_dialog(cx, move |this, _window, cx| {
this.max_h(rems(37.5))
this.max_h(px(600.))
.close_button(true)
.title("Invites")
.child(body(group.clone(), input.clone(), cx))
@@ -127,52 +126,19 @@ fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
let group = group.clone();
let code = code.to_owned();
Button::new(SharedString::from(format!("invite-revoke-{code}")))
.label("Revoke")
.ghost()
.small()
.on_click(move |_event, window, cx| {
confirm_revoke(group.clone(), id, code.clone(), window, cx);
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
Ok(task) => report(window, cx, task),
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
}
})
}
/// Confirm revoking an invite, then delete it and surface any relay error.
fn confirm_revoke(
group: WeakEntity<Group>,
id: nostr_sdk::prelude::EventId,
code: String,
window: &mut Window,
cx: &mut App,
) {
window.open_alert_dialog(cx, move |this, _window, _cx| {
let group = group.clone();
this.confirm()
.width(px(360.))
.title(format!("Revoke invite {code}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Revoke")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("Nobody will be able to ask to join with this code."),
)
.on_ok(move |_event, window, cx| {
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
Ok(task) => report(window, cx, task),
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
}
true
})
});
}
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
h_flex()
.items_end()
+44 -131
View File
@@ -8,7 +8,7 @@ use gpui::{
Action, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, FollowMode, InteractiveElement, IntoElement, ListAlignment,
ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task,
WeakEntity, Window, div, list, px, rems,
WeakEntity, Window, div, list, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
@@ -27,7 +27,7 @@ use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use speech::speaker::{END_GRACE, Speaker};
use speech::speaker::Speaker;
use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
@@ -167,7 +167,7 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
)
.child(
Button::new("group-delete")
.icon(IconName::Trash)
.icon(IconName::CircleX)
.tooltip("Delete group")
.ghost()
.small()
@@ -261,43 +261,17 @@ pub fn confirm_delete(group: WeakEntity<Group>, window: &mut Window, cx: &mut Ap
});
}
/// Confirm removing a member, then remove them and surface any relay error.
pub fn confirm_remove_member(
/// Remove a member from the group and surface any relay error as a notification.
pub fn remove_member(
group: WeakEntity<Group>,
public_key: PublicKey,
member: SharedString,
window: &mut Window,
cx: &mut App,
) {
let Some(group_name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
return;
};
window.open_alert_dialog(cx, move |this, _window, _cx| {
let group = group.clone();
this.confirm()
.width(px(360.))
.title(format!("Remove {member} from {group_name}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Remove")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("The relay will remove them from this group."),
)
.on_ok(move |_event, window, cx| {
match group.update(cx, |group, cx| group.remove_user(public_key, cx)) {
Ok(task) => report(window, cx, task),
Err(error) => log::warn!("nip29: removing a group member failed: {error}"),
}
true
})
});
match group.update(cx, |group, cx| group.remove_user(public_key, cx)) {
Ok(task) => report(window, cx, task),
Err(error) => log::warn!("nip29: removing a group member failed: {error}"),
}
}
/// Grant a member the admin role and surface any relay error as a notification.
@@ -585,39 +559,6 @@ impl GroupPanel {
}
}
/// Read a message aloud, tracking its reveal state until utterance end.
fn speak(
&mut self,
id: EventId,
text: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.speaker.speak(&text) {
Ok(duration) => {
self.speech_reveal = Some((id, duration));
let session = (id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
self.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => window.push_notification(Notification::error(error.to_string()), cx),
}
}
fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context<Self>) {
self.reply_to = Some(id);
self.input.update(cx, |input, cx| {
@@ -775,46 +716,18 @@ impl GroupPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
window.open_alert_dialog(cx, move |this, _window, _cx| {
let group = group.clone();
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) else {
return;
};
this.confirm()
.width(px(360.))
.title("Delete message?")
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Delete")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("The relay will delete this message for everyone."),
)
.on_ok(move |_event, window, cx| {
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx))
else {
return true;
};
window
.spawn(cx, async move |cx| -> Result<()> {
if let Err(error) = delete.await {
cx.update(|window, cx| {
window.push_notification(
Notification::error(error.to_string()),
cx,
);
})?;
}
Ok(())
})
.detach();
true
})
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Err(error) = delete.await {
this.update_in(cx, |_this, window, cx| {
window.push_notification(Notification::error(error.to_string()), cx);
})?;
}
Ok(())
}));
}
fn toggle_pin(
@@ -965,11 +878,16 @@ impl GroupPanel {
.py_1p5()
.border_b_1()
.border_color(cx.theme().border)
.children(
pins.iter().map(|pin| {
gpui_kit::component::tag::Tag::secondary().child(pin_label(group, pin))
}),
)
.children(pins.iter().map(|pin| {
div()
.px_1p5()
.py_0p5()
.rounded(cx.theme().radius)
.bg(cx.theme().primary)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(pin_label(group, pin))
}))
.into_any_element()
}
@@ -1020,7 +938,7 @@ impl GroupPanel {
.child(
Button::new("load-older")
.label(if loading {
"Loading earlier messages"
"Loading earlier messages…"
} else {
"Load earlier messages"
})
@@ -1078,7 +996,7 @@ impl GroupPanel {
row.child("You're in preview mode. Join this group to talk.")
.child(
Button::new("join")
.label("Request to join")
.label("Request to Join")
.small()
.ghost()
.on_click(cx.listener(move |this, _event, _window, cx| {
@@ -1147,11 +1065,7 @@ impl GroupPanel {
cx,
)
})
.element(
SharedString::from(format!("text-{}", message.id.to_hex())).into(),
window,
cx,
),
.element(ix.into(), window, cx),
};
let admin = nostr
@@ -1248,7 +1162,7 @@ impl GroupPanel {
.items_end()
.child(
Button::new("upload")
.icon(IconName::Paperclip)
.icon(ui::IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(dictating || self.uploading)
@@ -1288,9 +1202,8 @@ impl GroupPanel {
.child(self.render_emoji_menu(dictating, window, cx))
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.tooltip("Send")
.primary()
.icon(ui::IconName::PaperPlaneFill)
.ghost()
.disabled(dictating || self.uploading)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
@@ -1321,8 +1234,7 @@ impl GroupPanel {
.unwrap_or_default();
Button::new("emoji")
.icon(IconName::FaceGrinning)
.tooltip("Emoji")
.icon(ui::IconName::Emoji)
.ghost()
.disabled(disabled)
.dropdown_menu_with_anchor(Anchor::BottomLeft, move |this, _window, _cx| {
@@ -1377,7 +1289,7 @@ impl GroupPanel {
.child(
div()
.flex_shrink_0()
.max_w(rems(8.75))
.max_w(px(140.))
.truncate()
.font_semibold()
.text_color(cx.theme().foreground)
@@ -1393,7 +1305,7 @@ impl GroupPanel {
)
.child(
Button::new("cancel-reply")
.icon(IconName::Close)
.icon(ui::IconName::Close)
.tooltip("Cancel reply")
.ghost()
.small()
@@ -1407,7 +1319,8 @@ impl GroupPanel {
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
self.attachments
.iter()
.map(|url| {
.enumerate()
.map(|(ix, url)| {
let url = url.clone();
h_flex()
@@ -1423,13 +1336,13 @@ impl GroupPanel {
.child(Icon::new(IconName::Link).xsmall())
.child(
div()
.max_w(rems(12.5))
.max_w(px(200.))
.truncate()
.child(attachment_name(&url)),
)
.child(
Button::new(SharedString::from(format!("remove-attachment-{}", url)))
.icon(IconName::Close)
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
.icon(ui::IconName::Close)
.tooltip("Remove")
.ghost()
.xsmall()
+76 -88
View File
@@ -3,17 +3,19 @@ use chat_ui::Command;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, ClipboardItem, Entity, InteractiveElement, IntoElement, ParentElement,
SharedString, Styled, WeakEntity, div,
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind, ToBech32};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use speech::speaker::END_GRACE;
use state::NostrRegistry;
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
@@ -33,7 +35,7 @@ pub(crate) struct Actions {
}
pub(crate) fn render(
_ix: usize,
ix: usize,
message: &Message,
content: AnyElement,
show_author: bool,
@@ -58,36 +60,33 @@ pub(crate) fn render(
nostr.read(cx).current_user(),
);
let row = MessageRow::new(SharedString::from(format!(
"message-{}",
message.id.to_hex()
)))
.show_author(show_author)
.hide_avatar(hide_avatar)
.avatar(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.small()
.flex_shrink_0(),
)
.author(author.name())
.timestamp(message.created_at.to_human_time())
.when(!message.replies_to.is_empty(), |this| {
this.children(replies(
&message.replies_to,
&persons,
&actions.group,
&actions.panel,
cx,
))
})
.child(content)
.when(!message.media.is_empty(), |this| {
this.child(MessageMedia::new(&message.media))
})
.when(!reaction_groups.is_empty(), |this| {
this.child(ReactionBar::new(reaction_groups))
});
let row = MessageRow::new(ix)
.show_author(show_author)
.hide_avatar(hide_avatar)
.avatar(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.small()
.flex_shrink_0(),
)
.author(author.name())
.timestamp(message.created_at.to_human_time())
.when(!message.replies_to.is_empty(), |this| {
this.children(replies(
&message.replies_to,
&persons,
&actions.group,
&actions.panel,
cx,
))
})
.child(content)
.when(!message.media.is_empty(), |this| {
this.child(MessageMedia::new(&message.media))
})
.when(!reaction_groups.is_empty(), |this| {
this.child(ReactionBar::new(reaction_groups))
});
let id = message.id;
let admin = actions.admin;
@@ -130,15 +129,35 @@ pub(crate) fn render(
SharedString::from(format!("message-copy-{id}")),
message.preview(),
)
.speech(speaking, {
let speech_text = speech_text.clone();
.speech(speaking, move |_event, _window, cx| {
if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| {
match panel.speaker.speak(&speech_text) {
Ok(duration) => {
panel.speech_reveal = Some((speech_id, duration));
move |_event, _window, cx| {
if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| {
panel.speak(speech_id, speech_text.clone(), window, cx);
}) {
log::warn!("nip29: reading the message aloud failed: {error}");
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}) {
log::warn!("nip29: reading the message aloud failed: {error}");
}
});
@@ -170,11 +189,9 @@ pub(crate) fn render(
let panel = panel.clone();
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
let _ = panel.update_in(cx, |panel, window, cx| {
panel.start_reply(id, window, cx);
}) {
log::warn!("nip29: starting a reply failed: {error}");
}
});
})
})
.submenu("React", window, cx, {
@@ -194,29 +211,6 @@ pub(crate) fn render(
}
})
}
})
.item({
let panel = panel.clone();
let speech_text = speech_text.clone();
let speech_id = id;
PopupMenuItem::new("Read aloud")
.disabled(speaking)
.on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.speak(speech_id, speech_text.clone(), window, cx);
}) {
log::warn!("nip29: reading the message aloud failed: {error}");
}
})
})
.item({
let author = author;
PopupMenuItem::new("Copy author").on_click(move |_event, _window, cx| {
let npub = author.to_bech32().unwrap_or_default();
cx.write_to_clipboard(ClipboardItem::new_string(npub));
})
});
if !admin {
@@ -252,7 +246,7 @@ pub(crate) fn render(
}
pub(crate) fn render_activity(
_ix: usize,
ix: usize,
activity: &Activity,
persons: &Entity<Directory>,
cx: &App,
@@ -261,14 +255,12 @@ pub(crate) fn render_activity(
let hide_avatar = AppSettings::get_hide_avatar(cx);
h_flex()
.id(SharedString::from(format!(
"activity-{}",
activity.id.to_hex()
)))
.id(ix)
.w_full()
.gap_2()
.py_1()
.px_3()
.hover(|this| this.bg(cx.theme().list_hover))
.child(badge(activity, cx))
.when(!hide_avatar, |this| {
this.child(
@@ -345,7 +337,7 @@ fn replies(
let mut items = Vec::with_capacity(targets.len());
for id in targets.iter() {
for (ix, id) in targets.iter().enumerate() {
let context = entity.read_with(cx, |group, cx| {
let message = group.message(*id)?;
let author = persons.read(cx).person(&message.author, cx);
@@ -364,19 +356,15 @@ fn replies(
let panel = panel.clone();
items.push(
ReplyPreview::new(
SharedString::from(format!("reply-{}", id.to_hex())),
author,
preview,
)
.on_click(move |_event, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.scroll_to(&id, cx);
}) {
log::warn!("nip29: scrolling to message failed: {error}");
}
})
.into_any_element(),
ReplyPreview::new(ix, author, preview)
.on_click(move |_event, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.scroll_to(&id, cx);
}) {
log::warn!("nip29: scrolling to message failed: {error}");
}
})
.into_any_element(),
);
}
+18 -22
View File
@@ -11,7 +11,7 @@ use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::InputState;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupId, GroupMetadata};
use nostr_sdk::prelude::RelayUrl;
use ui::settings_row;
@@ -100,7 +100,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
let supported = *livekit.read(cx) != Some(false);
this.close_button(true)
.title("Edit group")
.title("Edit Group")
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
@@ -161,27 +161,24 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
.child(settings_row(
"LiveKit",
if supported {
"Announce audio and video support"
"Announce audio and video support."
} else {
"The relay doesn't announce LiveKit support."
},
Switch::new("livekit")
.checked(current.livekit)
.disabled(!supported)
.on_click({
let flags = flags.clone();
let livekit = livekit.clone();
Switch::new("livekit").checked(current.livekit).on_click({
let flags = flags.clone();
let livekit = livekit.clone();
move |next, _window, cx| {
if *next && *livekit.read(cx) == Some(false) {
return;
}
flags.update(cx, |flags, cx| {
flags.livekit = *next;
cx.notify();
});
move |next, _window, cx| {
if *next && *livekit.read(cx) == Some(false) {
return;
}
}),
flags.update(cx, |flags, cx| {
flags.livekit = *next;
cx.notify();
});
}
}),
cx,
)),
),
@@ -296,10 +293,10 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
.flex_shrink_0()
.gap_1()
.when(ix > 0, |this| {
this.child(move_button(children.clone(), child, ix, ix - 1, "up"))
this.child(move_button(children.clone(), ix, ix - 1, "up"))
})
.when(ix + 1 < list.len(), |this| {
this.child(move_button(children.clone(), child, ix, ix + 1, "down"))
this.child(move_button(children.clone(), ix, ix + 1, "down"))
});
section = section.child(
@@ -322,12 +319,11 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
fn move_button(
children: Entity<Vec<GroupId>>,
child: &GroupId,
ix: usize,
target: usize,
direction: &'static str,
) -> Button {
Button::new(SharedString::from(format!("child-{direction}-{child}")))
Button::new(SharedString::from(format!("child-{direction}-{ix}")))
.label(if direction == "up" { "Up" } else { "Down" })
.ghost()
.small()
+31 -190
View File
@@ -1,5 +1,5 @@
use std::cell::Cell;
use std::collections::{HashSet, VecDeque};
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
@@ -19,14 +19,12 @@ use nostr_sdk::prelude::*;
use person::Directory;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use state::{Announcement, NostrRegistry, UniversalSigner};
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
use ui::Avatar;
mod storage;
/// Keep the durable processed-request log bounded
const PROCESSED_REQUESTS_LIMIT: usize = 256;
/// Ignore key requests older than a day so stale events on relays cannot resurface
const REQUEST_MAX_AGE: u64 = SECONDS_PER_DAY;
@@ -79,8 +77,6 @@ pub struct DeviceRegistry {
app_keys: Option<Keys>,
/// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>,
/// Handled key-sync request ids, bounded and persisted
processed_requests: VecDeque<EventId>,
/// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>,
/// Whether the local key is stale against the current announcement.
@@ -110,7 +106,6 @@ impl DeviceRegistry {
/// Create a new device registry instance
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
@@ -121,7 +116,7 @@ impl DeviceRegistry {
// Subscribe to nostr state events
cx.observe(&settings, move |this, settings, cx| {
if settings.read(cx).is_nip4e_enabled(cx) {
this.init_encryption(cx);
this.get_announcement(cx);
};
}),
);
@@ -135,11 +130,6 @@ impl DeviceRegistry {
this.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account
this.app_keys = None;
// The encryption signer belongs to the old account
this.signer.update(cx, |signer, cx| {
*signer = None;
cx.notify();
});
if this.pending_request.take().is_some() {
// The old approval must not target the new account's signer
@@ -153,22 +143,15 @@ impl DeviceRegistry {
}
this.handle_notifications(cx);
this.init_encryption(cx);
this.get_announcement(cx);
}
}),
);
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
this.handle_notifications(cx);
});
});
Self {
signer,
app_keys: None,
pending_request: None,
processed_requests: VecDeque::new(),
announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)),
rotating: false,
@@ -189,8 +172,6 @@ impl DeviceRegistry {
let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100);
let read = credentials::read_processed_requests(cx);
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
@@ -225,21 +206,6 @@ impl DeviceRegistry {
}));
self.tasks.push(cx.spawn(async move |this, cx| {
// Hydrate the processed-request log before routing any events
match read.await {
Ok(bytes) => {
let processed = bytes
.map(|bytes| parse_processed_requests(&bytes))
.unwrap_or_default();
this.update(cx, |this, _cx| {
this.processed_requests = processed;
})
.ok();
}
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
}
while let Ok(event) = rx.recv_async().await {
match event.kind {
Kind::Custom(10044) => {
@@ -266,36 +232,11 @@ impl DeviceRegistry {
}));
}
/// Record a handled key-sync request id, persisting a bounded log
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
if self.processed_requests.contains(&event_id) {
return;
}
self.processed_requests.push_back(event_id);
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
self.processed_requests.pop_front();
}
let bytes: Vec<u8> = self
.processed_requests
.iter()
.flat_map(|id| id.to_hex().into_bytes())
.collect();
let write = credentials::write_processed_requests(cx, &bytes);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist processed key-sync requests: {e}");
}
})
.detach();
}
/// Withdraw the pending approval.
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
let Some(pending) = self.pending_request.take() else {
if self.pending_request.take().is_none() {
return;
};
self.mark_request_processed(pending.event.id, cx);
}
if let Some(window) = cx.windows().first().copied() {
window
@@ -377,33 +318,6 @@ impl DeviceRegistry {
}
}
/// Initialize encryption from the local database before reconciling with relays
fn init_encryption(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = get_keys(&client, &signer).await else {
// No local keys; the announcement reconciliation will guide setup
this.update(cx, |this, cx| this.get_announcement(cx))?;
return Ok(());
};
this.update(cx, |this, cx| {
// Relay reconciliation may have set the signer already
if this.signer.read(cx).is_none() {
this.set_signer(keys, cx);
this.wait_for_request(cx);
}
this.get_announcement(cx);
})?;
Ok(())
}));
}
/// Get device announcement for current user
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -441,11 +355,8 @@ impl DeviceRegistry {
}
if !announcement_existed.load(Ordering::Acquire) {
this.update(cx, |this, cx| {
// The device already initialized encryption from the local database
if this.signer.read(cx).is_none() {
cx.emit(DeviceEvent::NotSet);
}
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::NotSet);
})?;
}
@@ -539,6 +450,7 @@ impl DeviceRegistry {
let elapsed = Timestamp::now()
.as_secs()
.saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(());
}
@@ -565,7 +477,6 @@ impl DeviceRegistry {
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
let created_at = Timestamp::now();
cx.background_spawn(async move {
// Construct an announcement event
@@ -575,7 +486,7 @@ impl DeviceRegistry {
.await?;
// Persist the key locally before announcing it
set_keys(&client, &signer, &secret, created_at).await?;
set_keys(&client, &signer, &secret, Timestamp::now()).await?;
// Publish announcement
client
@@ -616,11 +527,8 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(keys) = task.await {
this.update(cx, |this, cx| {
// The local database may have initialized this device already
if this.signer.read(cx).is_none() {
this.set_signer(keys, cx);
this.wait_for_request(cx);
}
this.set_signer(keys, cx);
this.wait_for_request(cx);
this.rotate_if_due(cx);
})?;
} else {
@@ -799,18 +707,6 @@ impl DeviceRegistry {
let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
// Persist the transferred key so restarts initialize from the local database
if let Err(e) = set_keys(
&client,
&signer,
&keys.secret_key().to_secret_hex(),
Timestamp::now(),
)
.await
{
log::error!("Failed to persist the transferred encryption key: {e}");
}
Ok(keys)
});
@@ -881,7 +777,6 @@ impl DeviceRegistry {
let signer = nostr.read(cx).signer();
let event = event.clone();
let request_id = event.id;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys
@@ -940,9 +835,6 @@ impl DeviceRegistry {
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
// Record the request as handled so it cannot resurface
this.mark_request_processed(request_id, cx);
// Allow future requests to surface again
this.pending_request = None;
cx.notify();
})?;
@@ -983,51 +875,27 @@ impl DeviceRegistry {
return;
};
let read = credentials::read_processed_requests(cx);
match self.pending_request.take() {
// A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => {
pending.event = event;
self.pending_request = Some(pending);
}
cx.spawn(async move |this, cx| {
let processed = match read.await {
Ok(bytes) => bytes
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
.unwrap_or(false),
Err(e) => {
log::error!("Failed to read processed key-sync requests: {e}");
false
// A different client key is a remounted requester
other => {
if other.is_some()
&& let Some(window) = cx.windows().first().copied()
{
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
};
this.update(cx, |this, cx| {
if processed || this.processed_requests.contains(&event.id) {
return;
}
match this.pending_request.take() {
// A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => {
this.mark_request_processed(event.id, cx);
pending.event = event;
this.pending_request = Some(pending);
}
// A different client key is a remounted requester
other => {
if let Some(pending) = other {
this.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.show_approval(event, client_key, cx);
}
}
})
.ok();
})
.detach();
self.show_approval(event, client_key, cx);
}
}
}
/// Build and surface the approval notification for a key request
@@ -1153,19 +1021,6 @@ impl DeviceRegistry {
)
.into_any_element()
})
.on_close({
let entity = entity.clone();
let request_id = event.id;
move |_window, cx| {
// Closing without approving rejects the request, so it must not resurface
entity
.update(cx, |this, cx| {
this.mark_request_processed(request_id, cx);
})
.ok();
}
})
.action(move |_this, _window, _cx| {
let view = entity.clone();
let event = event.clone();
@@ -1190,20 +1045,6 @@ impl DeviceRegistry {
}
}
/// Parse concatenated hex event ids
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
let Ok(hex) = std::str::from_utf8(bytes) else {
return VecDeque::new();
};
hex.as_bytes()
.as_chunks::<64>()
.0
.iter()
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
.collect()
}
/// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
let key = event
+62 -7
View File
@@ -55,6 +55,9 @@ pub struct HistoryEntry {
pub created_at: Timestamp,
}
/// How many times a failed nutzap redemption is retried before giving up
const MAX_REDEEM_ATTEMPTS: u32 = 3;
pub struct WalletRegistry {
wallet: Option<keys::WalletData>,
tokens: HashMap<EventId, TokenEvent>,
@@ -69,6 +72,10 @@ pub struct WalletRegistry {
pending_nutzaps: Vec<Event>,
/// Nutzaps with a redemption job in flight
redeeming: HashSet<EventId>,
/// Redemption attempts per nutzap, reset on success
redeem_attempts: HashMap<EventId, u32>,
/// A job errored after possibly mutating the store
publish_failed: bool,
store: Option<Arc<WalletRedbDatabase>>,
wallets: HashMap<MintUrl, Wallet>,
loading: bool,
@@ -119,6 +126,8 @@ impl WalletRegistry {
redeemed: HashSet::new(),
pending_nutzaps: Vec::new(),
redeeming: HashSet::new(),
redeem_attempts: HashMap::new(),
publish_failed: false,
store: None,
wallets: HashMap::new(),
loading: false,
@@ -378,6 +387,8 @@ impl WalletRegistry {
self.redeemed.clear();
self.pending_nutzaps.clear();
self.redeeming.clear();
self.redeem_attempts.clear();
self.publish_failed = false;
self.store = None;
self.wallets.clear();
self.loading = false;
@@ -397,6 +408,7 @@ impl WalletRegistry {
let signer = nostr.read(cx).signer().clone();
let (tx, rx) = flume::bounded::<Event>(100);
let consumer_signer = signer.clone();
self.notification_listener = Some(cx.background_spawn(async move {
const MAX_PROCESSED: usize = 1000;
@@ -417,7 +429,7 @@ impl WalletRegistry {
| Kind::CashuWalletSpendingHistory
);
let wallet_event = ours && is_cashu || is_token_deletion(&event);
let wallet_event = ours && (is_cashu || is_token_deletion(&event));
let nutzap = event.kind == Kind::Custom(9321)
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
@@ -443,21 +455,22 @@ impl WalletRegistry {
}));
self.tasks.push(cx.spawn(async move |this, cx| {
let me = consumer_signer.get_public_key_async().await.ok();
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
this.absorb(event, cx);
this.absorb(event, me, cx);
})
.ok();
}
}));
}
fn absorb(&mut self, event: Event, cx: &mut Context<Self>) {
fn absorb(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
match event.kind {
Kind::CashuWallet => self.absorb_wallet(event, cx),
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, me, cx),
Kind::Custom(9321) => self.absorb_nutzap(event, cx),
_ => {}
}
@@ -549,7 +562,15 @@ impl WalletRegistry {
}));
}
fn absorb_deletion(&mut self, event: Event, cx: &mut Context<Self>) {
fn absorb_deletion(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
if me != Some(event.pubkey) {
log::warn!(
"ignoring kind 5 deletion {} from a foreign author",
event.id
);
return;
}
let mut removed_any = false;
for tag in event.tags.iter() {
if tag.kind() != "e" {
@@ -807,6 +828,9 @@ impl WalletRegistry {
self.job_running = true;
let job_handle = job.clone();
let publish_failed = self.publish_failed;
self.tasks.push(cx.spawn(async move |this, cx| {
let result = Tokio::spawn_result(cx, async move {
let ctx = ops::JobCtx {
@@ -815,6 +839,7 @@ impl WalletRegistry {
signer,
client,
tokens,
publish_failed,
};
job.run(ctx).await
@@ -823,7 +848,7 @@ impl WalletRegistry {
this.update(cx, |this, cx| {
this.job_running = false;
this.apply_outcome(result, cx);
this.apply_outcome(job_handle, result, cx);
for job in std::mem::take(&mut this.job_queued) {
this.schedule_job(job, cx);
@@ -833,10 +858,38 @@ impl WalletRegistry {
}));
}
fn apply_outcome(&mut self, result: Result<ops::Outcome, Error>, cx: &mut Context<Self>) {
fn apply_outcome(
&mut self,
job: ops::Job,
result: Result<ops::Outcome, Error>,
cx: &mut Context<Self>,
) {
let outcome = match result {
Ok(outcome) => outcome,
Err(error) => {
if !matches!(job, ops::Job::CreateQuote { .. }) {
self.publish_failed = true;
}
if !matches!(job, ops::Job::CreateQuote { .. } | ops::Job::Reconcile) {
self.schedule_job(ops::Job::Reconcile, cx);
}
if let ops::Job::RedeemNutzap { nutzap_id, .. } = &job {
self.redeeming.remove(nutzap_id);
let attempts = self.redeem_attempts.entry(*nutzap_id).or_default();
*attempts += 1;
if *attempts <= MAX_REDEEM_ATTEMPTS {
self.job_queued.push(job.clone());
} else {
log::warn!(
"giving up redeeming nutzap {nutzap_id} after {MAX_REDEEM_ATTEMPTS} attempts"
);
}
}
self.emit_error(format!("{error:#}"), cx);
return;
}
@@ -845,6 +898,7 @@ impl WalletRegistry {
match outcome {
ops::Outcome::Idle => {}
ops::Outcome::Reconciled { updates } => {
self.publish_failed = false;
let changed = !updates.is_empty();
for update in updates {
@@ -905,6 +959,7 @@ impl WalletRegistry {
amount,
} => {
self.redeeming.remove(&nutzap_id);
self.redeem_attempts.remove(&nutzap_id);
self.redeemed.insert(nutzap_id);
self.apply_token_update(update);
self.emit_updated(cx);
+128 -38
View File
@@ -361,6 +361,8 @@ pub(crate) struct JobCtx<S> {
pub(crate) client: Client,
/// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>,
/// A previous job errored after possibly mutating the store
pub(crate) publish_failed: bool,
}
impl<S> JobCtx<S>
@@ -426,35 +428,14 @@ where
log::warn!("saga recovery failed for {mint_url}: {error:#}");
}
let minted = wallet.mint_unissued_quotes().await?;
if minted == Amount::ZERO {
continue;
}
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
let states = vec![State::Pending, State::Reserved, State::PendingSpent];
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(minted),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
let in_flight = wallet.get_proofs_by_states(states).await?;
let in_flight_ys = proof_ys(in_flight.iter())?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
}
for (mint_url, wallet) in &self.wallets {
let mut relay_ys = HashSet::new();
let mut added = Vec::new();
let mut unseen = Vec::new();
for token in tokens
.values()
@@ -468,23 +449,104 @@ where
continue;
}
};
if relay_ys.insert(info.y) {
added.push(info);
if !relay_ys.insert(info.y) {
continue;
}
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
continue;
}
unseen.push(info);
}
}
let unspent = wallet.get_unspent_proofs().await?;
let removed = unspent
.iter()
.filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y),
Err(_) => false,
})
.map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?;
if unseen.is_empty() {
continue;
}
wallet.localstore.update_proofs(added, removed).await?;
let unseen_proofs: Proofs = unseen.iter().map(|info| info.proof.clone()).collect();
let spent = match wallet.check_proofs_spent(unseen_proofs).await {
Ok(states) => states
.into_iter()
.filter(|state| state.state == State::Spent)
.map(|state| state.y)
.collect::<HashSet<_>>(),
Err(error) => {
log::warn!("mint state check failed, accepting unseen proofs: {error:#}");
HashSet::new()
}
};
let added: Vec<ProofInfo> = unseen
.into_iter()
.filter(|info| !spent.contains(&info.y))
.collect();
if !added.is_empty() {
wallet.localstore.update_proofs(added, Vec::new()).await?;
}
}
for (mint_url, wallet) in &self.wallets {
let minted = wallet.mint_unissued_quotes().await?;
if minted != Amount::ZERO {
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(minted),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
}
let relay_ys = relay_proof_ys(&tokens, mint_url);
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
if local_ys != relay_ys {
if self.publish_failed {
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
None,
)
.await?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
} else {
let removed = wallet
.get_unspent_proofs()
.await?
.into_iter()
.filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y),
Err(_) => false,
})
.map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(Vec::new(), removed).await?;
}
}
}
Ok(Outcome::Reconciled { updates })
@@ -499,6 +561,34 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
.collect()
}
fn proof_ys<'a>(
proofs: impl Iterator<Item = &'a Proof>,
) -> Result<HashSet<cdk::nuts::PublicKey>, Error> {
proofs.map(|proof| proof.y().map_err(Error::from)).collect()
}
/// Proof ys referenced by the relay token events of one mint
fn relay_proof_ys(
tokens: &HashMap<EventId, TokenEvent>,
mint: &MintUrl,
) -> HashSet<cdk::nuts::PublicKey> {
let mut ys = HashSet::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint.to_string())
{
for proof in &token.proofs {
match proof_info(proof, mint) {
Ok(info) => {
ys.insert(info.y);
}
Err(error) => log::warn!("skipping proof: {error:#}"),
}
}
}
ys
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>,
+30 -57
View File
@@ -2,10 +2,9 @@
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, div, px};
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::{InputState, NumberInput};
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex};
use nip60::WalletRegistry;
@@ -21,12 +20,7 @@ pub fn open_nutzap(
recipient: PublicKey,
target: Option<EventId>,
) {
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let send: SubmitFn = {
let input = input.clone();
@@ -39,66 +33,45 @@ pub fn open_nutzap(
}
};
window.open_alert_dialog(cx, move |this, _window, _cx| {
this.confirm()
.width(px(360.))
.title(format!("Send {amount} sats?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Send")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("A nutzap cannot be undone once sent."),
)
.on_ok(move |_event, window, cx| {
let wallet = WalletRegistry::global(cx);
wallet.update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, target, None, cx);
});
// Close the confirmation, then the amount dialog beneath it.
window.close_dialog(cx);
window.close_dialog(cx);
true
})
let wallet = WalletRegistry::global(cx);
wallet.update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, target, None, cx);
});
// The amount dialog stays open beneath the confirmation.
false
true
})
};
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(380.))
.title("Send eCash")
.title("Send Nutzap")
.on_ok({
let send = send.clone();
move |event, window, cx| send(event, window, cx)
})
.child(
v_flex().gap_3().child(NumberInput::new(&input)).child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(Button::new("send").label("Send").primary().on_click({
let send = send.clone();
move |event, window, cx| {
if send(event, window, cx) {
window.close_dialog(cx);
v_flex()
.gap_3()
.child(Input::new(&input).cleanable(true))
.child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(Button::new("send").label("Send").primary().on_click({
let send = send.clone();
move |event, window, cx| {
if send(event, window, cx) {
window.close_dialog(cx);
}
}
}
})),
),
})),
),
)
});
}
+41 -73
View File
@@ -6,16 +6,15 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
relative, uniform_list,
InteractiveElement as _, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative,
uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{
@@ -126,7 +125,7 @@ impl WalletPanel {
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.child("No transactions yet")
.child("No transactions yet.")
.into_any_element();
}
@@ -228,7 +227,7 @@ impl SendDialog {
}),
cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.confirm_send(window, cx);
this.send(window, cx);
}
}),
];
@@ -414,45 +413,23 @@ impl SendDialog {
}
}
/// Validate the form, then confirm before the irreversible nutzap.
fn confirm_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
/// Validate the selection and amount, then schedule the nutzap.
fn send(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(recipient) = self.selected else {
self.set_error("Select a contact to send to", cx);
return;
return false;
};
let Some(amount) = parse_amount(&self.amount_input, cx) else {
self.set_error("Enter an amount in sats", cx);
return;
return false;
};
let recipient_name = self.directory.read(cx).person(&recipient, cx).name();
window.open_alert_dialog(cx, move |this, _window, _cx| {
this.confirm()
.width(px(360.))
.title(format!("Send {amount} sats to {recipient_name}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Send")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("A nutzap cannot be undone once sent."),
)
.on_ok(move |_event, window, cx| {
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, None, None, cx);
});
// Close the confirmation, then the send dialog beneath it.
window.close_dialog(cx);
window.close_dialog(cx);
true
})
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, None, None, cx);
});
true
}
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -491,28 +468,28 @@ impl SendDialog {
let selected = self.selected == Some(public_key);
let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
let mut item = ListItem::new(id)
.accessibility_label(profile.name())
.selected(selected)
.cursor_pointer()
h_flex()
.id(id)
.w_full()
.h_8()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.text_sm()
.cursor_pointer()
.hover(|this| this.bg(cx.theme().list_hover))
.when(selected, |this| this.bg(cx.theme().list_active))
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
})
.child(div().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_selection(&public_key, cx);
}));
if !AppSettings::get_hide_avatar(cx) {
item = item.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
);
}
item.child(div().flex_1().min_w_0().truncate().child(profile.name()))
}))
.into_any_element()
})
.collect()
@@ -530,10 +507,12 @@ impl SendDialog {
.gap_1()
.child(
h_flex()
.id(id)
.gap_1()
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
@@ -547,18 +526,10 @@ impl SendDialog {
.line_height(relative(1.0))
.child(profile.name()),
)
.child(
Button::new(id)
.icon(IconName::Close)
.tooltip("Remove recipient")
.accessibility_label("Remove recipient")
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _event, _window, cx| {
this.selected = None;
cx.notify();
})),
),
.on_click(cx.listener(move |this, _event, _window, cx| {
this.selected = None;
cx.notify();
})),
)
.into_any_element()
}
@@ -631,7 +602,9 @@ impl Render for SendDialog {
.label("Send")
.primary()
.on_click(cx.listener(move |this, _event, window, cx| {
this.confirm_send(window, cx);
if this.send(window, cx) {
window.close_dialog(cx);
}
})),
),
)
@@ -640,13 +613,8 @@ impl Render for SendDialog {
/// Create a lightning invoice or redeem a pasted cashu token
fn open_receive_dialog(window: &mut Window, cx: &mut App) {
let amount_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu"));
let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
let create_invoice: SubmitFn = {
let wallet = WalletRegistry::global(cx);
@@ -710,7 +678,7 @@ fn open_receive_dialog(window: &mut Window, cx: &mut App) {
h_flex()
.gap_1()
.w_full()
.child(NumberInput::new(&amount_input))
.child(Input::new(&amount_input).cleanable(true))
.child(
Button::new("invoice")
.label("Invoice")
+150 -22
View File
@@ -28,6 +28,14 @@ pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
fn default_onboarded() -> bool {
true
}
fn default_shadow() -> bool {
true
}
/// The user's appearance preference, persisted with the rest of the settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AppearanceMode {
@@ -78,6 +86,9 @@ pub struct ThemeSettings {
pub radius: Option<f32>,
/// Corner radius for large elements like dialogs; follows the OS theme when unset
pub radius_lg: Option<f32>,
/// Draw soft shadows on inputs, buttons and dialogs
#[serde(default = "default_shadow")]
pub shadow: bool,
}
impl Default for ThemeSettings {
@@ -87,6 +98,7 @@ impl Default for ThemeSettings {
focus_ring: false,
radius: None,
radius_lg: None,
shadow: true,
}
}
}
@@ -145,6 +157,12 @@ pub struct Settings {
/// Hide the notice that messages in public groups are not encrypted
#[serde(default)]
pub hide_group_notice: bool,
/// Enable the NIP-60 wallet
#[serde(default)]
pub wallet: bool,
/// Whether the first-run onboarding has been completed
#[serde(default = "default_onboarded")]
pub onboarded: bool,
/// Tuning applied on top of the native OS theme
#[serde(default)]
pub theme: ThemeSettings,
@@ -178,10 +196,12 @@ impl Default for Settings {
appearance_mode: AppearanceMode::default(),
hide_avatar: false,
screening: true,
wot_auto_trust: true,
wot_auto_trust: false,
nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false,
wallet: false,
onboarded: false,
theme: ThemeSettings::default(),
theme_light: None,
theme_dark: None,
@@ -203,6 +223,11 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings {
/// Settings
inner: Entity<Settings>,
/// The pending save task, so writes to the settings file complete in order
#[cfg(not(target_arch = "wasm32"))]
save_task: Option<Task<()>>,
/// Set after a failed load so its own save cannot overwrite the file with defaults
suppress_next_save: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
@@ -222,6 +247,9 @@ macro_rules! setting_accessors {
inner.$field = value;
cx.notify();
});
// Notify the handle too, so observers of AppSettings
// react to the change
cx.notify();
});
}
}
@@ -257,6 +285,8 @@ setting_accessors! {
pub panel_background_path: Option<PathBuf>,
pub theme_light: Option<String>,
pub theme_dark: Option<String>,
pub wallet: bool,
pub onboarded: bool,
}
setting_getters! {
@@ -290,11 +320,16 @@ impl AppSettings {
// Run at the end of current cycle
cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok();
if let Err(error) = entity.update(cx, |this, cx| this.load(cx)) {
log::error!("failed to load settings: {error:#}");
}
});
Self {
inner,
#[cfg(not(target_arch = "wasm32"))]
save_task: None,
suppress_next_save: false,
_subscriptions: subscriptions,
}
}
@@ -314,6 +349,8 @@ impl AppSettings {
edit(&mut inner.theme);
cx.notify();
});
// Notify the handle too, so observers of AppSettings react
cx.notify();
});
}
@@ -326,6 +363,7 @@ impl AppSettings {
let styled = Theme::global_mut(cx);
styled.font_size = px(tuning.font_size);
styled.focus_ring = tuning.focus_ring;
styled.shadow = tuning.shadow;
if let Some(radius) = tuning.radius {
styled.radius = px(radius);
}
@@ -353,27 +391,46 @@ impl AppSettings {
/// Load settings
fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
let task: Task<Result<Option<Settings>, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))]
{
let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await {
return Ok(serde_json::from_str(&content)?);
match smol::fs::read_to_string(&path).await {
Ok(content) => {
let settings: Settings =
serde_json::from_str(&content).map_err(|error| {
anyhow!("failed to parse {}: {error}", path.display())
})?;
return Ok(Some(settings));
}
// No settings file yet on a fresh install, so keep the defaults
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(anyhow!("failed to read {}: {error}", path.display()));
}
}
}
#[cfg(target_arch = "wasm32")]
{
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
return Ok(serde_json::from_str(&content)?);
let settings: Settings = serde_json::from_str(&content)
.map_err(|error| anyhow!("failed to parse saved settings: {error}"))?;
return Ok(Some(settings));
}
}
Err(anyhow!("Not found"))
Ok(None)
});
cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default());
let (mut settings, load_failed) = match task.await {
Ok(settings) => (settings.unwrap_or_default(), false),
Err(error) => {
log::error!("failed to load settings, keeping defaults: {error:#}");
(Settings::default(), true)
}
};
// Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
@@ -381,31 +438,63 @@ impl AppSettings {
}
// Update settings
this.update(cx, |this, cx| {
if let Err(error) = this.update(cx, |this, cx| {
// A failed load must not trigger the save that would overwrite the file
this.suppress_next_save = load_failed;
this.set_settings(settings, cx);
this.apply_theme(cx);
cx.refresh_windows();
})
.ok();
}) {
log::error!("failed to apply loaded settings: {error:#}");
}
})
.detach();
}
/// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) {
let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) {
#[cfg(not(target_arch = "wasm32"))]
cx.background_spawn(async move {
let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok();
})
.detach();
// Consume the failed-load suppression, so the next user change saves again
if self.suppress_next_save {
self.suppress_next_save = false;
return;
}
#[cfg(target_arch = "wasm32")]
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
log::warn!("failed to persist settings: {e}");
let settings = self.inner.read(cx);
let content = match serde_json::to_string(settings) {
Ok(content) => content,
Err(error) => {
log::error!("failed to serialize settings: {error:#}");
return;
}
};
#[cfg(not(target_arch = "wasm32"))]
{
let previous_task = self.save_task.take();
self.save_task = Some(cx.background_spawn(async move {
// Await the previous write, so saves to the file complete in order
if let Some(previous_task) = previous_task {
previous_task.await;
}
let path = config_dir().join(".settings");
let temporary_path = config_dir().join(".settings.tmp");
let result: std::io::Result<()> = async {
smol::fs::write(&temporary_path, &content).await?;
smol::fs::rename(&temporary_path, &path).await
}
.await;
if let Err(error) = result {
log::error!("failed to save settings to {}: {error}", path.display());
}
}));
}
#[cfg(target_arch = "wasm32")]
if let Err(error) = common::web_store::set(SETTINGS_KEY, &content) {
log::error!("failed to persist settings: {error:#}");
}
}
@@ -582,6 +671,11 @@ fn persisted_content() -> Option<String> {
common::web_store::get(SETTINGS_KEY).ok().flatten()
}
/// Read the persisted onboarding flag synchronously.
pub fn persisted_onboarded() -> bool {
persisted_settings().is_some_and(|settings| settings.onboarded)
}
pub fn persisted_appearance_mode() -> AppearanceMode {
persisted_settings()
.map(|settings| settings.appearance_mode)
@@ -672,6 +766,40 @@ mod tests {
assert_eq!(settings.theme, ThemeSettings::default());
assert_eq!(settings.theme_light, None);
assert_eq!(settings.theme_dark, None);
// Files written before onboarding and the wallet flag existed count
// as onboarded with the wallet off
assert!(settings.onboarded);
assert!(!settings.wallet);
}
/// Fresh installs start un-onboarded with optional features off, and the
/// flags round-trip through serde
#[test]
fn onboarding_and_wallet_persist() {
let settings = Settings::default();
assert!(!settings.onboarded);
assert!(!settings.wallet);
assert!(!settings.wot_auto_trust);
assert!(settings.theme.shadow);
let settings: Settings = serde_json::from_str(
r#"{
"appearance_mode": "Dark",
"hide_avatar": false,
"screening": true,
"wot_auto_trust": true,
"nip4e": false,
"trusted_relays": [],
"file_server": "https://nostr.download/",
"onboarded": true,
"wallet": true
}"#,
)
.unwrap();
assert!(settings.onboarded);
assert!(settings.wallet);
}
/// The theme selection round-trips through serde.
+49 -28
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
@@ -30,7 +30,7 @@ impl SpeechRecognizer for ParakeetRecognizer {
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
if super::model::installed() {
worker().send_or_log(Command::Warm(super::model::dir()));
send(Command::Warm(super::model::dir())).map_err(SpeechError::recognizer)?;
} else if super::model::status().is_downloading() {
return Err(SpeechError::recognizer(anyhow!(
"The speech model is still downloading. Try again when it finishes."
@@ -79,7 +79,11 @@ impl RecognitionSession for ParakeetSession {
}
let (sender, receiver) = flume::unbounded();
worker().send_or_log(Command::Transcribe { samples, sender });
if let Err(error) = send(Command::Transcribe { samples, sender }) {
sink.error(SpeechError::recognizer(error), cx);
return;
}
self._result = Some(cx.spawn(async move |cx| {
let result = receiver.recv_async().await;
@@ -109,23 +113,50 @@ enum Command {
Unload,
}
/// A handle to the single inference worker.
fn worker() -> &'static flume::Sender<Command> {
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
fn worker_slot() -> &'static Mutex<Option<flume::Sender<Command>>> {
static WORKER: OnceLock<Mutex<Option<flume::Sender<Command>>>> = OnceLock::new();
WORKER.get_or_init(|| Mutex::new(None))
}
WORKER.get_or_init(|| {
let (sender, receiver) = flume::unbounded::<Command>();
/// A handle to the live worker, spawning a replacement after an idle exit.
fn worker() -> Result<flume::Sender<Command>> {
let mut slot = worker_slot()
.lock()
.unwrap_or_else(|error| error.into_inner());
let spawned = std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver));
if slot.as_ref().is_some_and(|sender| sender.is_disconnected()) {
*slot = None;
}
if let Err(error) = spawned {
log::error!("failed to spawn the speech worker: {error}");
if let Some(sender) = slot.as_ref() {
return Ok(sender.clone());
}
let (sender, receiver) = flume::unbounded::<Command>();
match std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver))
{
Ok(_) => {
*slot = Some(sender.clone());
Ok(sender)
}
Err(error) => {
log::error!("failed to spawn the speech worker: {error}");
Err(anyhow!("Could not start the speech worker: {error}"))
}
}
}
sender
})
/// Deliver a command, retrying once on a fresh worker if the previous one exited.
fn send(command: Command) -> Result<()> {
let command = match worker()?.send(command) {
Ok(()) => return Ok(()),
Err(error) => error.into_inner(),
};
// The worker can exit between the liveness check and the send, so retry once.
worker()?.send(command).context("the speech worker exited")
}
fn run_worker(receiver: flume::Receiver<Command>) {
@@ -192,21 +223,11 @@ fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) ->
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
}
trait SendOrLog {
fn send_or_log(&self, command: Command);
}
impl SendOrLog for flume::Sender<Command> {
fn send_or_log(&self, command: Command) {
if self.send(command).is_err() {
log::debug!("speech worker is gone; command dropped");
}
}
}
/// Retire cached weights, e.g. after the model was deleted.
pub(crate) fn unload() {
worker().send_or_log(Command::Unload);
if let Err(error) = send(Command::Unload) {
log::warn!("failed to unload the speech model: {error}");
}
}
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
+1
View File
@@ -51,6 +51,7 @@ sqlite-wasm-vfs = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
nostr-memory.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
rustls = "0.23"
+1 -55
View File
@@ -1,13 +1,10 @@
use anyhow::Result;
#[cfg(target_arch = "wasm32")]
use common::web_store;
use gpui::{App, AppContext as _, Task};
use gpui::{App, Task};
use crate::USER_KEYRING;
/// The storage key for the log of handled NIP-4e key-sync request ids
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
/// Read the stored credential as `(username, secret)`
#[allow(clippy::type_complexity)]
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
@@ -52,54 +49,3 @@ pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
Task::ready(web_store::remove(USER_KEYRING))
}
}
/// Read named bytes from the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let read = cx.read_credentials(&key);
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
Task::ready(stored)
}
}
/// Write named bytes to the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let secret = secret.to_vec();
let write = cx.write_credentials(&key, "", &secret);
cx.background_spawn(write)
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let content = match std::str::from_utf8(secret) {
Ok(content) => content,
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
};
Task::ready(web_store::set(key, content))
}
}
/// Read the log of handled key-sync request ids, as concatenated hex ids
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, PROCESSED_REQUESTS_KEY)
}
/// Persist the log of handled key-sync request ids, as concatenated hex ids
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
}
+12 -14
View File
@@ -403,7 +403,7 @@ pub async fn download_and_decrypt_file(
.clone()
.unwrap_or_else(|| fallback_name(&file.url));
let local = store_file(&name, &file.mime, &data).await?;
let local = store_file(&file.mime, &data).await?;
Ok(DownloadedFile {
local,
@@ -415,10 +415,11 @@ pub async fn download_and_decrypt_file(
}
/// Persist decrypted bytes somewhere the platform can render or open them
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
async fn store_file(mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
#[cfg(not(target_arch = "wasm32"))]
{
let digest = sha256_hex(name.as_bytes());
// Key the cache entry on the content hash, so same-named attachments can't collide
let digest = sha256_hex(data);
let extension = mime_guess::get_mime_extensions_str(mime)
.and_then(|extensions| extensions.first())
.copied()
@@ -428,24 +429,21 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
.join("coop-files")
.join(format!("{digest}.{extension}"));
if smol::fs::metadata(&path).await.is_err() {
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
smol::fs::create_dir_all(parent).await?;
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
smol::fs::create_dir_all(parent).await?;
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
}
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(LocalFile::Path(path))
}
#[cfg(target_arch = "wasm32")]
{
let _ = name;
let url = crate::web_file::blob_url(mime, data)?;
Ok(LocalFile::Url(url))
}
+33 -8
View File
@@ -16,6 +16,8 @@ use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_memory::prelude::MemoryDatabase;
use nostr_sdk::prelude::*;
mod blossom;
@@ -114,15 +116,27 @@ impl NostrRegistry {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
// NostrLmdb::open schedules nothing back onto the GPUI executor, so blocking here can't deadlock
#[cfg(not(target_arch = "wasm32"))]
let database = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
.await
.expect("Failed to initialize database")
});
let (database, database_error) = match cx
.foreground_executor()
.block_on(NostrLmdb::open(config_dir().join("nostr")))
{
Ok(database) => (Arc::new(database) as Arc<dyn NostrDatabase>, None),
Err(e) => {
log::error!("Failed to open the local database, falling back to memory: {e}");
(
Arc::new(MemoryDatabase::unbounded()) as Arc<dyn NostrDatabase>,
Some(format!(
"Local database unavailable, using an in-memory database instead: {e}"
)),
)
}
};
#[cfg(target_arch = "wasm32")]
let database = web_database::take();
let (database, database_error) = (web_database::take(), None::<String>);
// Construct the nostr client
let client = ClientBuilder::default()
@@ -141,6 +155,10 @@ impl NostrRegistry {
// Connect to bootstrap relays once the registry has been returned to the app
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
if let Some(error) = database_error {
cx.emit(StateEvent::error(error));
}
this.connect_bootstrap_relays(cx);
if let Some(secret) = cli_key {
@@ -445,8 +463,14 @@ impl NostrRegistry {
};
let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = credentials::write(cx, "bunker", credential.as_bytes());
// The URI embeds the client key, so never persist it where web stores it unencrypted
#[cfg(not(target_arch = "wasm32"))]
let write =
credentials::write(cx, "bunker", with_master_key(&uri_string, &keys).as_bytes());
#[cfg(target_arch = "wasm32")]
let write = Task::ready(Ok::<(), Error>(()));
cx.spawn(async move |this, cx| {
let signer = NostrConnect::builder(connect_uri, keys)
@@ -709,6 +733,7 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
}
// Embed the master key in a bunker URI so reconnects reuse the same local key
#[cfg(not(target_arch = "wasm32"))]
fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible");
+9 -10
View File
@@ -1,6 +1,6 @@
use gpui::{
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Rems, RenderOnce, SharedString,
StyleRefinement, Styled, Window, div, rems,
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
SharedString, StyleRefinement, Styled, Window, div, px,
};
use gpui_base::AvatarImage;
use gpui_kit::base::StyledExt as _;
@@ -115,7 +115,7 @@ impl RenderOnce for Avatar {
#[derive(IntoElement)]
struct PixelAvatar {
seed: u64,
side: Rems,
side: Pixels,
style: StyleRefinement,
}
@@ -187,14 +187,13 @@ impl RenderOnce for PixelAvatar {
}
/// The rendered side length of an avatar at `size`.
fn side_length(size: Size) -> Rems {
fn side_length(size: Size) -> Pixels {
match size {
Size::XSmall => rems(1.),
Size::Small => rems(1.5),
Size::Medium => rems(3.),
Size::Large => rems(5.),
// Custom sizes arrive in px; assume the 16px default rem.
Size::Size(size) => rems(size.as_f32() / 16.),
Size::XSmall => px(16.),
Size::Small => px(24.),
Size::Medium => px(48.),
Size::Large => px(80.),
Size::Size(size) => size,
}
}
+75
View File
@@ -0,0 +1,75 @@
use gpui::SharedString;
use gpui_kit::assets::IconNamed;
/// Coop icons kept from before the gpui-kit migration; absent from the kit bundle.
#[derive(Clone, Copy)]
pub enum IconName {
ArrowDownCircle,
ArrowLeft,
Book,
CaretDown,
CaretRight,
CheckCircle,
Close,
Compass,
Door,
Emoji,
Group,
Input,
Invite,
Link,
Lock,
Message,
Paperclip,
PaperPlaneFill,
Plus,
PlusChat,
Profile,
Relay,
Speech,
Search,
Settings,
Settings2,
Unlock,
Upload,
Warning,
Wallet,
}
impl IconNamed for IconName {
fn path(self) -> SharedString {
match self {
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
Self::ArrowLeft => "icons/arrow-left.svg",
Self::Book => "icons/book.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
Self::Compass => "icons/compass.svg",
Self::Door => "icons/door.svg",
Self::Emoji => "icons/emoji.svg",
Self::Group => "icons/group.svg",
Self::Input => "icons/input.svg",
Self::Invite => "icons/invite.svg",
Self::Link => "icons/link.svg",
Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::Paperclip => "icons/paperclip.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
Self::Profile => "icons/profile.svg",
Self::Relay => "icons/relay.svg",
Self::Speech => "icons/speech.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Unlock => "icons/unlock.svg",
Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg",
Self::Wallet => "icons/wallet.svg",
}
.into()
}
}
+2
View File
@@ -1,11 +1,13 @@
pub use avatar::Avatar;
pub use form::*;
pub use icon::IconName;
pub use message::*;
pub use text::*;
pub use window_drag::title_bar_drag_handlers;
pub mod avatar;
pub mod form;
pub mod icon;
pub mod message;
pub mod text;
pub mod window_drag;
+17 -53
View File
@@ -7,7 +7,7 @@ use gpui::{
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
StyleRefinement, Styled, StyledImage, Window, div, img, rems,
StyleRefinement, Styled, StyledImage, Window, div, img, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _};
@@ -199,10 +199,10 @@ impl RenderOnce for MessageRow {
if show_author {
match avatar {
Some(avatar) => this.child(avatar),
None => this.child(div().flex_shrink_0().w_6()),
None => this.child(div().flex_shrink_0().w(px(24.))),
}
} else {
this.child(div().flex_shrink_0().w_6())
this.child(div().flex_shrink_0().w(px(24.)))
}
})
.child(
@@ -212,7 +212,7 @@ impl RenderOnce for MessageRow {
.min_w_0()
.flex_initial()
.overflow_hidden()
.text_base()
.text_size(px(15.))
.when(show_author, |this| {
this.child(
h_flex()
@@ -251,7 +251,6 @@ pub struct ReplyPreview {
author: SharedString,
preview: SharedString,
on_click: Option<ClickListener>,
on_remove: Option<ClickListener>,
}
impl ReplyPreview {
@@ -267,7 +266,6 @@ impl ReplyPreview {
author: author.into(),
preview: preview.into(),
on_click: None,
on_remove: None,
}
}
@@ -280,16 +278,6 @@ impl ReplyPreview {
self.on_click = Some(Box::new(listener));
self
}
/// Show a remove button, calling the handler when it is clicked.
#[must_use]
pub fn on_remove(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_remove = Some(Box::new(handler));
self
}
}
impl Styled for ReplyPreview {
@@ -306,7 +294,6 @@ impl RenderOnce for ReplyPreview {
author,
preview,
on_click,
on_remove,
} = self;
div()
@@ -317,28 +304,8 @@ impl RenderOnce for ReplyPreview {
.border_color(cx.theme().list_active)
.text_xs()
.refine_style(&style)
.child(
h_flex()
.w_full()
.child(
div()
.flex_1()
.min_w_0()
.child(div().font_semibold().child(author))
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview)),
)
.when_some(on_remove, |this, on_remove| {
this.child(
Button::new("remove-reply")
.icon(IconName::X)
.xsmall()
.ghost()
.tooltip("Remove reply")
.flex_shrink_0()
.on_click(on_remove),
)
}),
)
.child(div().font_semibold().child(author))
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
.hover(|this| this.bg(cx.theme().list_hover))
.when_some(on_click, |this, on_click| this.on_click(on_click))
.into_any_element()
@@ -383,7 +350,7 @@ impl RenderOnce for MessageMedia {
img(only.clone())
.border_1()
.border_color(cx.theme().border)
.h(rems(16.))
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius_lg),
)
@@ -398,17 +365,14 @@ impl RenderOnce for MessageMedia {
.flex_wrap()
.gap_2()
.refine_style(&style)
.children(media.into_iter().map(|item| {
div()
.id(SharedString::from(format!("media-{}", item)))
.flex_shrink_0()
.child(
img(item)
.h_32()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg),
)
.children(media.into_iter().enumerate().map(|(ix, item)| {
div().id(format!("media-{ix}")).flex_shrink_0().child(
img(item)
.h_32()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg),
)
}))
.into_any_element()
}
@@ -497,7 +461,7 @@ impl RenderOnce for ReactionBar {
.when(!group.mine, |this| this.border_color(cx.theme().border))
.when(group.image.is_none(), |this| this.child(group.emoji))
.when_some(group.image, |this, image| {
this.child(img(image).size_4().flex_shrink_0())
this.child(img(image).size(px(16.)).flex_shrink_0())
})
.child(SharedString::from(group.count.to_string()))
}))
@@ -768,7 +732,7 @@ impl RenderOnce for MessageActions {
.when_some(speech, |this, (disabled, speech)| {
this.child(
Button::new("speech")
.icon(IconName::Speech)
.icon(crate::IconName::Speech)
.tooltip("Read aloud")
.small()
.ghost()
+20 -13
View File
@@ -2,18 +2,17 @@ use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, Window, div, px,
Styled, Subscription, Task, Window, div, px, relative,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::alert::{Alert, AlertVariant};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_kit::component::form::{field, v_form};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, v_flex};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
use instant::Duration;
use nostr_connect::prelude::*;
use state::{NostrRegistry, StateEvent};
use ui::IconName;
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -278,7 +277,7 @@ impl ImportIdentity {
DialogFooter::new().child(
Button::new("unlock")
.primary()
.icon(IconName::LockOpen)
.icon(IconName::Unlock)
.label("Unlock")
.loading(self.loading)
.disabled(self.loading)
@@ -346,18 +345,26 @@ impl Render for ImportIdentity {
})
.when(bunker_warning, |this| {
this.child(
Alert::new("bunker-warning", BUNKER_WARN)
.with_variant(AlertVariant::Warning)
.icon(IconName::TriangleAlert)
.small(),
div()
.p_2()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().warning.opacity(0.15))
.text_xs()
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(BUNKER_WARN)),
)
})
.when(key_warning, |this| {
this.child(
Alert::new("key-warning", KEY_WARN)
.with_variant(AlertVariant::Warning)
.icon(IconName::TriangleAlert)
.small(),
div()
.p_2()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().warning.opacity(0.15))
.text_xs()
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(KEY_WARN)),
)
}),
)
+1 -1
View File
@@ -11,7 +11,7 @@ use nip29::{GroupReference, GroupsRegistry};
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub fn open(window: &mut Window, cx: &mut App) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1"));
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1…"));
let join: SubmitFn = {
let input = input.clone();
+1
View File
@@ -2,6 +2,7 @@ pub mod import;
pub mod join_group;
pub mod new_chat;
pub mod new_group;
pub mod onboarding;
pub mod profile;
pub mod relays;
pub mod restore;
+13 -15
View File
@@ -13,7 +13,6 @@ use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::Directory;
@@ -21,6 +20,8 @@ use settings::AppSettings;
use state::NostrRegistry;
use ui::Avatar;
use crate::nav::Nav;
/// Maximum number of members a chat can have.
const MAX_MEMBERS: usize = 3;
@@ -324,30 +325,27 @@ impl NewChat {
contacts: &[PublicKey],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let start = range.start;
contacts
.get(range)
.into_iter()
.flatten()
.map(|public_key| {
.enumerate()
.map(|(offset, public_key)| {
let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key;
ListItem::new(public_key.to_hex())
.accessibility_label(profile.name())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
Nav::new(start + offset)
.label(profile.name())
.text_sm()
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.child(
div().flex_shrink_0().child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
),
this.prefix(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
})
.child(div().flex_1().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_member(&public_key, cx);
}))
@@ -371,7 +369,7 @@ impl NewChat {
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
+133 -111
View File
@@ -1,22 +1,20 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Div, Entity, InteractiveElement as _, IntoElement,
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
Subscription, Task, WeakEntity, Window, div, px, rems,
};
use gpui_base::{Popup, Select};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem};
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{
ActiveTheme, Disableable, IndexPath, Sizable, WindowExt, h_flex, v_flex,
};
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use settings::AppSettings;
@@ -74,10 +72,13 @@ struct NewGroup {
name_input: Entity<InputState>,
about_input: Entity<TextareaState>,
custom_relay_input: Entity<InputState>,
/// The relays offered in the relay select.
relays: Vec<RelayUrl>,
/// The selected relay; `None` selects the custom relay field.
selected_relay: Option<RelayUrl>,
/// The relay select; its options end with the custom relay choice.
relay_select: Entity<SelectState<RelayDelegate>>,
/// Whether the relay select popup is open.
relay_select_open: bool,
relay_options_focus: FocusHandle,
/// Keep the group in the NIP-44 encrypted section of the user's group list.
list_private: bool,
/// The uploaded group picture, ready to be published with the metadata.
@@ -112,6 +113,8 @@ impl NewGroup {
.placeholder("What is this group about?")
});
let relay_options_focus = cx.focus_handle();
let relays: Vec<RelayUrl> = DEFAULT_RELAYS
.iter()
.filter_map(|url| match RelayUrl::parse(url) {
@@ -124,37 +127,9 @@ impl NewGroup {
.collect();
let selected_relay = relays.first().cloned();
let mut options: Vec<RelayOption> = relays
.iter()
.map(|relay| RelayOption {
label: relay_label(relay),
value: Some(relay.clone()),
})
.collect();
options.push(RelayOption {
label: "Custom relay".into(),
value: None,
});
let registry = GroupsRegistry::global(cx);
let mut subscriptions = vec![];
let delegate = RelayDelegate { options };
let selected_index = delegate.position(&selected_relay);
let relay_select = cx.new(|cx| SelectState::new(delegate, selected_index, window, cx));
subscriptions.push(cx.subscribe_in(
&relay_select,
window,
|this, _select, event, _window, cx| {
let SelectEvent::Confirm(value) = event;
if let Some(choice) = value {
this.select_relay(choice.clone(), cx);
}
},
));
subscriptions.push(cx.subscribe_in(
&registry,
window,
@@ -190,8 +165,10 @@ impl NewGroup {
name_input,
about_input,
custom_relay_input,
relays,
selected_relay,
relay_select,
relay_select_open: false,
relay_options_focus,
list_private: false,
picture: None,
uploading: false,
@@ -298,6 +275,20 @@ impl NewGroup {
cx.notify();
}
fn set_relay_select_open(&mut self, open: bool, cx: &mut Context<Self>) {
self.relay_select_open = open;
cx.notify();
}
fn activate_relay_option(&mut self, index: usize, cx: &mut Context<Self>) {
if index < self.relays.len() {
self.select_relay(Some(self.relays[index].clone()), cx);
} else {
self.select_relay(None, cx);
}
self.set_relay_select_open(false, cx);
}
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
self.uploading = uploading;
cx.notify();
@@ -348,7 +339,7 @@ impl NewGroup {
}
}
fn render_relay(&self, cx: &mut Context<Self>) -> impl IntoElement {
fn render_relay(&self, selected: &str, cx: &mut Context<Self>) -> impl IntoElement {
if let Some(parent) = &self.parent {
return v_flex().gap_1p5().child(field_label("Relay", cx)).child(
div()
@@ -361,13 +352,108 @@ impl NewGroup {
);
}
let this = cx.entity().downgrade();
let open = self.relay_select_open;
let trigger = h_flex()
.id("relay-trigger")
.justify_between()
.w_full()
.h_8()
.px_3()
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.bg(if open {
cx.theme().list_active
} else {
cx.theme().secondary
})
.when(!open, |this| {
this.hover(|this| this.bg(cx.theme().primary))
.active(|this| this.bg(cx.theme().list_active))
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.relay_select_open = !open;
cx.notify();
}))
.child(selected.to_owned())
.child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().muted_foreground),
);
let select = Select::new("relay-select")
.open(open)
.on_open_change({
let this = this.clone();
move |open, _, cx| {
let _ = this.update(cx, |this, cx| {
this.set_relay_select_open(open, cx);
});
}
})
.content_focus_handle(&self.relay_options_focus)
.accessibility_label("Relay")
.accessibility_value(selected.to_owned())
.w_full()
.child(trigger);
let options = v_flex()
.id("relay-options")
.occlude()
.track_focus(&self.relay_options_focus)
.key_context("Select")
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
this.set_relay_select_open(false, cx);
}))
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius_lg)
.p_1()
.gap_y_1()
.min_w(rems(8.))
.text_color(cx.theme().foreground)
.children(self.relays.iter().enumerate().map(|(index, relay)| {
h_flex()
.id(SharedString::from(format!("option-{relay}")))
.px_1p5()
.py_1()
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.hover(|this| this.bg(cx.theme().primary))
.child(relay_label(relay))
.on_click(cx.listener(move |this, _, _, cx| {
this.activate_relay_option(index, cx);
}))
}))
.child(Separator::horizontal())
.child(
h_flex()
.id("relay-add")
.px_1p5()
.py_1()
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.child("Custom relay")
.hover(|this| this.bg(cx.theme().primary))
.on_click(cx.listener(|this, _, _, cx| {
this.activate_relay_option(this.relays.len(), cx);
})),
);
v_flex()
.gap_1p5()
.child(field_label("Relay", cx))
.when(self.selected_relay.is_none(), |this| {
this.child(Input::new(&self.custom_relay_input))
})
.child(Select::new(&self.relay_select).accessibility_label("Relay"))
.child(Popup::new("relay-popup", select).when(open, |this| this.content(options)))
}
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -481,6 +567,12 @@ impl NewGroup {
impl Render for NewGroup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let selected = self
.selected_relay
.as_ref()
.map(relay_label)
.unwrap_or_else(|| "Add relay".into());
let web_url = self
.effective_relay(cx)
.ok()
@@ -491,7 +583,7 @@ impl Render for NewGroup {
.child(
v_flex()
.id("group-dialog-content")
.h(rems(31.25))
.h(px(500.))
.gap_3()
.when_some(self.error.clone(), |this, error| {
this.child(self.render_error(&error, web_url.as_deref(), cx))
@@ -522,7 +614,7 @@ impl Render for NewGroup {
),
)
.child(input_field("Name", Input::new(&self.name_input), cx))
.child(self.render_relay(cx))
.child(self.render_relay(&selected, cx))
.child(input_field("About", Textarea::new(&self.about_input), cx))
.child(self.render_access(cx))
.child(self.render_list_setting(cx))
@@ -554,76 +646,6 @@ impl Render for NewGroup {
}
}
/// One choice in the relay select: a fixed relay, or the custom relay field.
#[derive(Clone)]
struct RelayOption {
label: SharedString,
value: Option<RelayUrl>,
}
impl SearchableListItem for RelayOption {
type Value = Option<RelayUrl>;
fn title(&self) -> SharedString {
self.label.clone()
}
fn value(&self) -> &Self::Value {
&self.value
}
/// The trigger reads "Add relay" while the custom relay field is active.
fn display_title(&self) -> Option<AnyElement> {
(self.value.is_none()).then(|| "Add relay".into_any_element())
}
}
/// Relay options with a separator before the trailing custom relay choice.
struct RelayDelegate {
options: Vec<RelayOption>,
}
impl SearchableListDelegate for RelayDelegate {
type Item = RelayOption;
fn items_count(&self, _section: usize) -> usize {
self.options.len()
}
fn item(&self, ix: IndexPath) -> Option<&RelayOption> {
self.options.get(ix.row)
}
fn position<V>(&self, value: &V) -> Option<IndexPath>
where
Self::Item: SearchableListItem<Value = V>,
V: PartialEq,
{
self.options
.iter()
.position(|option| option.value() == value)
.map(IndexPath::new)
}
fn render_item(
&self,
_ix: IndexPath,
item: &RelayOption,
_checked: bool,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
(item.value.is_none()).then(|| {
v_flex()
.w_full()
.gap_1()
.child(Separator::horizontal())
.child(item.title())
.into_any_element()
})
}
}
fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
v_flex().gap_1p5().child(field_label(text, cx)).child(input)
}
+477
View File
@@ -0,0 +1,477 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, AppContext, Context, Div, Entity, Hsla, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
div, px,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{Confirm, DialogButtonProps, DialogFooter};
use gpui_kit::component::input::{InputEvent, InputState, NumberInput};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::theme::{ThemeColor, try_parse_color};
use gpui_kit::component::{
ActiveTheme, Theme, ThemeConfig, ThemeRegistry, WindowExt, h_flex, v_flex,
};
use gpui_kit::prelude::FluentBuilder;
use rand::seq::IndexedRandom;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use ui::{settings_block, settings_row};
use super::settings::{FONT_SIZE_RANGE, RADIUS_RANGE};
/// Open the first-run onboarding dialog
pub fn open(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| Onboarding::new(window, cx));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(640.))
.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Welcome to Coop")
.child(view.clone())
.button_props(
DialogButtonProps::default()
.ok_text("Get started")
.show_cancel(false)
.on_ok(|_, _, cx| {
AppSettings::update_onboarded(true, cx);
// Returning true closes the dialog
true
}),
)
.footer(
DialogFooter::new().child(
Button::new("get-started")
.label("Get started")
.primary()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
}),
),
)
});
}
pub struct Onboarding {
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
light_themes: Vec<Rc<ThemeConfig>>,
dark_themes: Vec<Rc<ThemeConfig>>,
_subscriptions: SmallVec<[Subscription; 3]>,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let theme = AppSettings::get_theme(cx);
let (light_themes, dark_themes) = Self::sample_themes(cx);
let font_size_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(theme.font_size.to_string())
.step(1.)
.min(FONT_SIZE_RANGE.0 as f64)
.max(FONT_SIZE_RANGE.1 as f64)
});
let radius_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(
theme
.radius
.map(|value| value.to_string())
.unwrap_or_default(),
)
.placeholder("System")
.step(1.)
.min(RADIUS_RANGE.0 as f64)
.max(RADIUS_RANGE.1 as f64)
});
let mut subscriptions = smallvec![];
subscriptions.push({
let state = font_size_input.clone();
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
if !matches!(event, InputEvent::Change) {
return;
}
if let Some(font_size) = Self::number_at(&state, FONT_SIZE_RANGE, cx) {
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
AppSettings::apply_theme_style(cx);
}
})
});
// One radius control tunes both radii so the whole UI stays coherent
subscriptions.push({
let state = radius_input.clone();
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
if !matches!(event, InputEvent::Change) {
return;
}
let radius = Self::number_at(&state, RADIUS_RANGE, cx);
AppSettings::update_theme(
move |theme| {
theme.radius = radius;
theme.radius_lg = radius;
},
cx,
);
AppSettings::apply_theme_style(cx);
})
});
Self {
font_size_input,
radius_input,
light_themes,
dark_themes,
_subscriptions: subscriptions,
}
}
/// The number in a theme input, clamped to the range, empty is None
fn number_at(state: &Entity<InputState>, range: (f32, f32), cx: &App) -> Option<f32> {
let (min, max) = range;
state
.read(cx)
.value()
.trim()
.parse::<f32>()
.ok()
.map(|value| value.clamp(min, max))
}
/// Three light and three dark bundled themes, sampled at random.
fn sample_themes(cx: &App) -> (Vec<Rc<ThemeConfig>>, Vec<Rc<ThemeConfig>>) {
let active = Theme::global(cx).theme_name().clone();
let mut rng = rand::rng();
let (lights, darks): (Vec<_>, Vec<_>) = ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.cloned()
.partition(|theme| !theme.mode.is_dark());
let mut sample = |group: &[Rc<ThemeConfig>]| {
let mut picks: Vec<_> = group.sample(&mut rng, 3).cloned().collect();
let active_config = group.iter().find(|theme| theme.name == active);
let already_sampled = picks.iter().any(|theme| theme.name == active);
if let (Some(config), Some(slot)) = (active_config, picks.first_mut())
&& !already_sampled
{
*slot = config.clone();
}
picks
};
(sample(&lights), sample(&darks))
}
/// The theme picker: a demo block per sampled bundled theme
fn themes_view(&self, cx: &App) -> AnyElement {
let active = Theme::global(cx).theme_name().clone();
settings_block(
"Theme",
"Customize UI colors. More in Settings.",
v_flex()
.gap_3()
.child(Self::theme_group("Light", &self.light_themes, &active, cx))
.child(Self::theme_group("Dark", &self.dark_themes, &active, cx)),
cx,
)
.into_any_element()
}
fn theme_group(
label: &'static str,
themes: &[Rc<ThemeConfig>],
active: &SharedString,
cx: &App,
) -> Div {
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(
div().flex().flex_wrap().gap_2().children(
themes
.iter()
.map(|config| Self::theme_card(config, active, cx)),
),
)
}
/// A card painting a mini app mockup in the theme's own colors
fn theme_card(config: &Rc<ThemeConfig>, active: &SharedString, cx: &App) -> impl IntoElement {
let selected = &config.name == active;
let name = config.name.clone();
let preview = Self::theme_preview(config);
let border = if selected {
cx.theme().list_active_border
} else {
preview.border
};
// Square block; the bigger inset keeps the mock window small inside it
let block = div()
.flex()
.w_full()
.aspect_square()
.p_4()
.rounded(cx.theme().radius_lg)
.overflow_hidden()
.bg(preview.background)
.border_1()
.border_color(border)
.hover(|this| this.border_color(cx.theme().border));
let block = if selected { block.border_2() } else { block };
v_flex()
.id(SharedString::from(format!("theme-{name}")))
.flex_1()
.cursor_pointer()
.child(
block.child(
h_flex()
.size_full()
.rounded(cx.theme().radius)
.when(cx.theme().shadow, |this| this.shadow_sm())
.overflow_hidden()
.border_1()
.border_color(preview.border)
.bg(preview.window)
.child(
v_flex()
.w_1_3()
.h_full()
.p_1()
.justify_between()
.bg(preview.sidebar)
.child(
v_flex()
.gap_1()
.child(h_flex().gap_0p5().children((0..3).map(|_| {
div().size_1().rounded_full().bg(preview.accent)
})))
.child(Self::demo_bar(preview.bar))
.child(Self::demo_bar(preview.bar)),
)
.child(
h_flex()
.gap_0p5()
.items_center()
.child(
div()
.size_2()
.rounded_full()
.bg(preview.bar.opacity(0.55)),
)
.child(
div()
.flex_1()
.h_1()
.rounded_full()
.bg(preview.bar.opacity(0.55)),
),
),
)
.child(
v_flex()
.flex_1()
.h_full()
.p_1()
.gap_1()
.child(Self::demo_bar(preview.bar))
.child(
h_flex()
.gap_1()
.child(div().flex_1().h_4().rounded_sm().bg(preview.block))
.child(div().flex_1().h_4().rounded_sm().bg(preview.block)),
),
),
),
)
.child(
div()
.mt_1()
.text_xs()
.truncate()
.text_color(if selected {
cx.theme().foreground
} else {
cx.theme().muted_foreground
})
.child(name),
)
.on_click({
let name = config.name.clone();
move |_, _window, cx| AppSettings::select_theme(&name, cx)
})
}
/// A skeleton text bar for the demo block
fn demo_bar(color: Hsla) -> Div {
div().w_full().h_1().rounded_full().bg(color.opacity(0.55))
}
/// The demo block colors resolved from a theme config.
fn theme_preview(config: &ThemeConfig) -> ThemePreview {
let defaults = if config.mode.is_dark() {
ThemeColor::dark()
} else {
ThemeColor::light()
};
let colors = &config.colors;
let resolve = |value: Option<&SharedString>, fallback: Hsla| {
value
.and_then(|value| try_parse_color(value).ok())
.unwrap_or(fallback)
};
ThemePreview {
background: resolve(colors.background.as_ref(), defaults.background),
border: resolve(colors.border.as_ref(), defaults.border),
window: resolve(colors.popover.as_ref(), defaults.popover),
sidebar: resolve(colors.secondary.as_ref(), defaults.secondary),
bar: resolve(colors.muted_foreground.as_ref(), defaults.muted_foreground),
block: resolve(colors.secondary.as_ref(), defaults.secondary),
accent: resolve(colors.primary.as_ref(), defaults.primary),
}
}
}
/// The colors of a theme's demo block
struct ThemePreview {
background: Hsla,
border: Hsla,
window: Hsla,
sidebar: Hsla,
bar: Hsla,
block: Hsla,
accent: Hsla,
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const INTRO: &str =
"Pick how Coop works and looks. You can change all of this later in Preferences.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
#[cfg(not(target_arch = "wasm32"))]
const WALLET: &str = "Show the wallet and allow ecash payments.";
const FONT_SIZE: &str = "The base font size for the interface.";
const RADIUS: &str =
"Corner radius for UI elements, including dialogs. Empty follows the theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let nip4e = AppSettings::get_nip4e(cx);
let theme = AppSettings::get_theme(cx);
let features = v_flex()
.gap_3()
.child(settings_row(
"Web of Trust",
WOT,
Switch::new("onboarding-wot")
.checked(wot_auto_trust)
.on_click(move |checked, _window, cx| {
AppSettings::update_wot_auto_trust(*checked, cx);
}),
cx,
))
.child(settings_row(
"Decoupling Encryption Key",
NIP4E,
Switch::new("onboarding-nip4e").checked(nip4e).on_click(
move |checked, _window, cx| {
AppSettings::update_nip4e(*checked, cx);
},
),
cx,
));
#[cfg(not(target_arch = "wasm32"))]
let features = features.child(settings_row(
"Wallet",
WALLET,
Switch::new("onboarding-wallet")
.checked(AppSettings::get_wallet(cx))
.on_click(move |checked, _window, cx| {
AppSettings::update_wallet(*checked, cx);
}),
cx,
));
v_flex()
.gap_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(INTRO),
)
.child(features)
.child(Separator::horizontal())
.child(self.themes_view(cx))
.child(Separator::horizontal())
.child(
v_flex()
.gap_3()
.child(settings_row(
"Font Size",
FONT_SIZE,
NumberInput::new(&self.font_size_input).w_24(),
cx,
))
.child(settings_row(
"Corner Radius",
RADIUS,
NumberInput::new(&self.radius_input).w_24(),
cx,
))
.child(settings_row(
"Focus Ring",
FOCUS_RING,
Switch::new("onboarding-focus-ring")
.checked(theme.focus_ring)
.on_click(move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
AppSettings::apply_theme_style(cx);
}),
cx,
))
.child(settings_row(
"Shadows",
SHADOW,
Switch::new("onboarding-shadow")
.checked(theme.shadow)
.on_click(move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
AppSettings::apply_theme_style(cx);
}),
cx,
)),
)
}
}
+3 -3
View File
@@ -72,7 +72,7 @@ impl ProfileDialog {
let bio_input = cx.new(|cx| {
TextareaState::new(window, cx)
.auto_grow(3, 8)
.placeholder("A short introduction about you")
.placeholder("A short introduce about you.")
});
let directory = Directory::new(cx);
@@ -438,7 +438,7 @@ impl Render for ProfileDialog {
Button::new("update")
.icon(IconName::SquarePen)
.label("Update")
.outline()
.primary()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.toggle_edit(window, cx);
})),
@@ -449,7 +449,7 @@ impl Render for ProfileDialog {
Button::new("message")
.icon(IconName::MessageSquare)
.label("Message")
.outline()
.primary()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.message(window, cx);
})),
+17 -27
View File
@@ -8,9 +8,10 @@ use gpui::{
WeakEntity, Window, div, rems,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::menu::{ContextMenu, ContextMenuExt as _, DropdownMenu, PopupMenuItem};
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
@@ -173,7 +174,7 @@ impl RelayManager {
})
}
fn gossip_rows(&self, cx: &mut Context<Self>) -> Vec<ContextMenu<Stateful<Div>>> {
fn gossip_rows(&self, cx: &mut Context<Self>) -> Vec<Stateful<Div>> {
let mut rows = Vec::new();
for (relay, metadata) in self.gossip.relays.iter() {
@@ -244,7 +245,7 @@ impl RelayManager {
})
}
fn inbox_rows(&self, cx: &mut Context<Self>) -> Vec<ContextMenu<Stateful<Div>>> {
fn inbox_rows(&self, cx: &mut Context<Self>) -> Vec<Stateful<Div>> {
let mut rows = Vec::new();
for relay in self.inbox.relays.iter() {
@@ -352,7 +353,7 @@ impl GossipRelays {
let metadata = self.metadata;
if !value.starts_with("ws") {
self.set_error("Relay URL is invalid", window, cx);
self.set_error("Relay URl is invalid", window, cx);
return;
}
@@ -364,7 +365,7 @@ impl GossipRelays {
cx.notify();
}
} else {
self.set_error("Relay URL is invalid", window, cx);
self.set_error("Relay URl is invalid", window, cx);
}
}
@@ -496,7 +497,7 @@ impl InboxRelays {
let value = self.input.read(cx).value().to_string();
if !value.starts_with("ws") {
self.set_error("Relay URL is invalid", window, cx);
self.set_error("Relay URl is invalid", window, cx);
return;
}
@@ -508,7 +509,7 @@ impl InboxRelays {
cx.notify();
}
} else {
self.set_error("Relay URL is invalid", window, cx);
self.set_error("Relay URl is invalid", window, cx);
}
}
@@ -634,9 +635,8 @@ fn relay_row(
badge: Option<SharedString>,
on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App,
) -> ContextMenu<Stateful<Div>> {
) -> Stateful<Div> {
let label = SharedString::from(url.to_string());
let on_remove = std::rc::Rc::new(on_remove);
h_flex()
.id(label.clone())
@@ -646,17 +646,7 @@ fn relay_row(
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.context_menu({
let on_remove = on_remove.clone();
move |menu, _window, _cx| {
menu.item(PopupMenuItem::new("Remove").on_click({
let on_remove = on_remove.clone();
move |event, window, cx| on_remove(event, window, cx)
}))
}
})
.bg(cx.theme().sidebar)
.child(
h_flex()
.gap_1()
@@ -664,8 +654,11 @@ fn relay_row(
.child(label)
.when_some(badge, |this, badge| {
this.child(
gpui_kit::component::tag::Tag::secondary()
.xsmall()
div()
.p_0p5()
.rounded_xs()
.text_xs()
.font_medium()
.child(badge),
)
}),
@@ -674,13 +667,10 @@ fn relay_row(
Button::new("remove")
.icon(IconName::X)
.xsmall()
.danger()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let on_remove = on_remove.clone();
move |event, window, cx| on_remove(event, window, cx)
}),
.on_click(on_remove),
)
}
+5 -5
View File
@@ -4,8 +4,8 @@ use anyhow::Error;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, Window, div, relative, rems, uniform_list,
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
@@ -313,6 +313,7 @@ impl Screening {
.gap_1p5()
.rounded(cx.theme().radius)
.text_xs()
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
@@ -324,7 +325,7 @@ impl Screening {
items
})
.h(rems(18.75)),
.h(px(300.)),
),
)
});
@@ -544,7 +545,6 @@ impl Render for Screening {
.child(
Button::new("mutuals")
.icon(IconName::Info)
.tooltip("Show mutual contacts")
.xsmall()
.ghost()
.disabled(mutuals == 0)
@@ -571,7 +571,7 @@ fn status_badge(status: Option<bool>, cx: &App) -> Div {
if let Some(status) = status {
this.child(Icon::new(IconName::CircleCheck).small().text_color({
if status {
cx.theme().success
cx.theme().primary
} else {
cx.theme().muted_foreground
}
+72 -40
View File
@@ -7,6 +7,8 @@ use gpui::PathPromptOptions;
#[cfg(not(target_arch = "wasm32"))]
use gpui::Task;
use gpui::http_client::Url;
#[cfg(not(target_arch = "wasm32"))]
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
Subscription, Window, div,
@@ -28,8 +30,8 @@ use speech::model::{self as speech_model};
use ui::{settings_block, settings_row};
/// Bounds for the theme number inputs
const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
const RADIUS_RANGE: (f32, f32) = (0., 24.);
pub(super) const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
pub(super) const RADIUS_RANGE: (f32, f32) = (0., 24.);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx))
@@ -93,9 +95,9 @@ impl Preferences {
});
#[cfg(not(target_arch = "wasm32"))]
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint"));
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
let subscriptions = vec![
let mut subscriptions = vec![
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
if let Some(font_size) = value {
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
@@ -112,6 +114,12 @@ impl Preferences {
}),
];
// Keep the switches in sync with the settings they edit
subscriptions.push({
let settings = AppSettings::global(cx);
cx.observe(&settings, |_, _, cx| cx.notify())
});
#[cfg(not(target_arch = "wasm32"))]
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
let timer = Duration::from_millis(500);
@@ -219,26 +227,42 @@ impl Preferences {
#[cfg(not(target_arch = "wasm32"))]
{
const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
let enabled = AppSettings::get_wallet(cx);
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
settings_block(
"Wallet",
WALLET,
h_flex()
.gap_1()
.child(div().flex_1().child(Input::new(&self.mint_input)))
.child(
Button::new("setup-mint")
.label(if has_wallet { "Add" } else { "Set up" })
.ghost()
.size_8()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.setup_mint(window, cx);
})),
),
cx,
)
.into_any_element()
v_flex()
.w_full()
.gap_3()
.child(settings_row(
"Wallet",
"Show the wallet and allow ecash payments.",
Switch::new("wallet")
.checked(enabled)
.on_click(move |checked, _window, cx| {
AppSettings::update_wallet(*checked, cx);
}),
cx,
))
.when(enabled, |this| {
this.child(settings_block(
"Mint",
WALLET,
h_flex()
.gap_1()
.child(div().flex_1().child(Input::new(&self.mint_input)))
.child(
Button::new("setup-mint")
.label(if has_wallet { "Add" } else { "Set up" })
.ghost()
.size_8()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.setup_mint(window, cx);
})),
),
cx,
))
})
.into_any_element()
}
}
@@ -446,7 +470,7 @@ impl Preferences {
impl Render for Preferences {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show a screening dialog to verify the unknown sender.";
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const APPEARANCE: &str =
@@ -458,6 +482,7 @@ impl Render for Preferences {
const RADIUS_LG: &str =
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
@@ -475,7 +500,7 @@ impl Render for Preferences {
let background_label = match &panel_background_path {
Some(path) => path.display().to_string(),
None => String::from("Default background"),
None => String::from("Default Background"),
};
v_flex()
@@ -497,7 +522,7 @@ impl Render for Preferences {
cx,
))
.child(settings_row(
"Web of trust",
"Web of Trust",
WOT,
Switch::new("wot-auto-trust")
.checked(wot_auto_trust)
@@ -536,44 +561,52 @@ impl Render for Preferences {
}),
cx,
))
.child(self.render_theme_choice("theme-light", "Light theme", LIGHT_THEME, false, cx))
.child(self.render_theme_choice("theme-dark", "Dark theme", DARK_THEME, true, cx))
.child(self.render_theme_choice("theme-light", "Light Theme", LIGHT_THEME, false, cx))
.child(self.render_theme_choice("theme-dark", "Dark Theme", DARK_THEME, true, cx))
.child(Separator::horizontal())
.child(
v_flex()
.w_full()
.gap_3()
.child(settings_row(
"Font size",
"Font Size",
FONT_SIZE,
NumberInput::new(&self.font_size_input).w_24(),
cx,
))
.child(settings_row(
"Corner radius",
"Corner Radius",
RADIUS,
NumberInput::new(&self.radius_input).w_24(),
cx,
))
.child(settings_row(
"Large corner radius",
"Large Corner Radius",
RADIUS_LG,
NumberInput::new(&self.radius_lg_input).w_24(),
cx,
))
.child(settings_row(
"Focus ring",
"Focus Ring",
FOCUS_RING,
Switch::new("focus-ring")
.checked(theme.focus_ring)
.on_click(move |_, _window, cx| {
AppSettings::update_theme(
|theme| theme.focus_ring = !theme.focus_ring,
cx,
);
.on_click(move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
AppSettings::apply_theme_style(cx);
}),
cx,
))
.child(settings_row(
"Shadows",
SHADOW,
Switch::new("shadow").checked(theme.shadow).on_click(
move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
AppSettings::apply_theme_style(cx);
},
),
cx,
)),
)
.child(Separator::horizontal())
@@ -624,7 +657,7 @@ impl Render for Preferences {
)
.child(Separator::horizontal())
.child(settings_row(
"Decoupling encryption key",
"Decoupling Encryption Key",
NIP4E,
Switch::new("nip4e")
.checked(nip4e)
@@ -634,7 +667,7 @@ impl Render for Preferences {
cx,
))
.child(settings_row(
"Rotate encryption key",
"Rotate Encryption Key",
NIP4E_ROTATION,
Switch::new("nip4e_rotation")
.checked(nip4e_rotation_days.is_some())
@@ -654,7 +687,7 @@ impl Render for Preferences {
.child(self.render_dictation_shortcut(window, cx))
.child(Separator::horizontal())
.child(settings_block(
"Media upload service",
"Media Upload Service",
MEDIA,
h_flex()
.gap_1()
@@ -662,7 +695,6 @@ impl Render for Preferences {
.child(
Button::new("update-file-server")
.icon(IconName::Check)
.tooltip("Update")
.ghost()
.size_8()
.on_click(cx.listener(move |this, _ev, window, cx| {
+1 -1
View File
@@ -678,7 +678,7 @@ impl TabGroupRenderer for CoopTabGroupSkin {
h_flex()
.id("tabs-inner")
.relative()
.gap_1()
.gap(px(4.))
.overflow_x_scroll()
.lock_scroll_axis()
.track_scroll(&self.scroll_handle)
@@ -4,7 +4,6 @@ use gpui::{
StatefulInteractiveElement, Styled, Window, div, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::tooltip::Tooltip;
use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex};
use super::TAB_BAR_HEIGHT;
@@ -54,15 +53,6 @@ impl ControlIcon {
}
}
fn label(&self) -> &'static str {
match self {
Self::Minimize => "Minimize",
Self::Restore => "Restore",
Self::Maximize => "Maximize",
Self::Close => "Close",
}
}
fn window_control_area(&self) -> gpui::WindowControlArea {
match self {
Self::Minimize => gpui::WindowControlArea::Min,
@@ -112,8 +102,6 @@ impl RenderOnce for ControlIcon {
let active_bg = self.active_bg(cx);
let icon = self.clone();
let label = self.label();
div()
.id(self.id())
.flex()
@@ -124,7 +112,6 @@ impl RenderOnce for ControlIcon {
.content_center()
.items_center()
.text_color(cx.theme().foreground)
.tooltip(move |window, cx| Tooltip::new(label).build(window, cx))
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(active_bg).text_color(hover_fg))
.when(is_windows, |this| {
+25 -15
View File
@@ -2,7 +2,7 @@ use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use ::settings::{AppSettings, AppearanceMode};
use ::settings::{AppSettings, AppearanceMode, persisted_onboarded};
use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
@@ -11,13 +11,12 @@ use chat::{ChatEvent, ChatRegistry};
use common::download_dir;
use gpui::{
Action, App, AppContext, Context, Entity, Focusable, InteractiveElement, IntoElement,
ParentElement, Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px, rems,
ParentElement, Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px,
};
use gpui_kit::component::button::{Button, ButtonVariant};
use gpui_kit::component::button::Button;
use gpui_kit::component::command::{
Command as CommandPalette, CommandGroup, CommandItem, CommandState,
};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{
@@ -31,12 +30,15 @@ use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
use crate::dialogs::{
import, join_group, new_chat, new_group, onboarding, profile, relays, settings,
};
use crate::panels::{backup, browse, contact_list, greeter, inbox};
use crate::sidebar::Sidebar;
mod dialogs;
mod dock;
mod nav;
mod panels;
mod sidebar;
@@ -94,6 +96,7 @@ impl Workspace {
let skin = dock::CoopDockSkin::new(cx);
gpui_kit::component::dock::DockArea::new("coop", None, window, cx).with_renderer(skin)
});
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
let left_sidebar = sidebar.clone();
@@ -107,10 +110,18 @@ impl Workspace {
}
}));
let login_nostr = nostr.clone();
subscriptions.push(
// Subscribe to the nostr events
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
StateEvent::SignerChanged => window.close_all_dialogs(cx),
StateEvent::SignerChanged => {
window.close_all_dialogs(cx);
// Show the first-run onboarding once the user is signed in
if !persisted_onboarded() && login_nostr.read(cx).current_user().is_some() {
onboarding::open(window, cx);
}
}
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
_ => {}
}),
@@ -265,6 +276,10 @@ impl Workspace {
this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
this.set_center(center, window, cx);
});
if !persisted_onboarded() && nostr.read(cx).current_user().is_some() {
onboarding::open(window, cx);
}
});
Self {
@@ -343,9 +358,10 @@ impl Workspace {
let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
Command::ShowWallet => {
Command::ShowWallet =>
{
#[cfg(not(target_arch = "wasm32"))]
{
if AppSettings::get_wallet(cx) {
let panel = nip60_ui::wallet::init(window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
@@ -480,7 +496,7 @@ impl Workspace {
.bordered(false)
.rounded(cx.theme().radius_lg)
.placeholder("Search themes...")
.max_h(rems(26.25))
.max_h(px(420.))
.on_confirm(move |index, _, cx| {
if let Some(name) = Self::theme_name_at(index, cx) {
AppSettings::select_theme(&name, cx);
@@ -618,12 +634,6 @@ impl Workspace {
.child(SharedString::from(ENC_WARN)),
),
)
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Reset key")
.ok_variant(ButtonVariant::Danger),
)
.on_ok(move |_ev, _window, cx| {
ent.update(cx, |this, cx| {
this.set_announcement(Keys::generate(), cx);
+142
View File
@@ -0,0 +1,142 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div,
};
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex};
/// A single navigation row, such as an entry in a sidebar list.
#[derive(IntoElement)]
pub struct Nav {
id: ElementId,
interactivity: Interactivity,
style: StyleRefinement,
prefix: Option<AnyElement>,
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
clickable: bool,
center_prefix: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl Nav {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
let mut interactivity = Interactivity::default();
interactivity.element_id = Some(id.clone());
Self {
id,
interactivity,
style: StyleRefinement::default(),
prefix: None,
label: SharedString::default(),
suffix: None,
selected: false,
clickable: false,
center_prefix: false,
on_click: None,
}
}
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
}
/// Whether to center the prefix in the avatar-width column.
pub fn center_prefix(mut self, center: bool) -> Self {
self.center_prefix = center;
self
}
/// Sets the element shown before the label, such as an avatar or icon.
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Sets the row's label.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
/// Sets the element shown after the label, such as a timestamp or badge.
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Selectable for Nav {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for Nav {
fn interactivity(&mut self) -> &mut Interactivity {
&mut self.interactivity
}
}
impl Styled for Nav {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
h_flex()
.id(self.id)
.h_8()
.w_full()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.when_some(self.prefix, |this, prefix| {
this.child(
h_flex()
.flex_shrink_0()
.when(self.center_prefix, |this| this.w_6().justify_center())
.child(prefix),
)
})
.child(div().flex_1().min_w_0().truncate().child(self.label))
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().list_hover))
.when(self.selected, |this| this.bg(cx.theme().list_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
.refine_style(&self.style)
}
}
+4 -4
View File
@@ -296,7 +296,7 @@ impl BrowsePanel {
.max_w_64()
.px_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.bg(cx.theme().sidebar)
.child(Input::new(&self.input).appearance(false).flex_1())
.child(
Button::new("relay-browse")
@@ -376,7 +376,7 @@ impl BrowsePanel {
.cursor_pointer()
.border_1()
.border_color(transparent_black())
.bg(cx.theme().group_box)
.bg(cx.theme().sidebar)
.hover(|this| {
this.bg(cx.theme().secondary)
.border_color(cx.theme().border)
@@ -510,12 +510,12 @@ impl Render for BrowsePanel {
} else {
let text = if discover {
if self.discovering {
"Discovering groups"
"Discovering groups…"
} else {
"No groups found."
}
} else if self.loading {
"Loading groups"
"Loading groups…"
} else {
"No groups found."
};
+41 -60
View File
@@ -12,7 +12,6 @@ use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
@@ -30,26 +29,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
pub struct ContactListPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Npub input
input: Entity<InputState>,
/// Whether the panel is updating
updating: bool,
/// Error message
error: Option<SharedString>,
/// All contacts
contacts: HashSet<PublicKey>,
contacts: HashSet<Contact>,
/// Person directory for the contacts
directory: Entity<Directory>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -93,33 +83,50 @@ impl ContactListPanel {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let task: Task<Result<HashSet<Contact>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contact_list: HashSet<PublicKey> = client
let contact_list: HashSet<Contact> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.map(|event| {
event
.tags
.iter()
.filter_map(|tag| match Nip02Tag::try_from(tag) {
Ok(Nip02Tag::PublicKey {
public_key,
relay_hint,
alias,
}) => Some(Contact {
public_key,
relay_url: relay_hint,
alias,
}),
Err(_) => None,
})
.collect()
})
.unwrap_or_default();
Ok(contact_list)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_keys = task.await?;
let contacts = task.await?;
// Update state
this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(public_keys.iter().copied(), cx)
directory.track(contacts.iter().map(|contact| contact.public_key), cx)
});
this.contacts.extend(public_keys);
this.contacts.extend(contacts);
cx.notify();
})?;
@@ -131,7 +138,7 @@ impl ContactListPanel {
let value = self.input.read(cx).value().to_string();
if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) {
if self.contacts.insert(Contact::new(public_key)) {
self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.input.update(cx, |this, cx| {
@@ -145,7 +152,8 @@ impl ContactListPanel {
}
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.contacts.remove(public_key);
self.contacts
.retain(|contact| contact.public_key != *public_key);
cx.notify();
}
@@ -185,11 +193,7 @@ impl ContactListPanel {
let signer = nostr.read(cx).signer();
// Get contacts
let contacts: Vec<Contact> = self
.contacts
.iter()
.map(|public_key| Contact::new(*public_key))
.collect();
let contacts: Vec<Contact> = self.contacts.iter().cloned().collect();
// Set updating state
self.set_updating(true, cx);
@@ -230,10 +234,10 @@ impl ContactListPanel {
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let mut items = Vec::new();
let panel = cx.weak_entity();
for (ix, public_key) in self.contacts.iter().enumerate() {
let profile = self.directory.read(cx).person(public_key, cx);
for (ix, contact) in self.contacts.iter().enumerate() {
let public_key = contact.public_key;
let profile = self.directory.read(cx).person(&public_key, cx);
items.push(
h_flex()
@@ -245,24 +249,7 @@ impl ContactListPanel {
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().list_hover))
.context_menu({
let panel = panel.clone();
let public_key = *public_key;
move |menu, _window, _cx| {
menu.item(PopupMenuItem::new("Remove").on_click({
let panel = panel.clone();
move |_, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.remove(&public_key, cx);
}) {
log::warn!("removing a contact failed: {error}");
}
}
}))
}
})
.hover(|this| this.bg(cx.theme().primary))
.child(
h_flex()
.gap_2()
@@ -275,21 +262,15 @@ impl ContactListPanel {
.child(profile.name()),
)
.child(
Button::new(SharedString::from(format!(
"remove-{}",
public_key.to_hex()
)))
.icon(IconName::X)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let public_key = public_key.to_owned();
cx.listener(move |this, _ev, _window, cx| {
Button::new("remove_{ix}")
.icon(IconName::X)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.remove(&public_key, cx);
})
}),
})),
),
)
}
@@ -307,7 +288,7 @@ impl ContactListPanel {
.rounded(cx.theme().radius_lg)
.text_xs()
.text_align(TextAlign::Center)
.child(SharedString::from("No contacts yet"))
.child(SharedString::from("Please add some relays."))
}
}
+16 -36
View File
@@ -13,7 +13,6 @@ use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nostr_sdk::prelude::*;
@@ -24,6 +23,7 @@ use ui::Avatar;
use super::browse;
use crate::dock::add_panel_to;
use crate::nav::Nav;
use crate::sidebar::open_room;
/// How many requests and follow ups to show per section.
@@ -213,17 +213,17 @@ impl GreeterPanel {
} else {
requests
.iter()
.map(|(room, preview)| {
.enumerate()
.map(|(index, (room, preview))| {
let room = room.clone();
let dock = dock.clone();
let name = room.read(cx).display_name(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 preview = preview.clone();
h_flex()
.id(format!("request-{id}"))
.id(format!("request-{index}"))
.group("")
.w_full()
.h_9()
@@ -280,8 +280,8 @@ impl GreeterPanel {
),
)
.child(
Button::new(format!("response-{id}"))
.label("Respond")
Button::new(format!("response-{index}"))
.label("Response")
.xsmall()
.ghost()
.invisible()
@@ -332,17 +332,17 @@ impl GreeterPanel {
let rows: Vec<AnyElement> = self
.follow_ups
.iter()
.map(|(room, preview)| {
.enumerate()
.map(|(index, (room, preview))| {
let room = room.clone();
let dock = dock.clone();
let name = room.read(cx).display_name(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 preview = preview.clone();
h_flex()
.id(format!("follow-up-{id}"))
.id(format!("follow-up-{index}"))
.group("")
.w_full()
.h_9()
@@ -399,7 +399,7 @@ impl GreeterPanel {
),
)
.child(
Button::new(format!("reply-{id}"))
Button::new(format!("reply-{index}"))
.label("Reply")
.xsmall()
.ghost()
@@ -457,19 +457,9 @@ impl GreeterPanel {
v_flex()
.w_full()
.child(
ListItem::new("browse-group")
.accessibility_label("Browse Public Groups")
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_shrink_0().child(Icon::new(IconName::Compass)))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.child("Browse Public Groups"),
)
Nav::new("browse-group")
.prefix(Icon::new(IconName::Compass))
.label("Browse Public Groups")
.text_sm()
.on_click(cx.listener(|this, _, window, cx| {
let panel = browse::init(this.dock.clone(), window, cx);
@@ -483,19 +473,9 @@ impl GreeterPanel {
})),
)
.child(
ListItem::new("browse-posts")
.accessibility_label("Browse Nostr via Jumble")
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_shrink_0().child(Icon::new(IconName::Link)))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.child("Browse Nostr via Jumble"),
)
Nav::new("browse-posts")
.prefix(Icon::new(IconName::Link))
.label("Browse Nostr via Jumble")
.text_sm()
.on_click(|_, _, cx| {
cx.open_url(JUMBLE_URL);
+31 -56
View File
@@ -6,19 +6,18 @@ use gpui::{
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::list::ListItem;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nip29::Group;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use state::NostrRegistry;
use ui::Avatar;
use ui::{Avatar, IconName};
use super::Sidebar;
use crate::dialogs::profile;
use crate::nav::Nav;
/// A collapsible section of the group view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -223,25 +222,19 @@ impl Sidebar {
GroupSection::Members => self.members_open,
};
let icon = if open {
IconName::ChevronDown
IconName::CaretDown
} else {
IconName::ChevronRight
IconName::CaretRight
};
let label = section.label();
ListItem::new(label)
.accessibility_label(label)
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_1().min_w_0().truncate().child(label))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
})
Nav::new(label)
.label(label)
.suffix(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
@@ -269,7 +262,6 @@ impl Sidebar {
let group = group.clone();
let public_key = *public_key;
let member = person.name();
let member_is_admin = !roles.is_empty();
let is_self = NostrRegistry::global(cx)
@@ -277,40 +269,31 @@ impl Sidebar {
.current_user()
.is_some_and(|me| me == public_key);
let item = ListItem::new(public_key.to_hex())
.accessibility_label(member.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
let nav = Nav::new(public_key.to_hex())
.label(person.name())
.text_sm()
.clickable(true)
.when(!hide_avatar, |this| {
this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
),
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
)
})
.child(div().flex_1().min_w_0().truncate().child(person.name()))
.when(!roles.is_empty(), |this| {
let roles = SharedString::from(roles.join(", "));
this.suffix(move |_, cx| {
div().flex_shrink_0().child(
div()
.font_normal()
.truncate()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(roles.clone()),
)
})
this.suffix(
div()
.font_normal()
.truncate()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(roles.join(", "))),
)
});
let row = div()
.id(public_key.to_hex())
.w_full()
.child(item)
.child(nav)
.context_menu(move |menu, _window, _cx| {
let menu = menu
.item(
@@ -343,21 +326,13 @@ impl Sidebar {
})
});
menu.separator().item({
let member = member.clone();
PopupMenuItem::new("Remove").on_click({
menu.separator()
.item(PopupMenuItem::new("Remove").on_click({
let group = group.clone();
move |_event, window, cx| {
nip29_ui::confirm_remove_member(
group.clone(),
public_key,
member.clone(),
window,
cx,
);
nip29_ui::remove_member(group.clone(), public_key, window, cx);
}
})
})
}))
});
row.into_any_element()
+11 -21
View File
@@ -7,14 +7,12 @@ use gpui::{
};
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
#[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::list::ListItem;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::popover::Popover;
use gpui_kit::component::separator::Separator;
@@ -23,11 +21,12 @@ use nostr_sdk::prelude::*;
use person::shorten_pubkey;
use settings::AppSettings;
use state::{CLIENT_NAME, NostrRegistry};
use ui::Avatar;
use ui::{Avatar, IconName};
use super::Sidebar;
use crate::Command;
use crate::dock::TAB_BAR_HEIGHT;
use crate::nav::Nav;
impl Sidebar {
pub(super) fn render_user(
@@ -102,7 +101,7 @@ impl Sidebar {
)
.child(
Button::new("sidebar-new")
.icon(IconName::MessageSquarePlus)
.icon(IconName::PlusChat)
.tooltip("New")
.ghost()
.small()
@@ -212,7 +211,7 @@ impl Sidebar {
})
}
pub(super) fn render_footer(&self, loading: bool, cx: &mut Context<Self>) -> Div {
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
v_flex()
.p_2()
.flex_shrink_0()
@@ -221,19 +220,10 @@ impl Sidebar {
.justify_start()
.when(!loading, |this| {
this.child(
ListItem::new("settings")
.accessibility_label("Settings")
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.w_6()
.justify_center()
.flex_shrink_0()
.child(Icon::new(IconName::Settings).small()),
)
.child(div().flex_1().min_w_0().truncate().child("Settings"))
Nav::new("settings")
.prefix(Icon::new(IconName::Settings).small())
.center_prefix(true)
.label("Settings")
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowSettings), cx)
@@ -253,7 +243,7 @@ impl Sidebar {
.when(!up_to_date, |this| {
this.child(
Button::new("update")
.icon(IconName::CircleArrowDown)
.icon(IconName::ArrowDownCircle)
.tooltip(status)
.small()
.ghost()
@@ -263,7 +253,7 @@ impl Sidebar {
.when(staged, |this| {
this.child(
Button::new("restart")
.icon(IconName::CircleArrowDown)
.icon(IconName::ArrowDownCircle)
.tooltip("Quit and relaunch into the installed update")
.small()
.ghost()
@@ -284,7 +274,7 @@ fn open_logout_dialog(window: &mut Window, cx: &mut App) {
window.open_alert_dialog(cx, |alert, _, _| {
alert
.title("Log out")
.title("Log Out?")
.description(DESCRIPTION)
.button_props(
DialogButtonProps::default()
+58 -75
View File
@@ -2,18 +2,19 @@ use std::rc::Rc;
use chat::{ChatRegistry, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{AnyElement, App, Context, IntoElement, ParentElement, Styled, div, uniform_list};
use gpui_kit::assets::IconName;
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::badge::Badge;
use gpui_kit::component::list::ListItem;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nip29::GroupsRegistry;
use settings::AppSettings;
use ui::IconName;
use super::Sidebar;
use super::rows::SidebarRow;
use crate::Command;
use crate::nav::Nav;
/// A collapsible section of the sidebar's main list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -37,7 +38,6 @@ 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 new_requests = self.new_requests;
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();
@@ -67,75 +67,66 @@ impl Sidebar {
.gap_1()
.items_start()
.justify_start()
.when(cfg!(not(target_arch = "wasm32")), |this| {
this.child(
ListItem::new("wallet")
.accessibility_label("Wallet")
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div().flex_shrink_0().child(
Icon::new(gpui_kit::assets::IconName::Wallet).small(),
),
)
.child(div().flex_1().min_w_0().truncate().child("Wallet"))
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
}),
)
})
.child(
ListItem::new("browse")
.accessibility_label("Browse")
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.flex_shrink_0()
.child(Icon::new(IconName::Compass).small()),
.when(
cfg!(not(target_arch = "wasm32")) && AppSettings::get_wallet(cx),
|this| {
this.child(
Nav::new("wallet")
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
.label("Wallet")
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(crate::Command::ShowWallet),
cx,
)
}),
)
.child(div().flex_1().min_w_0().truncate().child("Browse"))
},
)
.child(
Nav::new("browse")
.prefix(Icon::new(IconName::Compass).small())
.label("Browse")
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
)
.child(
ListItem::new("reqs")
.accessibility_label("Requests")
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.flex_shrink_0()
.child(Icon::new(IconName::UserPlus).small()),
)
.child(div().flex_1().min_w_0().truncate().child("Requests"))
Nav::new("reqs")
.prefix(Icon::new(IconName::Invite).small())
.label("Requests")
.text_sm()
.when(request_count > 0, |this| {
this.suffix(move |_, cx| {
div().flex_shrink_0().child(
h_flex()
.gap_1()
.child(
Badge::new()
.count(request_count)
.child(div().size_4()),
this.suffix(
h_flex()
.gap_1()
.child(
h_flex()
.py_0p5()
.px_1()
.min_w_6()
.justify_center()
.text_xs()
.font_medium()
.text_color(cx.theme().muted_foreground)
.text_center()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.child(SharedString::from(
request_count.to_string(),
)),
)
.when(self.new_requests && !requests_open, |this| {
this.child(
div()
.size_1()
.rounded_full()
.bg(cx.theme().primary),
)
.when(new_requests && !requests_open, |this| {
this.child(
div()
.size_1()
.rounded_full()
.bg(cx.theme().primary),
)
}),
)
})
}),
)
})
.when(requests_open, |this| this.bg(cx.theme().list_active))
.on_click(cx.listener(|this, _event, _window, cx| {
@@ -143,17 +134,9 @@ impl Sidebar {
})),
)
.child(
ListItem::new("contacts")
.accessibility_label("Contacts")
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.flex_shrink_0()
.child(Icon::new(IconName::Book).small()),
)
.child(div().flex_1().min_w_0().truncate().child("Contacts"))
Nav::new("contacts")
.prefix(Icon::new(IconName::Book).small())
.label("Contacts")
.text_sm()
.when(contacts_open, |this| this.bg(cx.theme().list_active))
.on_click(cx.listener(|this, _event, window, cx| {
+93 -129
View File
@@ -5,25 +5,23 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement,
SharedString, Styled, WeakEntity, Window, div,
SharedString, Styled, WeakEntity, Window, div, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::tag::Tag;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
use nip29::Group;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use ui::Avatar;
use ui::{Avatar, IconName};
use super::Sidebar;
use super::list::ListSection;
use super::search::MessageHit;
use crate::dialogs::{profile, screening};
use crate::nav::Nav;
/// Open a room in the dock, screening new requests when enabled.
pub(crate) fn open_room(
@@ -51,7 +49,7 @@ pub(crate) fn open_room(
.button_props(
DialogButtonProps::default()
.cancel_text("Ignore")
.ok_text("Respond"),
.ok_text("Response"),
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
@@ -94,10 +92,13 @@ impl Sidebar {
) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
rows.get(range)
rows.get(range.clone())
.into_iter()
.flatten()
.map(|row| {
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
let element = match row {
SidebarRow::Section(section) => {
let section = *section;
@@ -110,24 +111,18 @@ impl Sidebar {
};
let icon = if open {
IconName::ChevronDown
IconName::CaretDown
} else {
IconName::ChevronRight
IconName::CaretRight
};
ListItem::new(label)
.accessibility_label(label)
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_1().min_w_0().truncate().child(label))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
})
Nav::new(label)
.label(label)
.suffix(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
@@ -148,11 +143,8 @@ impl Sidebar {
SidebarRow::Heading(label) => {
let label = *label;
ListItem::new(label)
.accessibility_label(label)
.h_8()
.rounded(cx.theme().radius)
.child(div().flex_1().min_w_0().truncate().child(label))
Nav::new(label)
.label(label)
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
@@ -179,48 +171,48 @@ impl Sidebar {
let dock = self.dock.clone();
let room = room.clone();
let item = ListItem::new(id)
.accessibility_label(name.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
let nav = Nav::new(index)
.label(name)
.text_sm()
.when(!hide_avatar, |this| {
this.child(
div().flex_shrink_0().child(
Avatar::new(room.read(cx).display_image(cx))
.seed(seed)
.small(),
),
)
})
.child(div().flex_1().min_w_0().truncate().child(name))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
h_flex()
.gap_1()
.when(spam, |this| {
this.child(Tag::danger().xsmall().child("Spam"))
})
.when(!spam, |this| {
this.child(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(created_at.clone()),
)
}),
this.prefix(
Avatar::new(room.read(cx).display_image(cx))
.seed(seed)
.small(),
)
})
.suffix(
h_flex()
.gap_1()
.when(spam, |this| {
this.child(
div()
.px_1()
.rounded(cx.theme().radius)
.bg(cx.theme().danger.opacity(0.15))
.text_size(px(8.0))
.text_color(cx.theme().danger)
.child("SPAM"),
)
})
.when(!spam, |this| {
this.child(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(created_at),
)
}),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
});
div()
.id(id)
.id(index)
.w_full()
.child(item)
.child(nav)
.context_menu(move |menu, _window, _cx| {
let menu = menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
@@ -249,22 +241,16 @@ impl Sidebar {
let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned();
ListItem::new(public_key.to_hex())
.accessibility_label(person.name())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
Nav::new(index)
.label(person.name())
.text_sm()
.when(!hide_avatar, |this| {
this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
),
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
)
})
.child(div().flex_1().min_w_0().truncate().child(person.name()))
.selected(selected)
.on_click(move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
@@ -279,22 +265,16 @@ impl Sidebar {
let person = self.directory.read(cx).person(public_key, cx);
let public_key = public_key.to_owned();
ListItem::new(public_key.to_hex())
.accessibility_label(person.name())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
Nav::new(index)
.label(person.name())
.text_sm()
.when(!hide_avatar, |this| {
this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
),
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
)
})
.child(div().flex_1().min_w_0().truncate().child(person.name()))
.on_click(move |_event, window, cx| {
profile::open(public_key, window, cx);
})
@@ -318,32 +298,24 @@ impl Sidebar {
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
.map(SharedString::from);
let item = ListItem::new(tag.clone())
.accessibility_label(name.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
let nav = Nav::new(index)
.label(name)
.text_sm()
.when(!hide_avatar, |this| {
this.child(
div().flex_shrink_0().child(
Avatar::new(group.read(cx).display_image())
.seed(seed)
.small(),
),
this.prefix(
Avatar::new(group.read(cx).display_image())
.seed(seed)
.small(),
)
})
.child(div().flex_1().min_w_0().truncate().child(name))
.when_some(suffix, |this, suffix| {
this.suffix(move |_, cx| {
div().flex_shrink_0().child(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(suffix.clone()),
)
})
this.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(suffix),
)
})
.on_click(move |_event, window, cx| {
crate::dock::add_panel_to(
@@ -362,9 +334,9 @@ impl Sidebar {
});
div()
.id(tag.clone())
.id(index)
.w_full()
.child(item)
.child(nav)
.context_menu(move |menu, _window, _cx| {
menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
@@ -399,32 +371,24 @@ impl Sidebar {
let room = hit.room.clone();
let content = hit.content.clone();
ListItem::new(hit.author.to_hex())
.accessibility_label(content.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
Nav::new(index)
.label(content)
.text_sm()
.font_medium()
.when(!hide_avatar, |this| {
this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
),
)
})
.child(div().flex_1().min_w_0().truncate().child(content))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(detail.clone()),
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
)
})
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(detail),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
})
+1 -1
View File
@@ -309,7 +309,7 @@ impl Sidebar {
public_key: *public_key,
}));
}
None => rows.push(SidebarRow::Empty("Searching")),
None => rows.push(SidebarRow::Empty("Searching...")),
}
}
-3
View File
@@ -48,9 +48,6 @@ person = { path = "../crates/person" }
gpui.workspace = true
gpui_platform.workspace = true
gpui-kit.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
reqwest_client.workspace = true
log.workspace = true
+907
View File
@@ -0,0 +1,907 @@
# Coop Stability Audit — Pre-Release
Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire workspace (~35k lines, 18 crates + `desktop` + `web`)
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
> See §9 for the per-fix log.
## 1. Scope and Method
Every crate was audited file-by-file against the project conventions (`AGENTS.md`), Rust best
practices (ownership, error handling, async, API design, anti-patterns), and GPUI lifecycle rules
(entity updates, task/subscription ownership, `ElementId` stability, render purity). Severe claims
were verified against the vendored dependency sources (`gpui-pre 0.3.12`, `gpui-base 0.9.1`,
`gpui-component 0.9.1`, rust-nostr 0.45.6, `gpui-updater-pre 0.2.0`), and the highest-severity
findings were independently re-verified by hand.
Tooling results: `cargo clippy --workspace --all-targets` — **clean, zero warnings**.
`cargo fmt --all --check` — **clean**. `cargo check -p workspace` and `-p person` — pass.
## 2. Executive Summary
**Verdict: not ready for stable release yet.** The codebase is well-structured — clean clippy,
disciplined GPUI entity/subscription hygiene in most crates, good hostile-input handling in the
crypto-adjacent code, and no lock-across-await anywhere. But there are **3 Critical** and
**~27 High** findings that must be addressed first, concentrated in three areas:
1. **Money and key safety** (`nip60`, `state`, `browser-signer-proxy`): a one-event wallet-wipe
vector, a startup crash loop, a plaintext client key in web `localStorage`, and an
unauthenticated localhost signing API.
2. **Silent failure paths**: errors from wallet ops, message loads, relay connects, settings
persistence, and backup/keyring reads are swallowed; several failure states strand data or
wedge the UI (stuck `loading_more`, permanently-blacklisted profile fetches, dictation dying
after 30 s).
3. **Wrong results visible to users**: reaction double-counting, multi-line messages flattened to
one line, contact-list updates that erase relay hints and petnames, stale group state from
out-of-order relay delivery, duplicate tabs.
### Must fix before stable (P0)
| # | Severity | Location | Issue |
|---|----------|----------|-------|
| 1 | Critical | `nip60/src/lib.rs:420,552` | Spoofed kind:5 deletion from **any** author wipes wallet proofs (operator precedence bug + missing author check in `absorb_deletion`) |
| 2 | Critical | `state/src/lib.rs:117-122` | LMDB open `block_on` + `.expect` → unrecoverable startup crash loop on any DB error |
| 3 | Critical | `state/src/lib.rs:447-449` | Plaintext NIP-46 client secret key persisted to `localStorage` on web |
| 4 | High | `browser-signer-proxy/src/lib.rs:443-571` | Unauthenticated localhost signing API with `Access-Control-Allow-Origin: *`; plaintext theft, request suppression, response forgery |
| 5 | High | `nip60/src/ops.rs:455-488` | Reconcile marks relay proofs `Unspent` unconditionally → resurrects locally-spent proofs (double-spend risk) |
| 6 | High | `nip60/src/ops.rs:381-417` | Publish failure after store commit strands sent/received proofs; no retry path |
| 7 | High | `state/src/file.rs:418-443` | Decrypted-file cache keyed by filename only → user can be served another attachment's contents |
| 8 | High | `chat_ui/src/lib.rs:605-611` | Own reactions counted twice (backup gift-wrap re-delivery, no dedup) |
| 9 | High | `common/src/media_extractor.rs:52-58` | Whitespace collapse destroys line breaks in every message |
| 10 | High | `workspace/src/panels/contact_list.rs:187-191` | Contact-list Update silently strips NIP-02 relay hints and petnames from the published event |
| 11 | High | `nip29/src/lib.rs:541-555` + `group.rs:696-728` | No `created_at` guards: stale relay data resurrects left groups; group state applied last-write-wins |
| 12 | High | `settings/src/lib.rs:403-404,423-438` | Failed settings load silently resets and overwrites the user's file; saves discard errors and race |
| 13 | High | `speech/src/parakeet.rs:134` | Dictation worker exits after 30 s idle → longer dictations silently lost |
| 14 | High | `browser-signer-proxy/src/lib.rs:339-366` | Timed-out signing requests leak plaintext in memory forever |
| 15 | High | `dock/mod.rs:50-73` + `sidebar/rows.rs:54-57` | Tab dedup is dead code (duplicate tabs); "Ignore" screening action dispatches to a handler that can never receive it |
| 16 | High | `nip60_ui/src/wallet.rs:615-726` + `nip60/src/lib.rs:147-185` | Lightning invoice flow broken end-to-end (quotes never rendered or claimable) |
| 17 | High | `state/src/lib.rs:553-554` | Proxy credential write silently discarded (`save.await.ok()`) |
| 18 | High | `person/src/lib.rs:310-333` | One 2 s metadata timeout permanently blacklists those profiles until restart |
| 19 | High | `chat/src/lib.rs:398-403` | Failed-to-decrypt messages are trashed with no UI surface anywhere |
| 20 | High | `chat_ui/src/lib.rs:325-334`, `workspace/src/dialogs/profile.rs:227-235`, `workspace/src/panels/backup.rs:59-76` | Silent failure cluster: room loads, profile URL saves, keyring reads |
The full finding list follows, crate by crate.
## 3. Systemic Patterns (cross-cutting)
**S1 — Task handles accumulate unboundedly (~20 containers, ~48 declaration sites).**
`tasks: Vec<Task<_>>` / `SmallVec<[Task<_>; N]>` fields are pushed on every operation and never
drained; finished `Task` handles held in a container are not reaped by GPUI. Worst offenders:
`nip60/src/lib.rs:79` (a handle per relay event, whole session), `nip4e/src/lib.rs:90` (runs on
every settings change), `chat_ui/src/lib.rs:110`, `nip29/src/lib.rs:90`, `nip29_ui/src/lib.rs:337`,
`person/src/directory.rs:110`, `state/src/lib.rs:95`, plus every workspace dialog. The codebase
already contains the correct patterns: single `Option<Task<_>>` replacement
(`screening.rs`, `greeter.rs`, `debounced_delay.rs`) and reset-on-teardown (`nip29::reset`).
Fix convention-wide: replace-per-spawn for one-shots, `detach_and_log_err` for true fire-and-forget.
**S2 — Silent error discards (~156 `let _ =` / `.ok()` sites outside tests).**
Project rule forbids `let _ =` on fallible ops; `AGENTS.md` requires async errors to reach the UI.
Beyond the P0s above, the recurring case is `_ = this.update(cx, …)` — mostly benign
(entity dropped at shutdown) but should be `.log_err()` for visibility. Files with the most sites:
`dock/tab_panel.rs` (6), `nip60/src/lib.rs` (10), `nip4e/src/lib.rs` (8).
**S3 — `chat_ui` ↔ `nip29_ui` message-panel duplication.**
`ListState` sync + splice bookkeeping (including divergent `ix + 1` vs `ix + 2` offsets),
`rendered_texts_by_id` cache (both share the stale-mention bug), speech reveal + `RevealText`
fallback timer + `end_events` loop, `Speaker` wiring, reaction grouping, `MessageRow` assembly are
near-verbatim copies — and they have already diverged: `nip29_ui` splices at the prepend position
while `chat_ui` always appends (bug 5.7.4). Extract a shared message-panel controller in `ui`.
**S4 — Dialog error-banner + auto-clear timer duplicated four times** with drifted timings
(`restore.rs` 3 s, `import.rs` 3 s, `relays.rs` 2 s, `contact_list.rs` 2 s). One shared
`error: Entity<Option<SharedString>>` helper removes the drift.
**S5 — `DefaultHasher` used for network-persisted IDs.**
`common::EventExt::uniq_id` (`event.rs:12-18`) is hashed into published `r` tags by
`chat/src/lib.rs:1031-1041`; `DefaultHasher`'s algorithm is not stable across Rust versions, so a
toolchain bump silently changes every published room id. Also hand-rolled in three places
(`nip29/protocol.rs:282-286`, `nip29/utils.rs:27-32`). Replace with a stable hash (SHA-256 of
sorted hex pubkeys) in one shared helper.
**S6 — Index-based element ids.** Rows keyed by list index (`sidebar/rows.rs:174+`,
`contact_list.rs:238`, `greeter.rs:226+`, `nip29_ui` reply previews `message.rs:359`) reassign to
different data after mutations; benign today because handlers capture data, but any future
stateful element (hover/focus) in a row will mis-attach. Prefer domain ids (pubkey hex, event id,
room id) as `group_view.rs` already does.
## 4. Findings by Crate
Severity: **[C]** Critical · **[H]** High · **[M]** Medium · **[L]** Low
### 4.1 `state` — central Nostr registry, signers, persistence, files
- **[C] `lib.rs:117-122` — Startup LMDB open: `block_on` + `.expect` = crash loop.** The
`block_on` itself is acceptable (runs once before the window opens; `NostrLmdb::open` schedules
nothing back onto the GPUI executor — add a comment saying so). The real bug: a corrupted or
unwritable data dir makes **every** launch panic with no dialog and no recovery except manually
deleting data. The web path already does it right (`web_database.rs:30-33` falls back to memory
with a logged error). Fix: mirror that on desktop — memory DB fallback + `StateEvent::Error` so
the UI can tell the user local history is unavailable.
- **[C] `lib.rs:447-449` (+ `credentials.rs:29-38`, `common/web_store.rs:21-25`) — Plaintext
bunker client key in web `localStorage`.** `import_bunker` embeds a fresh `Keys::generate()` into
the bunker URI and stores the credential unencrypted on wasm. XSS/malicious extension/shared
machine = signature capability through the bunker (bunkers commonly auto-approve registered
clients). Desktop stores it in the OS keyring, which is fine. Fix: on wasm, passphrase-encrypt
(NIP-49) like `import_nsec` does, or keep the session memory-only.
- **[H] `lib.rs:553-554` — Proxy credential write silently discarded.**
`cx.background_spawn(async move { save.await.ok() }).detach();` — keyring failure = user silently
dropped to login next launch. Log + emit `StateEvent::error`.
- **[H] `file.rs:418-443` — Decrypted-file cache collides by filename.** Cache key is
`sha256(name)` and the `metadata().is_err()` guard skips the write, so two attachments named
`invoice.pdf` from different chats resolve to the **first** file's path. Fix: include the
attachment sha256/URL in the key, or always overwrite via the atomic-rename path.
- **[M] `lib.rs:373-386` — Web `ncryptsec` stored with `KeySecurity::Medium` (2¹⁶ PBKDF2).**
Encrypted, but offline guessing at 2¹⁶ defeats human passphrases. Raise to `Strong` and/or move
to IndexedDB + session-only keys. Acceptable if documented as a tradeoff.
- **[M] `lib.rs:564-589` — Stale proxy monitor can log out a newer session.** The monitor's guard
is only `current_user.is_some()`; if the user disconnects the proxy and signs in with nsec/bunker
before the next 5 s tick, the monitor swaps the **active** signer for `Keys::generate()`. Fix:
capture the proxy pubkey at spawn and only act if it's still the active signer.
- **[M] `lib.rs:95` — `tasks` vec grows forever** (see S1; `connect_proxy` alone pushes four).
- **[L] `lib.rs:714` `to_bech32().expect("infallible")`; `signer.rs` ×7
`.expect("RwLock poisoned")`.** Practically unreachable, but a poisoned signer lock is the worst
failure mode for a signer — prefer `unwrap_or_else(|e| e.into_inner())`.
- **[L] `lib.rs:93` — `passphrase_required` written, never read** (UI learns via
`StateEvent::PassphraseRequired`). Delete or expose.
- **[L] `file.rs:381-384` — `DownloadedFile::save` (wasm) has no callers.** Remove or wire up.
- **[L] `lib.rs:647-656` — Dead search-relay guard.** Vendored nostr-sdk: `Client::relay()` returns
`Result<Option<_>>`, so `is_ok()` is always true and the "Failed to add search relay" branch is
unreachable. Test `is_ok_and(|r| r.is_some())` or drop the check.
- **[L] `file.rs:221-258` — No size limit on pick/upload** (`MAX_FILE_SIZE` enforced only on
download); a multi-GB pick is read fully into memory. Check size before reading.
- **[L] `file.rs:427-441` — Plaintext decrypted files in `temp_dir()/coop-files`, default
permissions, never purged.** Consider 0600 + best-effort purge on exit. Also `file.rs:150`
builds `file://{path}` by string concat (breaks on `#`/`?`/`%`).
- **[L] `file.rs:291` — Blossom rejection classified by `message.contains("415")`** — match the
HTTP status instead. `file.rs:172` — 16-byte GCM nonce; 12 bytes is the norm.
- **[L] `lib.rs:280-289` — `restore_session` cfg branches compile to empty bodies on wrong
target**, silently emitting no event. `debug_assert!` or fall through to `NoSigner`.
- **[L] `file.rs:446-451` + `web_file.rs:164-174` — Web blob URLs never revoked** when replaced;
every decrypted file pins its bytes for the page lifetime.
- **[L] `lib.rs:683-687` — Search results can contain duplicate pubkeys.** Dedupe.
Positives: device key stored only NIP-44-encrypted to self in the local DB; bunker credentials
verified before persisting; `UniversalSigner` clones the inner `Arc` out of the lock before
awaiting (no locks across `.await`); downloads size-capped and hash-verified; `Keys`' vendored
`Debug` hides secrets.
### 4.2 `nip60` — NIP-60 Cashu wallet engine
- **[C] `lib.rs:420` + `lib.rs:552-572` — Spoofed kind:5 deletion can wipe the wallet.**
`let wallet_event = ours && is_cashu || is_token_deletion(&event);` parses as
`(ours && is_cashu) || is_token_deletion(&event)` — deletion events from **any** author are
forwarded, and `absorb_deletion` never checks `event.pubkey == me`. Kind 7375 event ids are
public on relays; one attacker event removes our tokens, marks them stale, and the Reconcile
deletes the real proofs from the store (restore-only recovery). Fix:
`wallet_event = ours && (is_cashu || is_token_deletion(&event))` + author check inside
`absorb_deletion` for defense in depth.
- **[H] `ops.rs:455-488` (with `proof_info` `ops.rs:601-617`) — Reconcile resurrects spent
proofs.** Every relay-token proof is added to the store hardcoded `State::Unspent`, ignoring the
proof's existing store state. After any failed-publish op, reconcile re-adds proofs this device
itself marked spent/reserved (incl. offline sends the recipient also holds) → bogus balance,
real double-spend race. Fix: skip `y`s already in the store with non-Unspent state; verify
unknown proofs against the mint (NUT-08) before adding.
- **[H] `ops.rs:381-417` + `lib.rs:836-843` — Publish failure after store commit strands funds.**
If `send_event` fails, the confirmed `TokenUpdate` is dropped: for `SendToken` the token string
is lost (proofs spent locally, recipient never gets it); for `Receive` proofs sit unpublished and
a retry errors as duplicate. Reconcile only republishes when `mint_unissued_quotes() != 0`
(`ops.rs:429-432`), so it doesn't heal this. Fix: retain the confirmed update on error; force
republish whenever the store's unspent set differs from relay state.
- **[H] `ops.rs:420-453` — Quote-minting publish can `del` events whose proofs aren't converged
yet.** `previous_ids` is the schedule-time relay snapshot; if another device's token was absorbed
but not yet converged, the published event `del`s the id while omitting its proofs — those proofs
get pruned everywhere while remaining spendable at the mint. Fix: converge before publish, or
only `del` when the published proof set fully covers the id.
- **[H] `lib.rs:616,651` + `lib.rs:902-912` — Failed nutzap redemption stuck until restart.**
`redeeming.insert` happens before scheduling; the `Err` branch never removes the id and
`absorb_nutzap` skips anything in `redeeming`, and nothing requeues to `pending_nutzaps`. Fix:
remove + requeue with bounded retries on error.
- **[H] `lib.rs:79` — `tasks` grows by a handle per relay event** (see S1).
- **[H] `lib.rs:147-149,172-185` — `mint_quotes()`/`claim_mint_quote()` dead → created invoices
are never displayable or claimable.** `Outcome::QuoteCreated` pushes into `self.quotes` and
nothing ever reads it (workspace-wide grep). Quotes also accumulate forever. Fix with the invoice finding in 4.3 below.
- **[M] `lib.rs:188-215` — `melt()` and `send_token()` unreachable; `WalletEvent::TokenCreated`
emitted only from the dead path** while `workspace/src/lib.rs:226-236` promises "copy it from
the wallet panel". Wire into UI or remove; fix the notification text regardless.
- **[M] `lib.rs:285-370` — Concurrent `setup()` can fork the wallet key** (only guarded by
`loading`); absorption tie-breaks with `created_at >=` on second-granularity timestamps, and
stores are keyed by wallet pubkey — the losing key orphans its funds. Fix: `setup_in_progress`
guard; deterministic tie-break (event id).
- **[M] `lib.rs:430-432` — `processed_events.clear()` at 1000 entries** re-delivers a burst;
evict oldest instead.
- **[M] `lib.rs:615-661` — Nutzap `unit` tag never validated**; a `usd` nutzap passes local
validation. Require `unit == "sat"` in `Nutzap::from_event`.
- **[L] `lib.rs:301-303,699-701` — Silent no-ops**: `setup()` when mint already listed;
`ensure_wallets` on unparseable mint URLs forever. Surface feedback.
- **[L] `ops.rs:107` — ClaimQuote history records `quote.amount.unwrap_or_default()`** (0-sat
inflow when mint omits amount); use the summed proof amount.
- **[L] `Cargo.toml:23` — `tokio` dep unused** (only `gpui_tokio::Tokio` is referenced). Remove.
Positives: store-before-publish enforced via cdk prepare/confirm sagas; all relay-derived content
author-checked before decryption; `keys.rs` secret handling clean (no Debug on `WalletData`, no
secrets in logs, HKDF salt pinned by test); single-flight job queue; `WalletEvent::Error` reaches
notifications even with the panel closed.
### 4.3 `nip60_ui` — wallet panel + dialogs
- **[H] `wallet.rs:615-726` — Invoice button creates an invoice the user can never see or pay.**
After `create_mint_quote` the bolt11 string lands in `WalletRegistry::quotes`, which nothing
renders. Receive-via-lightning is broken end-to-end, silently. Fix: render pending quotes
(request + copy + check/claim) in the dialog/panel, refreshed on `WalletEvent::Updated`.
- **[M] `wallet.rs:765` — `render()` clones the whole history `Vec` every frame** (history is
unbounded). Snapshot into `Rc` only on `WalletEvent::Updated`. Same for `filtered_contacts`
(`wallet.rs:542`) — cache on input change.
- **[L] `wallet.rs:206,284,356` — `SendDialog::tasks` accumulates** (see S1).
- **[L] `wallet.rs:262-264` — `load_contacts` gives up silently pre-login**; retry on
`StateEvent::SignerChanged` (sidebar already does this).
- **[L] `wallet.rs:728-733` vs `lib.rs:28-34` — amount parsing duplicated.** Share `parse_amount`.
Positives: `cx.notify()` on all mutating paths; per-key hex ElementIds; stored subscriptions;
history list windowed via `uniform_list`.
### 4.4 `browser-signer-proxy` — NIP-46 localhost bridge
- **[H] `lib.rs:443-571` — Unauthenticated API + `ACAO: *`.** Every response sends
`Access-Control-Allow-Origin: *` (verified at :556, :588, :607, :631); no `Host`/`Origin` checks.
Consequences: (1) `GET /api/pending` returns full `sign_event`/`nip44_encrypt` params (message
plaintext) readable by any web page in Firefox-family browsers and by DNS rebinding anywhere;
(2) `:516-517` clears the queue on read → third party can steal requests so the real extension
page never sees them; (3) `POST /api/response` accepts any `{id, result}` → forged
`get_public_key` substitutes an attacker pubkey as "the user". Fix: drop CORS entirely (the
served page is same-origin), reject non-`127.0.0.1:7400` Host, gate `/api/*` on a per-session
random token embedded in the served page.
- **[H] `lib.rs:339-366` — Timed-out requests leak forever.** The `RequestData` and oneshot sender
stay in the maps after the 30 s timeout — plaintext retained for the session, re-served to
whoever polls next, map grows unboundedly. Remove entries on timeout (self-removing guard or
id-returning `request`).
- **[M] `lib.rs:374-380` — `_sign_event` verifies the signature but not the event.** Nothing checks
the returned event matches the requested `UnsignedEvent` (pubkey/content/tags/kind/created_at);
a compromised signer page can substitute content. Compare before accepting.
- **[M] `lib.rs:538` — Decrypted plaintext debug-logged.** `tracing::debug!("Received response …
{message:?}")` leaks `nip44_decrypt` output and event content the moment anyone runs with
`RUST_LOG=debug`. Log id + method only.
- **[M] `lib.rs:447,465-480,523` — Unbounded reads/allocations**: header line grows unbounded;
`vec![0u8; content_length]` trusts `Content-Length` (4 GiB reservation from one POST); no
connection timeout. Cap header block (16 KiB), cap body (1 MiB), add idle timeout.
- **[L] `lib.rs:650-678` — `race_timeout` reports channel-closed as timeout.** Distinguish.
- **[L] `lib.rs:574-638` — Three near-identical response writers**; one function with a header
options struct (also makes the CORS removal enforceable in one place).
- **[L] `lib.rs:546,563,593,612,636` — bare `let _ =` on writes**; centralize the
best-effort-write rationale in the helper.
Positives: loopback-only bind, graceful shutdown tied to handle lifetime, per-request timeouts,
UUID ids, bounded channels.
### 4.5 `nip4e` — per-device keys
- **[M] `lib.rs:1036,774-860` — Approval button wedged + double-clickable.** `loading.set(true)`
without `cx.notify()` keeps the old button enabled (double-click sends the secret in two 4455
events); on failure `loading` is never reset and the notification isn't removed. Notify after
setting, reset on error, rebuild the notification on both outcomes.
- **[M] `lib.rs:403-405` — Rotation skips archiving silently, then overwrites the key.** If the DB
read/decrypt fails transiently, `archive_key` is skipped but `set_keys` still overwrites → old
chats permanently undecryptable. Make archive failure abort rotation (or log loudly).
- **[M] `lib.rs:175-206` — Relay listener can die silently.** `tx.send_async(...).await?` aborts on
full bounded(100) channel or dead consumer; the task's `Err` is never inspected. All 4e event
processing stops until the next signer change. Log and handle backpressure.
- **[M] `lib.rs:90` — `tasks` grows on every settings change** (`get_announcement` runs per
settings mutation) — see S1.
- **[M] `lib.rs:177,184` — `processed_events` inserts **every** relay event** (insert precedes the
kind filter) and grows unbounded. Filter first; bound the set.
- **[L] `lib.rs:303-309` — Desktop key backup file written 0644** (subject to umask); use 0600.
- **[L] `lib.rs:178` — `current_user` snapshotted per listener**; events between signer change and
`get_public_key_async()` resolving are dropped, and a stale snapshot can briefly forward one
account's events to the next handler. Re-check per event.
- **[L] `storage.rs:29-32` — Malformed stored `created_at` silently resets the rotation
interval.** Log the fallback.
- **[L] Zeroization gaps** (`app_keys`, decrypted secrets in `decrypt_approval`/`approve`): Low —
the nostr stack itself doesn't zeroize; note as defense-in-depth.
- **[L] `lib.rs:141,246,426,458,845,854,894,1041` — `.ok()` discards** (see S2); `lib.rs:479`
single-letter `n`.
- **[L] `lib.rs:136,241,888,914` — `cx.windows().first()` targets an arbitrary window** (matters
only if multi-window ever lands).
Positives: secret never published/logged; transfer encrypted to a fresh one-time keypair;
approvals re-validated against the live announcement; stale requests dropped by age; bounded
archive; account switch wipes pending state.
### 4.6 `chat` — chat registry + rooms
- **[H] `lib.rs:331-338` + `message.rs:203-220` — Failed-to-decrypt messages silently trashed;
nothing renders `trash`.** The `trash: Entity<BTreeSet<FailedMessage>>` is written, cleared, and
reprocessed, but has **no reader or renderer workspace-wide**. A user whose incoming message
can't be decrypted gets zero feedback — core E2EE UX gap. Emit a `ChatEvent` or render an
indicator.
- **[M] `lib.rs:284,327,620-635,726,733,854,892` — `RwLock` `.read().unwrap()`/`.write().unwrap()`
poisoning crash loop.** `search.write()` runs on a background thread; one panic while holding it
turns every later foreground read into a panic. These maps are reconstructible heuristics — use
the tolerant `poisoned.into_inner()` pattern the codebase already has
(`person/src/directory.rs:226-233`).
- **[M] `lib.rs:779-780` — Off-by-one index stored in `room_map`** (`rooms.len()` after push).
Currently unreachable (batch rooms come from an id-keyed set) but any future reader panics at
`self.rooms[index]`. Store `len() - 1`, or drop the never-usefully-read index bookkeeping.
- **[M] `lib.rs:440-443,454-461,825-833` — Discarded subscribe result + `unwrap_or_default()`.**
`_ = client.subscribe(...)` (rule violation); a DB error becomes "not found" → false
"Messaging Relays not found" retry prompt, and a contacts DB error classifies peer rooms as
`Request` instead of `Ongoing` (wrong trust classification). Distinguish errors from empty.
- **[M] `lib.rs:110,199,232-358,362-414` — Stored task results never inspected** (see S1/S2); a
dead notification listener silently stops message delivery — at least `detach_and_log_err`.
- **[L] `room.rs:637-646` — `deliver<S>` generic has one implementation**; inline it.
- **[L] `lib.rs:867` — `max_by_key().unwrap()`** relies on an invariant three lines up;
`let Some(...) else { continue }` documents it.
- **[L] `lib.rs:332-338` — Empty-tags rumor is both trashed and delivered** (appears as failure
entry *and* rendered message until reprocess). Pick one path.
- **[L] `room.rs:350` — `self.members[0]` safe only via a cross-crate invariant**
(`extract_public_keys` always appends the author). Add `debug_assert!` or a comment.
- **[L] `Cargo.toml:28-29` — `smol` unused.** Remove.
- **[L] `lib.rs:182-186` — `let _ = entity.update(...)`** in deferred init; `.log_err()`.
Positives: signal batching bounds per-update work; wasm yield loop; gift-wrap dedup; seal
verification; `WeakEntity` room handles; `ChatEvent::Error`/`Loaded`/`Ping` genuinely surfaced.
### 4.7 `chat_ui` — chat panel
- **[H] `lib.rs:605-611` — Your own reactions count twice.** `insert_reaction` pushes
unconditionally (keyed only by target id); with `backup: true` (default) the reaction comes back
as your own backup gift wrap → inserted again → "👍 2". Text messages are dedup'd by
`binary_search`; reactions aren't. The dormant `RoomEvent::Reload` path double-counts too.
Track seen reaction event ids.
- **[H] `lib.rs:325-334` (+ `272-277`) — Room message-load and connect failures swallowed.** The
stored task's `Result` is never inspected: user sees an empty room with no error; relay
subscription failures invisible. Push a notification on error.
- **[M] `lib.rs:557-581` — Bulk load is O(n²) on the foreground thread.** Per-event
binary-search + index rebuild + splice inside one `update`; multi-second stall on big rooms.
Add a bulk variant (sort once, rebuild index once, splice once).
- **[M] `lib.rs:569-570` — Insert position and `ListState::splice` disagree.** Messages are
binary-inserted by `created_at` but the list always appends at `len..len`; mid-`Vec` arrivals
shift rows while the list state learns "appended one" → stale height caches, scroll jumps.
`nip29_ui/src/lib.rs:516` does it right: `splice(pos + 1..pos + 1, 1)`.
- **[M] `lib.rs:1183-1198` — `rendered_texts_by_id` caches mention names forever** (same defect as
`nip29_ui` 4.8). A message rendered before the profile loaded shows `@npub1…` all session.
Key the cache on directory generation or drop the resolved-text cache.
- **[M] `lib.rs:50-51,395-398` — `\p{Emoji}` matches plain digits: typing "2" while replying to
exactly one message sends a "2" reaction instead of a message.** Require
`[\p{Emoji_Presentation}\p{Extended_Pictographic}\u{200D}\u{FE0F}\u{20E3}]`.
- **[M] `lib.rs:110` — `tasks: Vec<Task>` grows without bound** (see S1).
- **[M] `lib.rs:547-549,618,623` — `reports_by_id: Arc<RwLock<...>>` nothing else shares.** Plain
`BTreeMap` field; removes the poisoning risk for zero benefit.
- **[L] `lib.rs:499` — `rumor.id.expect("rumor must have an id")`** — invariant holds today, but
degrade + notify instead of panic per house style (also `to_bech32().unwrap()` at 652, 1072).
- **[L] `lib.rs:362-370,797-802` — `.ok()` discards `room.update`** (subject change, retry action).
- **[L] `lib.rs:722-727` — `encrypted` snapshotted before the file-picker await**; typing while the
picker is open classifies the file by stale input state. Re-read after the await.
- **[L] `lib.rs:1170-1178` — `load_file` invoked from the render path** (guarded, but render should
be pure — move to a subscription).
- **[L] `lib.rs:147-221` — Summary comments** ("Define input state", …) violate the comment rule.
### 4.8 `nip29` — NIP-29 registry + protocol
- **[H] `lib.rs:541-555` (merge), `582-607` (absorb_list) — Stale group-list events resurrect left
groups and get republished.** The kind 10004 list is absorbed without comparing `created_at` and
`merge` never removes entries absent from older snapshots; a relay still holding the pre-leave
version re-adds the left group, `ensure()` re-creates subscription, and the next
`refresh_entry`/`publish_list` republishes the left group to all write relays (privacy angle).
Track the last accepted list event's `created_at`; ignore older.
- **[H] `group.rs:696-728` (absorb_state) — Group state applied last-write-wins, no ordering
guard.** Out-of-order relay delivery (newest-first is common) leaves the *oldest*
metadata/members/admins/roles/pins displayed indefinitely. Cache hydration is safe (sorted
ascending at `group.rs:507`); the live path is not. Keep `created_at` per state kind (the
`forks` map at `lib.rs:669-675` shows the pattern).
- **[M] `lib.rs:173-174,189` — Join published optimistically; refusal classified by relay error
prose** (`reason.starts_with("duplicate")`); a refused join stays in the public list. Drop the
entry on refusal; detect duplicates via a stable marker.
- **[M] `lib.rs:173,825-834` — Rejoin flips a private list entry to public** (`join_key` hardcodes
`private: false`, `upsert` replaces the whole entry). Preserve the existing flag.
- **[M] `group.rs:418-448` + `lib.rs:383-397,1146-1159` — Failed page load leaves `loading_more`
stuck true forever** (subscribe error path emits no signal; guard then permanently blocks
pagination for the session). Route a synthetic settle on error.
- **[M] `lib.rs:308-330` + `discovery.rs:253,267,274,281` — Network failures silently produce
"empty" results** (`and_connect().await.ok()`, discarded sync errors): browse shows zero
candidates, discovery silently stale. Surface `GroupsEvent::Error`.
- **[M] `discovery.rs:240-252,264-267` — Empty contact list → `Filter::authors(∅)` serializes as
absent → the sync reconciles **every user's** list, and the pool-wide sweep (kinds
`[GroupMetadata, GroupMembers]`, no ids/limit) pulls all NIP-29 state from all relays.**
Early-return when `authors.is_empty()`; constrain the sweep.
- **[M] `group.rs:457-511` + `lib.rs:906-941,1042-1071` — Every live event triggers a full
unlimited `load_envelopes` reload** (re-query + JSON-decode + sort of entire group history per
message) and every event is cached forever. Use the single-event absorb path live; cap/prune the
cache.
- **[M] `lib.rs:90` — `tasks: SmallVec<[Task; 2]>` accumulates per incoming event** (see S1).
- **[L] `protocol.rs:389-399,534,650,964` — One malformed optional tag fails the whole parse**
(one bad `supported_kinds` value blanks a group's metadata display). Skip + log instead.
- **[L] `protocol.rs:24` — Custom kind 9010 not in rust-nostr 0.45.6's NIP-29 range**; confirm
against target relay implementations before stable.
- **[L] `group.rs:602-611` — `previous` mismatch only logs a warning**; an empty `previous` tag
counts as fully verified (`protocol.rs:1046-1056`). Align comment with behavior or escalate.
- **[L] `protocol.rs:995-1016` — `TimelineWindow.seen` grows unbounded** (u32/event, never pruned).
- **[L] `lib.rs:582-607,661-677` — No client-side author re-check on list/fork subscriptions.**
Mitigated by `verify_subscriptions(true)` + `ban_relay_on_mismatch(true)`; note the residual
NIP-29 trust model rests on relay admission policy.
- **[L] `lib.rs:860-904` — Concurrent `publish_list` can complete out of order** (each snapshots at
call time; older snapshot can win). Serialize publishes.
- **[L] `lib.rs:1225-1230` — Negentropy/foreign-EOSE cross-talk** fires spurious `Synced` signals
from unrelated subscriptions.
- **[L] `utils.rs:24,31,35` — Subscription ids truncate the digest to 32 bits**; collision would
cross-wire routes. Negligible at this cardinality; use the full digest anyway.
- **[L] `lib.rs:1198-1200` — `processed` dedup wholesale-cleared at 10k**; evict oldest instead.
- **[L] `lib.rs:723-736` — `previews` grow unbounded per session** (entity + subscription each).
- **[L] `group.rs:197-202` — `Group::message(id)` O(n) scan** called per reply preview in render
paths → O(n²) timelines. Build an id→row map.
- **[L] `Cargo.toml:14` — `person` dep unused.** Remove.
- **[L] `lib.rs:1208-1229` — `.ok()` on signal sends** (benign; log or comment per rule).
- **[L] `lib.rs:358,949,975` — Inline global-handle chains** (rule: bind to a local).
Positives: zero reachable panics; tag parsing consistently `get`/`skip`/`first`;
`saturating_sub` timestamp math; tasks/subscriptions stored; `WeakEntity` spawns; membership
derivation order-independent; strong publish-error propagation.
### 4.9 `nip29_ui` — group panel + dialogs
- **[H] `lib.rs:1053-1067` — `rendered_texts_by_id` bakes mention names in permanently.**
Computed once per `EventId`; when the profile arrives later the body keeps `@npub1ab…` (the
header recovers via re-render, the body doesn't). Key on directory generation or don't cache the
resolved text (only the markdown source needs caching).
- **[M] `lib.rs:337` — `tasks` accumulates** from every send/upload/react/delete/pin (see S1).
- **[M] `lib.rs:1068` — Content `TextView` keyed by row index.** Prepending shifts every index →
every visible `TextViewState` re-parses markdown in one frame ("Load earlier messages" jank).
Use the message id as element id.
- **[M] `message.rs:359` + `lib.rs:1068` — Sibling ElementId collision: reply previews use bare
reply index as sibling of the row-index TextView**; when `replies.count > row_index` both sides
share `…/[N]/[N]`. Latent today, breaks silently when element types coincide. Namespaced ids.
- **[L] `message.rs:192,227,238` — `let _ = panel.update_in(...)`** where sibling call sites log
(`log::warn!` at 110-160). Mirror the logged pattern.
- **[L] `lib.rs:630` — File-picker failure silently swallowed** (task result never inspected).
- **[L] `lib.rs:436-445` — Utterance-end loop clears the reveal unconditionally**; interrupted
utterances still fire `on_utterance_end` on many backends, cancelling B's reveal while B's audio
plays. Guard with the session id like `message.rs:144` does.
- **[L] `message.rs:97,130,168-180` — Per-frame per-row allocations** (`preview()` computed twice
per row per frame, custom-emoji `Vec` rebuilt + cloned into the submenu closure every render —
and gpui-pre redraws the window on every keystroke). Compute once per render.
- **[L] `lib.rs:677,956` — Inline global chains** (rule).
- **[L] `lib.rs:407-419` — Subscribe + observe both run `sync` + `track_participants`** (events
and notifies fire together) → double O(rows) scan per change.
- **[L] `message.rs:322` — Empty role list renders "is now "** (trailing space, no roles). Emit
"now a regular member" for the empty case.
### 4.10 `common` — shared utilities
- **[H] `media_extractor.rs:52-58` — Every message body has all whitespace collapsed, destroying
line breaks.** `cleanup_text`'s `\s+ → " "` runs on the entire remaining text unconditionally,
so multi-line messages render as one line in both chat and groups (via `chat/message.rs:99-103`).
Normalize only around removed URLs; never touch `\n` in untouched regions.
- **[M] `media_extractor.rs:15-18,56,62-65` — Regexes compiled per message** (the cache struct's
own entry point defeats it). `static LazyLock<Regex>`.
- **[M] `parser.rs:240,254,260` — `NostrParserIter::next` recurses per token; depth is
attacker-controlled** (~9 bytes/token of crafted `nostr:` URIs). Desktop's 8 MB stack survives;
the wasm32 web build can crash the tab. Rewrite as a loop.
- **[M] `event.rs:12-18` — `uniq_id` uses `DefaultHasher` and is persisted to published events**
(see S5).
- **[M] `paths.rs:7,14,23,31` — `expect()` on `dirs::download_dir()` etc.** — legitimately `None`
on Linux without XDG user-dirs → panic on export (`workspace/src/lib.rs:405`). Return `Option`
and handle.
- **[L] `display.rs:18,20,39` — `as i64` on relay-controlled timestamps** (contained by
`LocalResult`); fallback magic strings `"9999"`/`"1m"`.
- **[L] `event.rs:12-53` — `Event`/`UnsignedEvent` impls duplicate the same logic with drift**
(one sorts, one doesn't). Factor into one helper — same helper fixes S5.
- **[L] `debounced_delay.rs:33` — `_ = channel.send(())`** explicit silent discard (harmless;
satisfy the rule with a comment + `if let Err`).
Positives: `web_store.rs` error handling exemplary; parser char-boundary-safe throughout;
`DebouncedDelay` cancellation correct; time formatting degrades gracefully.
### 4.11 `person` — profiles + web of trust
- **[H] `lib.rs:310-333` + `204-245` — One failed metadata batch permanently blacklists those
pubkeys.** `in_flight` removal happens only on metadata arrival; `get_metadata` uses
`TIMEOUT = 2 s` (tight for bootstrap relays + EOSE) and on failure keys stay in `in_flight`
forever → those people show truncated-pubkey placeholders until restart. Return failed keys on
error and release them; raise the request timeout independently of the batching window.
- **[M] `lib.rs:175` — Malformed metadata wipes a good profile.**
`Metadata::from_json(...).unwrap_or_default()` replaces previously-loaded data with a default
`Person`; content is attacker-influenceable (anyone can publish metadata for any key). Skip on
parse failure.
- **[M] `directory.rs:110` (+ `lib.rs:255-262`) — Task handles accumulate per `track`** (see S1).
- **[M] `directory.rs:161-172` — `person()` performs side effects (lock write + network fetch) from
a render-time getter.** Deduped via `pending`, but order-dependent render behavior; consider an
explicit `ensure_tracked` from update paths.
- **[L] `wot.rs:100,343` — DB errors silently become empty results**; add `log::warn`.
- **[L] `wot.rs:150-153` — `let _ = this.update(...)`** (commented, periodic; noted for the rule).
- **[L] `person.rs:23-47` — `PartialEq/Eq/PartialOrd/Ord/Hash` for `Person` unused
workspace-wide**; the `Ord` impl clones `Metadata` twice per comparison. Delete.
- **[L] `person.rs:66-68,124-138` — `metadata()` clones per call; `name()` calls it twice** per
invocation → 2+ clones per avatar/name row per frame. Borrow or cache.
- **[L] `person.rs:166-177` — `shorten_pubkey` doc example doesn't match output; panics if
`len ≥ 63`** (callers use 4/8 today). Note: the `let Ok(...) =` pattern compiles only because
`ToBech32::Err = Infallible` — worth a comment since it looks like a compile error.
Positives: WoT builder caps hostile publishers; poisoning-tolerant lock helpers; bounded dispatch
channel; `limit(1)` newest-event lookups verified correct against nostr-lmdb ordering.
### 4.12 `speech` — dictation + TTS
- **[H] `parakeet.rs:134` — Inference worker exits after 30 s idle, permanently disabling
dictation.** `recv_timeout(30s)` kills the thread; the `OnceLock` sender survives, so after
that every `send` fails — and `send_or_log` (`:200-204`) logs at **debug** and drops. A
`MAX_SECONDS = 120` dictation records fine, then `finish()`'s `Transcribe` goes into the void;
gpui-kit's 180 s `stop_timeout` then force-ends with an **empty** transcript. Net: dictations
longer than 30 s silently lost after up to a 3-minute hang. Fix: block on `recv()` (an `Unload`
command already exists for explicit retirement), and make `finish()` surface send failure via
`sink.error`.
- **[M] `parakeet.rs:63-68` — Audio past 120 s silently discarded** mid-sentence with no
indication. Surface once or stop capture at the cap.
- **[M] `parakeet.rs:119-128` — Worker-spawn failure leaves a permanently dead channel** (receiver
dropped, sender cached in the `OnceLock` forever). Don't cache on failure.
- **[M] `speaker.rs:36-53` — `Tts::default()` constructed lazily inside `speak()` invoked from
render paths**; first-use engine init can take tens-to-hundreds of ms on the UI thread
mid-render. Initialize eagerly at panel creation or on the background executor.
- **[L] `model.rs:82-97` — `status()` does filesystem I/O per call** while the settings dialog
polls every 500 ms on the foreground. Cache status in the state mutex.
- **[L] `model.rs:183` — `let _unused = fs::remove_file(...)`**; log per rule.
- **[L] `web.rs:23-25` — `is_available` hardcodes `true`**; mic button renders enabled in browsers
without `SpeechRecognition`. Reflect actual support.
- **[L] `web.rs:200,218` — `let _ignored` abort/stop** — commented, best-effort; fine.
Positives: `catch_unwind` isolation around model load/transcribe; staged download with SHA-256 +
size cap + atomic rename; dedicated threads for inference/download; all other fallible paths
reach `sink.error`.
### 4.13 `settings` — persistence + themes
- **[H] `lib.rs:403-404` + `311-314` — Failed load silently resets settings and destroys the saved
file.** `unwrap_or(Settings::default())` swallows I/O and JSON errors; the observer then
immediately writes defaults over the user's file (losing `trusted_relays`, pins, wallet prefs)
with no log. Log, keep defaults in memory, but don't persist until a real user change.
- **[H] `lib.rs:423-438` — Saves discard errors and race each other.** `.await.ok()` hides
disk-full/permission failures; every change detaches a new concurrent write to the same path —
last-completion-wins can overwrite newer snapshots and interleaved truncate/write can corrupt
the file (font-size input ticks each trigger a save). Single writer slot + temp-file + rename +
`log_err`.
- **[L] `lib.rs:425` — Serialization failure silently skips saving**; log.
- **[L] `lib.rs:596-639,671-683` — `apply_persisted_theme` re-reads and re-parses the settings
file four times.** Parse once.
- **[L] `lib.rs:211,408` — `Url::parse(DEFAULT_FILE_SERVER).unwrap()`**; `OnceLock` or a reasoned
`expect`.
Positives: accessor macros keep 20 settings consistent; serde default/alias migration fields with
tests covering old formats; wasm path handled.
### 4.14 `workspace` — core + dock + sidebar
- **[H] `dock/mod.rs:50-73` + `lib.rs:309-320` — Tab dedup is unreachable; repeated opens stack
duplicate tabs.** `find_panel` matches on `PanelId`, which is always `PanelId::from(entity_id())`
of a panel created in the same statement — so it can never match (verified against gpui-base
0.9.1 `add_panel_inner`, which inserts without dedup). Clicking the same chat row twice opens
two live panels for one room; ShowInbox/Browse/Contacts/Backup multiply tabs. Key panels by
domain identity (singleton entities per panel name, or room/group key). The ~55 lines of
`find_panel`/`move_panel` are dead until then.
- **[H] `sidebar/rows.rs:54-57` — "Ignore" dispatches `ClosePanel` where it can never be handled.**
The only handler is on the tab-group frame; the alert dialog holds focus at that moment, and
dispatch resolves along the focus node's ancestor path — the dock tree isn't on it. Clicking
"Ignore" is a silent no-op for a destructive-intent action. Close the room via the dock handle
already in scope, or restore focus first.
- **[H] `sidebar/header.rs:38,236-268` — Updater buttons render stale status.** Nothing observes
the `AutoUpdater` entity (gpui-pre has no read-tracking); a staged update can sit invisible on
an idle window indefinitely. Add `cx.observe(&updater, …)`.
- **[M] `sidebar/mod.rs:192-206` + `search.rs:70-117` — Full message-index scan on the foreground
thread for every keystroke.** Only the *global* search is debounced; local search synchronously
scans every indexed message, sorts the entire match set, then truncates. Debounce + background
spawn + partial top-`limit` select.
- **[M] `sidebar/list.rs:41,178-248` + `group_view.rs:88-136` — Row model rebuilt on every sidebar
render** (clone + partition + pin-tag scans ×visible rows; sidebar re-renders on 500 ms ticks,
keystrokes, group updates). Cache the row model in state, refresh on registry events.
- **[M] `sidebar/search.rs:152-155` — Global search failure renders as "No profiles found".**
Surface the error state.
- **[M] `lib.rs:410-421` — Save-dialog failure silently cancels the backup** (`Err(_) => return`):
indistinguishable from user cancel; user believes the key was backed up. Split arms + notify.
- **[M] `sidebar/mod.rs:139-142` — Whole-sidebar re-render on every `GroupsEvent::Updated`**
(acknowledged TODO; compounds the row-rebuild cost). Gate on visibility.
- **[L] `dock/skin.rs:25,35-37,55` — `toggle_button_visible: Cell<bool>` is write-once** (set true
in ctor, never elsewhere) → dead state + dead branch at `tab_panel.rs:212`. Delete.
- **[L] `sidebar/rows.rs:174-374` — Index-based row ids** (see S6).
- **[L] `dock/tab_panel.rs` ×6, `skin.rs:262`, `sidebar/search.rs:164,178`, `lib.rs:641` — `let _ =
`/`.ok()` on fallible updates** (see S2); `lib.rs:641` is pre-destructive-reset — make it visible.
- **[L] `rows.rs:220-222,346-351`, `list.rs:53-55` — Inline global chains** (rule).
- **[L] `lib.rs:84`, `sidebar/mod.rs:99`, `sidebar/inbox.rs:68,118` — `tasks` accumulate** (see S1).
- **[L] `lib.rs:54` — Typo: `DeviceNotifcation`.**
Positives: careful `WeakEntity` hygiene; virtualized lists via `uniform_list` + `Rc` snapshots;
`InvalidPanel` preserves unknown panels across load/save; `theme_name_at` maps the palette's
original `IndexPath` correctly; `cargo check -p workspace` passes.
### 4.15 `workspace` — dialogs + panels
- **[H] `panels/contact_list.rs:187-191` — "Update" republishes the contact list with relay hints
and petnames stripped.** `load()` keeps only `tags.public_keys()`; `update()` rebuilds every
entry as `Contact::new(*public_key)` (`relay_url: None, alias: None`) while the UI reports
success. Silent NIP-02 data loss on relays. Carry the original `Contact`s through.
- **[H] `dialogs/new_chat.rs:60-64` — Contact list doesn't re-filter while typing.** No
`InputEvent::Change` handling; the input renders its own state entity so typing doesn't notify
`NewChat`; results stay stale until an unrelated click/Enter. `cx.notify()` on Change. Same
pattern, cosmetic: `profile.rs:320-326`.
- **[H] `panels/backup.rs:59-76` — Keyring read failures silently swallowed**; backup panel just
shows empty inputs. Log + surface.
- **[H] `dialogs/profile.rs:227-235` — Invalid avatar/website URLs silently dropped on save** while
"Profile updated successfully" shows. Validate both up front; inline error.
- **[M] `dialogs/screening.rs:52-55` — `on_release_in` calls `window.close_all_dialogs(cx)`**,
nuking unrelated stacked dialogs (including the mutual-contacts dialog this view opens). Close
just the hosting dialog / cancel tasks only.
- **[M] `dialogs/screening.rs:126-168` — DB-fallback "mutual contacts" has different semantics than
the WoT list** (followers vs. your-contacts-who-follow). Intersect or relabel.
- **[M] `dialogs/screening.rs:211-232` — NIP-05 network errors render as "address does not
match"** (`unwrap_or(false)` asserts infrastructure failure as fact about the person). Tri-state.
- **[M] `dialogs/screening.rs:250-282` — Report submit gives no failure feedback.**
- **[M] `dialogs/import.rs:129-185` — Enter bypasses the loading guard** (no in-method guard; all
three `PressEnter` subscriptions call `login()` directly) → concurrent double import. Same class:
`contact_list.rs:176-228`, `relays.rs:377,521`.
- **[M] `dialogs/import.rs:31-40` — Unlock dialog is inescapable** (no cancel/logout path for a
forgotten passphrase). Add a "start over" escape hatch for stable.
- **[M] `panels/browse.rs:484-493,316-348` — Card list recomputed per render *and* per visible
row** (O(n) clone + registry reads ×(rows+1) per frame during scroll). Compute once per render
(`new_chat.rs`'s `Rc<Vec>` pattern).
- **[M] `panels/greeter.rs:127-137` — Every refresh fetches the full message history of every room
to take `.last()`.** Query latest-per-room instead.
- **[M] `dialogs/new_chat.rs:138-160,388` — Filter allocates per contact per keystroke-scale
render** (`to_hex().to_lowercase()` per contact). Cache lowercase names / filter once per change.
- **[M] `dialogs/settings.rs:124-148` — Speech-model errors can be consumed before the status
change check** and dropped. Check `error` before the equality early-return.
- **[M] `new_chat.rs:282-315` vs `profile.rs:274-313` — Duplicated room-open logic with divergent
dedup.** Unify on find-or-create.
- **[L] `panels/contact_list.rs:238,259` + `greeter.rs:226-402` — HashSet/index-based element ids**
(see S6).
- **[L] `panels/contact_list.rs:288` — Copy bug: contacts panel says "Please add some relays."**
- **[L] `dialogs/new_group.rs:392` — `let _ = this.update(...)`** (see S2; `relays.rs:623-629` is
the correct logged pattern).
- **[L] `panels/backup.rs:66,70` — `.unwrap()` on bech32** (infallible today; still banned pattern).
- **[L] `dialogs/restore.rs:52-62`, `contact_list.rs:130`, `relays.rs:352-355,497-500` — Untrimmed
paste input fails parse** ("trailing newline = invalid key"); `relays` has a weak
`starts_with("ws")` check.
- **[L] `dialogs/relays.rs:124-140,360-366` — Relay metadata can never be reset; duplicate add is a
silent no-op** that can't update metadata.
- **[L] `dialogs/settings.rs:517-543` — Toggles compute `!rendered_value` instead of the switch's
`checked`**; two fast clicks collapse into one. Match `onboarding.rs:398-399`.
- **[L] Dialog `tasks` fields never pruned** (`import.rs:63`, `restore.rs:24`, `relays.rs:45,57`,
`contact_list.rs:52`, `profile.rs:55`, `new_chat.rs:50`, `new_group.rs:94`) — see S1;
`screening.rs`/`greeter.rs` show the right patterns.
- **[L] `new_chat.rs:93-95`, `profile.rs:278-280`, `screening.rs:90-92` — Signed-out paths return
silently**; no retry when login completes later.
- **[L] Dialogs can be stacked repeatedly** — no `has_active_dialog` checks in any `open()`.
- **[L] `new_group.rs:137-140` — `GroupsEvent::Created { warning, .. }` discards the non-fatal
warning.**
Positives: `limit(1)` newest-event pattern verified correct on both backends; entities created
before `open_dialog` per gpui-kit contract; `new_group.rs` has a solid error lifecycle; heavy work
consistently on background; no unused deps in the crate.
### 4.16 `ui` — shared components
- **[M] `avatar.rs:128-186` — `PixelAvatar` regenerates the full pattern every frame** (FNV-1a +
xorshift 64-cell grid + ~50 child divs per avatar; ~30 visible avatars, redrawn on every
keystroke) — the biggest hot-path cost in the main view. Memoize the deterministic pattern
(seed → `[u8; 64]` cache); hoist `FALLBACK_SEED` to a `&'static str` path.
- **[L] `text.rs:11-38` — `InlineReplacement` is `pub` but unreachable externally**; same for
`RUN_WINDOW_SECS` (`message.rs:28`). `pub(crate)` or document as extension points.
- **[L] `message.rs:766` — `RevealText::new` is the only public constructor without a doc line.**
- **[L] `message.rs:775-796` — `RevealText` re-splits text into a `Vec<SharedString>` every
animation frame**; split once in `new`.
- **[L] `text.rs:89-95` — Non-web, non-nevent Nostr links are silently dead** (`npub`/`nprofile`
clicks do nothing). Route to a profile view or drop the link markup.
- **[L] `Cargo.toml:15,19` — `web-time` and `uuid` unused.** Remove.
Positives: consistent `RenderOnce` + `Styled` builder pattern; `#[must_use]` builders; documented
public items; char-boundary-safe slicing; truncating casts provably bounded.
### 4.17 `auto_update`
- **[M] `lib.rs:92` — Checksum-only verification; no signing key configured.** SHA256SUMS comes
from the same host as the artifact — protects against corruption, not a compromised release
host/account. The engine already supports minisign/Ed25519; publish a sidecar signature and pin
the public key before stable.
- **Verified non-issue:** the `futures::executor::block_on` in `source.rs:152,170` runs on the GPUI
**background** executor (verified through `gpui-updater-pre 0.2.0`'s `Updater::check` →
`engine.check()` → `fetch_latest` call chain) — no UI freeze. Severity Low: it parks one
background thread up to ~60 s during a check. Optional tidy-up only.
- **[L] `source.rs:156-163,180-183` — Asset URLs not scheme-checked**; assert `https://`.
- **[L] `lib.rs:119-131,145-147` — A failed check shows for 5 s then reports "Up to date"**
(`error_revert` → `Idle`, and `Idle` counts as up-to-date). Distinct "check failed" state.
- **[L] `lib.rs:103,128` — `.ok()` on entity updates** (see S2).
- **[L] `lib.rs:134-139` — Auto-download/stage ~2 min after startup without a prompt**; fine on
unmetered, worth a setting.
Positives: strict `latest > current` gate (no downgrades); drafts/prereleases skipped; 30 s
timeouts; defensive checksum parsing; good tests.
### 4.18 `gpui_tokio`
- **[L] `lib.rs:14` — `.expect("Failed to initialize Tokio")`**; per the no-panic rule, `init`
returning `Result` would be more in keeping (realistically unreachable).
- **[L] `lib.rs:38` — `pub struct Tokio {}`** namespace struct; `pub struct Tokio;` or free
functions.
Positives: the `defer`-guard cancellation pattern is correct in both completion and drop cases;
`shutdown_background()` avoids blocking exit; `spawn_result` maps `JoinError` into `anyhow`.
No deadlock risk across the two runtimes (verified: nothing waits on GPUI from tokio or holds a
lock across the boundary).
### 4.19 `desktop` / `web` / `assets` entries
- **[M] `desktop/src/main.rs:20-26,153-170` — Secret key accepted as a CLI argument.** `--sec
nsec1…` puts the master key in the process list (`ps`, `/proc`, shell history); the parse-error
path echoes the raw (near-)key to stderr. Read from stdin/env; print error kind only, never the
input.
- **[L] `desktop/src/main.rs:131-150` — Seven startup unwraps on embedded assets.** Deterministic,
but one corrupt font aborts launch; skip bad fonts with a warning and fail only if zero loaded.
`open_window().expect` is fair (genuinely unrecoverable). The startup `block_on` for fonts
(main.rs:135) is intentional and acceptable — noted so it isn't confused with the anti-pattern.
- **[L] `web/src/lib.rs:122` — `expect("Failed to open window…")` aborts the wasm module** (blank
page + console error; `panic = "abort"`). A DOM-level error would be kinder.
- **[L] `web/src/lib.rs:43` — `.ok()` on console-log init** (justified; noted for completeness).
- **[L] `desktop/Cargo.toml:37` + `web/Cargo.toml:13` — `ui` dep unused in both entries.** Remove
unless it exists to force feature unification (then comment why).
- **[L] `assets/Cargo.toml:9` — `anyhow` unused** (code uses `gpui::Result`). Remove.
Positives: `web` opens IndexedDB before `run()`, panic hook + both logging stacks via `OnceLock`;
`assets` ships the theme-schema test.
## 5. Tooling Validation
| Check | Result |
|---|---|
| `cargo clippy --workspace --all-targets` | clean — zero warnings |
| `cargo fmt --all --check` | clean |
| `cargo check -p workspace`, `-p person` | pass |
| `let _ =` / `.ok()` sites (non-test) | ~156 — mostly entity-update-expected-error cases; see S2 |
| `tasks:` container declarations | ~48 sites across 20 owners — see S1 |
| Startup `block_on` sites | 3 (`state` DB open, `desktop` fonts — both acceptable-at-startup; `auto_update` — background thread, non-issue) |
## 6. Verified Non-Issues (checked, do not re-flag)
- `auto_update/source.rs` `block_on`: background executor only (verified through the
`gpui-updater-pre 0.2.0` call chain) — no UI freeze.
- `nip29/src/protocol.rs:1072` `block_on`: test-only.
- No locks held across `.await` anywhere in the workspace (all audited crates).
- `person.rs:170` `let Ok(pubkey) = to_bech32()` — irrefutable (`Err = Infallible`).
- `stored_contact_list`'s `limit(1)` + `.next()` — correct: nostr-lmdb orders by `created_at`
descending.
- `nip60`/`nip60_ui` have zero non-test `unwrap`/`expect`/`let _ =`; indexing is guarded; no
truncating casts.
- `nip29_ui`'s `sync()` splice math traced against `ListState` semantics — correct (it's `chat_ui`
' that has the mismatch).
- gpui-pre's entity read-tracking re-renders panels on `Directory`/`SpeechState` notifications —
the missing-`cx.notify()` suspicion in speech handlers is fine; the stale-cache bugs above are
real, the observation isn't.
- `nip60::init` startup ordering is sound for normal flows; the CLI-key path fires `SignerChanged`
before wallet subscription exists (app-wide init-order caveat, affects all registries equally).
- `nip60` melt double-spend paths are latent (dead API), not live.
## 7. What's Done Well
- **Clippy-clean, fmt-clean workspace** with no reachable panics in `nip29`, `nip60`, `ui`,
`nip29_ui`, `person` production paths; hostile-input discipline in the WoT builder, speech
downloads (SHA-256 + size cap + atomic rename), and auto-update (fail-closed checksums, no
downgrades).
- **`verify_subscriptions(true)` + `ban_relay_on_mismatch(true)`** in `state` is the single most
valuable client-side trust boundary, and it's on.
- **Entity/subscription hygiene is strong overall**: subscriptions stored in `_subscriptions`,
`WeakEntity` used for room/group/dock back-references, no strong entity cycles found anywhere,
no dropped-subscription bugs.
- **Secrets discipline**: no secret is ever logged or published in `nip4e`/`nip60`/`state` desktop
paths (verified against every log call); `Keys`' vendored `Debug` hides secrets.
- **Errors generally do reach the UI** where it matters most (group writes via `util::report`,
wallet events, speech errors, import/restore flows) — the gaps are enumerated above.
## 8. Recommended Remediation Order
**P0 — before any release candidate (blocks release):**
1. nip60 deletion spoofing (4.2 #1) — one-line precedence fix + author check.
2. browser-signer-proxy auth/CORS/token + timeout cleanup (4.4 #1-2).
3. state startup DB fallback (4.1 #1) and web localStorage encryption (4.1 #2).
4. nip60 reconcile/republish money-safety trio (4.2 #2-4) + stuck `redeeming` (4.2 #5).
5. state file-cache collision (4.1 #4) — wrong-file disclosure.
6. chat_ui reaction double-count (4.7 #1) and common whitespace collapse (4.10 #1).
7. contact-list Update data loss (4.15 #1).
8. speech parakeet worker lifetime (4.12 #1).
9. settings load/save durability (4.13 #1-2).
10. nip29 `created_at` guards (4.8 #1-2).
**P1 — fix during the release cycle:**
Dock panel identity + "Ignore" dispatch (4.14 #1-2); updater observation (4.14 #3); invoice flow
or removal (4.3 #1); person `in_flight` release (4.11 #1); chat trash UI (4.6 #1); chat_ui load
errors + splice + quadratic insert (4.7 #2-4); profile URL validation, backup keyring surfacing,
new_chat re-filter, screening dialog cluster (4.15); parser recursion for wasm (4.10 #3);
`DefaultHasher` → stable hash before more room ids ship (S5); nip4e approval button + rotation
archive + listener death (4.5 #1-3); proxy plaintext leak + event-substitution check (4.4 #3-4).
**P2 — hygiene, in one mechanical sweep:**
S1 (`tasks` containers → replace-per-spawn / `detach_and_log_err`), S2 (`let _ =`/`.ok()` →
`.log_err()`), S6 (domain-keyed element ids), unused deps (`chat`: smol; `nip29`: person;
`nip60`: tokio; `ui`: web-time, uuid; `assets`: anyhow; entries: ui), dead code
(`nip60` melt/send/quotes decision, `state::passphrase_required`, `DownloadedFile::save`,
`dock::find_panel` fix-or-delete, `skin.rs` write-once flag, `Person` trait impls), avatar
memoization, sidebar row-model caching, rename `DeviceNotifcation`, summary-comment cleanup.
---
*Method note: every crate's sources were read in full by a dedicated audit pass; GPUI/fork API
claims were verified against the vendored `gpui-pre 0.3.12`, `gpui-base 0.9.1`,
`gpui-component 0.9.1`, and rust-nostr sources; the Critical and top-High findings were
independently re-verified by hand against the working tree at `8fa79aef`. No files were modified
during the audit.*
## 9. P0 Remediation Log (2026-11-12)
All ten P0 items fixed. Validation after the full batch: `cargo clippy --workspace --all-targets`
zero warnings, `cargo fmt --all --check` clean, `cargo test --workspace` 35 passed / 0 failed.
Diff: 15 files, +816/−273.
1. **nip60 deletion spoofing** — `nip60/src/lib.rs`: precedence fixed to
`ours && (is_cashu || is_token_deletion(..))`; `absorb`/`absorb_deletion` now take `me` and
reject foreign-authored kind 5 events (defense in depth; the live notification gate was the
only protection before, and `absorb_deletion` had no author check). The DB load path was
already author-filtered and is unchanged.
2. **browser-signer-proxy hardening** — per-session UUID token generated at startup, embedded in
the served page (`inject_session_token`), required on `/api/*` (403 otherwise); exact `Host`
match (DNS-rebinding defense); all CORS headers and the OPTIONS preflight removed;
`cancel_request` removes timed-out requests from both maps so plaintext is dropped;
centralized best-effort `write_response`. `proxy.js` authenticates all three fetches. Token
never appears in the URL the app opens.
3. **state startup crash loop** — `NostrLmdb::open` failure now logs and falls back to
`MemoryDatabase::unbounded()` (nostr-memory added to the desktop deps), emitting
`StateEvent::error` via the existing deferred path; the `block_on` stays (safe: the LMDB open
schedules nothing back onto the GPUI executor — now commented).
4. **state web localStorage key** — `import_bunker` on wasm32 no longer persists the bunker
credential (session is memory-only; desktop keyring unchanged); `restore_session` degrades to
`NoSigner` when nothing is stored (verified). **Known behavior change: web users re-enter their
bunker URI after each reload.**
5. **state file-cache collision** — cache filename now derived from the decrypted content's
sha256 instead of the filename; the metadata guard is gone and every store goes through
temp-write + atomic rename.
6. **nip60 reconcile money-safety** — `ops.rs::reconcile` rewritten: relay tokens are converged
into the store BEFORE any publish (closes the `del`-before-converge race); relay proofs already
tracked locally (unspent or in-flight Pending/Reserved/PendingSpent) are never re-added, and
unseen proofs are checked against the mint (NUT-08 `check_proofs_spent`, best-effort) before
being accepted as Unspent; when a prior job errored after mutating the store
(`publish_failed` flag), reconcile republishes the store's unspent set instead of trusting
relay state — healing stranded sends/receives; without the flag the relay-trust removal path
(cross-device spend convergence) is preserved. A failing Reconcile does not reschedule itself
(no infinite loop).
7. **nip60 stuck nutzap redemptions** — `apply_outcome` now receives the failed job; on
`RedeemNutzap` error the id is removed from `redeeming` and requeued up to
`MAX_REDEEM_ATTEMPTS = 3` (cleared on success, reset on signer change), so transient mint
failures no longer wedge redemptions until restart.
8. **Reactions double-count** — `chat_ui::insert_reaction` replaces per (author, target) per
NIP-25, making the backup gift-wrap echo idempotent (verified: optimistic rumor and the
re-delivered seal carry the same event id) and history reloads non-duplicating.
9. **Whitespace collapse** — `common/media_extractor.rs` rewritten: whitespace is tidied only at
URL-removal boundaries; line breaks in untouched regions survive; regex hoisted to
`LazyLock`; 9 unit tests added (multi-line preservation, URL-only messages, digit/emoji edges).
10. **Contact-list data loss** — `contact_list.rs` now parses the stored event into full
`nostr::nips::nip02::Contact` values (relay hint + alias preserved through load → add/remove
→ republish); new contacts still get `Contact::new`.
11. **Speech worker lifetime** — `parakeet.rs`: the `OnceLock<Sender>` became a resettable
`Mutex<Option<Sender>>`; a dead worker (30 s idle exit) is detected via `is_disconnected`, the
slot cleared, and a replacement thread spawned lazily on the next command; spawn failure no
longer caches a dead sender; failures surface through `sink.error` / returned `Err` instead
of a debug-level swallow (one retry covers the liveness-check/send race).
12. **Settings durability** — a failed load logs and keeps defaults in memory without
persisting (`suppress_next_save`, consumed by the next real change); fresh-install NotFound is
no longer conflated with read/parse failures; saves are serialized through a single
`save_task` slot (chained, FIFO order) with temp-file + atomic rename; serialization and
write failures are logged.
13. **nip29 ordering guards** — shared `is_newer_event` (strict `(created_at, id)` total order):
`Group::absorb_state` keeps a per-kind stamp (written only after a successful apply, so a
malformed newer event can't poison the guard) and rejects stale replays while ascending cache
hydration still applies; the registry's `list_stamp` seeds from the cached list event at
startup, is checked in `merge` before applying, updated by `publish_list` (closing the
leave→resurrect window), and reset on signer change. 3 new tests across protocol/group.
14. **nip60_ui invoice flow** — *not* in this batch: it is P1 (the audit lists it as High but the
P0 list ranks it 16th via `nip60_ui/wallet.rs`); `mint_quotes()`/`claim_mint_quote()` remain
dead until the P1 UI work lands. No other P0 code paths depend on it.
Behavior changes to be aware of when testing:
- Web: bunker credentials are no longer persisted (re-connect after each reload).
- Desktop: a corrupted/unreadable local database now starts with an empty in-memory history and
shows an error event instead of crash-looping; relays re-sync as before.
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.
-1
View File
@@ -21,7 +21,6 @@ person = { path = "../crates/person" }
gpui.workspace = true
gpui_platform.workspace = true
gpui_web.workspace = true
gpui-kit.workspace = true
log.workspace = true