Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9143de6cfe | ||
|
|
10d74c5396 | ||
|
|
66768e71a6 | ||
|
|
80f4bc387e | ||
|
|
a22aecc694 | ||
|
|
f5afc772b2 | ||
|
|
03f98f5fa8 | ||
|
|
deed021332 |
Generated
+88
-89
@@ -267,7 +267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "assets"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui-pre",
|
||||
@@ -430,7 +430,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "async-utility"
|
||||
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 = [
|
||||
"futures-util",
|
||||
"gloo-timers",
|
||||
@@ -509,7 +509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "auto_update"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"gpui-pre",
|
||||
@@ -933,9 +933,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
|
||||
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -992,7 +992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chat"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
@@ -1015,7 +1015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chat_ui"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chat",
|
||||
@@ -1066,7 +1066,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"inout 0.2.2",
|
||||
"inout 0.2.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1162,9 +1162,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
version = "4.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
@@ -1172,7 +1172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
@@ -1280,7 +1280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coop"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"assets",
|
||||
"auto_update",
|
||||
@@ -1306,7 +1306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coop_web"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"chat",
|
||||
"console_error_panic_hook",
|
||||
@@ -1599,18 +1599,18 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "0.3.100"
|
||||
version = "0.3.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||
checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53"
|
||||
dependencies = [
|
||||
"defmt 1.1.1",
|
||||
"defmt 1.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
@@ -1618,30 +1618,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
@@ -1894,9 +1894,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
@@ -1971,7 +1971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"defmt 0.3.100",
|
||||
"defmt 0.3.101",
|
||||
"heapless 0.8.0",
|
||||
"serde",
|
||||
]
|
||||
@@ -2013,9 +2013,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "flatbuffers"
|
||||
@@ -2117,9 +2117,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.6"
|
||||
version = "0.12.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
|
||||
checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
@@ -2696,7 +2696,7 @@ dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3392,7 +3392,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3616,9 +3616,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
@@ -3826,9 +3826,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
@@ -4258,9 +4258,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "negentropy"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81c353b400a5503efdcf398f11a83fb7aa84f59f5d76fc4bf5bbc1e4f5366caa"
|
||||
checksum = "a3d66662cb5ca384d4991509755ed5a42dd8e31c4282bf881938e506fc3146ec"
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
@@ -4270,7 +4270,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nip29"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chat",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nip29_ui"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chat",
|
||||
@@ -4309,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nip4e"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flume 0.11.1",
|
||||
@@ -4352,7 +4352,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
[[package]]
|
||||
name = "nostr"
|
||||
version = "0.45.5"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
@@ -4378,7 +4378,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-blossom"
|
||||
version = "0.45.1"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitcoin_hashes 1.2.0",
|
||||
@@ -4391,10 +4391,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-connect"
|
||||
version = "0.45.2"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"futures-core",
|
||||
"nostr",
|
||||
"nostr-sdk",
|
||||
"opaquerr 0.2.0",
|
||||
@@ -4405,7 +4404,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-database"
|
||||
version = "0.45.2"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"nostr",
|
||||
"opaquerr 0.2.0",
|
||||
@@ -4414,7 +4413,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-gossip"
|
||||
version = "0.45.1"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"nostr",
|
||||
"opaquerr 0.2.0",
|
||||
@@ -4423,7 +4422,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-gossip-memory"
|
||||
version = "0.45.1"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"lru",
|
||||
@@ -4435,7 +4434,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-lmdb"
|
||||
version = "0.45.3"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"flatbuffers",
|
||||
@@ -4450,7 +4449,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-memory"
|
||||
version = "0.45.2"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"btreecap",
|
||||
"nostr",
|
||||
@@ -4461,7 +4460,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-sdk"
|
||||
version = "0.45.4"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653"
|
||||
source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"async-wsocket",
|
||||
@@ -5157,7 +5156,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "person"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
@@ -5822,7 +5821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types 0.12.6",
|
||||
"font-types 0.12.7",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -5943,7 +5942,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6088,9 +6087,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -6104,9 +6103,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
@@ -6223,9 +6222,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -6477,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settings"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
@@ -6714,7 +6713,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "state"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7006,7 +7005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theme"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui-base",
|
||||
@@ -7164,9 +7163,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
version = "1.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -7520,7 +7519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ui"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"gpui-base",
|
||||
"gpui-pre",
|
||||
@@ -7575,9 +7574,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
@@ -7656,14 +7655,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
|
||||
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
@@ -8043,9 +8042,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -8056,14 +8055,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -8919,7 +8918,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "workspace"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_update",
|
||||
@@ -9179,9 +9178,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
version = "4.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
@@ -9278,18 +9277,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.62"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
|
||||
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.62"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
|
||||
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -9408,9 +9407,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
version = "5.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
|
||||
checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
@@ -9424,9 +9423,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.15.0"
|
||||
version = "5.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
|
||||
+21
-2
@@ -577,7 +577,12 @@ impl ChatRegistry {
|
||||
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 entity = cx.new(|_| room);
|
||||
entity.update(cx, |room, cx| room.ensure_directory(cx));
|
||||
@@ -652,6 +657,11 @@ impl ChatRegistry {
|
||||
|
||||
/// Extend the registry with new rooms.
|
||||
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
|
||||
.rooms
|
||||
.iter()
|
||||
@@ -660,6 +670,13 @@ impl ChatRegistry {
|
||||
.collect();
|
||||
|
||||
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
|
||||
if let Some(&index) = room_map.get(&new_room.id) {
|
||||
self.rooms[index].update(cx, |this, cx| {
|
||||
@@ -789,7 +806,9 @@ impl ChatRegistry {
|
||||
match self.room_index.get(&message.room).cloned() {
|
||||
Some(room) => {
|
||||
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.push_message(message, cx);
|
||||
|
||||
+50
-8
@@ -8,8 +8,8 @@ use instant::Duration;
|
||||
use itertools::Itertools;
|
||||
use nip4e::DeviceRegistry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use person::{Directory, Person, WebOfTrust};
|
||||
use settings::{AppSettings, RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||
@@ -73,22 +73,16 @@ pub enum RoomKind {
|
||||
pub struct Room {
|
||||
/// Conversation ID
|
||||
pub id: u64,
|
||||
|
||||
/// The timestamp of the last message in the room
|
||||
pub created_at: Timestamp,
|
||||
|
||||
/// Subject of the room
|
||||
pub(crate) subject: Option<SharedString>,
|
||||
|
||||
/// All members of the room
|
||||
pub(super) members: Vec<PublicKey>,
|
||||
|
||||
/// Kind
|
||||
pub kind: RoomKind,
|
||||
|
||||
/// Configuration
|
||||
config: RoomConfig,
|
||||
|
||||
/// Person directory, attached when the room is registered
|
||||
directory: Option<Entity<Directory>>,
|
||||
}
|
||||
@@ -189,6 +183,54 @@ impl Room {
|
||||
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 = WebOfTrust::global(cx).read(cx);
|
||||
|
||||
Self::trusted_by(&self.members, me, |key| wot.is_in_network(key))
|
||||
}
|
||||
|
||||
/// Whether the room looks like spam: everyone other than the current user
|
||||
/// is outside the web of trust
|
||||
pub fn is_spam(&self, cx: &App) -> bool {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let wot = WebOfTrust::global(cx).read(cx);
|
||||
|
||||
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
|
||||
pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
|
||||
@@ -320,6 +320,8 @@ pub struct GroupPanel {
|
||||
attachments: Vec<Url>,
|
||||
/// Whether an upload is in flight.
|
||||
uploading: bool,
|
||||
/// Whether the user closed the encryption notice this visit
|
||||
notice_dismissed: bool,
|
||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
@@ -388,6 +390,7 @@ impl GroupPanel {
|
||||
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
|
||||
head,
|
||||
reply_to: None,
|
||||
notice_dismissed: false,
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
input,
|
||||
attachments: Vec::new(),
|
||||
@@ -741,6 +744,63 @@ impl GroupPanel {
|
||||
.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 {
|
||||
let Some(group) = self.group.upgrade() else {
|
||||
return div().into_any_element();
|
||||
@@ -1218,6 +1278,7 @@ impl Render for GroupPanel {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.child(self.render_encryption_notice(cx))
|
||||
.child(self.render_banner(cx))
|
||||
.child(self.render_pins(cx))
|
||||
.child(
|
||||
|
||||
@@ -11,12 +11,15 @@ use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||
|
||||
mod directory;
|
||||
mod person;
|
||||
mod wot;
|
||||
|
||||
pub use directory::*;
|
||||
pub use person::*;
|
||||
pub use wot::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
WebOfTrust::set_global(cx.new(WebOfTrust::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, 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;
|
||||
|
||||
struct GlobalWebOfTrust(Entity<WebOfTrust>);
|
||||
|
||||
impl Global for GlobalWebOfTrust {}
|
||||
|
||||
/// A change to the web of trust
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WoTEvent {
|
||||
/// The graph changed
|
||||
Updated,
|
||||
/// The initial load finished
|
||||
Ready,
|
||||
}
|
||||
|
||||
impl EventEmitter<WoTEvent> for WebOfTrust {}
|
||||
|
||||
/// Web of trust
|
||||
///
|
||||
/// Scores public keys by how many of the current user's contacts follow them,
|
||||
/// built from the contact lists (NIP-02) of the people the user follows.
|
||||
#[derive(Debug)]
|
||||
pub struct WebOfTrust {
|
||||
/// Current user's public key
|
||||
self_key: Option<PublicKey>,
|
||||
/// Latest known contact list of the current user
|
||||
follows: HashSet<PublicKey>,
|
||||
/// Created at of the latest contact list of the current user
|
||||
follows_at: Option<Timestamp>,
|
||||
/// Latest known contact list per followed author, with its created at
|
||||
lists: HashMap<PublicKey, (Timestamp, HashSet<PublicKey>)>,
|
||||
/// Reverse index: target -> contacts who follow them
|
||||
reverse: HashMap<PublicKey, HashSet<PublicKey>>,
|
||||
/// Whether scoring is disabled due to an oversized follow set
|
||||
disabled: bool,
|
||||
/// Whether the initial load has finished
|
||||
ready: bool,
|
||||
/// Whether the next refresh completes the initial load
|
||||
initial: bool,
|
||||
/// Task loading the graph, replaced by newer loads
|
||||
hydration: Option<Task<()>>,
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: SmallVec<[Task<()>; 4]>,
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl WebOfTrust {
|
||||
/// Retrieve the global web of trust
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalWebOfTrust>().0.clone()
|
||||
}
|
||||
|
||||
/// Set the global web of trust instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalWebOfTrust(state));
|
||||
}
|
||||
|
||||
/// Create a new web of trust instance
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_secs(SYNC_INTERVAL))
|
||||
.await;
|
||||
// Ignore errors, the next refresh will retry
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.sync(cx);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.hydrate(cx);
|
||||
}
|
||||
});
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.hydrate(cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
self_key: None,
|
||||
follows: HashSet::new(),
|
||||
follows_at: None,
|
||||
lists: HashMap::new(),
|
||||
reverse: HashMap::new(),
|
||||
disabled: false,
|
||||
ready: false,
|
||||
initial: false,
|
||||
hydration: None,
|
||||
tasks,
|
||||
_subscriptions: smallvec![subscription],
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the graph for the current account
|
||||
fn hydrate(&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.self_key = Some(self_key);
|
||||
self.initial = true;
|
||||
|
||||
// The locally stored contact list, applied before the relays respond
|
||||
let local: Task<Option<Event>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.author(self_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
});
|
||||
|
||||
self.hydration = Some(cx.spawn(async move |this, cx| {
|
||||
let latest = local.await;
|
||||
let at = latest.as_ref().map(|event| event.created_at);
|
||||
|
||||
let snapshot: HashSet<PublicKey> = latest
|
||||
.as_ref()
|
||||
.map(|event| event.tags.public_keys().take(MAX_FOLLOWS + 1).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Credit the local graph before the relays respond
|
||||
this.update(cx, |this, cx| {
|
||||
if this.self_key == Some(self_key) && this.store_follows(at, snapshot.clone()) {
|
||||
cx.emit(WoTEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Reconcile with the relays; the refresh this triggers finalizes the initial load
|
||||
this.update(cx, |this, cx| {
|
||||
this.sync(cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Reconcile the follow set's contact lists with the relays and apply whatever came back
|
||||
fn sync(&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;
|
||||
};
|
||||
|
||||
let covered: HashSet<PublicKey> = self.follows.clone();
|
||||
|
||||
let mut authors: Vec<PublicKey> = covered.iter().copied().collect();
|
||||
authors.push(self_key);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||
|
||||
// Negentropy transfers only the lists the relays hold and the database is missing
|
||||
if let Err(error) = client.sync(filter).await {
|
||||
log::warn!("Failed to reconcile contact lists: {error}");
|
||||
}
|
||||
|
||||
let mut authors: Vec<PublicKey> = covered.iter().copied().collect();
|
||||
authors.push(self_key);
|
||||
|
||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||
let events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut grew = false;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let changed = this.apply_latest(self_key, events, cx);
|
||||
|
||||
if this.initial {
|
||||
this.initial = false;
|
||||
this.ready = true;
|
||||
cx.emit(WoTEvent::Ready);
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.emit(WoTEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
grew = !covered.is_superset(&this.follows);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// The follow set grew beyond the covered authors,
|
||||
// reconcile once more so their contact lists are included
|
||||
if grew {
|
||||
this.update(cx, |this, cx| {
|
||||
this.sync(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Apply the newest stored contact list per author, returning whether the graph changed
|
||||
fn apply_latest(
|
||||
&mut self,
|
||||
self_key: PublicKey,
|
||||
events: Vec<Event>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if self.self_key != Some(self_key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep the newest contact list per author
|
||||
let mut latest: HashMap<PublicKey, Event> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
if event.pubkey != self_key && (self.disabled || !self.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
for event in latest.into_values() {
|
||||
let was_disabled = self.disabled;
|
||||
|
||||
changed |= if event.pubkey == self_key {
|
||||
self.apply_own(&event)
|
||||
} else {
|
||||
self.apply_contact(&event)
|
||||
};
|
||||
|
||||
if was_disabled && !self.disabled {
|
||||
// The graph was cleared while disabled and has to be rebuilt
|
||||
self.sync(cx);
|
||||
}
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply the current user's contact list, returning whether the graph changed
|
||||
fn apply_own(&mut self, event: &Event) -> bool {
|
||||
if self.follows_at.is_some_and(|at| at > event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let follows: HashSet<PublicKey> = event.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
|
||||
|
||||
self.store_follows(Some(event.created_at), follows)
|
||||
}
|
||||
|
||||
/// Apply a followed author's contact list, returning whether the graph changed
|
||||
fn apply_contact(&mut self, event: &Event) -> bool {
|
||||
if self.disabled || !self.follows.contains(&event.pubkey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let contacts = self.parse_contacts(event);
|
||||
|
||||
self.set_contact_set(event.pubkey, event.created_at, contacts)
|
||||
}
|
||||
|
||||
/// Parse a capped contact set from a contact list event
|
||||
fn parse_contacts(&self, event: &Event) -> HashSet<PublicKey> {
|
||||
event.tags.public_keys().take(MAX_TAGS_PER_LIST).collect()
|
||||
}
|
||||
|
||||
/// Store a follow set, un-credit removed contacts, and report changes
|
||||
fn store_follows(&mut self, at: Option<Timestamp>, follows: HashSet<PublicKey>) -> bool {
|
||||
self.follows_at = at;
|
||||
|
||||
if follows.len() > MAX_FOLLOWS {
|
||||
if self.disabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.follows.clear();
|
||||
self.lists.clear();
|
||||
self.reverse.clear();
|
||||
self.disabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
let removed: Vec<PublicKey> = self.follows.difference(&follows).copied().collect();
|
||||
let added: Vec<PublicKey> = follows.difference(&self.follows).copied().collect();
|
||||
|
||||
let changed = !removed.is_empty() || !added.is_empty();
|
||||
|
||||
self.follows = follows;
|
||||
self.disabled = false;
|
||||
|
||||
for follower in removed {
|
||||
self.uncredit(&follower);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Store a followed author's contact set, returning whether the graph changed
|
||||
fn set_contact_set(
|
||||
&mut self,
|
||||
follower: PublicKey,
|
||||
at: Timestamp,
|
||||
contacts: HashSet<PublicKey>,
|
||||
) -> bool {
|
||||
if let Some((current, _)) = self.lists.get(&follower)
|
||||
&& *current > at
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let contacts: HashSet<PublicKey> = contacts
|
||||
.into_iter()
|
||||
.filter(|key| *key != follower && Some(*key) != self.self_key)
|
||||
.collect();
|
||||
|
||||
let (added, removed) = match self.lists.remove(&follower) {
|
||||
Some((_, previous)) => (
|
||||
contacts
|
||||
.difference(&previous)
|
||||
.copied()
|
||||
.collect::<Vec<PublicKey>>(),
|
||||
previous
|
||||
.difference(&contacts)
|
||||
.copied()
|
||||
.collect::<Vec<PublicKey>>(),
|
||||
),
|
||||
None => (contacts.iter().copied().collect(), Vec::new()),
|
||||
};
|
||||
|
||||
self.lists.insert(follower, (at, contacts));
|
||||
|
||||
let changed = !added.is_empty() || !removed.is_empty();
|
||||
|
||||
for target in added {
|
||||
self.reverse.entry(target).or_default().insert(follower);
|
||||
}
|
||||
|
||||
for target in removed {
|
||||
self.remove_credit(&follower, &target);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Remove a contact's credits from the reverse index
|
||||
fn uncredit(&mut self, follower: &PublicKey) {
|
||||
if let Some((_, contacts)) = self.lists.remove(follower) {
|
||||
for target in contacts {
|
||||
self.remove_credit(follower, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a single credit from the reverse index
|
||||
fn remove_credit(&mut self, follower: &PublicKey, target: &PublicKey) {
|
||||
if let Some(creditors) = self.reverse.get_mut(target) {
|
||||
creditors.remove(follower);
|
||||
|
||||
if creditors.is_empty() {
|
||||
self.reverse.remove(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the graph on sign out or account switch
|
||||
fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.self_key = None;
|
||||
self.follows.clear();
|
||||
self.follows_at = None;
|
||||
self.lists.clear();
|
||||
self.reverse.clear();
|
||||
self.disabled = false;
|
||||
self.ready = false;
|
||||
self.initial = false;
|
||||
self.hydration = None;
|
||||
|
||||
cx.emit(WoTEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// How many of the current user's contacts follow the public key
|
||||
pub fn score(&self, public_key: &PublicKey) -> usize {
|
||||
self.reverse
|
||||
.get(public_key)
|
||||
.map_or(0, |creditors| creditors.len())
|
||||
}
|
||||
|
||||
/// 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.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.ready && !self.disabled && !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.reverse
|
||||
.get(public_key)
|
||||
.map_or_else(Vec::new, |creditors| {
|
||||
let mut contacts: Vec<PublicKey> = creditors.iter().copied().collect();
|
||||
contacts.sort();
|
||||
contacts
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the initial load has finished
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
/// Whether scoring is disabled due to an oversized follow set
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
self.disabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(seed: usize) -> PublicKey {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[30] = (seed >> 8) as u8;
|
||||
bytes[31] = seed as u8;
|
||||
|
||||
Keys::new(SecretKey::from_slice(&bytes).expect("valid secret key")).public_key()
|
||||
}
|
||||
|
||||
fn registry() -> WebOfTrust {
|
||||
WebOfTrust {
|
||||
self_key: None,
|
||||
follows: HashSet::new(),
|
||||
follows_at: None,
|
||||
lists: HashMap::new(),
|
||||
reverse: HashMap::new(),
|
||||
disabled: false,
|
||||
ready: false,
|
||||
initial: false,
|
||||
hydration: None,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_contacts_of_followed_authors() {
|
||||
let mut wot = registry();
|
||||
let (me, alice, bob, carol) = (key(1), key(2), key(3), key(4));
|
||||
|
||||
wot.self_key = Some(me);
|
||||
|
||||
assert!(wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice, bob])));
|
||||
assert!(!wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice, bob])));
|
||||
|
||||
assert!(wot.set_contact_set(alice, Timestamp::from_secs(10), HashSet::from([carol]),));
|
||||
assert_eq!(wot.score(&carol), 1);
|
||||
assert_eq!(wot.mutuals(&carol), vec![alice]);
|
||||
assert!(wot.is_in_network(&carol));
|
||||
|
||||
assert!(wot.set_contact_set(bob, Timestamp::from_secs(10), HashSet::from([carol]),));
|
||||
assert_eq!(wot.score(&carol), 2);
|
||||
assert_eq!(wot.mutuals(&carol), vec![alice, bob]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_older_contact_lists() {
|
||||
let mut wot = registry();
|
||||
let (me, alice, carol, dave) = (key(1), key(2), key(3), key(4));
|
||||
|
||||
wot.self_key = Some(me);
|
||||
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
|
||||
|
||||
assert!(wot.set_contact_set(alice, Timestamp::from_secs(20), HashSet::from([carol]),));
|
||||
assert!(!wot.set_contact_set(alice, Timestamp::from_secs(10), HashSet::from([dave]),));
|
||||
|
||||
assert_eq!(wot.mutuals(&carol), vec![alice]);
|
||||
assert_eq!(wot.score(&dave), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncredits_removed_follows() {
|
||||
let mut wot = registry();
|
||||
let (me, alice, bob, carol) = (key(1), key(2), key(3), key(4));
|
||||
|
||||
wot.self_key = Some(me);
|
||||
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice, bob]));
|
||||
wot.set_contact_set(alice, Timestamp::from_secs(10), HashSet::from([carol]));
|
||||
wot.set_contact_set(bob, Timestamp::from_secs(10), HashSet::from([carol]));
|
||||
assert_eq!(wot.score(&carol), 2);
|
||||
|
||||
assert!(wot.store_follows(Some(Timestamp::from_secs(11)), HashSet::from([bob])));
|
||||
|
||||
assert_eq!(wot.score(&carol), 1);
|
||||
assert_eq!(wot.mutuals(&carol), vec![bob]);
|
||||
assert!(!wot.lists.contains_key(&alice));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_self_and_follower() {
|
||||
let mut wot = registry();
|
||||
let (me, alice, carol) = (key(1), key(2), key(3));
|
||||
|
||||
wot.self_key = Some(me);
|
||||
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
|
||||
|
||||
wot.set_contact_set(
|
||||
alice,
|
||||
Timestamp::from_secs(10),
|
||||
HashSet::from([me, alice, carol]),
|
||||
);
|
||||
|
||||
assert_eq!(wot.score(&me), 0);
|
||||
assert_eq!(wot.score(&alice), 0);
|
||||
assert_eq!(wot.mutuals(&carol), vec![alice]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_on_oversized_follow_set() {
|
||||
let mut wot = registry();
|
||||
let me = key(1);
|
||||
let follows: HashSet<PublicKey> = (2..(MAX_FOLLOWS + 3)).map(key).collect();
|
||||
|
||||
wot.self_key = Some(me);
|
||||
wot.set_contact_set(key(2), Timestamp::from_secs(10), HashSet::from([key(3)]));
|
||||
assert!(wot.store_follows(Some(Timestamp::from_secs(10)), follows));
|
||||
|
||||
assert!(wot.disabled);
|
||||
assert!(wot.follows.is_empty());
|
||||
assert!(wot.lists.is_empty());
|
||||
assert!(wot.reverse.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_requires_a_loaded_graph() {
|
||||
let mut wot = registry();
|
||||
let (me, alice, carol) = (key(1), key(2), key(3));
|
||||
|
||||
wot.self_key = Some(me);
|
||||
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
|
||||
|
||||
// The graph is not loaded yet
|
||||
assert!(!wot.is_outside(&carol));
|
||||
|
||||
wot.ready = true;
|
||||
|
||||
assert!(wot.is_outside(&carol));
|
||||
assert!(!wot.is_outside(&alice));
|
||||
|
||||
// A disabled graph marks nothing as outside
|
||||
wot.disabled = true;
|
||||
|
||||
assert!(!wot.is_outside(&carol));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_ingested_contact_lists() {
|
||||
let mut wot = registry();
|
||||
let (me, alice) = (key(1), key(2));
|
||||
|
||||
wot.self_key = Some(me);
|
||||
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
|
||||
|
||||
let tags: Vec<Tag> = (3..(MAX_TAGS_PER_LIST + 4))
|
||||
.map(|seed| Tag::public_key(key(seed)))
|
||||
.collect();
|
||||
|
||||
let keys = Keys::new(
|
||||
SecretKey::from_slice(&{
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[31] = 2;
|
||||
bytes
|
||||
})
|
||||
.expect("valid secret key"),
|
||||
);
|
||||
|
||||
let event = EventBuilder::new(Kind::ContactList, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys)
|
||||
.expect("valid event");
|
||||
|
||||
let contacts = wot.parse_contacts(&event);
|
||||
|
||||
assert_eq!(contacts.len(), MAX_TAGS_PER_LIST);
|
||||
assert!(!contacts.contains(&key(MAX_TAGS_PER_LIST + 3)));
|
||||
}
|
||||
}
|
||||
+10
-36
@@ -14,6 +14,7 @@ pub fn init(cx: &mut App) {
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
@@ -58,7 +59,9 @@ setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
@@ -124,34 +127,30 @@ impl RoomConfig {
|
||||
pub struct Settings {
|
||||
#[serde(default, alias = "theme_mode")]
|
||||
pub appearance_mode: AppearanceMode,
|
||||
|
||||
/// Hide user avatars
|
||||
pub hide_avatar: bool,
|
||||
|
||||
/// Enable screening for unknown chat requests
|
||||
pub screening: bool,
|
||||
|
||||
/// Promote chat requests from people followed by the user's contacts
|
||||
pub wot_auto_trust: bool,
|
||||
/// Enable decoupling encryption key
|
||||
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
|
||||
#[serde(default)]
|
||||
pub panel_background: bool,
|
||||
|
||||
/// Custom image for the panel background; the built-in one when unset
|
||||
#[serde(default)]
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
|
||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||
pub trusted_relays: Vec<String>,
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Pinned chat rooms, by conversation ID
|
||||
#[serde(default)]
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
|
||||
/// Pinned groups, by cache tag
|
||||
#[serde(default)]
|
||||
pub pinned_groups: Vec<String>,
|
||||
@@ -163,7 +162,9 @@ impl Default for Settings {
|
||||
appearance_mode: AppearanceMode::default(),
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
wot_auto_trust: true,
|
||||
nip4e: false,
|
||||
hide_group_notice: false,
|
||||
panel_background: true,
|
||||
panel_background_path: None,
|
||||
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
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -260,15 +261,9 @@ impl NostrRegistry {
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(uri, keys)
|
||||
.auth_url_handler(Arc::new(CoopAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
@@ -447,16 +442,9 @@ impl NostrRegistry {
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(connect_uri, keys)
|
||||
.auth_url_handler(Arc::new(CoopAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
// Verify the bunker responds before persisting the credential
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
@@ -101,14 +101,14 @@ impl RenderOnce for GroupBox {
|
||||
v_flex()
|
||||
.id(self.id.unwrap_or("group-box".into()))
|
||||
.w_full()
|
||||
.when(has_paddings, |this| this.gap_3())
|
||||
.when(!has_paddings, |this| this.gap_4())
|
||||
.when(has_paddings, |this| this.gap_1p5())
|
||||
.when(!has_paddings, |this| this.gap_2())
|
||||
.refine_style(&self.style)
|
||||
.when_some(self.title, |this, title| {
|
||||
this.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_style(TextStyle::Subheadline)
|
||||
.font_semibold()
|
||||
.child(title),
|
||||
)
|
||||
|
||||
@@ -660,10 +660,15 @@ impl PopupMenu {
|
||||
.border_color(cx.theme().border_faint)
|
||||
.disabled(true),
|
||||
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
|
||||
h_flex().cursor_default().items_center().gap_x_1().child(
|
||||
h_flex()
|
||||
.cursor_default()
|
||||
.items_center()
|
||||
.gap_x_1()
|
||||
.h_6()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_style(TextStyle::Footnote)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label.clone()),
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(380.))
|
||||
.title("Join Group")
|
||||
.title("Join Public Group")
|
||||
.on_ok({
|
||||
let join = join.clone();
|
||||
move |event, window, cx| join(event, window, cx)
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person, shorten_pubkey};
|
||||
use person::{Directory, Person, WebOfTrust, shorten_pubkey};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||
@@ -33,14 +33,19 @@ pub struct Screening {
|
||||
last_active: Option<Timestamp>,
|
||||
/// All mutual contacts of the person being screened.
|
||||
mutual_contacts: Vec<PublicKey>,
|
||||
/// Whether the database fallback for mutual contacts has run.
|
||||
mutuals_fallback: bool,
|
||||
/// Person directory for the person and their mutual contacts
|
||||
directory: Entity<Directory>,
|
||||
tasks: SmallVec<[Task<()>; 3]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let wot = WebOfTrust::global(cx);
|
||||
let directory = Directory::new(cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
|
||||
@@ -48,6 +53,14 @@ impl Screening {
|
||||
window.close_all_dialogs(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| {
|
||||
this.check_wot(cx);
|
||||
}));
|
||||
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([public_key], cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.check_contact(cx);
|
||||
this.check_wot(cx);
|
||||
@@ -55,15 +68,13 @@ impl Screening {
|
||||
this.verify_identifier(cx);
|
||||
});
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
directory.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
mutuals_fallback: false,
|
||||
directory,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
@@ -110,7 +121,25 @@ impl Screening {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Check mutual contacts through the web of trust,
|
||||
/// falling back to the database while it loads
|
||||
fn check_wot(&mut self, cx: &mut Context<Self>) {
|
||||
let trusted = {
|
||||
let wot = WebOfTrust::global(cx).read(cx);
|
||||
(wot.is_ready() && !wot.is_disabled()).then(|| wot.mutuals(&self.public_key))
|
||||
};
|
||||
|
||||
if let Some(contacts) = trusted {
|
||||
self.mutual_contacts = contacts;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.mutuals_fallback {
|
||||
return;
|
||||
}
|
||||
self.mutuals_fallback = true;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
@@ -119,33 +148,33 @@ impl Screening {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
// Check mutual contacts
|
||||
let task: Task<Vec<PublicKey>> = cx.background_spawn(async move {
|
||||
// Check mutual contacts among the locally stored contact lists
|
||||
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 {
|
||||
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| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
let contacts = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let wot = WebOfTrust::global(cx).read(cx);
|
||||
// The web of trust wins once its load has finished
|
||||
if wot.is_ready() && !wot.is_disabled() {
|
||||
return;
|
||||
}
|
||||
this.mutual_contacts = contacts;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch mutual contacts: {}", e);
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -365,7 +394,7 @@ impl Render for Screening {
|
||||
Button::new("njump")
|
||||
.icon(IconName::Link)
|
||||
.label("njump.me")
|
||||
.secondary()
|
||||
.ghost_alt()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
@@ -376,7 +405,6 @@ impl Render for Screening {
|
||||
Button::new("report")
|
||||
.tooltip("Report as a scam or impostor")
|
||||
.icon(IconName::Warning)
|
||||
.small()
|
||||
.warning()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
|
||||
@@ -83,12 +83,14 @@ impl Preferences {
|
||||
impl Render for Preferences {
|
||||
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 WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||
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 BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||
|
||||
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 nip4e = AppSettings::get_nip4e(cx);
|
||||
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||
@@ -116,6 +118,15 @@ impl Render for Preferences {
|
||||
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(
|
||||
Switch::new("avatar")
|
||||
.label("Hide user avatar")
|
||||
|
||||
@@ -205,9 +205,11 @@ impl Workspace {
|
||||
});
|
||||
});
|
||||
|
||||
let greeter_dock = dock.downgrade();
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
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| {
|
||||
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
|
||||
|
||||
@@ -1,38 +1,456 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use chat::{ChatRegistry, Message, Room, RoomKind};
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
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 theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use nostr_sdk::prelude::*;
|
||||
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::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 crate::dialogs::profile;
|
||||
use super::browse;
|
||||
use crate::sidebar::open_room;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||
cx.new(|cx| GreeterPanel::new(window, cx))
|
||||
/// How many requests and follow ups to show per section.
|
||||
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 {
|
||||
name: SharedString,
|
||||
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; 2]>,
|
||||
}
|
||||
|
||||
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 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(
|
||||
// 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 {
|
||||
name: "Onboarding".into(),
|
||||
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);
|
||||
|
||||
if let Some(public_key) = nostr.read(cx).current_user() {
|
||||
profile::open(public_key, window, cx);
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
// 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>) -> Option<AnyElement> {
|
||||
if self.requests.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dock = self.dock.clone();
|
||||
|
||||
let rows: Vec<AnyElement> = self
|
||||
.requests
|
||||
.iter()
|
||||
.take(SECTION_SIZE)
|
||||
.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();
|
||||
|
||||
Some(
|
||||
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 +484,30 @@ impl Focusable for GreeterPanel {
|
||||
|
||||
impl Render for GreeterPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let logged_in = nostr.read(cx).current_user().is_some();
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w_112()
|
||||
.gap_6()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.mb_4()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.child(
|
||||
.size_full()
|
||||
.when(!logged_in, |this| {
|
||||
this.items_center().justify_center().child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().text_faint),
|
||||
)
|
||||
.child(
|
||||
})
|
||||
.when(logged_in, |this| {
|
||||
this.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)
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
.size_full()
|
||||
.p_2()
|
||||
.gap_6()
|
||||
.children(self.render_requests(cx))
|
||||
.children(self.render_follow_ups(cx))
|
||||
.child(self.render_find_friends(cx)),
|
||||
)
|
||||
})
|
||||
.overflow_y_scrollbar()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +84,17 @@ impl Sidebar {
|
||||
.ghost()
|
||||
.small()
|
||||
.dropdown_menu(|menu, _window, _cx| {
|
||||
menu.menu_with_icon(
|
||||
menu.label("Public Group")
|
||||
.menu_with_icon(
|
||||
"New Group",
|
||||
IconName::Group,
|
||||
Box::new(Command::NewGroup),
|
||||
)
|
||||
.menu_with_icon("Join Group", IconName::Door, Box::new(Command::JoinGroup))
|
||||
.menu_with_icon(
|
||||
"Join Group",
|
||||
IconName::Door,
|
||||
Box::new(Command::JoinGroup),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"New Chat",
|
||||
|
||||
@@ -29,7 +29,7 @@ impl ListSection {
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pins => "Pinned",
|
||||
Self::Groups => "Groups",
|
||||
Self::Groups => "Public Groups",
|
||||
Self::Chats => "Chats",
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ impl Sidebar {
|
||||
.px_4()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(list_title),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ mod onboarding;
|
||||
mod rows;
|
||||
mod search;
|
||||
|
||||
pub(crate) use rows::open_room;
|
||||
use search::SearchResult;
|
||||
|
||||
pub struct Sidebar {
|
||||
|
||||
@@ -24,7 +24,12 @@ use super::search::MessageHit;
|
||||
use crate::dialogs::{profile, screening};
|
||||
|
||||
/// Open a room in the dock, screening new requests when enabled.
|
||||
fn open_room(room: &Entity<Room>, dock: &WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
pub(crate) fn open_room(
|
||||
room: &Entity<Room>,
|
||||
dock: &WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
ui::dock::add_panel_to(
|
||||
dock,
|
||||
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
||||
@@ -88,10 +93,10 @@ impl Sidebar {
|
||||
|
||||
Nav::new(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)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
match section {
|
||||
ListSection::Pins => this.pins_open = !this.pins_open,
|
||||
@@ -113,7 +118,7 @@ impl Sidebar {
|
||||
.label(label)
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::Empty(text) => v_flex()
|
||||
@@ -131,17 +136,20 @@ impl Sidebar {
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(button_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(*text),
|
||||
)
|
||||
.into_any_element(),
|
||||
SidebarRow::Room { room } => {
|
||||
let id = room.read(cx).id;
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let id = room.read(cx).id;
|
||||
let member = room.read(cx).members().first().copied();
|
||||
let 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 dock = self.dock.clone();
|
||||
let room = room.clone();
|
||||
@@ -158,12 +166,29 @@ impl Sidebar {
|
||||
)
|
||||
})
|
||||
.suffix(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.when(spam, |this| {
|
||||
this.child(
|
||||
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| {
|
||||
open_room(&room, &dock, window, cx);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Web of Trust — implementation plan
|
||||
|
||||
A plan for scoring strangers by the user's follow graph, based on how Amethyst
|
||||
implements web of trust. The goal is to make the screening dialog and the chat
|
||||
requests list more useful: "3 of your contacts follow this person" instead of a
|
||||
bare npub, and a way to let trusted strangers skip screening.
|
||||
|
||||
## 1. How Amethyst does it
|
||||
|
||||
Amethyst (vitorpamplona/amethyst) has two independent WoT layers:
|
||||
|
||||
**Layer 1 — follow-graph score (`WoTService`)**, in
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`:
|
||||
|
||||
- For every pubkey `X`, the score is the number of people the user follows who
|
||||
also follow `X` (friends-of-friends, computed only from kind 3 contact lists).
|
||||
- The user's own follow set and each followed user's kind 3 feed the graph; the
|
||||
service keeps a reverse index `target -> {followers}` and derives scores from
|
||||
set sizes.
|
||||
- Purely in memory, rebuilt on start; a single writer coroutine serializes
|
||||
updates via an op channel and diffs each follower's list on republish, so a
|
||||
changed kind 3 only touches the entries it added/removed.
|
||||
- Guardrails: WoT is disabled when the user follows more than `MAX_FOLLOWS`
|
||||
(2000) accounts, and each ingested contact list is capped
|
||||
(`MAX_FOLLOWS_PER_EVENT`, 5000) to bound hostile publishers.
|
||||
- A `ready` flag (set on first EOSE) tells the UI when scores are usable.
|
||||
- Consumers sort people follows-first, then by score (`PeopleOrder.kt`) and show
|
||||
a desktop badge.
|
||||
|
||||
**Layer 2 — NIP-85 trust-provider index**, in
|
||||
`commons/.../wot/network/` and `quartz/.../nip85TrustedAssertions/users/index/`:
|
||||
|
||||
- The user names a "trust provider" (e.g. Brainstorm/GrapeRank) in a kind 10040
|
||||
event; the provider publishes a signed rank card (kind 30382) for every member
|
||||
of the user's extended network (~150–300k events).
|
||||
- The client downloads the whole set once, verifies every signature, and stores
|
||||
a compact binary index (~6 MB: 128-bit key prefixes + rank/hops/followers
|
||||
arrays) with cursor-based incremental sync (NIP-45 COUNT + NIP-77 negentropy).
|
||||
- The gate is `accepted(pk) = pk == me || pk in follows || rank(pk) >= minScore`
|
||||
and it drives the DM Known/New split, the "Curated" notification filter, push
|
||||
filtering, and collapsing out-of-network replies. Exemptions live in one
|
||||
predicate (`TrustVerdicts.kt` / `CuratedTrustFilter.kt`) so in-app and push
|
||||
can't drift: zaps always pass, chats follow the DM rule, everything else is
|
||||
gated on network membership.
|
||||
|
||||
### What we take, what we skip
|
||||
|
||||
| Amethyst piece | Take? |
|
||||
|---|---|
|
||||
| Friends-of-friends score, reverse index, diffing, caps, ready flag | Yes — this is the whole feature for coop |
|
||||
| Follows-first ordering helper | Only if we ever list non-contacts; not needed now |
|
||||
| NIP-85 provider onboarding, kind 30382 index, negentropy sync, binary files | No — massive sync + storage complexity for a DM client |
|
||||
| One shared exemption predicate per surface | Yes as a principle: gate requests in one place |
|
||||
|
||||
Layer 2 exists because Amethyst gates a 300k-user social graph. Coop gates
|
||||
incoming DMs; the follow graph (one hop of contact lists) is enough signal and
|
||||
needs no new events, relays, or storage formats.
|
||||
|
||||
## 2. Design: `WebOfTrust` in the `person` crate
|
||||
|
||||
One new logical component, one new file: `crates/person/src/wot.rs`, re-exported
|
||||
from `crates/person/src/lib.rs` and initialized in `person::init` as a global
|
||||
entity, exactly like `PersonRegistry`.
|
||||
|
||||
### State
|
||||
|
||||
```rust
|
||||
pub struct WebOfTrust {
|
||||
/// The signed-in user's contacts (latest kind 3)
|
||||
follows: HashSet<PublicKey>,
|
||||
/// The current user, excluded from credit
|
||||
self_key: Option<PublicKey>,
|
||||
/// Per-follower cached follow set, for diffing on republish
|
||||
lists: HashMap<PublicKey, HashSet<PublicKey>>,
|
||||
/// Reverse index: target -> contacts who follow them
|
||||
reverse: HashMap<PublicKey, HashSet<PublicKey>>,
|
||||
/// Disabled when the follow set exceeds MAX_FOLLOWS
|
||||
disabled: bool,
|
||||
ready: bool,
|
||||
tasks, subscriptions, ...
|
||||
}
|
||||
```
|
||||
|
||||
API (all reads are plain methods; writes go through the entity):
|
||||
|
||||
```rust
|
||||
pub fn score(&self, key: &PublicKey) -> usize // reverse[key].len()
|
||||
pub fn is_in_network(&self, key: &PublicKey) -> bool // follows || score >= 1
|
||||
pub fn mutuals(&self, key: &PublicKey) -> Vec<PublicKey> // who vouches, for the dialog
|
||||
pub fn is_ready(&self) -> bool
|
||||
pub fn is_disabled(&self) -> bool
|
||||
```
|
||||
|
||||
Events: `WoTEvent::Updated` and `WoTEvent::Ready`, emitted after hydration and
|
||||
on every graph change; consumers subscribe like they do to `PersonRegistry`.
|
||||
|
||||
### Data flow — reuses the existing pattern end to end
|
||||
|
||||
Same shape as `PersonRegistry::new` (background listener -> flume channel ->
|
||||
entity update -> `cx.emit`):
|
||||
|
||||
1. **Hydrate from the local database** on creation and on
|
||||
`StateEvent::SignerChanged`:
|
||||
- own kind 3 (newest event, `limit(1)` like `Directory::track` and
|
||||
`query_chat_rooms` do) -> `follows`;
|
||||
- applied immediately so the dialog has data before the relays respond.
|
||||
2. **Stay fresh with NIP-77** (`WebOfTrust::sync`, no persistent subscription):
|
||||
- `client.sync(Filter::new().kind(Kind::ContactList).authors(follows + self))`
|
||||
reconciles with every relay in the pool and downloads only the contact
|
||||
lists the local database is missing (negentropy items come from the
|
||||
database, direction is down-only);
|
||||
- after each reconcile, the newest stored list per covered author is applied
|
||||
to the graph and a `Ready`/`Updated` event marks what changed;
|
||||
- triggers: hydrate, a 15-minute timer (Amethyst's D10 cadence), and a
|
||||
follow-set change; when a reconcile grows the follow set (fresh install),
|
||||
it re-syncs once more to cover the new authors.
|
||||
3. **Rules ported from `WoTService`**:
|
||||
- exclude the follower and `self_key` from every ingested list;
|
||||
- on follow-set change, un-credit removed followers from the reverse index;
|
||||
- `MAX_FOLLOWS = 2000` disables the service (clear state, emit ready);
|
||||
- `MAX_TAGS_PER_LIST = 5000` caps one follower's ingested list;
|
||||
- reset everything on sign-out / account switch.
|
||||
|
||||
No persistence: the graph rebuilds from the LMDB cache on every start, the same
|
||||
way `ChatRegistry` rebuilds rooms. No new relay events, no settings file, no new
|
||||
dependencies. Relays without NIP-77 fail their reconcile (logged); the graph
|
||||
then reflects whatever the database already holds.
|
||||
|
||||
## 3. Consumers (in order)
|
||||
|
||||
### Phase 1 — screening dialog (`crates/workspace/src/dialogs/screening.rs`)
|
||||
|
||||
Status: **implemented**. The `WebOfTrust` registry lives in `crates/person/src/wot.rs` and
|
||||
is initialized in `person::init`.
|
||||
|
||||
Replace `check_wot`'s database query (which only sees contact lists that happen
|
||||
to be cached, so "mutual contacts" is usually empty) with the registry:
|
||||
|
||||
- `mutual_contacts = wot.mutuals(&public_key)` — the people **the user follows**
|
||||
who follow the stranger, which is the correct WoT semantic and matches
|
||||
Amethyst's score.
|
||||
- The existing "Mutual contacts" dialog keeps working unchanged: it already
|
||||
renders a `Vec<PublicKey>` through a `Directory`.
|
||||
- When `wot.is_disabled()` or not ready, fall back to the current query so the
|
||||
dialog degrades to today's behavior.
|
||||
|
||||
### Phase 2 — requests list badge
|
||||
|
||||
Status: **partially implemented**. Request rooms whose members are all outside
|
||||
the web of trust show a `SPAM` badge in the sidebar
|
||||
(`Room::is_spam` + `WebOfTrust::is_outside`, rendered in
|
||||
`crates/workspace/src/sidebar/rows.rs`; the sidebar subscribes to `WoTEvent` so
|
||||
badges update when the graph loads). The mutual-count caption below is still
|
||||
open and will follow a different flow.
|
||||
|
||||
Where request rooms are rendered, show the mutual count for the sender:
|
||||
|
||||
- `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and
|
||||
`crates/workspace/src/panels/greeter.rs` (`load`): one caption
|
||||
`"N mutual contacts"` under the request preview, via the room's other member.
|
||||
- Subscribe to `WoTEvent::Updated` where `PersonEvent` is already subscribed so
|
||||
rows re-render when the graph changes.
|
||||
|
||||
### Phase 3 — opt-in auto-trust (behind a setting)
|
||||
|
||||
Status: **implemented** (Phase 2 skipped for now; it will follow a different
|
||||
flow).
|
||||
|
||||
The payoff: strangers vouched for by the user's contacts can skip screening.
|
||||
|
||||
- `pub wot_auto_trust: bool` in `setting_accessors!` in
|
||||
`crates/settings/src/lib.rs` (enabled out of the box: `Default` is `true` and
|
||||
a `#[serde(default = "default_true")]` fallback covers settings files saved
|
||||
before the field existed) and a "Web of trust" toggle in
|
||||
the preferences screen (`crates/workspace/src/dialogs/settings.rs`).
|
||||
- The rule lives in one predicate — `Room::auto_trusted(me, cx)` in
|
||||
`crates/chat/src/room.rs`: the setting is on and every member other than the
|
||||
current user is inside the web of trust (`is_in_network`, so followed
|
||||
senders always pass and strangers need at least one mutual contact).
|
||||
It's applied at every point a room's kind is decided so the states can't
|
||||
drift: `ChatRegistry::add_room` (incoming first message),
|
||||
`ChatRegistry::new_message` (live promotion next to the existing
|
||||
I-sent-it rule), and `ChatRegistry::extend_rooms` (rooms loaded from the
|
||||
database, so the split survives a restart).
|
||||
|
||||
Deliberately not planned: sorting contacts (the new-chat dialog lists only
|
||||
follows already), notification feeds (coop has none), muting/spam scoring.
|
||||
|
||||
## 4. Non-goals
|
||||
|
||||
- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank
|
||||
thresholds — revisit only if coop ever needs a network-scale gate.
|
||||
- Persisting the graph or multi-hop (3+) scores — kind 3s are one LMDB query
|
||||
away on restart, and negentropy keeps them current.
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- Unit tests for the pure graph logic in `wot.rs` (no `cx` needed): seeding,
|
||||
per-follower diffing, follow-set removal un-crediting, self/follower
|
||||
exclusion, both caps and the disabled transition.
|
||||
- One GPUI test for hydration ordering (newest kind 3 wins) using
|
||||
`TestAppContext`; use `cx.background_executor().timer(...)` for any waits per
|
||||
the project guidelines.
|
||||
|
||||
## 6. References
|
||||
|
||||
- `WoTService.kt` — the model this plan ports:
|
||||
https://github.com/vitorpamplona/amethyst/blob/main/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt
|
||||
- `PeopleOrder.kt`, `CuratedTrustFilter.kt`, `TrustVerdicts.kt` — consumers and
|
||||
the shared-predicate pattern.
|
||||
- Design doc for Amethyst's NIP-85 index (the layer we skip, with the reasoning
|
||||
and measurements): `commons/plans/2026-10-07-wot-network-index.md` on main.
|
||||
Reference in New Issue
Block a user