4 Commits
Author SHA1 Message Date
reya 9143de6cfe add spam badge for requests 2026-10-20 10:53:45 +07:00
reya 10d74c5396 auto-trust web of trust chat requests behind a setting 2026-10-20 10:01:13 +07:00
reya 66768e71a6 use WebOfTrust registry for screening dialog mutuals 2026-10-20 09:47:34 +07:00
reya 80f4bc387e add plan 2026-10-20 08:44:32 +07:00
12 changed files with 1051 additions and 122 deletions
Generated
+29 -30
View File
@@ -933,9 +933,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.8.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -1066,7 +1066,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [ dependencies = [
"block-buffer 0.12.1", "block-buffer 0.12.1",
"crypto-common 0.2.2", "crypto-common 0.2.2",
"inout 0.2.4", "inout 0.2.5",
] ]
[[package]] [[package]]
@@ -2013,9 +2013,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]] [[package]]
name = "flatbuffers" name = "flatbuffers"
@@ -2696,7 +2696,7 @@ dependencies = [
"log", "log",
"rustls", "rustls",
"rustls-platform-verifier", "rustls-platform-verifier",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
@@ -3392,7 +3392,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
@@ -3616,9 +3616,9 @@ dependencies = [
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.2.4" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [ dependencies = [
"hybrid-array", "hybrid-array",
] ]
@@ -4352,7 +4352,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.45.5" version = "0.45.5"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"aes", "aes",
"base64 0.22.1", "base64 0.22.1",
@@ -4378,7 +4378,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-blossom" name = "nostr-blossom"
version = "0.45.1" version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bitcoin_hashes 1.2.0", "bitcoin_hashes 1.2.0",
@@ -4391,10 +4391,9 @@ dependencies = [
[[package]] [[package]]
name = "nostr-connect" name = "nostr-connect"
version = "0.45.2" version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"futures-core",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"opaquerr 0.2.0", "opaquerr 0.2.0",
@@ -4405,7 +4404,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-database" name = "nostr-database"
version = "0.45.2" version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"nostr", "nostr",
"opaquerr 0.2.0", "opaquerr 0.2.0",
@@ -4414,7 +4413,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-gossip" name = "nostr-gossip"
version = "0.45.1" version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"nostr", "nostr",
"opaquerr 0.2.0", "opaquerr 0.2.0",
@@ -4423,7 +4422,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-gossip-memory" name = "nostr-gossip-memory"
version = "0.45.1" version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"lru", "lru",
@@ -4435,7 +4434,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-lmdb" name = "nostr-lmdb"
version = "0.45.3" version = "0.45.3"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"flatbuffers", "flatbuffers",
@@ -4450,7 +4449,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-memory" name = "nostr-memory"
version = "0.45.2" version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"btreecap", "btreecap",
"nostr", "nostr",
@@ -4461,7 +4460,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-sdk" name = "nostr-sdk"
version = "0.45.4" version = "0.45.4"
source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"async-wsocket", "async-wsocket",
@@ -5943,7 +5942,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
@@ -7164,9 +7163,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.3" version = "1.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -7656,14 +7655,14 @@ dependencies = [
"serde_json", "serde_json",
"ureq-proto", "ureq-proto",
"utf8-zero", "utf8-zero",
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
name = "ureq-proto" name = "ureq-proto"
version = "0.6.4" version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"http", "http",
@@ -8043,9 +8042,9 @@ dependencies = [
[[package]] [[package]]
name = "webpki-root-certs" name = "webpki-root-certs"
version = "1.0.9" version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -8056,14 +8055,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [ dependencies = [
"webpki-roots 1.0.9", "webpki-roots 1.0.10",
] ]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.9" version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
+21 -2
View File
@@ -577,7 +577,12 @@ impl ChatRegistry {
return; return;
}; };
let room: Room = room.into().organize(&public_key); let mut room: Room = room.into().organize(&public_key);
if room.auto_trusted(public_key, cx) {
room.kind = RoomKind::Ongoing;
}
let room_id = room.id; let room_id = room.id;
let entity = cx.new(|_| room); let entity = cx.new(|_| room);
entity.update(cx, |room, cx| room.ensure_directory(cx)); entity.update(cx, |room, cx| room.ensure_directory(cx));
@@ -652,6 +657,11 @@ impl ChatRegistry {
/// Extend the registry with new rooms. /// Extend the registry with new rooms.
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) { fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return;
};
let mut room_map: HashMap<u64, usize> = self let mut room_map: HashMap<u64, usize> = self
.rooms .rooms
.iter() .iter()
@@ -660,6 +670,13 @@ impl ChatRegistry {
.collect(); .collect();
for new_room in rooms.into_iter() { for new_room in rooms.into_iter() {
// Auto-trust also applies to rooms loaded from the database
let new_room = if new_room.kind == RoomKind::Request && new_room.auto_trusted(me, cx) {
new_room.kind(RoomKind::Ongoing)
} else {
new_room
};
// Check if we already have a room with this ID // Check if we already have a room with this ID
if let Some(&index) = room_map.get(&new_room.id) { if let Some(&index) = room_map.get(&new_room.id) {
self.rooms[index].update(cx, |this, cx| { self.rooms[index].update(cx, |this, cx| {
@@ -789,7 +806,9 @@ impl ChatRegistry {
match self.room_index.get(&message.room).cloned() { match self.room_index.get(&message.room).cloned() {
Some(room) => { Some(room) => {
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key { if this.kind == RoomKind::Request
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
{
this.set_ongoing(cx); this.set_ongoing(cx);
} }
this.push_message(message, cx); this.push_message(message, cx);
+50 -2
View File
@@ -8,8 +8,8 @@ use instant::Duration;
use itertools::Itertools; use itertools::Itertools;
use nip4e::DeviceRegistry; use nip4e::DeviceRegistry;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, Person}; use person::{Directory, Person, WebOfTrust};
use settings::{RoomConfig, SignerKind}; use settings::{AppSettings, RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner}; use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
@@ -183,6 +183,54 @@ impl Room {
self self
} }
/// Whether the auto-trust setting promotes this room out of the requests:
/// everyone other than the current user is inside the web of trust
pub fn auto_trusted(&self, me: PublicKey, cx: &App) -> bool {
if !AppSettings::get_wot_auto_trust(cx) {
return false;
}
let wot = WebOfTrust::global(cx).read(cx);
Self::trusted_by(&self.members, me, |key| wot.is_in_network(key))
}
/// Whether the room looks like spam: everyone other than the current user
/// is outside the web of trust
pub fn is_spam(&self, cx: &App) -> bool {
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return false;
};
let wot = WebOfTrust::global(cx).read(cx);
Self::trusted_by(&self.members, me, |key| !wot.is_outside(key))
}
/// Whether every member other than `me` passes the check
fn trusted_by(
members: &[PublicKey],
me: PublicKey,
check: impl Fn(&PublicKey) -> bool,
) -> bool {
let mut others = false;
for key in members {
if *key == me {
continue;
}
others = true;
if !check(key) {
return false;
}
}
others
}
/// Sets this room is ongoing conversation /// Sets this room is ongoing conversation
pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) { pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) {
self.kind = RoomKind::Ongoing; self.kind = RoomKind::Ongoing;
+3
View File
@@ -11,12 +11,15 @@ use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod directory; mod directory;
mod person; mod person;
mod wot;
pub use directory::*; pub use directory::*;
pub use person::*; pub use person::*;
pub use wot::*;
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
WebOfTrust::set_global(cx.new(WebOfTrust::new), cx);
} }
struct GlobalPersonRegistry(Entity<PersonRegistry>); struct GlobalPersonRegistry(Entity<PersonRegistry>);
+633
View File
@@ -0,0 +1,633 @@
use std::collections::{HashMap, HashSet};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
/// Disable scoring when the follow set exceeds this size
const MAX_FOLLOWS: usize = 2000;
/// Cap a followed author's contact list to bound hostile publishers
const MAX_TAGS_PER_LIST: usize = 5000;
/// Time between reconciliations with the relays
const SYNC_INTERVAL: u64 = 15 * 60;
struct GlobalWebOfTrust(Entity<WebOfTrust>);
impl Global for GlobalWebOfTrust {}
/// A change to the web of trust
#[derive(Debug, Clone)]
pub enum WoTEvent {
/// The graph changed
Updated,
/// The initial load finished
Ready,
}
impl EventEmitter<WoTEvent> for WebOfTrust {}
/// Web of trust
///
/// Scores public keys by how many of the current user's contacts follow them,
/// built from the contact lists (NIP-02) of the people the user follows.
#[derive(Debug)]
pub struct WebOfTrust {
/// Current user's public key
self_key: Option<PublicKey>,
/// Latest known contact list of the current user
follows: HashSet<PublicKey>,
/// Created at of the latest contact list of the current user
follows_at: Option<Timestamp>,
/// Latest known contact list per followed author, with its created at
lists: HashMap<PublicKey, (Timestamp, HashSet<PublicKey>)>,
/// Reverse index: target -> contacts who follow them
reverse: HashMap<PublicKey, HashSet<PublicKey>>,
/// Whether scoring is disabled due to an oversized follow set
disabled: bool,
/// Whether the initial load has finished
ready: bool,
/// Whether the next refresh completes the initial load
initial: bool,
/// Task loading the graph, replaced by newer loads
hydration: Option<Task<()>>,
/// Tasks for asynchronous operations
tasks: SmallVec<[Task<()>; 4]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl WebOfTrust {
/// Retrieve the global web of trust
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalWebOfTrust>().0.clone()
}
/// Set the global web of trust instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalWebOfTrust(state));
}
/// Create a new web of trust instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let entity = cx.entity().downgrade();
let mut tasks = smallvec![];
tasks.push(cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_secs(SYNC_INTERVAL))
.await;
// Ignore errors, the next refresh will retry
let _ = this.update(cx, |this, cx| {
this.sync(cx);
});
}
}));
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() {
this.reset(cx);
this.hydrate(cx);
}
});
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
this.hydrate(cx);
});
});
Self {
self_key: None,
follows: HashSet::new(),
follows_at: None,
lists: HashMap::new(),
reverse: HashMap::new(),
disabled: false,
ready: false,
initial: false,
hydration: None,
tasks,
_subscriptions: smallvec![subscription],
}
}
/// Load the graph for the current account
fn hydrate(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(self_key) = nostr.read(cx).current_user() else {
return;
};
self.self_key = Some(self_key);
self.initial = true;
// The locally stored contact list, applied before the relays respond
let local: Task<Option<Event>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(self_key)
.kind(Kind::ContactList)
.limit(1);
client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
});
self.hydration = Some(cx.spawn(async move |this, cx| {
let latest = local.await;
let at = latest.as_ref().map(|event| event.created_at);
let snapshot: HashSet<PublicKey> = latest
.as_ref()
.map(|event| event.tags.public_keys().take(MAX_FOLLOWS + 1).collect())
.unwrap_or_default();
// Credit the local graph before the relays respond
this.update(cx, |this, cx| {
if this.self_key == Some(self_key) && this.store_follows(at, snapshot.clone()) {
cx.emit(WoTEvent::Updated);
cx.notify();
}
})
.ok();
// Reconcile with the relays; the refresh this triggers finalizes the initial load
this.update(cx, |this, cx| {
this.sync(cx);
})
.ok();
}));
}
/// Reconcile the follow set's contact lists with the relays and apply whatever came back
fn sync(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(self_key) = nostr.read(cx).current_user() else {
return;
};
let covered: HashSet<PublicKey> = self.follows.clone();
let mut authors: Vec<PublicKey> = covered.iter().copied().collect();
authors.push(self_key);
self.tasks.push(cx.spawn(async move |this, cx| {
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
// Negentropy transfers only the lists the relays hold and the database is missing
if let Err(error) = client.sync(filter).await {
log::warn!("Failed to reconcile contact lists: {error}");
}
let mut authors: Vec<PublicKey> = covered.iter().copied().collect();
authors.push(self_key);
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
let events: Vec<Event> = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.collect();
let mut grew = false;
this.update(cx, |this, cx| {
let changed = this.apply_latest(self_key, events, cx);
if this.initial {
this.initial = false;
this.ready = true;
cx.emit(WoTEvent::Ready);
}
if changed {
cx.emit(WoTEvent::Updated);
cx.notify();
}
grew = !covered.is_superset(&this.follows);
})
.ok();
// The follow set grew beyond the covered authors,
// reconcile once more so their contact lists are included
if grew {
this.update(cx, |this, cx| {
this.sync(cx);
})
.ok();
}
}));
}
/// Apply the newest stored contact list per author, returning whether the graph changed
fn apply_latest(
&mut self,
self_key: PublicKey,
events: Vec<Event>,
cx: &mut Context<Self>,
) -> bool {
if self.self_key != Some(self_key) {
return false;
}
// Keep the newest contact list per author
let mut latest: HashMap<PublicKey, Event> = HashMap::new();
for event in events {
if event.pubkey != self_key && (self.disabled || !self.follows.contains(&event.pubkey))
{
continue;
}
match latest.get(&event.pubkey) {
Some(current) if current.created_at > event.created_at => continue,
_ => {
latest.insert(event.pubkey, event);
}
}
}
let mut changed = false;
for event in latest.into_values() {
let was_disabled = self.disabled;
changed |= if event.pubkey == self_key {
self.apply_own(&event)
} else {
self.apply_contact(&event)
};
if was_disabled && !self.disabled {
// The graph was cleared while disabled and has to be rebuilt
self.sync(cx);
}
}
changed
}
/// Apply the current user's contact list, returning whether the graph changed
fn apply_own(&mut self, event: &Event) -> bool {
if self.follows_at.is_some_and(|at| at > event.created_at) {
return false;
}
let follows: HashSet<PublicKey> = event.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
self.store_follows(Some(event.created_at), follows)
}
/// Apply a followed author's contact list, returning whether the graph changed
fn apply_contact(&mut self, event: &Event) -> bool {
if self.disabled || !self.follows.contains(&event.pubkey) {
return false;
}
let contacts = self.parse_contacts(event);
self.set_contact_set(event.pubkey, event.created_at, contacts)
}
/// Parse a capped contact set from a contact list event
fn parse_contacts(&self, event: &Event) -> HashSet<PublicKey> {
event.tags.public_keys().take(MAX_TAGS_PER_LIST).collect()
}
/// Store a follow set, un-credit removed contacts, and report changes
fn store_follows(&mut self, at: Option<Timestamp>, follows: HashSet<PublicKey>) -> bool {
self.follows_at = at;
if follows.len() > MAX_FOLLOWS {
if self.disabled {
return false;
}
self.follows.clear();
self.lists.clear();
self.reverse.clear();
self.disabled = true;
return true;
}
let removed: Vec<PublicKey> = self.follows.difference(&follows).copied().collect();
let added: Vec<PublicKey> = follows.difference(&self.follows).copied().collect();
let changed = !removed.is_empty() || !added.is_empty();
self.follows = follows;
self.disabled = false;
for follower in removed {
self.uncredit(&follower);
}
changed
}
/// Store a followed author's contact set, returning whether the graph changed
fn set_contact_set(
&mut self,
follower: PublicKey,
at: Timestamp,
contacts: HashSet<PublicKey>,
) -> bool {
if let Some((current, _)) = self.lists.get(&follower)
&& *current > at
{
return false;
}
let contacts: HashSet<PublicKey> = contacts
.into_iter()
.filter(|key| *key != follower && Some(*key) != self.self_key)
.collect();
let (added, removed) = match self.lists.remove(&follower) {
Some((_, previous)) => (
contacts
.difference(&previous)
.copied()
.collect::<Vec<PublicKey>>(),
previous
.difference(&contacts)
.copied()
.collect::<Vec<PublicKey>>(),
),
None => (contacts.iter().copied().collect(), Vec::new()),
};
self.lists.insert(follower, (at, contacts));
let changed = !added.is_empty() || !removed.is_empty();
for target in added {
self.reverse.entry(target).or_default().insert(follower);
}
for target in removed {
self.remove_credit(&follower, &target);
}
changed
}
/// Remove a contact's credits from the reverse index
fn uncredit(&mut self, follower: &PublicKey) {
if let Some((_, contacts)) = self.lists.remove(follower) {
for target in contacts {
self.remove_credit(follower, &target);
}
}
}
/// Remove a single credit from the reverse index
fn remove_credit(&mut self, follower: &PublicKey, target: &PublicKey) {
if let Some(creditors) = self.reverse.get_mut(target) {
creditors.remove(follower);
if creditors.is_empty() {
self.reverse.remove(target);
}
}
}
/// Clear the graph on sign out or account switch
fn reset(&mut self, cx: &mut Context<Self>) {
self.self_key = None;
self.follows.clear();
self.follows_at = None;
self.lists.clear();
self.reverse.clear();
self.disabled = false;
self.ready = false;
self.initial = false;
self.hydration = None;
cx.emit(WoTEvent::Updated);
cx.notify();
}
/// How many of the current user's contacts follow the public key
pub fn score(&self, public_key: &PublicKey) -> usize {
self.reverse
.get(public_key)
.map_or(0, |creditors| creditors.len())
}
/// Whether the public key is followed by the user or by any of their contacts
pub fn is_in_network(&self, public_key: &PublicKey) -> bool {
self.follows.contains(public_key) || self.score(public_key) > 0
}
/// Whether the graph is loaded and the public key is outside the network
pub fn is_outside(&self, public_key: &PublicKey) -> bool {
self.ready && !self.disabled && !self.is_in_network(public_key)
}
/// The current user's contacts who follow the public key
pub fn mutuals(&self, public_key: &PublicKey) -> Vec<PublicKey> {
self.reverse
.get(public_key)
.map_or_else(Vec::new, |creditors| {
let mut contacts: Vec<PublicKey> = creditors.iter().copied().collect();
contacts.sort();
contacts
})
}
/// Whether the initial load has finished
pub fn is_ready(&self) -> bool {
self.ready
}
/// Whether scoring is disabled due to an oversized follow set
pub fn is_disabled(&self) -> bool {
self.disabled
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(seed: usize) -> PublicKey {
let mut bytes = [0u8; 32];
bytes[30] = (seed >> 8) as u8;
bytes[31] = seed as u8;
Keys::new(SecretKey::from_slice(&bytes).expect("valid secret key")).public_key()
}
fn registry() -> WebOfTrust {
WebOfTrust {
self_key: None,
follows: HashSet::new(),
follows_at: None,
lists: HashMap::new(),
reverse: HashMap::new(),
disabled: false,
ready: false,
initial: false,
hydration: None,
tasks: smallvec![],
_subscriptions: smallvec![],
}
}
#[test]
fn credits_contacts_of_followed_authors() {
let mut wot = registry();
let (me, alice, bob, carol) = (key(1), key(2), key(3), key(4));
wot.self_key = Some(me);
assert!(wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice, bob])));
assert!(!wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice, bob])));
assert!(wot.set_contact_set(alice, Timestamp::from_secs(10), HashSet::from([carol]),));
assert_eq!(wot.score(&carol), 1);
assert_eq!(wot.mutuals(&carol), vec![alice]);
assert!(wot.is_in_network(&carol));
assert!(wot.set_contact_set(bob, Timestamp::from_secs(10), HashSet::from([carol]),));
assert_eq!(wot.score(&carol), 2);
assert_eq!(wot.mutuals(&carol), vec![alice, bob]);
}
#[test]
fn ignores_older_contact_lists() {
let mut wot = registry();
let (me, alice, carol, dave) = (key(1), key(2), key(3), key(4));
wot.self_key = Some(me);
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
assert!(wot.set_contact_set(alice, Timestamp::from_secs(20), HashSet::from([carol]),));
assert!(!wot.set_contact_set(alice, Timestamp::from_secs(10), HashSet::from([dave]),));
assert_eq!(wot.mutuals(&carol), vec![alice]);
assert_eq!(wot.score(&dave), 0);
}
#[test]
fn uncredits_removed_follows() {
let mut wot = registry();
let (me, alice, bob, carol) = (key(1), key(2), key(3), key(4));
wot.self_key = Some(me);
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice, bob]));
wot.set_contact_set(alice, Timestamp::from_secs(10), HashSet::from([carol]));
wot.set_contact_set(bob, Timestamp::from_secs(10), HashSet::from([carol]));
assert_eq!(wot.score(&carol), 2);
assert!(wot.store_follows(Some(Timestamp::from_secs(11)), HashSet::from([bob])));
assert_eq!(wot.score(&carol), 1);
assert_eq!(wot.mutuals(&carol), vec![bob]);
assert!(!wot.lists.contains_key(&alice));
}
#[test]
fn excludes_self_and_follower() {
let mut wot = registry();
let (me, alice, carol) = (key(1), key(2), key(3));
wot.self_key = Some(me);
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
wot.set_contact_set(
alice,
Timestamp::from_secs(10),
HashSet::from([me, alice, carol]),
);
assert_eq!(wot.score(&me), 0);
assert_eq!(wot.score(&alice), 0);
assert_eq!(wot.mutuals(&carol), vec![alice]);
}
#[test]
fn disables_on_oversized_follow_set() {
let mut wot = registry();
let me = key(1);
let follows: HashSet<PublicKey> = (2..(MAX_FOLLOWS + 3)).map(key).collect();
wot.self_key = Some(me);
wot.set_contact_set(key(2), Timestamp::from_secs(10), HashSet::from([key(3)]));
assert!(wot.store_follows(Some(Timestamp::from_secs(10)), follows));
assert!(wot.disabled);
assert!(wot.follows.is_empty());
assert!(wot.lists.is_empty());
assert!(wot.reverse.is_empty());
}
#[test]
fn outside_requires_a_loaded_graph() {
let mut wot = registry();
let (me, alice, carol) = (key(1), key(2), key(3));
wot.self_key = Some(me);
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
// The graph is not loaded yet
assert!(!wot.is_outside(&carol));
wot.ready = true;
assert!(wot.is_outside(&carol));
assert!(!wot.is_outside(&alice));
// A disabled graph marks nothing as outside
wot.disabled = true;
assert!(!wot.is_outside(&carol));
}
#[test]
fn caps_ingested_contact_lists() {
let mut wot = registry();
let (me, alice) = (key(1), key(2));
wot.self_key = Some(me);
wot.store_follows(Some(Timestamp::from_secs(10)), HashSet::from([alice]));
let tags: Vec<Tag> = (3..(MAX_TAGS_PER_LIST + 4))
.map(|seed| Tag::public_key(key(seed)))
.collect();
let keys = Keys::new(
SecretKey::from_slice(&{
let mut bytes = [0u8; 32];
bytes[31] = 2;
bytes
})
.expect("valid secret key"),
);
let event = EventBuilder::new(Kind::ContactList, "")
.tags(tags)
.finalize(&keys)
.expect("valid event");
let contacts = wot.parse_contacts(&event);
assert_eq!(contacts.len(), MAX_TAGS_PER_LIST);
assert!(!contacts.contains(&key(MAX_TAGS_PER_LIST + 3)));
}
}
+5 -37
View File
@@ -14,6 +14,7 @@ pub fn init(cx: &mut App) {
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band"; const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Built-in image shown behind chat and group panels /// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg"; pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
@@ -58,6 +59,7 @@ setting_accessors! {
pub appearance_mode: AppearanceMode, pub appearance_mode: AppearanceMode,
pub hide_avatar: bool, pub hide_avatar: bool,
pub screening: bool, pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool, pub nip4e: bool,
pub hide_group_notice: bool, pub hide_group_notice: bool,
pub file_server: Url, pub file_server: Url,
@@ -125,38 +127,30 @@ impl RoomConfig {
pub struct Settings { pub struct Settings {
#[serde(default, alias = "theme_mode")] #[serde(default, alias = "theme_mode")]
pub appearance_mode: AppearanceMode, pub appearance_mode: AppearanceMode,
/// Hide user avatars /// Hide user avatars
pub hide_avatar: bool, pub hide_avatar: bool,
/// Enable screening for unknown chat requests /// Enable screening for unknown chat requests
pub screening: bool, pub screening: bool,
/// Promote chat requests from people followed by the user's contacts
pub wot_auto_trust: bool,
/// Enable decoupling encryption key /// Enable decoupling encryption key
pub nip4e: bool, pub nip4e: bool,
/// Hide the notice that messages in public groups are not encrypted /// Hide the notice that messages in public groups are not encrypted
#[serde(default)] #[serde(default)]
pub hide_group_notice: bool, pub hide_group_notice: bool,
/// Show an image behind chat and group panels /// Show an image behind chat and group panels
#[serde(default)] #[serde(default)]
pub panel_background: bool, pub panel_background: bool,
/// Custom image for the panel background; the built-in one when unset /// Custom image for the panel background; the built-in one when unset
#[serde(default)] #[serde(default)]
pub panel_background_path: Option<PathBuf>, pub panel_background_path: Option<PathBuf>,
/// Trusted relays; Coop will automatically authenticate with these relays /// Trusted relays; Coop will automatically authenticate with these relays
pub trusted_relays: Vec<String>, pub trusted_relays: Vec<String>,
/// Server for blossom media attachments /// Server for blossom media attachments
pub file_server: Url, pub file_server: Url,
/// Pinned chat rooms, by conversation ID /// Pinned chat rooms, by conversation ID
#[serde(default)] #[serde(default)]
pub pinned_rooms: Vec<u64>, pub pinned_rooms: Vec<u64>,
/// Pinned groups, by cache tag /// Pinned groups, by cache tag
#[serde(default)] #[serde(default)]
pub pinned_groups: Vec<String>, pub pinned_groups: Vec<String>,
@@ -168,6 +162,7 @@ impl Default for Settings {
appearance_mode: AppearanceMode::default(), appearance_mode: AppearanceMode::default(),
hide_avatar: false, hide_avatar: false,
screening: true, screening: true,
wot_auto_trust: true,
nip4e: false, nip4e: false,
hide_group_notice: false, hide_group_notice: false,
panel_background: true, panel_background: true,
@@ -339,30 +334,3 @@ impl AppSettings {
}); });
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
// Every field the persisted format has always required; `partial`
// contributes the fields under test.
serde_json::from_str(&format!(
r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false,
"trusted_relays": [], "file_server": "https://nostr.download/" }}"#,
partial
))
}
#[test]
fn old_theme_mode_key_keeps_its_pinned_choice() {
let settings = json_with(r#""theme_mode": "Dark", "#).unwrap();
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
}
#[test]
fn missing_appearance_mode_falls_back_to_dark() {
let settings = json_with("").unwrap();
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
}
}
+7 -19
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -260,15 +261,9 @@ impl NostrRegistry {
let uri = NostrConnectUri::parse(base)?; let uri = NostrConnectUri::parse(base)?;
// Construct the nostr connect signer // Construct the nostr connect signer
let mut signer = NostrConnect::new( let signer = NostrConnect::builder(uri, keys)
uri, .auth_url_handler(Arc::new(CoopAuthUrlHandler))
keys, .build()?;
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(signer, cx); this.set_signer(signer, cx);
@@ -447,16 +442,9 @@ impl NostrRegistry {
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// Construct the nostr connect signer let signer = NostrConnect::builder(connect_uri, keys)
let mut signer = NostrConnect::new( .auth_url_handler(Arc::new(CoopAuthUrlHandler))
connect_uri, .build()?;
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Verify the bunker responds before persisting the credential // Verify the bunker responds before persisting the credential
let public_key = signer.get_public_key_async().await?; let public_key = signer.get_public_key_async().await?;
+47 -19
View File
@@ -9,7 +9,7 @@ use gpui::{
}; };
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, Person, shorten_pubkey}; use person::{Directory, Person, WebOfTrust, shorten_pubkey};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
@@ -33,14 +33,19 @@ pub struct Screening {
last_active: Option<Timestamp>, last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened. /// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>, mutual_contacts: Vec<PublicKey>,
/// Whether the database fallback for mutual contacts has run.
mutuals_fallback: bool,
/// Person directory for the person and their mutual contacts /// Person directory for the person and their mutual contacts
directory: Entity<Directory>, directory: Entity<Directory>,
tasks: SmallVec<[Task<()>; 3]>, tasks: SmallVec<[Task<()>; 3]>,
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
impl Screening { impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let wot = WebOfTrust::global(cx);
let directory = Directory::new(cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| { subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
@@ -48,6 +53,14 @@ impl Screening {
window.close_all_dialogs(cx); 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| { cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx); this.check_contact(cx);
this.check_wot(cx); this.check_wot(cx);
@@ -55,15 +68,13 @@ impl Screening {
this.verify_identifier(cx); this.verify_identifier(cx);
}); });
let directory = Directory::new(cx);
directory.update(cx, |directory, cx| directory.track([public_key], cx));
Self { Self {
public_key, public_key,
verified: false, verified: false,
followed: false, followed: false,
last_active: None, last_active: None,
mutual_contacts: vec![], mutual_contacts: vec![],
mutuals_fallback: false,
directory, directory,
tasks: smallvec![], tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
@@ -110,7 +121,25 @@ impl Screening {
})); }));
} }
/// Check mutual contacts through the web of trust,
/// falling back to the database while it loads
fn check_wot(&mut self, cx: &mut Context<Self>) { fn check_wot(&mut self, cx: &mut Context<Self>) {
let trusted = {
let wot = WebOfTrust::global(cx).read(cx);
(wot.is_ready() && !wot.is_disabled()).then(|| wot.mutuals(&self.public_key))
};
if let Some(contacts) = trusted {
self.mutual_contacts = contacts;
cx.notify();
return;
}
if self.mutuals_fallback {
return;
}
self.mutuals_fallback = true;
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let public_key = self.public_key; let public_key = self.public_key;
@@ -119,33 +148,33 @@ impl Screening {
return; return;
}; };
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move { let task: Task<Vec<PublicKey>> = cx.background_spawn(async move {
// Check mutual contacts // Check mutual contacts among the locally stored contact lists
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![]; let mut contacts = vec![];
if let Ok(events) = client.database().query(filter).await { if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) { for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey); contacts.push(event.pubkey);
} }
} }
Ok(mutual_contacts) contacts
}); });
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { let contacts = task.await;
Ok(contacts) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let wot = WebOfTrust::global(cx).read(cx);
// The web of trust wins once its load has finished
if wot.is_ready() && !wot.is_disabled() {
return;
}
this.mutual_contacts = contacts; this.mutual_contacts = contacts;
cx.notify(); cx.notify();
}) })
.ok(); .ok();
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
})); }));
} }
@@ -365,7 +394,7 @@ impl Render for Screening {
Button::new("njump") Button::new("njump")
.icon(IconName::Link) .icon(IconName::Link)
.label("njump.me") .label("njump.me")
.secondary() .ghost_alt()
.small() .small()
.rounded() .rounded()
.on_click(cx.listener(move |this, _e, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
@@ -376,7 +405,6 @@ impl Render for Screening {
Button::new("report") Button::new("report")
.tooltip("Report as a scam or impostor") .tooltip("Report as a scam or impostor")
.icon(IconName::Warning) .icon(IconName::Warning)
.small()
.warning() .warning()
.rounded() .rounded()
.on_click(cx.listener(move |this, _e, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
+11
View File
@@ -83,12 +83,14 @@ impl Preferences {
impl Render for Preferences { impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender."; const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance."; const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const MODE: &str = "Follow the OS, or always use light or dark."; const MODE: &str = "Follow the OS, or always use light or dark.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const BACKGROUND: &str = "Show an image behind chat and group panels."; const BACKGROUND: &str = "Show an image behind chat and group panels.";
let screening = AppSettings::get_screening(cx); let screening = AppSettings::get_screening(cx);
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx); let nip4e = AppSettings::get_nip4e(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx); let appearance_mode = AppSettings::get_appearance_mode(cx);
@@ -116,6 +118,15 @@ impl Render for Preferences {
AppSettings::update_screening(!screening, cx); AppSettings::update_screening(!screening, cx);
}), }),
) )
.child(
Switch::new("wot-auto-trust")
.label("Web of Trust")
.description(WOT)
.checked(wot_auto_trust)
.on_click(move |_, _window, cx| {
AppSettings::update_wot_auto_trust(!wot_auto_trust, cx);
}),
)
.child( .child(
Switch::new("avatar") Switch::new("avatar")
.label("Hide user avatar") .label("Hide user avatar")
+1 -1
View File
@@ -120,7 +120,7 @@ impl Sidebar {
.px_4() .px_4()
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.font_semibold() .font_semibold()
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_muted)
.child(list_title), .child(list_title),
) )
}) })
+23 -3
View File
@@ -118,7 +118,7 @@ impl Sidebar {
.label(label) .label(label)
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.font_semibold() .font_semibold()
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_muted)
.into_any_element() .into_any_element()
} }
SidebarRow::Empty(text) => v_flex() SidebarRow::Empty(text) => v_flex()
@@ -136,17 +136,20 @@ impl Sidebar {
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(px(button_radius())) .rounded(px(button_radius()))
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_muted)
.child(*text), .child(*text),
) )
.into_any_element(), .into_any_element(),
SidebarRow::Room { room } => { SidebarRow::Room { room } => {
let id = room.read(cx).id;
let name = room.read(cx).display_name(cx); let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx); let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let id = room.read(cx).id;
let member = room.read(cx).members().first().copied(); let member = room.read(cx).members().first().copied();
let spam =
room.read(cx).is_spam(cx) && room.read(cx).kind == RoomKind::Request;
let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
let dock = self.dock.clone(); let dock = self.dock.clone();
let room = room.clone(); let room = room.clone();
@@ -163,12 +166,29 @@ impl Sidebar {
) )
}) })
.suffix( .suffix(
h_flex()
.gap_1()
.when(spam, |this| {
this.child(
div()
.px_1()
.rounded(px(button_radius()))
.bg(cx.theme().danger.opacity(0.15))
.text_size(px(8.0))
.text_color(cx.theme().danger)
.child("SPAM"),
)
})
.when(!spam, |this| {
this.child(
div() div()
.font_normal() .font_normal()
.text_style(TextStyle::Caption) .text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint) .text_color(cx.theme().text_faint)
.child(created_at), .child(created_at),
) )
}),
)
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx); open_room(&room, &dock, window, cx);
}); });
+212
View File
@@ -0,0 +1,212 @@
# Web of Trust — implementation plan
A plan for scoring strangers by the user's follow graph, based on how Amethyst
implements web of trust. The goal is to make the screening dialog and the chat
requests list more useful: "3 of your contacts follow this person" instead of a
bare npub, and a way to let trusted strangers skip screening.
## 1. How Amethyst does it
Amethyst (vitorpamplona/amethyst) has two independent WoT layers:
**Layer 1 — follow-graph score (`WoTService`)**, in
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`:
- For every pubkey `X`, the score is the number of people the user follows who
also follow `X` (friends-of-friends, computed only from kind 3 contact lists).
- The user's own follow set and each followed user's kind 3 feed the graph; the
service keeps a reverse index `target -> {followers}` and derives scores from
set sizes.
- Purely in memory, rebuilt on start; a single writer coroutine serializes
updates via an op channel and diffs each follower's list on republish, so a
changed kind 3 only touches the entries it added/removed.
- Guardrails: WoT is disabled when the user follows more than `MAX_FOLLOWS`
(2000) accounts, and each ingested contact list is capped
(`MAX_FOLLOWS_PER_EVENT`, 5000) to bound hostile publishers.
- A `ready` flag (set on first EOSE) tells the UI when scores are usable.
- Consumers sort people follows-first, then by score (`PeopleOrder.kt`) and show
a desktop badge.
**Layer 2 — NIP-85 trust-provider index**, in
`commons/.../wot/network/` and `quartz/.../nip85TrustedAssertions/users/index/`:
- The user names a "trust provider" (e.g. Brainstorm/GrapeRank) in a kind 10040
event; the provider publishes a signed rank card (kind 30382) for every member
of the user's extended network (~150–300k events).
- The client downloads the whole set once, verifies every signature, and stores
a compact binary index (~6 MB: 128-bit key prefixes + rank/hops/followers
arrays) with cursor-based incremental sync (NIP-45 COUNT + NIP-77 negentropy).
- The gate is `accepted(pk) = pk == me || pk in follows || rank(pk) >= minScore`
and it drives the DM Known/New split, the "Curated" notification filter, push
filtering, and collapsing out-of-network replies. Exemptions live in one
predicate (`TrustVerdicts.kt` / `CuratedTrustFilter.kt`) so in-app and push
can't drift: zaps always pass, chats follow the DM rule, everything else is
gated on network membership.
### What we take, what we skip
| Amethyst piece | Take? |
|---|---|
| Friends-of-friends score, reverse index, diffing, caps, ready flag | Yes — this is the whole feature for coop |
| Follows-first ordering helper | Only if we ever list non-contacts; not needed now |
| NIP-85 provider onboarding, kind 30382 index, negentropy sync, binary files | No — massive sync + storage complexity for a DM client |
| One shared exemption predicate per surface | Yes as a principle: gate requests in one place |
Layer 2 exists because Amethyst gates a 300k-user social graph. Coop gates
incoming DMs; the follow graph (one hop of contact lists) is enough signal and
needs no new events, relays, or storage formats.
## 2. Design: `WebOfTrust` in the `person` crate
One new logical component, one new file: `crates/person/src/wot.rs`, re-exported
from `crates/person/src/lib.rs` and initialized in `person::init` as a global
entity, exactly like `PersonRegistry`.
### State
```rust
pub struct WebOfTrust {
/// The signed-in user's contacts (latest kind 3)
follows: HashSet<PublicKey>,
/// The current user, excluded from credit
self_key: Option<PublicKey>,
/// Per-follower cached follow set, for diffing on republish
lists: HashMap<PublicKey, HashSet<PublicKey>>,
/// Reverse index: target -> contacts who follow them
reverse: HashMap<PublicKey, HashSet<PublicKey>>,
/// Disabled when the follow set exceeds MAX_FOLLOWS
disabled: bool,
ready: bool,
tasks, subscriptions, ...
}
```
API (all reads are plain methods; writes go through the entity):
```rust
pub fn score(&self, key: &PublicKey) -> usize // reverse[key].len()
pub fn is_in_network(&self, key: &PublicKey) -> bool // follows || score >= 1
pub fn mutuals(&self, key: &PublicKey) -> Vec<PublicKey> // who vouches, for the dialog
pub fn is_ready(&self) -> bool
pub fn is_disabled(&self) -> bool
```
Events: `WoTEvent::Updated` and `WoTEvent::Ready`, emitted after hydration and
on every graph change; consumers subscribe like they do to `PersonRegistry`.
### Data flow — reuses the existing pattern end to end
Same shape as `PersonRegistry::new` (background listener -> flume channel ->
entity update -> `cx.emit`):
1. **Hydrate from the local database** on creation and on
`StateEvent::SignerChanged`:
- own kind 3 (newest event, `limit(1)` like `Directory::track` and
`query_chat_rooms` do) -> `follows`;
- applied immediately so the dialog has data before the relays respond.
2. **Stay fresh with NIP-77** (`WebOfTrust::sync`, no persistent subscription):
- `client.sync(Filter::new().kind(Kind::ContactList).authors(follows + self))`
reconciles with every relay in the pool and downloads only the contact
lists the local database is missing (negentropy items come from the
database, direction is down-only);
- after each reconcile, the newest stored list per covered author is applied
to the graph and a `Ready`/`Updated` event marks what changed;
- triggers: hydrate, a 15-minute timer (Amethyst's D10 cadence), and a
follow-set change; when a reconcile grows the follow set (fresh install),
it re-syncs once more to cover the new authors.
3. **Rules ported from `WoTService`**:
- exclude the follower and `self_key` from every ingested list;
- on follow-set change, un-credit removed followers from the reverse index;
- `MAX_FOLLOWS = 2000` disables the service (clear state, emit ready);
- `MAX_TAGS_PER_LIST = 5000` caps one follower's ingested list;
- reset everything on sign-out / account switch.
No persistence: the graph rebuilds from the LMDB cache on every start, the same
way `ChatRegistry` rebuilds rooms. No new relay events, no settings file, no new
dependencies. Relays without NIP-77 fail their reconcile (logged); the graph
then reflects whatever the database already holds.
## 3. Consumers (in order)
### Phase 1 — screening dialog (`crates/workspace/src/dialogs/screening.rs`)
Status: **implemented**. The `WebOfTrust` registry lives in `crates/person/src/wot.rs` and
is initialized in `person::init`.
Replace `check_wot`'s database query (which only sees contact lists that happen
to be cached, so "mutual contacts" is usually empty) with the registry:
- `mutual_contacts = wot.mutuals(&public_key)` — the people **the user follows**
who follow the stranger, which is the correct WoT semantic and matches
Amethyst's score.
- The existing "Mutual contacts" dialog keeps working unchanged: it already
renders a `Vec<PublicKey>` through a `Directory`.
- When `wot.is_disabled()` or not ready, fall back to the current query so the
dialog degrades to today's behavior.
### Phase 2 — requests list badge
Status: **partially implemented**. Request rooms whose members are all outside
the web of trust show a `SPAM` badge in the sidebar
(`Room::is_spam` + `WebOfTrust::is_outside`, rendered in
`crates/workspace/src/sidebar/rows.rs`; the sidebar subscribes to `WoTEvent` so
badges update when the graph loads). The mutual-count caption below is still
open and will follow a different flow.
Where request rooms are rendered, show the mutual count for the sender:
- `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and
`crates/workspace/src/panels/greeter.rs` (`load`): one caption
`"N mutual contacts"` under the request preview, via the room's other member.
- Subscribe to `WoTEvent::Updated` where `PersonEvent` is already subscribed so
rows re-render when the graph changes.
### Phase 3 — opt-in auto-trust (behind a setting)
Status: **implemented** (Phase 2 skipped for now; it will follow a different
flow).
The payoff: strangers vouched for by the user's contacts can skip screening.
- `pub wot_auto_trust: bool` in `setting_accessors!` in
`crates/settings/src/lib.rs` (enabled out of the box: `Default` is `true` and
a `#[serde(default = "default_true")]` fallback covers settings files saved
before the field existed) and a "Web of trust" toggle in
the preferences screen (`crates/workspace/src/dialogs/settings.rs`).
- The rule lives in one predicate — `Room::auto_trusted(me, cx)` in
`crates/chat/src/room.rs`: the setting is on and every member other than the
current user is inside the web of trust (`is_in_network`, so followed
senders always pass and strangers need at least one mutual contact).
It's applied at every point a room's kind is decided so the states can't
drift: `ChatRegistry::add_room` (incoming first message),
`ChatRegistry::new_message` (live promotion next to the existing
I-sent-it rule), and `ChatRegistry::extend_rooms` (rooms loaded from the
database, so the split survives a restart).
Deliberately not planned: sorting contacts (the new-chat dialog lists only
follows already), notification feeds (coop has none), muting/spam scoring.
## 4. Non-goals
- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank
thresholds — revisit only if coop ever needs a network-scale gate.
- Persisting the graph or multi-hop (3+) scores — kind 3s are one LMDB query
away on restart, and negentropy keeps them current.
## 5. Testing
- Unit tests for the pure graph logic in `wot.rs` (no `cx` needed): seeding,
per-follower diffing, follow-set removal un-crediting, self/follower
exclusion, both caps and the disabled transition.
- One GPUI test for hydration ordering (newest kind 3 wins) using
`TestAppContext`; use `cx.background_executor().timer(...)` for any waits per
the project guidelines.
## 6. References
- `WoTService.kt` — the model this plan ports:
https://github.com/vitorpamplona/amethyst/blob/main/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt
- `PeopleOrder.kt`, `CuratedTrustFilter.kt`, `TrustVerdicts.kt` — consumers and
the shared-predicate pattern.
- Design doc for Amethyst's NIP-85 index (the layer we skip, with the reasoning
and measurements): `commons/plans/2026-10-07-wot-network-index.md` on main.