From 80f4bc387e16ba1e5ee32f79a764c0dc8d6728b5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 08:44:32 +0700 Subject: [PATCH 1/9] add plan --- Cargo.lock | 59 ++++++------ crates/state/src/lib.rs | 26 ++---- docs/web-of-trust.md | 201 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 49 deletions(-) create mode 100644 docs/web-of-trust.md diff --git a/Cargo.lock b/Cargo.lock index 59e72c25..a1fcf204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -1066,7 +1066,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout 0.2.4", + "inout 0.2.5", ] [[package]] @@ -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" @@ -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.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] @@ -4352,7 +4352,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.45.5" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +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#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "async-utility", "async-wsocket", @@ -5943,7 +5942,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -7164,9 +7163,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "bytes", "libc", @@ -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", ] diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 673e65b1..5a53fcf6 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -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?; diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md new file mode 100644 index 00000000..f8e8b46c --- /dev/null +++ b/docs/web-of-trust.md @@ -0,0 +1,201 @@ +# 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, + /// The current user, excluded from credit + self_key: Option, + /// Per-follower cached follow set, for diffing on republish + lists: HashMap>, + /// Reverse index: target -> contacts who follow them + reverse: HashMap>, + /// 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 // 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, authors sorted by `Reverse(created_at)` like + `Directory::track` does) -> `follows`; + - one batch query `Filter::new().kind(Kind::ContactList).authors(follows)` + -> dedupe per author keeping the newest -> seed `lists`/`reverse`. +2. **Stay fresh**: one persistent subscription with a dedicated + `SubscriptionId` (add `WOT_CONTACTS` beside `DEVICE_GIFTWRAP`/`USER_GIFTWRAP` + in `state::constants`), filter `kind(Kind::ContactList).authors(follows)`. + The background listener accepts any kind 3 whose author is in `follows` + (don't couple to subscription ids), diffs it against the cached list, and + updates the reverse index. Re-issue the subscription when the user's own + contact list changes. +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. + +## 3. Consumers (in order) + +### Phase 1 — screening dialog (`crates/workspace/src/dialogs/screening.rs`) + +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` 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 + +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) + +The payoff: strangers vouched for by the user's contacts can skip screening. + +- Add `pub wot_auto_trust: bool` to `setting_accessors!` in + `crates/settings/src/lib.rs` (default `false`) and a toggle in the existing + preferences screen (`crates/workspace/src/dialogs/settings.rs`). +- When enabled, apply it in one place — `ChatRegistry::new_message` in + `crates/chat/src/lib.rs`, next to the existing promotion rule — so in-app and + any future consumer can't drift (Amethyst's lesson from `TrustVerdicts`): + + ```rust + if this.kind == RoomKind::Request + && (message.rumor.pubkey == public_key + || (AppSettings::get_wot_auto_trust(cx) && wot.is_in_network(&message.rumor.pubkey))) + { + this.set_ongoing(cx); + } + ``` + + Group requests stay conservative: every member must be in the network (or + already a contact) to promote, mirroring how `is_contact` works today. + +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. +- Background jobs / WorkManager equivalents — hydration is two local queries. + +## 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. -- 2.54.0 From 66768e71a6daa2124e4702a88006aef7e42fe024 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 09:47:34 +0700 Subject: [PATCH 2/9] use WebOfTrust registry for screening dialog mutuals --- crates/person/src/lib.rs | 3 + crates/person/src/wot.rs | 606 ++++++++++++++++++++++ crates/workspace/src/dialogs/screening.rs | 71 ++- docs/web-of-trust.md | 33 +- 4 files changed, 678 insertions(+), 35 deletions(-) create mode 100644 crates/person/src/wot.rs diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index aec3cf1d..48caaf9f 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -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); diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs new file mode 100644 index 00000000..2613764d --- /dev/null +++ b/crates/person/src/wot.rs @@ -0,0 +1,606 @@ +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); + +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 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, + /// Latest known contact list of the current user + follows: HashSet, + /// Created at of the latest contact list of the current user + follows_at: Option, + /// Latest known contact list per followed author, with its created at + lists: HashMap)>, + /// Reverse index: target -> contacts who follow them + reverse: HashMap>, + /// 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>, + /// 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 { + cx.global::().0.clone() + } + + /// Set the global web of trust instance + pub(crate) fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalWebOfTrust(state)); + } + + /// Create a new web of trust instance + pub(crate) fn new(cx: &mut Context) -> 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) { + 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> = 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 = 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) { + 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 = self.follows.clone(); + + let mut authors: Vec = 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 = covered.iter().copied().collect(); + authors.push(self_key); + + let filter = Filter::new().kind(Kind::ContactList).authors(authors); + let events: Vec = 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, + cx: &mut Context, + ) -> bool { + if self.self_key != Some(self_key) { + return false; + } + + // Keep the newest contact list per author + let mut latest: HashMap = 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 = 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 { + 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, follows: HashSet) -> 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 = self.follows.difference(&follows).copied().collect(); + let added: Vec = 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, + ) -> bool { + if let Some((current, _)) = self.lists.get(&follower) + && *current > at + { + return false; + } + + let contacts: HashSet = 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::>(), + previous + .difference(&contacts) + .copied() + .collect::>(), + ), + 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_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 + } + + /// The current user's contacts who follow the public key + pub fn mutuals(&self, public_key: &PublicKey) -> Vec { + self.reverse + .get(public_key) + .map_or_else(Vec::new, |creditors| { + let mut contacts: Vec = 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 = (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 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 = (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))); + } +} diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 52b84221..f52965d1 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -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, /// All mutual contacts of the person being screened. mutual_contacts: Vec, + /// Whether the database fallback for mutual contacts has run. + mutuals_fallback: bool, /// Person directory for the person and their mutual contacts directory: Entity, 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 { + 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) { + 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, Error>> = cx.background_spawn(async move { - // Check mutual contacts + let task: Task> = 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) => { - this.update(cx, |this, cx| { - this.mutual_contacts = contacts; - cx.notify(); - }) - .ok(); + 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; } - Err(e) => { - log::error!("Failed to fetch mutual contacts: {}", e); - } - }; + this.mutual_contacts = contacts; + cx.notify(); + }) + .ok(); })); } diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md index f8e8b46c..827b10c2 100644 --- a/docs/web-of-trust.md +++ b/docs/web-of-trust.md @@ -101,17 +101,19 @@ entity update -> `cx.emit`): 1. **Hydrate from the local database** on creation and on `StateEvent::SignerChanged`: - - own kind 3 (newest event, authors sorted by `Reverse(created_at)` like - `Directory::track` does) -> `follows`; - - one batch query `Filter::new().kind(Kind::ContactList).authors(follows)` - -> dedupe per author keeping the newest -> seed `lists`/`reverse`. -2. **Stay fresh**: one persistent subscription with a dedicated - `SubscriptionId` (add `WOT_CONTACTS` beside `DEVICE_GIFTWRAP`/`USER_GIFTWRAP` - in `state::constants`), filter `kind(Kind::ContactList).authors(follows)`. - The background listener accepts any kind 3 whose author is in `follows` - (don't couple to subscription ids), diffs it against the cached list, and - updates the reverse index. Re-issue the subscription when the user's own - contact list changes. + - 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; @@ -121,12 +123,16 @@ entity update -> `cx.emit`): 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. +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: @@ -179,8 +185,7 @@ follows already), notification feeds (coop has none), muting/spam scoring. - 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. -- Background jobs / WorkManager equivalents — hydration is two local queries. + away on restart, and negentropy keeps them current. ## 5. Testing -- 2.54.0 From 10d74c53966ec5349a851481a5045fd058a18e7d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 10:01:13 +0700 Subject: [PATCH 3/9] auto-trust web of trust chat requests behind a setting --- crates/chat/src/lib.rs | 23 +++++++++++++++-- crates/chat/src/room.rs | 30 +++++++++++++++++++-- crates/settings/src/lib.rs | 6 +++++ crates/workspace/src/dialogs/settings.rs | 11 ++++++++ docs/web-of-trust.md | 33 +++++++++++------------- 5 files changed, 81 insertions(+), 22 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index ef1668c8..c64db7fb 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -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, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + let mut room_map: HashMap = 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); diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index c119f823..18b58be2 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -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}; @@ -183,6 +183,32 @@ 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); + + let mut others = false; + + for key in &self.members { + if *key == me { + continue; + } + + others = true; + + if !wot.is_in_network(key) { + return false; + } + } + + others + } + /// Sets this room is ongoing conversation pub(crate) fn set_ongoing(&mut self, cx: &mut Context) { self.kind = RoomKind::Ongoing; diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 5b5b34aa..6c1f0af8 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -58,6 +58,7 @@ 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, @@ -132,6 +133,10 @@ pub struct Settings { /// Enable screening for unknown chat requests pub screening: bool, + /// Promote chat requests from people followed by the user's contacts + #[serde(default)] + pub wot_auto_trust: bool, + /// Enable decoupling encryption key pub nip4e: bool, @@ -168,6 +173,7 @@ impl Default for Settings { appearance_mode: AppearanceMode::default(), hide_avatar: false, screening: true, + wot_auto_trust: false, nip4e: false, hide_group_notice: false, panel_background: true, diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index 7bea2318..57b8d9c0 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -83,12 +83,14 @@ impl Preferences { impl Render for Preferences { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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") diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md index 827b10c2..4cedff0b 100644 --- a/docs/web-of-trust.md +++ b/docs/web-of-trust.md @@ -156,26 +156,23 @@ Where request rooms are rendered, show the mutual count for the sender: ### 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. -- Add `pub wot_auto_trust: bool` to `setting_accessors!` in - `crates/settings/src/lib.rs` (default `false`) and a toggle in the existing - preferences screen (`crates/workspace/src/dialogs/settings.rs`). -- When enabled, apply it in one place — `ChatRegistry::new_message` in - `crates/chat/src/lib.rs`, next to the existing promotion rule — so in-app and - any future consumer can't drift (Amethyst's lesson from `TrustVerdicts`): - - ```rust - if this.kind == RoomKind::Request - && (message.rumor.pubkey == public_key - || (AppSettings::get_wot_auto_trust(cx) && wot.is_in_network(&message.rumor.pubkey))) - { - this.set_ongoing(cx); - } - ``` - - Group requests stay conservative: every member must be in the network (or - already a contact) to promote, mirroring how `is_contact` works today. +- `pub wot_auto_trust: bool` in `setting_accessors!` in + `crates/settings/src/lib.rs` (default `false`) 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. -- 2.54.0 From 9143de6cfe6759c389dd1842698ed24f0cc9d629 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 10:53:45 +0700 Subject: [PATCH 4/9] add spam badge for requests --- crates/chat/src/room.rs | 26 ++++++++++++-- crates/person/src/wot.rs | 27 +++++++++++++++ crates/settings/src/lib.rs | 42 ++--------------------- crates/workspace/src/dialogs/screening.rs | 3 +- crates/workspace/src/sidebar/list.rs | 2 +- crates/workspace/src/sidebar/rows.rs | 36 ++++++++++++++----- docs/web-of-trust.md | 11 +++++- 7 files changed, 93 insertions(+), 54 deletions(-) diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 18b58be2..cb7c5307 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -192,16 +192,38 @@ impl Room { 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 &self.members { + for key in members { if *key == me { continue; } others = true; - if !wot.is_in_network(key) { + if !check(key) { return false; } } diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs index 2613764d..ccd363c7 100644 --- a/crates/person/src/wot.rs +++ b/crates/person/src/wot.rs @@ -434,6 +434,11 @@ impl WebOfTrust { 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 { self.reverse @@ -572,6 +577,28 @@ mod tests { 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(); diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 6c1f0af8..e50e4c7a 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -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"; @@ -126,42 +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 - #[serde(default)] 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, - /// Trusted relays; Coop will automatically authenticate with these relays pub trusted_relays: Vec, - /// Server for blossom media attachments pub file_server: Url, - /// Pinned chat rooms, by conversation ID #[serde(default)] pub pinned_rooms: Vec, - /// Pinned groups, by cache tag #[serde(default)] pub pinned_groups: Vec, @@ -173,7 +162,7 @@ impl Default for Settings { appearance_mode: AppearanceMode::default(), hide_avatar: false, screening: true, - wot_auto_trust: false, + wot_auto_trust: true, nip4e: false, hide_group_notice: false, panel_background: true, @@ -345,30 +334,3 @@ impl AppSettings { }); } } - -#[cfg(test)] -mod tests { - use super::*; - - fn json_with(partial: &str) -> Result { - // 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); - } -} diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index f52965d1..96fb46a5 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -394,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| { @@ -405,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| { diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index f5779955..b4e90d5f 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -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), ) }) diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index 986eb145..824316c8 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -118,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() @@ -136,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(); @@ -163,11 +166,28 @@ impl Sidebar { ) }) .suffix( - div() - .font_normal() - .text_style(TextStyle::Caption) - .text_color(cx.theme().text_faint) - .child(created_at), + 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); diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md index 4cedff0b..ad752e5f 100644 --- a/docs/web-of-trust.md +++ b/docs/web-of-trust.md @@ -146,6 +146,13 @@ to be cached, so "mutual contacts" is usually empty) with the registry: ### 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 @@ -162,7 +169,9 @@ 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` (default `false`) and a "Web of trust" toggle 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 -- 2.54.0 From 0f0216051f860ec67e7e63ba863207296d926bb1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 10:58:32 +0700 Subject: [PATCH 5/9] add web of trust score to profile dialog --- crates/person/src/wot.rs | 5 +++++ crates/workspace/src/dialogs/profile.rs | 17 ++++++++++++++++- crates/workspace/src/dialogs/screening.rs | 6 +++--- docs/web-of-trust.md | 6 ++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs index ccd363c7..58ff5f96 100644 --- a/crates/person/src/wot.rs +++ b/crates/person/src/wot.rs @@ -455,6 +455,11 @@ impl WebOfTrust { self.ready } + /// Whether the graph is loaded and score queries are meaningful + pub fn is_loaded(&self) -> bool { + self.ready && !self.disabled + } + /// Whether scoring is disabled due to an oversized follow set pub fn is_disabled(&self) -> bool { self.disabled diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index fc8313ec..fceedd78 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::{Directory, Person, PersonRegistry, shorten_pubkey}; +use person::{Directory, Person, PersonRegistry, WebOfTrust, shorten_pubkey}; use settings::AppSettings; use state::{NostrRegistry, upload}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -459,9 +459,24 @@ impl Render for ProfileDialog { .filter(|website| !website.trim().is_empty()) .map(SharedString::from); + let score = { + let wot = WebOfTrust::global(cx).read(cx); + wot.is_loaded().then(|| wot.score(&self.public_key)) + } + .map(|score| { + SharedString::from(format!( + "{} mutual {}", + score, + if score == 1 { "contact" } else { "contacts" } + )) + }); + this.child(field("Name", name, "Unknown", cx)) .child(field("About", about, "No bio", cx)) .child(field("Website", website, "No website", cx)) + .when(!self.is_current_user, |this| { + this.child(field("Web of Trust", score, "Not loaded yet", cx)) + }) }) .child( h_flex() diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 96fb46a5..819b453b 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -126,7 +126,7 @@ impl Screening { fn check_wot(&mut self, cx: &mut Context) { let trusted = { let wot = WebOfTrust::global(cx).read(cx); - (wot.is_ready() && !wot.is_disabled()).then(|| wot.mutuals(&self.public_key)) + wot.is_loaded().then(|| wot.mutuals(&self.public_key)) }; if let Some(contacts) = trusted { @@ -166,11 +166,11 @@ impl Screening { 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() { + if WebOfTrust::global(cx).read(cx).is_loaded() { return; } + this.mutual_contacts = contacts; cx.notify(); }) diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md index ad752e5f..02123865 100644 --- a/docs/web-of-trust.md +++ b/docs/web-of-trust.md @@ -150,8 +150,10 @@ 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. +badges update when the graph loads). The profile dialog shows the score as a +"Web of Trust" field (`crates/workspace/src/dialogs/profile.rs`, other users +only). 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: -- 2.54.0 From d64da17c5c1a49372eea81f9c028a945f438361c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 14:36:56 +0700 Subject: [PATCH 6/9] refactor --- crates/chat/src/room.rs | 6 +- crates/person/src/lib.rs | 15 +- crates/person/src/wot.rs | 79 ++------ crates/workspace/src/dialogs/profile.rs | 4 +- crates/workspace/src/dialogs/screening.rs | 66 ++++--- docs/web-of-trust.md | 214 ---------------------- 6 files changed, 70 insertions(+), 314 deletions(-) delete mode 100644 docs/web-of-trust.md diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index cb7c5307..ce649a04 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -8,7 +8,7 @@ use instant::Duration; use itertools::Itertools; use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; -use person::{Directory, Person, WebOfTrust}; +use person::{Directory, Person, PersonRegistry}; use settings::{AppSettings, RoomConfig, SignerKind}; use state::{NostrRegistry, TIMEOUT, UniversalSigner}; @@ -190,7 +190,7 @@ impl Room { return false; } - let wot = WebOfTrust::global(cx).read(cx); + let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); Self::trusted_by(&self.members, me, |key| wot.is_in_network(key)) } @@ -203,7 +203,7 @@ impl Room { return false; }; - let wot = WebOfTrust::global(cx).read(cx); + let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); Self::trusted_by(&self.members, me, |key| !wot.is_outside(key)) } diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 48caaf9f..d885e079 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -19,7 +19,6 @@ 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); @@ -63,6 +62,8 @@ enum Dispatch { pub struct PersonRegistry { /// Public keys with a metadata request in flight in_flight: RwLock>, + /// Web of trust graph of the signed-in user + wot: Entity, sender: flume::Sender, tasks: SmallVec<[Task<()>; 4]>, _subscriptions: SmallVec<[Subscription; 1]>, @@ -83,6 +84,7 @@ impl PersonRegistry { fn new(cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let wot = cx.new(WebOfTrust::new); // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(100); @@ -125,17 +127,28 @@ impl PersonRegistry { let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| { if event.signer_changed() { this.get_emojis(cx); + + this.wot.update(cx, |wot, cx| { + wot.reset(cx); + wot.hydrate(cx); + }); } }); Self { in_flight: RwLock::new(HashSet::new()), + wot, sender: metadata_tx, tasks, _subscriptions: smallvec![subscription], } } + /// The web of trust graph of the signed-in user + pub fn wot(&self) -> Entity { + self.wot.clone() + } + /// Handle nostr notifications async fn handle_notifications(client: &Client, tx: &flume::Sender) { let mut notifications = client.notifications(); diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs index 58ff5f96..fead0fcd 100644 --- a/crates/person/src/wot.rs +++ b/crates/person/src/wot.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; +use gpui::{AppContext, Context, EventEmitter, Task}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -11,20 +11,11 @@ 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; +const INTERVAL: u64 = 15 * 60; -struct GlobalWebOfTrust(Entity); - -impl Global for GlobalWebOfTrust {} - -/// A change to the web of trust +/// The web of trust graph changed #[derive(Debug, Clone)] -pub enum WoTEvent { - /// The graph changed - Updated, - /// The initial load finished - Ready, -} +pub struct WoTEvent; impl EventEmitter for WebOfTrust {} @@ -48,38 +39,22 @@ pub struct WebOfTrust { 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>, /// 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 { - cx.global::().0.clone() - } - - /// Set the global web of trust instance - pub(crate) fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalWebOfTrust(state)); - } - /// Create a new web of trust instance pub(crate) fn new(cx: &mut Context) -> 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)) + .timer(Duration::from_secs(INTERVAL)) .await; // Ignore errors, the next refresh will retry let _ = this.update(cx, |this, cx| { @@ -88,13 +63,6 @@ impl WebOfTrust { } })); - 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); @@ -109,23 +77,21 @@ impl WebOfTrust { 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) { + pub(crate) fn hydrate(&mut self, cx: &mut Context) { 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> = cx.background_spawn(async move { @@ -155,7 +121,7 @@ impl WebOfTrust { // 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.emit(WoTEvent); cx.notify(); } }) @@ -206,16 +172,12 @@ impl WebOfTrust { let mut grew = false; this.update(cx, |this, cx| { + let was_ready = this.ready; let changed = this.apply_latest(self_key, events, cx); + this.ready = true; - if this.initial { - this.initial = false; - this.ready = true; - cx.emit(WoTEvent::Ready); - } - - if changed { - cx.emit(WoTEvent::Updated); + if changed || !was_ready { + cx.emit(WoTEvent); cx.notify(); } @@ -407,7 +369,7 @@ impl WebOfTrust { } /// Clear the graph on sign out or account switch - fn reset(&mut self, cx: &mut Context) { + pub(crate) fn reset(&mut self, cx: &mut Context) { self.self_key = None; self.follows.clear(); self.follows_at = None; @@ -415,10 +377,9 @@ impl WebOfTrust { self.reverse.clear(); self.disabled = false; self.ready = false; - self.initial = false; self.hydration = None; - cx.emit(WoTEvent::Updated); + cx.emit(WoTEvent); cx.notify(); } @@ -450,20 +411,10 @@ impl WebOfTrust { }) } - /// Whether the initial load has finished - pub fn is_ready(&self) -> bool { - self.ready - } - /// Whether the graph is loaded and score queries are meaningful pub fn is_loaded(&self) -> bool { self.ready && !self.disabled } - - /// Whether scoring is disabled due to an oversized follow set - pub fn is_disabled(&self) -> bool { - self.disabled - } } #[cfg(test)] @@ -487,10 +438,8 @@ mod tests { reverse: HashMap::new(), disabled: false, ready: false, - initial: false, hydration: None, tasks: smallvec![], - _subscriptions: smallvec![], } } diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index fceedd78..9e4d950e 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::{Directory, Person, PersonRegistry, WebOfTrust, shorten_pubkey}; +use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use settings::AppSettings; use state::{NostrRegistry, upload}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -460,7 +460,7 @@ impl Render for ProfileDialog { .map(SharedString::from); let score = { - let wot = WebOfTrust::global(cx).read(cx); + let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); wot.is_loaded().then(|| wot.score(&self.public_key)) } .map(|score| { diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 819b453b..a8d315c9 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::{Directory, Person, WebOfTrust, shorten_pubkey}; +use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use smallvec::{SmallVec, smallvec}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -33,7 +33,7 @@ pub struct Screening { last_active: Option, /// All mutual contacts of the person being screened. mutual_contacts: Vec, - /// Whether the database fallback for mutual contacts has run. + /// Whether the fallback lookup for mutual contacts has run. mutuals_fallback: bool, /// Person directory for the person and their mutual contacts directory: Entity, @@ -43,7 +43,7 @@ pub struct Screening { impl Screening { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { - let wot = WebOfTrust::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let directory = Directory::new(cx); let mut subscriptions = smallvec![]; @@ -53,8 +53,8 @@ impl Screening { window.close_all_dialogs(cx); })); - subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| { - this.check_wot(cx); + subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| { + cx.notify(); })); directory.update(cx, |directory, cx| { @@ -63,7 +63,7 @@ impl Screening { cx.defer_in(window, |this, _window, cx| { this.check_contact(cx); - this.check_wot(cx); + this.fetch_fallback(cx); this.check_last_activity(cx); this.verify_identifier(cx); }); @@ -121,27 +121,17 @@ 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) { - let trusted = { - let wot = WebOfTrust::global(cx).read(cx); - wot.is_loaded().then(|| wot.mutuals(&self.public_key)) - }; + /// Fetch mutual contacts from the database while the web of trust loads + fn fetch_fallback(&mut self, cx: &mut Context) { + let persons = PersonRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); - if let Some(contacts) = trusted { - self.mutual_contacts = contacts; - cx.notify(); - return; - } - - if self.mutuals_fallback { + if self.mutuals_fallback || persons.read(cx).wot().read(cx).is_loaded() { return; } self.mutuals_fallback = true; - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); let public_key = self.public_key; let Some(current_user) = nostr.read(cx).current_user() else { @@ -166,11 +156,9 @@ impl Screening { let contacts = task.await; this.update(cx, |this, cx| { - // The web of trust wins once its load has finished - if WebOfTrust::global(cx).read(cx).is_loaded() { + if persons.read(cx).wot().read(cx).is_loaded() { return; } - this.mutual_contacts = contacts; cx.notify(); }) @@ -292,8 +280,12 @@ impl Screening { })); } - fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let contacts = self.mutual_contacts.clone(); + fn mutual_contacts( + &mut self, + contacts: Vec, + window: &mut Window, + cx: &mut Context, + ) { let directory = self.directory.clone(); window.open_dialog(cx, move |this, _window, _cx| { @@ -346,11 +338,23 @@ impl Render for Screening { const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; const NO_NIP05: &str = "This person has not set up their friendly address"; + let persons = PersonRegistry::global(cx); let profile = self.profile(cx); let shorten_pubkey = shorten_pubkey(self.public_key, 8); let last_active = self.last_active.map(|_| true); - let mutuals = self.mutual_contacts.len(); + + // Pull from the web of trust once loaded, else use the database fallback + let mutual_contacts = { + let wot = persons.read(cx).wot().read(cx); + if wot.is_loaded() { + wot.mutuals(&self.public_key) + } else { + self.mutual_contacts.clone() + } + }; + + let mutuals = mutual_contacts.len(); let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals); v_flex() @@ -543,7 +547,11 @@ impl Render for Screening { .rounded() .disabled(mutuals == 0) .on_click(cx.listener(move |this, _, window, cx| { - this.mutual_contacts(window, cx); + this.mutual_contacts( + mutual_contacts.clone(), + window, + cx, + ); })), ), ), diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md deleted file mode 100644 index 02123865..00000000 --- a/docs/web-of-trust.md +++ /dev/null @@ -1,214 +0,0 @@ -# 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, - /// The current user, excluded from credit - self_key: Option, - /// Per-follower cached follow set, for diffing on republish - lists: HashMap>, - /// Reverse index: target -> contacts who follow them - reverse: HashMap>, - /// 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 // 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` 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 profile dialog shows the score as a -"Web of Trust" field (`crates/workspace/src/dialogs/profile.rs`, other users -only). 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. -- 2.54.0 From 4bb658f4f508e9ea1fc3a403d2d77cf22b64920d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 14:54:12 +0700 Subject: [PATCH 7/9] update --- crates/workspace/src/sidebar/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 46add454..08280709 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -13,7 +13,7 @@ use gpui::{ use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; -use person::Directory; +use person::{Directory, PersonRegistry}; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _}; @@ -103,6 +103,7 @@ impl Sidebar { let chat = ChatRegistry::global(cx); let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let find_input = cx.new(|cx| { InputState::new(window, cx) @@ -121,6 +122,12 @@ impl Sidebar { }), ); + subscriptions.push( + cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| { + cx.notify(); + }), + ); + subscriptions.push( cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { match event { -- 2.54.0 From dd81b95e36d5403e7cca4cb111850d82a3f9e9bb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 18:16:58 +0700 Subject: [PATCH 8/9] update greeter panel --- crates/workspace/src/panels/greeter.rs | 244 ++++++++++++++----------- 1 file changed, 135 insertions(+), 109 deletions(-) diff --git a/crates/workspace/src/panels/greeter.rs b/crates/workspace/src/panels/greeter.rs index 60bb59e6..e51b2867 100644 --- a/crates/workspace/src/panels/greeter.rs +++ b/crates/workspace/src/panels/greeter.rs @@ -10,6 +10,7 @@ use gpui::{ StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, svg, }; use nostr_sdk::prelude::*; +use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, StateEvent}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -57,13 +58,14 @@ pub struct GreeterPanel { /// The coalesced reload scheduled after registry updates refresh_task: Option>>, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, + _subscriptions: SmallVec<[Subscription; 3]>, } impl GreeterPanel { fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let mut subscriptions = smallvec![]; @@ -76,6 +78,13 @@ impl GreeterPanel { }), ); + subscriptions.push( + // Redraw when the web of trust loads or changes + cx.subscribe(&wot, |_, _, _, cx| { + cx.notify(); + }), + ); + subscriptions.push( // Reload soon after rooms load, messages arrive, or requests change cx.observe(&chat, |this, _, cx| { @@ -176,120 +185,137 @@ impl GreeterPanel { })); } - fn render_requests(&mut self, cx: &mut Context) -> Option { - if self.requests.is_empty() { - return None; - } + fn render_requests(&mut self, cx: &mut Context) -> AnyElement { + // Spam requests stay out of the greeter entirely + let requests: Vec<_> = self + .requests + .iter() + .filter(|(room, _)| !room.read(cx).is_spam(cx)) + .cloned() + .collect(); let dock = self.dock.clone(); - let rows: Vec = 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(); - + let rows: Vec = if requests.is_empty() { + vec![ h_flex() - .id(format!("request-{index}")) - .group("") + .h_16() .w_full() - .h_9() - .px_1p5() - .gap_2() + .justify_center() + .border_1() + .border_dashed() + .border_color(cx.theme().border) .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(); + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child("No chat requests") + .into_any_element(), + ] + } else { + requests + .iter() + .enumerate() + .map(|(index, (room, preview))| { + let room = room.clone(); + let dock = dock.clone(); + let name = room.read(cx).display_name(cx); + let picture = room.read(cx).display_image(cx); + let seed = room.read(cx).display_image_seed(cx); + let created_at = room.read(cx).created_at.to_ago(); + let preview = preview.clone(); - Some( - GroupBox::new() - .id("requests") - .title("Requests") - .fill() - .child(v_flex().gap_0().w_full().children(rows)) - .into_any_element(), - ) + h_flex() + .id(format!("request-{index}")) + .group("") + .w_full() + .h_9() + .px_1p5() + .gap_2() + .rounded(px(button_radius())) + .cursor_pointer() + .hover(|this| this.bg(cx.theme().element_hover)) + .on_click({ + let room = room.clone(); + let dock = dock.clone(); + move |_, window, cx| { + open_room(&room, &dock, window, cx); + } + }) + .child( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) + .child( + div() + .min_w_0() + .truncate() + .text_style(TextStyle::Callout) + .child(name), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_style(TextStyle::Callout) + .text_color(cx.theme().text_muted) + .italic() + .child(preview), + ) + .child( + div() + .relative() + .flex_shrink_0() + .h_6() + .flex() + .items_center() + .justify_end() + .child( + div() + .absolute() + .left_0() + .right_0() + .top_0() + .bottom_0() + .group_hover("", |this| this.invisible()) + .child( + div() + .text_align(gpui::TextAlign::Right) + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child(created_at), + ), + ) + .child( + Button::new(format!("response-{index}")) + .label("Response") + .xsmall() + .ghost() + .invisible() + .group_hover("", |this| this.visible()) + .on_click({ + let room = room.clone(); + let dock = dock.clone(); + move |_, window, cx| { + cx.stop_propagation(); + open_room(&room, &dock, window, cx); + } + }), + ), + ) + .into_any_element() + }) + .collect() + }; + + GroupBox::new() + .id("requests") + .title("Requests") + .fill() + .child(v_flex().gap_0().w_full().children(rows)) + .into_any_element() } fn render_follow_ups(&mut self, cx: &mut Context) -> Option { @@ -503,7 +529,7 @@ impl Render for GreeterPanel { .size_full() .p_2() .gap_6() - .children(self.render_requests(cx)) + .child(self.render_requests(cx)) .children(self.render_follow_ups(cx)) .child(self.render_find_friends(cx)), ) -- 2.54.0 From 6e6360c488650b70f6c53b5adf8dc721738cc513 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 20 Oct 2026 20:42:55 +0700 Subject: [PATCH 9/9] update --- crates/chat/src/lib.rs | 27 +- crates/chat/src/room.rs | 15 +- crates/person/src/wot.rs | 655 +++++++++++---------------------------- 3 files changed, 218 insertions(+), 479 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index c64db7fb..f306fd9b 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -14,6 +14,7 @@ use gpui::{ use instant::Duration; use nip4e::{DeviceEvent, DeviceRegistry}; use nostr_sdk::prelude::*; +use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; @@ -139,13 +140,13 @@ impl ChatRegistry { /// Create a new chat registry instance fn new(cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); + let device = DeviceRegistry::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let entity = cx.entity().downgrade(); let (tx, rx) = flume::unbounded::(); let mut subscriptions = smallvec![]; - let device = DeviceRegistry::global(cx); - subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { if event.signer_changed() { this.reset(cx); @@ -163,6 +164,10 @@ impl ChatRegistry { } })); + subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| { + this.promote_trusted(cx); + })); + cx.defer(move |cx| { let _ = entity.update(cx, |this, cx| { this.get_rooms(cx); @@ -567,7 +572,23 @@ impl ChatRegistry { .unwrap_or_default() } - /// Add a new room to the start of list. + /// Promote request rooms whose senders are inside the web of trust + fn promote_trusted(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + + for room in &self.rooms { + room.update(cx, |room, cx| { + if room.kind == RoomKind::Request && room.auto_trusted(me, cx) { + room.set_ongoing(cx); + } + }); + } + } + + /// Promote a room to ongoing when its latest message is from the current user pub(crate) fn add_room(&mut self, room: I, cx: &mut Context) where I: Into, diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index ce649a04..42454a69 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -195,17 +195,24 @@ impl Room { 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 + /// Whether the room looks like spam: the web of trust is loaded and + /// everyone other than the current user is outside of it pub fn is_spam(&self, cx: &App) -> bool { + let person = PersonRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { return false; }; - let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); + let wot = person.read(cx).wot().read(cx); - Self::trusted_by(&self.members, me, |key| !wot.is_outside(key)) + // An unloaded graph cannot classify anything as spam + if !wot.is_loaded() { + return false; + } + + Self::trusted_by(&self.members, me, |key| wot.is_outside(key)) } /// Whether every member other than `me` passes the check diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs index fead0fcd..f0475edb 100644 --- a/crates/person/src/wot.rs +++ b/crates/person/src/wot.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use gpui::{AppContext, Context, EventEmitter, Task}; +use gpui::{Context, EventEmitter, Task}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -11,38 +11,89 @@ 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 INTERVAL: u64 = 15 * 60; +const SYNC_INTERVAL: u64 = 15 * 60; +/// Longest wait between retries while the graph has no data yet +const MAX_RETRY_WAIT: u64 = 60; /// The web of trust graph changed #[derive(Debug, Clone)] pub struct WoTEvent; +/// A built graph: the follow set and, per public key, who follows it +#[derive(Debug)] +struct Snapshot { + follows: HashSet, + scores: HashMap, + mutuals: HashMap>, + disabled: bool, +} + +impl Snapshot { + /// Build the snapshot from the newest stored contact list of every author + fn build(self_key: PublicKey, follows: HashSet, events: Vec) -> Self { + let disabled = follows.len() > MAX_FOLLOWS; + + let mut snapshot = Self { + follows, + scores: HashMap::new(), + mutuals: HashMap::new(), + disabled, + }; + + if snapshot.disabled { + return snapshot; + } + + // Keep the newest contact list per author + let mut latest: HashMap = HashMap::new(); + + for event in events { + if event.pubkey == self_key || !snapshot.follows.contains(&event.pubkey) { + continue; + } + + match latest.get(&event.pubkey) { + Some(current) if current.created_at > event.created_at => continue, + _ => { + latest.insert(event.pubkey, event); + } + } + } + + for (author, event) in latest { + for key in event.tags.public_keys().take(MAX_TAGS_PER_LIST) { + if key == author || key == self_key { + continue; + } + + *snapshot.scores.entry(key).or_default() += 1; + snapshot.mutuals.entry(key).or_default().push(author); + } + } + + for contacts in snapshot.mutuals.values_mut() { + contacts.sort(); + } + + snapshot + } +} + impl EventEmitter 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. +/// Scores public keys by how many of the current user's contacts follow them. #[derive(Debug)] pub struct WebOfTrust { /// Current user's public key self_key: Option, - /// Latest known contact list of the current user - follows: HashSet, - /// Created at of the latest contact list of the current user - follows_at: Option, - /// Latest known contact list per followed author, with its created at - lists: HashMap)>, - /// Reverse index: target -> contacts who follow them - reverse: HashMap>, - /// Whether scoring is disabled due to an oversized follow set - disabled: bool, - /// Whether the initial load has finished - ready: bool, - /// Task loading the graph, replaced by newer loads - hydration: Option>, - /// Tasks for asynchronous operations - tasks: SmallVec<[Task<()>; 4]>, + /// Latest built snapshot, none until the first refresh completes + snapshot: Option, + /// In-flight refresh, replaced by newer ones + refresh_task: Option>, + /// Keeps the periodic reconciliation loop alive + _tasks: SmallVec<[Task<()>; 4]>, } impl WebOfTrust { @@ -52,14 +103,24 @@ impl WebOfTrust { let mut tasks = smallvec![]; tasks.push(cx.spawn(async move |this, cx| { + let mut delay = Duration::from_secs(5); + loop { - cx.background_executor() - .timer(Duration::from_secs(INTERVAL)) - .await; + cx.background_executor().timer(delay).await; + + let mut has_data = false; + // Ignore errors, the next refresh will retry let _ = this.update(cx, |this, cx| { - this.sync(cx); + this.refresh(cx); + has_data = this.has_data(); }); + + delay = if has_data { + Duration::from_secs(SYNC_INTERVAL) + } else { + (delay * 2).min(Duration::from_secs(MAX_RETRY_WAIT)) + }; } })); @@ -71,72 +132,35 @@ impl WebOfTrust { Self { self_key: None, - follows: HashSet::new(), - follows_at: None, - lists: HashMap::new(), - reverse: HashMap::new(), - disabled: false, - ready: false, - hydration: None, - tasks, + snapshot: None, + refresh_task: None, + _tasks: tasks, } } /// Load the graph for the current account pub(crate) fn hydrate(&mut self, cx: &mut Context) { 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); - - // The locally stored contact list, applied before the relays respond - let local: Task> = 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 = 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); - cx.notify(); - } - }) - .ok(); - - // Reconcile with the relays; the refresh this triggers finalizes the initial load - this.update(cx, |this, cx| { - this.sync(cx); - }) - .ok(); - })); + self.refresh(cx); } - /// Reconcile the follow set's contact lists with the relays and apply whatever came back - fn sync(&mut self, cx: &mut Context) { + /// Clear the graph on sign out or account switch + pub(crate) fn reset(&mut self, cx: &mut Context) { + self.self_key = None; + self.snapshot = None; + self.refresh_task = None; + + cx.emit(WoTEvent); + cx.notify(); + } + + /// Reconcile the follow set's contact lists with the relays and rebuild. + fn refresh(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -144,444 +168,131 @@ impl WebOfTrust { return; }; - let covered: HashSet = self.follows.clone(); + self.refresh_task = Some(cx.spawn(async move |this, cx| { + // Scope the reconcile to the follow set known so far + let known: HashSet = stored_contact_list(&client, self_key) + .await + .map(|event| event.tags.public_keys().take(MAX_FOLLOWS + 1).collect()) + .unwrap_or_default(); - let mut authors: Vec = covered.iter().copied().collect(); - authors.push(self_key); + let mut authors: Vec = known.iter().copied().collect(); + authors.push(self_key); - self.tasks.push(cx.spawn(async move |this, cx| { + // Negentropy transfers only the lists the relays hold + // and the database is missing 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 = covered.iter().copied().collect(); + // The follow set after the reconcile + let Some(own) = stored_contact_list(&client, self_key).await else { + return; + }; + + let follows: HashSet = + own.tags.public_keys().take(MAX_FOLLOWS + 1).collect(); + + let mut authors: Vec = follows.iter().copied().collect(); authors.push(self_key); - let filter = Filter::new().kind(Kind::ContactList).authors(authors); - let events: Vec = client - .database() - .query(filter) - .await - .unwrap_or_default() - .into_iter() - .collect(); + let events: Vec = if follows.is_empty() { + Vec::new() + } else { + let filter = Filter::new().kind(Kind::ContactList).authors(authors); + client + .database() + .query(filter) + .await + .unwrap_or_default() + .into_iter() + .collect() + }; - let mut grew = false; + let snapshot = Snapshot::build(self_key, follows.clone(), events); + log::debug!( + "Web of trust rebuilt: {} follows, {} scored keys", + snapshot.follows.len(), + snapshot.scores.len() + ); + + // Newly followed authors were not part of the reconcile + let grew = !known.is_superset(&follows); this.update(cx, |this, cx| { - let was_ready = this.ready; - let changed = this.apply_latest(self_key, events, cx); - this.ready = true; - - if changed || !was_ready { - cx.emit(WoTEvent); - cx.notify(); - } - - grew = !covered.is_superset(&this.follows); + this.snapshot = Some(snapshot); + cx.emit(WoTEvent); + cx.notify(); }) .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); + this.refresh(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, - cx: &mut Context, - ) -> bool { - if self.self_key != Some(self_key) { - return false; - } - - // Keep the newest contact list per author - let mut latest: HashMap = 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 = 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 { - 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, follows: HashSet) -> 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 = self.follows.difference(&follows).copied().collect(); - let added: Vec = 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, - ) -> bool { - if let Some((current, _)) = self.lists.get(&follower) - && *current > at - { - return false; - } - - let contacts: HashSet = 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::>(), - previous - .difference(&contacts) - .copied() - .collect::>(), - ), - 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 - pub(crate) fn reset(&mut self, cx: &mut Context) { - self.self_key = None; - self.follows.clear(); - self.follows_at = None; - self.lists.clear(); - self.reverse.clear(); - self.disabled = false; - self.ready = false; - self.hydration = None; - - cx.emit(WoTEvent); - 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()) + self.snapshot.as_ref().map_or(0, |snapshot| { + snapshot.scores.get(public_key).copied().unwrap_or(0) + }) } /// Whether the public key is followed by the user or by any of their contacts pub fn is_in_network(&self, public_key: &PublicKey) -> bool { - self.follows.contains(public_key) || self.score(public_key) > 0 + self.snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.follows.contains(public_key)) + || self.score(public_key) > 0 } /// Whether the graph is loaded and the public key is outside the network pub fn is_outside(&self, public_key: &PublicKey) -> bool { - self.ready && !self.disabled && !self.is_in_network(public_key) + self.is_loaded() && !self.is_in_network(public_key) } /// The current user's contacts who follow the public key pub fn mutuals(&self, public_key: &PublicKey) -> Vec { - self.reverse - .get(public_key) - .map_or_else(Vec::new, |creditors| { - let mut contacts: Vec = creditors.iter().copied().collect(); - contacts.sort(); - contacts - }) + self.snapshot.as_ref().map_or_else(Vec::new, |snapshot| { + snapshot + .mutuals + .get(public_key) + .cloned() + .unwrap_or_default() + }) } /// Whether the graph is loaded and score queries are meaningful pub fn is_loaded(&self) -> bool { - self.ready && !self.disabled + self.snapshot + .as_ref() + .is_some_and(|snapshot| !snapshot.disabled) + } + + /// Whether the snapshot covers a follow set + fn has_data(&self) -> bool { + self.snapshot + .as_ref() + .is_some_and(|snapshot| !snapshot.follows.is_empty()) } } -#[cfg(test)] -mod tests { - use super::*; +/// The newest stored contact list of the public key +async fn stored_contact_list(client: &Client, self_key: PublicKey) -> Option { + let filter = Filter::new() + .author(self_key) + .kind(Kind::ContactList) + .limit(1); - 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, - hydration: None, - tasks: 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 = (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 = (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))); - } + client + .database() + .query(filter) + .await + .unwrap_or_default() + .into_iter() + .next() } -- 2.54.0