Author SHA1 Message Date
reya f44f0704f3 feat: add support for basic web of trust (#65)
Reviewed-on: #65
2026-10-20 13:43:37 +00:00
reya a22aecc694 chore: add public group notice 2026-10-20 08:14:11 +07:00
reya f5afc772b2 chore: improve clarity about public group 2026-10-20 08:13:39 +07:00
reya 03f98f5fa8 fix: align message timestamps in greeter panel 2026-10-19 20:55:37 +07:00
reya deed021332 feat: new greeter panel 2026-10-17 17:04:50 +07:00
20 changed files with 1235 additions and 309 deletions
Generated
+88 -89
View File
@@ -267,7 +267,7 @@ dependencies = [
[[package]] [[package]]
name = "assets" name = "assets"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui-pre", "gpui-pre",
@@ -430,7 +430,7 @@ dependencies = [
[[package]] [[package]]
name = "async-utility" name = "async-utility"
version = "0.3.2" version = "0.3.2"
source = "git+https://github.com/yukibtc/async-utility?rev=7d3a79983d0e848aafed9d321637af7334e01349#7d3a79983d0e848aafed9d321637af7334e01349" source = "git+https://github.com/yukibtc/async-utility?rev=b323ae0c1c1424b78bf8bb8832b95e86771f1dee#b323ae0c1c1424b78bf8bb8832b95e86771f1dee"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"gloo-timers", "gloo-timers",
@@ -509,7 +509,7 @@ dependencies = [
[[package]] [[package]]
name = "auto_update" name = "auto_update"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"futures", "futures",
"gpui-pre", "gpui-pre",
@@ -933,9 +933,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.8.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -992,7 +992,7 @@ dependencies = [
[[package]] [[package]]
name = "chat" name = "chat"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]] [[package]]
name = "chat_ui" name = "chat_ui"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
@@ -1066,7 +1066,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [ dependencies = [
"block-buffer 0.12.1", "block-buffer 0.12.1",
"crypto-common 0.2.2", "crypto-common 0.2.2",
"inout 0.2.2", "inout 0.2.5",
] ]
[[package]] [[package]]
@@ -1162,9 +1162,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.8" version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
dependencies = [ dependencies = [
"bytes", "bytes",
"memchr", "memchr",
@@ -1172,7 +1172,7 @@ dependencies = [
[[package]] [[package]]
name = "common" name = "common"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs", "dirs",
@@ -1280,7 +1280,7 @@ dependencies = [
[[package]] [[package]]
name = "coop" name = "coop"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"assets", "assets",
"auto_update", "auto_update",
@@ -1306,7 +1306,7 @@ dependencies = [
[[package]] [[package]]
name = "coop_web" name = "coop_web"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"chat", "chat",
"console_error_panic_hook", "console_error_panic_hook",
@@ -1599,18 +1599,18 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "defmt" name = "defmt"
version = "0.3.100" version = "0.3.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53"
dependencies = [ dependencies = [
"defmt 1.1.1", "defmt 1.2.0",
] ]
[[package]] [[package]]
name = "defmt" name = "defmt"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"defmt-macros", "defmt-macros",
@@ -1618,30 +1618,30 @@ dependencies = [
[[package]] [[package]]
name = "defmt-macros" name = "defmt-macros"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
dependencies = [ dependencies = [
"defmt-parser", "defmt-parser",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.9",
] ]
[[package]] [[package]]
name = "defmt-parser" name = "defmt-parser"
version = "1.0.0" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
dependencies = [ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]] [[package]]
name = "derive_more" name = "derive_more"
@@ -1894,9 +1894,9 @@ dependencies = [
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]] [[package]]
name = "errno" name = "errno"
@@ -1971,7 +1971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"defmt 0.3.100", "defmt 0.3.101",
"heapless 0.8.0", "heapless 0.8.0",
"serde", "serde",
] ]
@@ -2013,9 +2013,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]] [[package]]
name = "flatbuffers" name = "flatbuffers"
@@ -2117,9 +2117,9 @@ dependencies = [
[[package]] [[package]]
name = "font-types" name = "font-types"
version = "0.12.6" version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
] ]
@@ -2696,7 +2696,7 @@ dependencies = [
"log", "log",
"rustls", "rustls",
"rustls-platform-verifier", "rustls-platform-verifier",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
@@ -3392,7 +3392,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
@@ -3616,9 +3616,9 @@ dependencies = [
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.2.2" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [ dependencies = [
"hybrid-array", "hybrid-array",
] ]
@@ -3826,9 +3826,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.190" version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]] [[package]]
name = "libfuzzer-sys" name = "libfuzzer-sys"
@@ -4258,9 +4258,9 @@ dependencies = [
[[package]] [[package]]
name = "negentropy" name = "negentropy"
version = "0.5.1" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81c353b400a5503efdcf398f11a83fb7aa84f59f5d76fc4bf5bbc1e4f5366caa" checksum = "a3d66662cb5ca384d4991509755ed5a42dd8e31c4282bf881938e506fc3146ec"
[[package]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
@@ -4270,7 +4270,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nip29" name = "nip29"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
@@ -4288,7 +4288,7 @@ dependencies = [
[[package]] [[package]]
name = "nip29_ui" name = "nip29_ui"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
@@ -4309,7 +4309,7 @@ dependencies = [
[[package]] [[package]]
name = "nip4e" name = "nip4e"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"flume 0.11.1", "flume 0.11.1",
@@ -4352,7 +4352,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.45.5" version = "0.45.5"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"aes", "aes",
"base64 0.22.1", "base64 0.22.1",
@@ -4378,7 +4378,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-blossom" name = "nostr-blossom"
version = "0.45.1" version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bitcoin_hashes 1.2.0", "bitcoin_hashes 1.2.0",
@@ -4391,10 +4391,9 @@ dependencies = [
[[package]] [[package]]
name = "nostr-connect" name = "nostr-connect"
version = "0.45.2" version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"futures-core",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"opaquerr 0.2.0", "opaquerr 0.2.0",
@@ -4405,7 +4404,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-database" name = "nostr-database"
version = "0.45.2" version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"nostr", "nostr",
"opaquerr 0.2.0", "opaquerr 0.2.0",
@@ -4414,7 +4413,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-gossip" name = "nostr-gossip"
version = "0.45.1" version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"nostr", "nostr",
"opaquerr 0.2.0", "opaquerr 0.2.0",
@@ -4423,7 +4422,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-gossip-memory" name = "nostr-gossip-memory"
version = "0.45.1" version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"lru", "lru",
@@ -4435,7 +4434,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-lmdb" name = "nostr-lmdb"
version = "0.45.3" version = "0.45.3"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"flatbuffers", "flatbuffers",
@@ -4450,7 +4449,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-memory" name = "nostr-memory"
version = "0.45.2" version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"btreecap", "btreecap",
"nostr", "nostr",
@@ -4461,7 +4460,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-sdk" name = "nostr-sdk"
version = "0.45.4" version = "0.45.4"
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"async-wsocket", "async-wsocket",
@@ -5157,7 +5156,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "person" name = "person"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -5822,7 +5821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"font-types 0.12.6", "font-types 0.12.7",
"once_cell", "once_cell",
] ]
@@ -5943,7 +5942,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
@@ -6088,9 +6087,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.45" version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
@@ -6104,9 +6103,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-native-certs" name = "rustls-native-certs"
version = "0.8.4" version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [ dependencies = [
"openssl-probe", "openssl-probe",
"rustls-pki-types", "rustls-pki-types",
@@ -6223,9 +6222,9 @@ dependencies = [
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -6477,7 +6476,7 @@ dependencies = [
[[package]] [[package]]
name = "settings" name = "settings"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -6714,7 +6713,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "state" name = "state"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -7006,7 +7005,7 @@ dependencies = [
[[package]] [[package]]
name = "theme" name = "theme"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui-base", "gpui-base",
@@ -7164,9 +7163,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.2" version = "1.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -7520,7 +7519,7 @@ dependencies = [
[[package]] [[package]]
name = "ui" name = "ui"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"gpui-base", "gpui-base",
"gpui-pre", "gpui-pre",
@@ -7575,9 +7574,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
version = "0.1.25" version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [ dependencies = [
"tinyvec", "tinyvec",
] ]
@@ -7656,14 +7655,14 @@ dependencies = [
"serde_json", "serde_json",
"ureq-proto", "ureq-proto",
"utf8-zero", "utf8-zero",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
name = "ureq-proto" name = "ureq-proto"
version = "0.6.4" version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"http", "http",
@@ -8043,9 +8042,9 @@ dependencies = [
[[package]] [[package]]
name = "webpki-root-certs" name = "webpki-root-certs"
version = "1.0.9" version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -8056,14 +8055,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [ dependencies = [
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.9" version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -8919,7 +8918,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "workspace" name = "workspace"
version = "1.0.2" version = "2.0.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"auto_update", "auto_update",
@@ -9179,9 +9178,9 @@ dependencies = [
[[package]] [[package]]
name = "zbus_names" name = "zbus_names"
version = "4.3.4" version = "4.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
dependencies = [ dependencies = [
"serde", "serde",
"winnow 1.0.4", "winnow 1.0.4",
@@ -9278,18 +9277,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.62" version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.62" version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -9408,9 +9407,9 @@ dependencies = [
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.15.0" version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
dependencies = [ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
@@ -9424,9 +9423,9 @@ dependencies = [
[[package]] [[package]]
name = "zvariant_derive" name = "zvariant_derive"
version = "5.15.0" version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
+45 -5
View File
@@ -14,6 +14,7 @@ use gpui::{
use instant::Duration; use instant::Duration;
use nip4e::{DeviceEvent, DeviceRegistry}; use nip4e::{DeviceEvent, DeviceRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
@@ -139,13 +140,13 @@ impl ChatRegistry {
/// Create a new chat registry instance /// Create a new chat registry instance
fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let device = DeviceRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let entity = cx.entity().downgrade(); let entity = cx.entity().downgrade();
let (tx, rx) = flume::unbounded::<Signal>(); let (tx, rx) = flume::unbounded::<Signal>();
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
let device = DeviceRegistry::global(cx);
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() { if event.signer_changed() {
this.reset(cx); this.reset(cx);
@@ -163,6 +164,10 @@ impl ChatRegistry {
} }
})); }));
subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| {
this.promote_trusted(cx);
}));
cx.defer(move |cx| { cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| { let _ = entity.update(cx, |this, cx| {
this.get_rooms(cx); this.get_rooms(cx);
@@ -567,7 +572,23 @@ impl ChatRegistry {
.unwrap_or_default() .unwrap_or_default()
} }
/// Add a new room to the start of list. /// Promote request rooms whose senders are inside the web of trust
fn promote_trusted(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return;
};
for room in &self.rooms {
room.update(cx, |room, cx| {
if room.kind == RoomKind::Request && room.auto_trusted(me, cx) {
room.set_ongoing(cx);
}
});
}
}
/// Promote a room to ongoing when its latest message is from the current user
pub(crate) fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>) pub(crate) fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where where
I: Into<Room>, I: Into<Room>,
@@ -577,7 +598,12 @@ impl ChatRegistry {
return; return;
}; };
let room: Room = room.into().organize(&public_key); let mut room: Room = room.into().organize(&public_key);
if room.auto_trusted(public_key, cx) {
room.kind = RoomKind::Ongoing;
}
let room_id = room.id; let room_id = room.id;
let entity = cx.new(|_| room); let entity = cx.new(|_| room);
entity.update(cx, |room, cx| room.ensure_directory(cx)); entity.update(cx, |room, cx| room.ensure_directory(cx));
@@ -652,6 +678,11 @@ impl ChatRegistry {
/// Extend the registry with new rooms. /// Extend the registry with new rooms.
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) { fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return;
};
let mut room_map: HashMap<u64, usize> = self let mut room_map: HashMap<u64, usize> = self
.rooms .rooms
.iter() .iter()
@@ -660,6 +691,13 @@ impl ChatRegistry {
.collect(); .collect();
for new_room in rooms.into_iter() { for new_room in rooms.into_iter() {
// Auto-trust also applies to rooms loaded from the database
let new_room = if new_room.kind == RoomKind::Request && new_room.auto_trusted(me, cx) {
new_room.kind(RoomKind::Ongoing)
} else {
new_room
};
// Check if we already have a room with this ID // Check if we already have a room with this ID
if let Some(&index) = room_map.get(&new_room.id) { if let Some(&index) = room_map.get(&new_room.id) {
self.rooms[index].update(cx, |this, cx| { self.rooms[index].update(cx, |this, cx| {
@@ -789,7 +827,9 @@ impl ChatRegistry {
match self.room_index.get(&message.room).cloned() { match self.room_index.get(&message.room).cloned() {
Some(room) => { Some(room) => {
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key { if this.kind == RoomKind::Request
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
{
this.set_ongoing(cx); this.set_ongoing(cx);
} }
this.push_message(message, cx); this.push_message(message, cx);
+57 -8
View File
@@ -8,8 +8,8 @@ use instant::Duration;
use itertools::Itertools; use itertools::Itertools;
use nip4e::DeviceRegistry; use nip4e::DeviceRegistry;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, Person}; use person::{Directory, Person, PersonRegistry};
use settings::{RoomConfig, SignerKind}; use settings::{AppSettings, RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner}; use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
@@ -73,22 +73,16 @@ pub enum RoomKind {
pub struct Room { pub struct Room {
/// Conversation ID /// Conversation ID
pub id: u64, pub id: u64,
/// The timestamp of the last message in the room /// The timestamp of the last message in the room
pub created_at: Timestamp, pub created_at: Timestamp,
/// Subject of the room /// Subject of the room
pub(crate) subject: Option<SharedString>, pub(crate) subject: Option<SharedString>,
/// All members of the room /// All members of the room
pub(super) members: Vec<PublicKey>, pub(super) members: Vec<PublicKey>,
/// Kind /// Kind
pub kind: RoomKind, pub kind: RoomKind,
/// Configuration /// Configuration
config: RoomConfig, config: RoomConfig,
/// Person directory, attached when the room is registered /// Person directory, attached when the room is registered
directory: Option<Entity<Directory>>, directory: Option<Entity<Directory>>,
} }
@@ -189,6 +183,61 @@ impl Room {
self self
} }
/// Whether the auto-trust setting promotes this room out of the requests:
/// everyone other than the current user is inside the web of trust
pub fn auto_trusted(&self, me: PublicKey, cx: &App) -> bool {
if !AppSettings::get_wot_auto_trust(cx) {
return false;
}
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
Self::trusted_by(&self.members, me, |key| wot.is_in_network(key))
}
/// Whether the room looks like spam: the web of trust is loaded and
/// everyone other than the current user is outside of it
pub fn is_spam(&self, cx: &App) -> bool {
let person = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return false;
};
let wot = person.read(cx).wot().read(cx);
// An unloaded graph cannot classify anything as spam
if !wot.is_loaded() {
return false;
}
Self::trusted_by(&self.members, me, |key| wot.is_outside(key))
}
/// Whether every member other than `me` passes the check
fn trusted_by(
members: &[PublicKey],
me: PublicKey,
check: impl Fn(&PublicKey) -> bool,
) -> bool {
let mut others = false;
for key in members {
if *key == me {
continue;
}
others = true;
if !check(key) {
return false;
}
}
others
}
/// Sets this room is ongoing conversation /// Sets this room is ongoing conversation
pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) { pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) {
self.kind = RoomKind::Ongoing; self.kind = RoomKind::Ongoing;
+61
View File
@@ -320,6 +320,8 @@ pub struct GroupPanel {
attachments: Vec<Url>, attachments: Vec<Url>,
/// Whether an upload is in flight. /// Whether an upload is in flight.
uploading: bool, uploading: bool,
/// Whether the user closed the encryption notice this visit
notice_dismissed: bool,
tasks: SmallVec<[Task<Result<()>>; 4]>, tasks: SmallVec<[Task<Result<()>>; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>, _subscriptions: SmallVec<[Subscription; 3]>,
} }
@@ -388,6 +390,7 @@ impl GroupPanel {
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)), list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
head, head,
reply_to: None, reply_to: None,
notice_dismissed: false,
rendered_texts_by_id: BTreeMap::new(), rendered_texts_by_id: BTreeMap::new(),
input, input,
attachments: Vec::new(), attachments: Vec::new(),
@@ -741,6 +744,63 @@ impl GroupPanel {
.into_any_element() .into_any_element()
} }
/// Notice shown while viewing a public group, where messages are plaintext
fn render_encryption_notice(&self, cx: &mut Context<Self>) -> AnyElement {
let is_public = self
.group
.upgrade()
.and_then(|group| group.read(cx).metadata().map(|metadata| !metadata.private))
.unwrap_or(false);
if !is_public || self.notice_dismissed || AppSettings::get_hide_group_notice(cx) {
return div().into_any_element();
}
h_flex()
.flex_shrink_0()
.w_full()
.gap(px(theme::SPACE))
.px_3()
.py_2()
.text_style(TextStyle::Callout)
.bg(cx.theme().surface_raised)
.child(
Icon::new(IconName::Info)
.small()
.text_color(cx.theme().warning),
)
.child(div().flex_1().min_w_0().child(
"This group is public. Messages are not encrypted and can be read by anyone.",
))
.child(
h_flex()
.gap_1()
.child(
Button::new("hide")
.label("Don't show again")
.xsmall()
.secondary()
.on_click(cx.listener(|this, _event, _window, cx| {
AppSettings::update_hide_group_notice(true, cx);
this.notice_dismissed = true;
cx.notify();
})),
)
.child(
Button::new("dismiss")
.icon(IconName::Close)
.small()
.ghost()
.tooltip("Close")
.on_click(cx.listener(|this, _event, _window, cx| {
this.notice_dismissed = true;
cx.notify();
})),
),
)
.into_any_element()
}
fn render_pins(&self, cx: &mut Context<Self>) -> AnyElement { fn render_pins(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(group) = self.group.upgrade() else { let Some(group) = self.group.upgrade() else {
return div().into_any_element(); return div().into_any_element();
@@ -1218,6 +1278,7 @@ impl Render for GroupPanel {
v_flex() v_flex()
.size_full() .size_full()
.min_w_0() .min_w_0()
.child(self.render_encryption_notice(cx))
.child(self.render_banner(cx)) .child(self.render_banner(cx))
.child(self.render_pins(cx)) .child(self.render_pins(cx))
.child( .child(
+16
View File
@@ -11,9 +11,11 @@ use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod directory; mod directory;
mod person; mod person;
mod wot;
pub use directory::*; pub use directory::*;
pub use person::*; pub use person::*;
pub use wot::*;
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
@@ -60,6 +62,8 @@ enum Dispatch {
pub struct PersonRegistry { pub struct PersonRegistry {
/// Public keys with a metadata request in flight /// Public keys with a metadata request in flight
in_flight: RwLock<HashSet<PublicKey>>, in_flight: RwLock<HashSet<PublicKey>>,
/// Web of trust graph of the signed-in user
wot: Entity<WebOfTrust>,
sender: flume::Sender<PublicKey>, sender: flume::Sender<PublicKey>,
tasks: SmallVec<[Task<()>; 4]>, tasks: SmallVec<[Task<()>; 4]>,
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
@@ -80,6 +84,7 @@ impl PersonRegistry {
fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let wot = cx.new(WebOfTrust::new);
// Channel for communication between nostr and gpui // Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100); let (tx, rx) = flume::bounded::<Dispatch>(100);
@@ -122,17 +127,28 @@ impl PersonRegistry {
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| { let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() { if event.signer_changed() {
this.get_emojis(cx); this.get_emojis(cx);
this.wot.update(cx, |wot, cx| {
wot.reset(cx);
wot.hydrate(cx);
});
} }
}); });
Self { Self {
in_flight: RwLock::new(HashSet::new()), in_flight: RwLock::new(HashSet::new()),
wot,
sender: metadata_tx, sender: metadata_tx,
tasks, tasks,
_subscriptions: smallvec![subscription], _subscriptions: smallvec![subscription],
} }
} }
/// The web of trust graph of the signed-in user
pub fn wot(&self) -> Entity<WebOfTrust> {
self.wot.clone()
}
/// Handle nostr notifications /// Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) { async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
+298
View File
@@ -0,0 +1,298 @@
use std::collections::{HashMap, HashSet};
use gpui::{Context, EventEmitter, Task};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
/// Disable scoring when the follow set exceeds this size
const MAX_FOLLOWS: usize = 2000;
/// Cap a followed author's contact list to bound hostile publishers
const MAX_TAGS_PER_LIST: usize = 5000;
/// Time between reconciliations with the relays
const SYNC_INTERVAL: u64 = 15 * 60;
/// Longest wait between retries while the graph has no data yet
const MAX_RETRY_WAIT: u64 = 60;
/// The web of trust graph changed
#[derive(Debug, Clone)]
pub struct WoTEvent;
/// A built graph: the follow set and, per public key, who follows it
#[derive(Debug)]
struct Snapshot {
follows: HashSet<PublicKey>,
scores: HashMap<PublicKey, usize>,
mutuals: HashMap<PublicKey, Vec<PublicKey>>,
disabled: bool,
}
impl Snapshot {
/// Build the snapshot from the newest stored contact list of every author
fn build(self_key: PublicKey, follows: HashSet<PublicKey>, events: Vec<Event>) -> Self {
let disabled = follows.len() > MAX_FOLLOWS;
let mut snapshot = Self {
follows,
scores: HashMap::new(),
mutuals: HashMap::new(),
disabled,
};
if snapshot.disabled {
return snapshot;
}
// Keep the newest contact list per author
let mut latest: HashMap<PublicKey, Event> = HashMap::new();
for event in events {
if event.pubkey == self_key || !snapshot.follows.contains(&event.pubkey) {
continue;
}
match latest.get(&event.pubkey) {
Some(current) if current.created_at > event.created_at => continue,
_ => {
latest.insert(event.pubkey, event);
}
}
}
for (author, event) in latest {
for key in event.tags.public_keys().take(MAX_TAGS_PER_LIST) {
if key == author || key == self_key {
continue;
}
*snapshot.scores.entry(key).or_default() += 1;
snapshot.mutuals.entry(key).or_default().push(author);
}
}
for contacts in snapshot.mutuals.values_mut() {
contacts.sort();
}
snapshot
}
}
impl EventEmitter<WoTEvent> for WebOfTrust {}
/// Web of trust
///
/// Scores public keys by how many of the current user's contacts follow them.
#[derive(Debug)]
pub struct WebOfTrust {
/// Current user's public key
self_key: Option<PublicKey>,
/// Latest built snapshot, none until the first refresh completes
snapshot: Option<Snapshot>,
/// In-flight refresh, replaced by newer ones
refresh_task: Option<Task<()>>,
/// Keeps the periodic reconciliation loop alive
_tasks: SmallVec<[Task<()>; 4]>,
}
impl WebOfTrust {
/// Create a new web of trust instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let mut tasks = smallvec![];
tasks.push(cx.spawn(async move |this, cx| {
let mut delay = Duration::from_secs(5);
loop {
cx.background_executor().timer(delay).await;
let mut has_data = false;
// Ignore errors, the next refresh will retry
let _ = this.update(cx, |this, cx| {
this.refresh(cx);
has_data = this.has_data();
});
delay = if has_data {
Duration::from_secs(SYNC_INTERVAL)
} else {
(delay * 2).min(Duration::from_secs(MAX_RETRY_WAIT))
};
}
}));
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
this.hydrate(cx);
});
});
Self {
self_key: None,
snapshot: None,
refresh_task: None,
_tasks: tasks,
}
}
/// Load the graph for the current account
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(self_key) = nostr.read(cx).current_user() else {
return;
};
self.self_key = Some(self_key);
self.refresh(cx);
}
/// Clear the graph on sign out or account switch
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.self_key = None;
self.snapshot = None;
self.refresh_task = None;
cx.emit(WoTEvent);
cx.notify();
}
/// Reconcile the follow set's contact lists with the relays and rebuild.
fn refresh(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(self_key) = nostr.read(cx).current_user() else {
return;
};
self.refresh_task = Some(cx.spawn(async move |this, cx| {
// Scope the reconcile to the follow set known so far
let known: HashSet<PublicKey> = stored_contact_list(&client, self_key)
.await
.map(|event| event.tags.public_keys().take(MAX_FOLLOWS + 1).collect())
.unwrap_or_default();
let mut authors: Vec<PublicKey> = known.iter().copied().collect();
authors.push(self_key);
// Negentropy transfers only the lists the relays hold
// and the database is missing
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
if let Err(error) = client.sync(filter).await {
log::warn!("Failed to reconcile contact lists: {error}");
}
// The follow set after the reconcile
let Some(own) = stored_contact_list(&client, self_key).await else {
return;
};
let follows: HashSet<PublicKey> =
own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
let mut authors: Vec<PublicKey> = follows.iter().copied().collect();
authors.push(self_key);
let events: Vec<Event> = if follows.is_empty() {
Vec::new()
} else {
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.collect()
};
let snapshot = Snapshot::build(self_key, follows.clone(), events);
log::debug!(
"Web of trust rebuilt: {} follows, {} scored keys",
snapshot.follows.len(),
snapshot.scores.len()
);
// Newly followed authors were not part of the reconcile
let grew = !known.is_superset(&follows);
this.update(cx, |this, cx| {
this.snapshot = Some(snapshot);
cx.emit(WoTEvent);
cx.notify();
})
.ok();
if grew {
this.update(cx, |this, cx| {
this.refresh(cx);
})
.ok();
}
}));
}
/// How many of the current user's contacts follow the public key
pub fn score(&self, public_key: &PublicKey) -> usize {
self.snapshot.as_ref().map_or(0, |snapshot| {
snapshot.scores.get(public_key).copied().unwrap_or(0)
})
}
/// Whether the public key is followed by the user or by any of their contacts
pub fn is_in_network(&self, public_key: &PublicKey) -> bool {
self.snapshot
.as_ref()
.is_some_and(|snapshot| snapshot.follows.contains(public_key))
|| self.score(public_key) > 0
}
/// Whether the graph is loaded and the public key is outside the network
pub fn is_outside(&self, public_key: &PublicKey) -> bool {
self.is_loaded() && !self.is_in_network(public_key)
}
/// The current user's contacts who follow the public key
pub fn mutuals(&self, public_key: &PublicKey) -> Vec<PublicKey> {
self.snapshot.as_ref().map_or_else(Vec::new, |snapshot| {
snapshot
.mutuals
.get(public_key)
.cloned()
.unwrap_or_default()
})
}
/// Whether the graph is loaded and score queries are meaningful
pub fn is_loaded(&self) -> bool {
self.snapshot
.as_ref()
.is_some_and(|snapshot| !snapshot.disabled)
}
/// Whether the snapshot covers a follow set
fn has_data(&self) -> bool {
self.snapshot
.as_ref()
.is_some_and(|snapshot| !snapshot.follows.is_empty())
}
}
/// The newest stored contact list of the public key
async fn stored_contact_list(client: &Client, self_key: PublicKey) -> Option<Event> {
let filter = Filter::new()
.author(self_key)
.kind(Kind::ContactList)
.limit(1);
client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
}
+10 -36
View File
@@ -14,6 +14,7 @@ pub fn init(cx: &mut App) {
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band"; const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Built-in image shown behind chat and group panels /// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg"; pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
@@ -58,7 +59,9 @@ setting_accessors! {
pub appearance_mode: AppearanceMode, pub appearance_mode: AppearanceMode,
pub hide_avatar: bool, pub hide_avatar: bool,
pub screening: bool, pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool, pub nip4e: bool,
pub hide_group_notice: bool,
pub file_server: Url, pub file_server: Url,
pub panel_background: bool, pub panel_background: bool,
pub panel_background_path: Option<PathBuf>, pub panel_background_path: Option<PathBuf>,
@@ -124,34 +127,30 @@ impl RoomConfig {
pub struct Settings { pub struct Settings {
#[serde(default, alias = "theme_mode")] #[serde(default, alias = "theme_mode")]
pub appearance_mode: AppearanceMode, pub appearance_mode: AppearanceMode,
/// Hide user avatars /// Hide user avatars
pub hide_avatar: bool, pub hide_avatar: bool,
/// Enable screening for unknown chat requests /// Enable screening for unknown chat requests
pub screening: bool, pub screening: bool,
/// Promote chat requests from people followed by the user's contacts
pub wot_auto_trust: bool,
/// Enable decoupling encryption key /// Enable decoupling encryption key
pub nip4e: bool, pub nip4e: bool,
/// Hide the notice that messages in public groups are not encrypted
#[serde(default)]
pub hide_group_notice: bool,
/// Show an image behind chat and group panels /// Show an image behind chat and group panels
#[serde(default)] #[serde(default)]
pub panel_background: bool, pub panel_background: bool,
/// Custom image for the panel background; the built-in one when unset /// Custom image for the panel background; the built-in one when unset
#[serde(default)] #[serde(default)]
pub panel_background_path: Option<PathBuf>, pub panel_background_path: Option<PathBuf>,
/// Trusted relays; Coop will automatically authenticate with these relays /// Trusted relays; Coop will automatically authenticate with these relays
pub trusted_relays: Vec<String>, pub trusted_relays: Vec<String>,
/// Server for blossom media attachments /// Server for blossom media attachments
pub file_server: Url, pub file_server: Url,
/// Pinned chat rooms, by conversation ID /// Pinned chat rooms, by conversation ID
#[serde(default)] #[serde(default)]
pub pinned_rooms: Vec<u64>, pub pinned_rooms: Vec<u64>,
/// Pinned groups, by cache tag /// Pinned groups, by cache tag
#[serde(default)] #[serde(default)]
pub pinned_groups: Vec<String>, pub pinned_groups: Vec<String>,
@@ -163,7 +162,9 @@ impl Default for Settings {
appearance_mode: AppearanceMode::default(), appearance_mode: AppearanceMode::default(),
hide_avatar: false, hide_avatar: false,
screening: true, screening: true,
wot_auto_trust: true,
nip4e: false, nip4e: false,
hide_group_notice: false,
panel_background: true, panel_background: true,
panel_background_path: None, panel_background_path: None,
trusted_relays: vec![], trusted_relays: vec![],
@@ -333,30 +334,3 @@ impl AppSettings {
}); });
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
// Every field the persisted format has always required; `partial`
// contributes the fields under test.
serde_json::from_str(&format!(
r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false,
"trusted_relays": [], "file_server": "https://nostr.download/" }}"#,
partial
))
}
#[test]
fn old_theme_mode_key_keeps_its_pinned_choice() {
let settings = json_with(r#""theme_mode": "Dark", "#).unwrap();
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
}
#[test]
fn missing_appearance_mode_falls_back_to_dark() {
let settings = json_with("").unwrap();
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
}
}
+7 -19
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -260,15 +261,9 @@ impl NostrRegistry {
let uri = NostrConnectUri::parse(base)?; let uri = NostrConnectUri::parse(base)?;
// Construct the nostr connect signer // Construct the nostr connect signer
let mut signer = NostrConnect::new( let signer = NostrConnect::builder(uri, keys)
uri, .auth_url_handler(Arc::new(CoopAuthUrlHandler))
keys, .build()?;
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(signer, cx); this.set_signer(signer, cx);
@@ -447,16 +442,9 @@ impl NostrRegistry {
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// Construct the nostr connect signer let signer = NostrConnect::builder(connect_uri, keys)
let mut signer = NostrConnect::new( .auth_url_handler(Arc::new(CoopAuthUrlHandler))
connect_uri, .build()?;
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Verify the bunker responds before persisting the credential // Verify the bunker responds before persisting the credential
let public_key = signer.get_public_key_async().await?; let public_key = signer.get_public_key_async().await?;
+3 -3
View File
@@ -101,14 +101,14 @@ impl RenderOnce for GroupBox {
v_flex() v_flex()
.id(self.id.unwrap_or("group-box".into())) .id(self.id.unwrap_or("group-box".into()))
.w_full() .w_full()
.when(has_paddings, |this| this.gap_3()) .when(has_paddings, |this| this.gap_1p5())
.when(!has_paddings, |this| this.gap_4()) .when(!has_paddings, |this| this.gap_2())
.refine_style(&self.style) .refine_style(&self.style)
.when_some(self.title, |this, title| { .when_some(self.title, |this, title| {
this.child( this.child(
div() div()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.text_style(TextStyle::Callout) .text_style(TextStyle::Subheadline)
.font_semibold() .font_semibold()
.child(title), .child(title),
) )
+13 -8
View File
@@ -660,14 +660,19 @@ impl PopupMenu {
.border_color(cx.theme().border_faint) .border_color(cx.theme().border_faint)
.disabled(true), .disabled(true),
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child( PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
h_flex().cursor_default().items_center().gap_x_1().child( h_flex()
div() .cursor_default()
.flex_1() .items_center()
.text_style(TextStyle::Callout) .gap_x_1()
.font_semibold() .h_6()
.text_color(cx.theme().text_muted) .child(
.child(label.clone()), div()
), .flex_1()
.text_style(TextStyle::Footnote)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone()),
),
), ),
PopupMenuItem::Item { PopupMenuItem::Item {
icon, icon,
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |this, _window, _cx| { window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(380.)) this.width(px(380.))
.title("Join Group") .title("Join Public Group")
.on_ok({ .on_ok({
let join = join.clone(); let join = join.clone();
move |event, window, cx| join(event, window, cx) move |event, window, cx| join(event, window, cx)
+15
View File
@@ -459,9 +459,24 @@ impl Render for ProfileDialog {
.filter(|website| !website.trim().is_empty()) .filter(|website| !website.trim().is_empty())
.map(SharedString::from); .map(SharedString::from);
let score = {
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
wot.is_loaded().then(|| wot.score(&self.public_key))
}
.map(|score| {
SharedString::from(format!(
"{} mutual {}",
score,
if score == 1 { "contact" } else { "contacts" }
))
});
this.child(field("Name", name, "Unknown", cx)) this.child(field("Name", name, "Unknown", cx))
.child(field("About", about, "No bio", cx)) .child(field("About", about, "No bio", cx))
.child(field("Website", website, "No website", cx)) .child(field("Website", website, "No website", cx))
.when(!self.is_current_user, |this| {
this.child(field("Web of Trust", score, "Not loaded yet", cx))
})
}) })
.child( .child(
h_flex() h_flex()
+65 -29
View File
@@ -9,7 +9,7 @@ use gpui::{
}; };
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, Person, shorten_pubkey}; use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
@@ -33,14 +33,19 @@ pub struct Screening {
last_active: Option<Timestamp>, last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened. /// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>, mutual_contacts: Vec<PublicKey>,
/// Whether the fallback lookup for mutual contacts has run.
mutuals_fallback: bool,
/// Person directory for the person and their mutual contacts /// Person directory for the person and their mutual contacts
directory: Entity<Directory>, directory: Entity<Directory>,
tasks: SmallVec<[Task<()>; 3]>, tasks: SmallVec<[Task<()>; 3]>,
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
impl Screening { impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let wot = PersonRegistry::global(cx).read(cx).wot();
let directory = Directory::new(cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| { subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
@@ -48,22 +53,28 @@ impl Screening {
window.close_all_dialogs(cx); window.close_all_dialogs(cx);
})); }));
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
cx.notify();
}));
directory.update(cx, |directory, cx| {
directory.track([public_key], cx);
});
cx.defer_in(window, |this, _window, cx| { cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx); this.check_contact(cx);
this.check_wot(cx); this.fetch_fallback(cx);
this.check_last_activity(cx); this.check_last_activity(cx);
this.verify_identifier(cx); this.verify_identifier(cx);
}); });
let directory = Directory::new(cx);
directory.update(cx, |directory, cx| directory.track([public_key], cx));
Self { Self {
public_key, public_key,
verified: false, verified: false,
followed: false, followed: false,
last_active: None, last_active: None,
mutual_contacts: vec![], mutual_contacts: vec![],
mutuals_fallback: false,
directory, directory,
tasks: smallvec![], tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
@@ -110,42 +121,48 @@ impl Screening {
})); }));
} }
fn check_wot(&mut self, cx: &mut Context<Self>) { /// Fetch mutual contacts from the database while the web of trust loads
fn fetch_fallback(&mut self, cx: &mut Context<Self>) {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
if self.mutuals_fallback || persons.read(cx).wot().read(cx).is_loaded() {
return;
}
self.mutuals_fallback = true;
let public_key = self.public_key; let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else { let Some(current_user) = nostr.read(cx).current_user() else {
return; return;
}; };
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move { let task: Task<Vec<PublicKey>> = cx.background_spawn(async move {
// Check mutual contacts // Check mutual contacts among the locally stored contact lists
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![]; let mut contacts = vec![];
if let Ok(events) = client.database().query(filter).await { if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) { for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey); contacts.push(event.pubkey);
} }
} }
Ok(mutual_contacts) contacts
}); });
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { let contacts = task.await;
Ok(contacts) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.mutual_contacts = contacts; if persons.read(cx).wot().read(cx).is_loaded() {
cx.notify(); return;
})
.ok();
} }
Err(e) => { this.mutual_contacts = contacts;
log::error!("Failed to fetch mutual contacts: {}", e); cx.notify();
} })
}; .ok();
})); }));
} }
@@ -263,8 +280,12 @@ impl Screening {
})); }));
} }
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn mutual_contacts(
let contacts = self.mutual_contacts.clone(); &mut self,
contacts: Vec<PublicKey>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let directory = self.directory.clone(); let directory = self.directory.clone();
window.open_dialog(cx, move |this, _window, _cx| { window.open_dialog(cx, move |this, _window, _cx| {
@@ -317,11 +338,23 @@ impl Render for Screening {
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address"; const NO_NIP05: &str = "This person has not set up their friendly address";
let persons = PersonRegistry::global(cx);
let profile = self.profile(cx); let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8); let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let last_active = self.last_active.map(|_| true); let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
// Pull from the web of trust once loaded, else use the database fallback
let mutual_contacts = {
let wot = persons.read(cx).wot().read(cx);
if wot.is_loaded() {
wot.mutuals(&self.public_key)
} else {
self.mutual_contacts.clone()
}
};
let mutuals = mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals); let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex() v_flex()
@@ -365,7 +398,7 @@ impl Render for Screening {
Button::new("njump") Button::new("njump")
.icon(IconName::Link) .icon(IconName::Link)
.label("njump.me") .label("njump.me")
.secondary() .ghost_alt()
.small() .small()
.rounded() .rounded()
.on_click(cx.listener(move |this, _e, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
@@ -376,7 +409,6 @@ impl Render for Screening {
Button::new("report") Button::new("report")
.tooltip("Report as a scam or impostor") .tooltip("Report as a scam or impostor")
.icon(IconName::Warning) .icon(IconName::Warning)
.small()
.warning() .warning()
.rounded() .rounded()
.on_click(cx.listener(move |this, _e, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
@@ -515,7 +547,11 @@ impl Render for Screening {
.rounded() .rounded()
.disabled(mutuals == 0) .disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx); this.mutual_contacts(
mutual_contacts.clone(),
window,
cx,
);
})), })),
), ),
), ),
+11
View File
@@ -83,12 +83,14 @@ impl Preferences {
impl Render for Preferences { impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an 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 AVATAR: &str = "Hide all avatar pictures to improve performance.";
const MODE: &str = "Follow the OS, or always use light or dark."; const MODE: &str = "Follow the OS, or always use light or dark.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const BACKGROUND: &str = "Show an image behind chat and group panels."; const BACKGROUND: &str = "Show an image behind chat and group panels.";
let screening = AppSettings::get_screening(cx); let screening = AppSettings::get_screening(cx);
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx); let nip4e = AppSettings::get_nip4e(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx); let appearance_mode = AppSettings::get_appearance_mode(cx);
@@ -116,6 +118,15 @@ impl Render for Preferences {
AppSettings::update_screening(!screening, cx); AppSettings::update_screening(!screening, cx);
}), }),
) )
.child(
Switch::new("wot-auto-trust")
.label("Web of Trust")
.description(WOT)
.checked(wot_auto_trust)
.on_click(move |_, _window, cx| {
AppSettings::update_wot_auto_trust(!wot_auto_trust, cx);
}),
)
.child( .child(
Switch::new("avatar") Switch::new("avatar")
.label("Hide user avatar") .label("Hide user avatar")
+3 -1
View File
@@ -205,9 +205,11 @@ impl Workspace {
}); });
}); });
let greeter_dock = dock.downgrade();
cx.defer_in(window, move |this, window, cx| { cx.defer_in(window, move |this, window, cx| {
let sidebar = PanelHandle::new(left_sidebar); let sidebar = PanelHandle::new(left_sidebar);
let greeter = PanelHandle::new(greeter::init(window, cx)); let greeter = PanelHandle::new(greeter::init(greeter_dock, window, cx));
this.dock.update(cx, |this, cx| { this.dock.update(cx, |this, cx| {
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx); let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
+478 -84
View File
@@ -1,38 +1,482 @@
use std::time::Duration;
use anyhow::Error;
use chat::{ChatRegistry, Message, Room, RoomKind};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, px, svg, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, svg,
}; };
use state::NostrRegistry; use nostr_sdk::prelude::*;
use theme::{ActiveTheme, TextStyle, Typeset as _}; use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent}; use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle, add_panel_to};
use ui::group_box::{GroupBox, GroupBoxVariants};
use ui::nav::Nav;
use ui::scroll::ScrollableElement;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use crate::dialogs::profile; use super::browse;
use crate::sidebar::open_room;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> { /// How many requests and follow ups to show per section.
cx.new(|cx| GreeterPanel::new(window, cx)) const SECTION_SIZE: usize = 3;
/// Delay to coalesce bursts of chat registry updates into one reload.
const REFRESH_DELAY: Duration = Duration::from_millis(300);
/// Public posts browser on the web.
const JUMBLE_URL: &str = "https://jumble.social";
/// Rooms with their kind, paired with the task loading their messages
type PendingMessages = Vec<(
Entity<Room>,
RoomKind,
Task<Result<Vec<UnsignedEvent>, Error>>,
)>;
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
cx.new(|cx| GreeterPanel::new(dock, window, cx))
} }
pub struct GreeterPanel { pub struct GreeterPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock: WeakEntity<DockArea>,
/// Open chat requests paired with their last message, newest first
requests: Vec<(Entity<Room>, SharedString)>,
/// Ongoing chats whose latest message is not from the user, with previews
follow_ups: Vec<(Entity<Room>, SharedString)>,
/// The async load of the greeter's data
load_task: Option<Task<Result<(), Error>>>,
/// The coalesced reload scheduled after registry updates
refresh_task: Option<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>,
} }
impl GreeterPanel { impl GreeterPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self { fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let mut subscriptions = smallvec![];
subscriptions.push(
// Reload when the user signs in or out
cx.subscribe(&nostr, |this, _, event, cx| {
if let StateEvent::SignerChanged = event {
this.load(cx);
}
}),
);
subscriptions.push(
// Redraw when the web of trust loads or changes
cx.subscribe(&wot, |_, _, _, cx| {
cx.notify();
}),
);
subscriptions.push(
// Reload soon after rooms load, messages arrive, or requests change
cx.observe(&chat, |this, _, cx| {
this.schedule_refresh(cx);
}),
);
// Run at the end of the current cycle
cx.defer_in(window, |this, _window, cx| {
this.load(cx);
});
Self { Self {
name: "Onboarding".into(), name: "Onboarding".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock,
requests: vec![],
follow_ups: vec![],
load_task: None,
refresh_task: None,
_subscriptions: subscriptions,
} }
} }
fn open_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) { /// Load the data for the sections shown while signed in
fn load(&mut self, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
if let Some(public_key) = nostr.read(cx).current_user() { let Some(public_key) = nostr.read(cx).current_user() else {
profile::open(public_key, window, cx); // Dropping the tasks cancels any in-flight load
self.load_task = None;
self.refresh_task = None;
self.requests.clear();
self.follow_ups.clear();
cx.notify();
return;
};
// The latest message of each room drives its preview and follow up state
let pending: PendingMessages = chat
.read(cx)
.rooms(&RoomKind::Ongoing, cx)
.into_iter()
.chain(chat.read(cx).rooms(&RoomKind::Request, cx))
.map(|room| {
let kind = room.read(cx).kind;
let messages = room.read(cx).get_messages(cx);
(room, kind, messages)
})
.collect();
self.load_task.replace(cx.spawn(async move |this, cx| {
let mut requests = Vec::new();
let mut follow_ups = Vec::new();
for (room, kind, messages) in pending {
let messages = match messages.await {
Ok(messages) => messages,
Err(error) => {
log::warn!("Failed to load messages for the greeter: {error}");
continue;
}
};
let Some(last) = messages.last() else {
continue;
};
let preview = Message::from(last).preview();
if kind == RoomKind::Request {
requests.push((room, preview));
} else if last.pubkey != public_key {
follow_ups.push((room, preview));
}
}
this.update(cx, |this, cx| {
follow_ups.sort_by(|a, b| b.0.read(cx).cmp(a.0.read(cx)));
follow_ups.truncate(SECTION_SIZE);
this.follow_ups = follow_ups;
this.requests = requests;
cx.notify();
})?;
Ok(())
}));
}
/// Schedule one reload, collapsing bursts of registry updates
fn schedule_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh_task.replace(cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DELAY).await;
this.update(cx, |this, cx| this.load(cx))?;
Ok(())
}));
}
fn render_requests(&mut self, cx: &mut Context<Self>) -> AnyElement {
// Spam requests stay out of the greeter entirely
let requests: Vec<_> = self
.requests
.iter()
.filter(|(room, _)| !room.read(cx).is_spam(cx))
.cloned()
.collect();
let dock = self.dock.clone();
let rows: Vec<AnyElement> = if requests.is_empty() {
vec![
h_flex()
.h_16()
.w_full()
.justify_center()
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(px(button_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child("No chat requests")
.into_any_element(),
]
} else {
requests
.iter()
.enumerate()
.map(|(index, (room, preview))| {
let room = room.clone();
let dock = dock.clone();
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let preview = preview.clone();
h_flex()
.id(format!("request-{index}"))
.group("")
.w_full()
.h_9()
.px_1p5()
.gap_2()
.rounded(px(button_radius()))
.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
open_room(&room, &dock, window, cx);
}
})
.child(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
.child(
div()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.child(name),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.italic()
.child(preview),
)
.child(
div()
.relative()
.flex_shrink_0()
.h_6()
.flex()
.items_center()
.justify_end()
.child(
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.group_hover("", |this| this.invisible())
.child(
div()
.text_align(gpui::TextAlign::Right)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(created_at),
),
)
.child(
Button::new(format!("response-{index}"))
.label("Response")
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
cx.stop_propagation();
open_room(&room, &dock, window, cx);
}
}),
),
)
.into_any_element()
})
.collect()
};
GroupBox::new()
.id("requests")
.title("Requests")
.fill()
.child(v_flex().gap_0().w_full().children(rows))
.into_any_element()
}
fn render_follow_ups(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.follow_ups.is_empty() {
return None;
} }
let dock = self.dock.clone();
let rows: Vec<AnyElement> = self
.follow_ups
.iter()
.enumerate()
.map(|(index, (room, preview))| {
let room = room.clone();
let dock = dock.clone();
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let preview = preview.clone();
h_flex()
.id(format!("follow-up-{index}"))
.group("")
.w_full()
.h_9()
.px_1p5()
.gap_2()
.rounded(px(button_radius()))
.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
open_room(&room, &dock, window, cx);
}
})
.child(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
.child(
div()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.child(name),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.italic()
.child(preview),
)
.child(
div()
.relative()
.flex_shrink_0()
.h_6()
.flex()
.items_center()
.justify_end()
.child(
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.group_hover("", |this| this.invisible())
.child(
div()
.text_align(gpui::TextAlign::Right)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(created_at),
),
)
.child(
Button::new(format!("reply-{index}"))
.label("Reply")
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let room = room.clone();
let dock = dock.clone();
move |_, window, cx| {
cx.stop_propagation();
open_room(&room, &dock, window, cx);
}
}),
),
)
.into_any_element()
})
.collect();
Some(
GroupBox::new()
.id("follow-ups")
.title("Follow ups")
.fill()
.child(v_flex().gap_0().w_full().children(rows))
.into_any_element(),
)
}
fn render_find_friends(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_color(cx.theme().text_muted)
.text_style(TextStyle::Subheadline)
.font_semibold()
.child("Find new friends"),
)
.child(
v_flex()
.w_full()
.child(
Nav::new("browse-group")
.prefix(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().text_faint),
)
.label("Browse Public Groups")
.text_style(TextStyle::Callout)
.on_click(cx.listener(|this, _, window, cx| {
let panel = browse::init(this.dock.clone(), window, cx);
add_panel_to(
&this.dock,
PanelHandle::new(panel),
DockPlacement::Center,
window,
cx,
);
})),
)
.child(
Nav::new("browse-posts")
.prefix(Icon::new(IconName::Link).small())
.label("Browse Nostr via Jumble")
.text_style(TextStyle::Callout)
.on_click(|_, _, cx| {
cx.open_url(JUMBLE_URL);
}),
),
)
} }
} }
@@ -66,80 +510,30 @@ impl Focusable for GreeterPanel {
impl Render for GreeterPanel { impl Render for GreeterPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const TITLE: &str = "Welcome to Coop!"; let nostr = NostrRegistry::global(cx);
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr."; let logged_in = nostr.read(cx).current_user().is_some();
h_flex() v_flex()
.size_full() .size_full()
.items_center() .when(!logged_in, |this| {
.justify_center() this.items_center().justify_center().child(
.p_2() svg()
.child( .path("brand/coop.svg")
v_flex() .size_12()
.h_full() .text_color(cx.theme().text_faint),
.w_112() )
.gap_6() })
.items_center() .when(logged_in, |this| {
.justify_center() this.child(
.child( v_flex()
h_flex() .size_full()
.mb_4() .p_2()
.gap(px(theme::SPACE)) .gap_6()
.w_full() .child(self.render_requests(cx))
.child( .children(self.render_follow_ups(cx))
svg() .child(self.render_find_friends(cx)),
.path("brand/coop.svg") )
.size_12() })
.text_color(cx.theme().text_faint), .overflow_y_scrollbar()
)
.child(
v_flex()
.child(
div()
.font_semibold()
.text_color(cx.theme().text)
.child(SharedString::from(TITLE)),
)
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(SharedString::from(DESCRIPTION)),
),
),
)
.child(
v_flex()
.gap(px(theme::SPACE))
.w_full()
.child(
h_flex()
.gap(px(theme::SPACE))
.w_full()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Get Started"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.gap(px(theme::SPACE))
.w_full()
.justify_start()
.items_start()
.child(
Button::new("profile")
.icon(Icon::new(IconName::Profile))
.label("Update profile")
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_profile(window, cx)
})),
),
),
),
)
} }
} }
+17 -12
View File
@@ -84,18 +84,23 @@ impl Sidebar {
.ghost() .ghost()
.small() .small()
.dropdown_menu(|menu, _window, _cx| { .dropdown_menu(|menu, _window, _cx| {
menu.menu_with_icon( menu.label("Public Group")
"New Group", .menu_with_icon(
IconName::Group, "New Group",
Box::new(Command::NewGroup), IconName::Group,
) Box::new(Command::NewGroup),
.menu_with_icon("Join Group", IconName::Door, Box::new(Command::JoinGroup)) )
.separator() .menu_with_icon(
.menu_with_icon( "Join Group",
"New Chat", IconName::Door,
IconName::Message, Box::new(Command::JoinGroup),
Box::new(Command::NewChat), )
) .separator()
.menu_with_icon(
"New Chat",
IconName::Message,
Box::new(Command::NewChat),
)
}), }),
) )
.when(self.group_open, |this| { .when(self.group_open, |this| {
+2 -2
View File
@@ -29,7 +29,7 @@ impl ListSection {
pub(super) fn label(self) -> &'static str { pub(super) fn label(self) -> &'static str {
match self { match self {
Self::Pins => "Pinned", Self::Pins => "Pinned",
Self::Groups => "Groups", Self::Groups => "Public Groups",
Self::Chats => "Chats", Self::Chats => "Chats",
} }
} }
@@ -120,7 +120,7 @@ impl Sidebar {
.px_4() .px_4()
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.font_semibold() .font_semibold()
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_muted)
.child(list_title), .child(list_title),
) )
}) })
+9 -1
View File
@@ -13,7 +13,7 @@ use gpui::{
use instant::Duration; use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::Directory; use person::{Directory, PersonRegistry};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent}; use state::{FIND_DELAY, NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _}; use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
@@ -31,6 +31,7 @@ mod onboarding;
mod rows; mod rows;
mod search; mod search;
pub(crate) use rows::open_room;
use search::SearchResult; use search::SearchResult;
pub struct Sidebar { pub struct Sidebar {
@@ -102,6 +103,7 @@ impl Sidebar {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx); let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let find_input = cx.new(|cx| { let find_input = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
@@ -120,6 +122,12 @@ impl Sidebar {
}), }),
); );
subscriptions.push(
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
cx.notify();
}),
);
subscriptions.push( subscriptions.push(
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
match event { match event {
+36 -11
View File
@@ -24,7 +24,12 @@ use super::search::MessageHit;
use crate::dialogs::{profile, screening}; use crate::dialogs::{profile, screening};
/// Open a room in the dock, screening new requests when enabled. /// Open a room in the dock, screening new requests when enabled.
fn open_room(room: &Entity<Room>, dock: &WeakEntity<DockArea>, window: &mut Window, cx: &mut App) { pub(crate) fn open_room(
room: &Entity<Room>,
dock: &WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) {
ui::dock::add_panel_to( ui::dock::add_panel_to(
dock, dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
@@ -88,10 +93,10 @@ impl Sidebar {
Nav::new(label) Nav::new(label)
.label(label) .label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().text_faint)) .suffix(Icon::new(icon).small().text_color(cx.theme().text_muted))
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.font_semibold() .font_semibold()
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_muted)
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
match section { match section {
ListSection::Pins => this.pins_open = !this.pins_open, ListSection::Pins => this.pins_open = !this.pins_open,
@@ -113,7 +118,7 @@ impl Sidebar {
.label(label) .label(label)
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.font_semibold() .font_semibold()
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_muted)
.into_any_element() .into_any_element()
} }
SidebarRow::Empty(text) => v_flex() SidebarRow::Empty(text) => v_flex()
@@ -131,17 +136,20 @@ impl Sidebar {
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(px(button_radius())) .rounded(px(button_radius()))
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_muted)
.child(*text), .child(*text),
) )
.into_any_element(), .into_any_element(),
SidebarRow::Room { room } => { SidebarRow::Room { room } => {
let id = room.read(cx).id;
let name = room.read(cx).display_name(cx); let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx); let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let id = room.read(cx).id;
let member = room.read(cx).members().first().copied(); let member = room.read(cx).members().first().copied();
let spam =
room.read(cx).is_spam(cx) && room.read(cx).kind == RoomKind::Request;
let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
let dock = self.dock.clone(); let dock = self.dock.clone();
let room = room.clone(); let room = room.clone();
@@ -158,11 +166,28 @@ impl Sidebar {
) )
}) })
.suffix( .suffix(
div() h_flex()
.font_normal() .gap_1()
.text_style(TextStyle::Caption) .when(spam, |this| {
.text_color(cx.theme().text_faint) this.child(
.child(created_at), div()
.px_1()
.rounded(px(button_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_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(created_at),
)
}),
) )
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx); open_room(&room, &dock, window, cx);