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