use WebOfTrust registry for screening dialog mutuals
This commit is contained in:
@@ -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>);
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
|
use instant::Duration;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
/// Disable scoring when the follow set exceeds this size
|
||||||
|
const MAX_FOLLOWS: usize = 2000;
|
||||||
|
/// Cap a followed author's contact list to bound hostile publishers
|
||||||
|
const MAX_TAGS_PER_LIST: usize = 5000;
|
||||||
|
/// Time between reconciliations with the relays
|
||||||
|
const SYNC_INTERVAL: u64 = 15 * 60;
|
||||||
|
|
||||||
|
struct GlobalWebOfTrust(Entity<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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-14
@@ -101,17 +101,19 @@ entity update -> `cx.emit`):
|
|||||||
|
|
||||||
1. **Hydrate from the local database** on creation and on
|
1. **Hydrate from the local database** on creation and on
|
||||||
`StateEvent::SignerChanged`:
|
`StateEvent::SignerChanged`:
|
||||||
- own kind 3 (newest event, authors sorted by `Reverse(created_at)` like
|
- own kind 3 (newest event, `limit(1)` like `Directory::track` and
|
||||||
`Directory::track` does) -> `follows`;
|
`query_chat_rooms` do) -> `follows`;
|
||||||
- one batch query `Filter::new().kind(Kind::ContactList).authors(follows)`
|
- applied immediately so the dialog has data before the relays respond.
|
||||||
-> dedupe per author keeping the newest -> seed `lists`/`reverse`.
|
2. **Stay fresh with NIP-77** (`WebOfTrust::sync`, no persistent subscription):
|
||||||
2. **Stay fresh**: one persistent subscription with a dedicated
|
- `client.sync(Filter::new().kind(Kind::ContactList).authors(follows + self))`
|
||||||
`SubscriptionId` (add `WOT_CONTACTS` beside `DEVICE_GIFTWRAP`/`USER_GIFTWRAP`
|
reconciles with every relay in the pool and downloads only the contact
|
||||||
in `state::constants`), filter `kind(Kind::ContactList).authors(follows)`.
|
lists the local database is missing (negentropy items come from the
|
||||||
The background listener accepts any kind 3 whose author is in `follows`
|
database, direction is down-only);
|
||||||
(don't couple to subscription ids), diffs it against the cached list, and
|
- after each reconcile, the newest stored list per covered author is applied
|
||||||
updates the reverse index. Re-issue the subscription when the user's own
|
to the graph and a `Ready`/`Updated` event marks what changed;
|
||||||
contact list changes.
|
- 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`**:
|
3. **Rules ported from `WoTService`**:
|
||||||
- exclude the follower and `self_key` from every ingested list;
|
- exclude the follower and `self_key` from every ingested list;
|
||||||
- on follow-set change, un-credit removed followers from the reverse index;
|
- on follow-set change, un-credit removed followers from the reverse index;
|
||||||
@@ -121,12 +123,16 @@ entity update -> `cx.emit`):
|
|||||||
|
|
||||||
No persistence: the graph rebuilds from the LMDB cache on every start, the same
|
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
|
way `ChatRegistry` rebuilds rooms. No new relay events, no settings file, no new
|
||||||
dependencies.
|
dependencies. Relays without NIP-77 fail their reconcile (logged); the graph
|
||||||
|
then reflects whatever the database already holds.
|
||||||
|
|
||||||
## 3. Consumers (in order)
|
## 3. Consumers (in order)
|
||||||
|
|
||||||
### Phase 1 — screening dialog (`crates/workspace/src/dialogs/screening.rs`)
|
### 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
|
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:
|
to be cached, so "mutual contacts" is usually empty) with the registry:
|
||||||
|
|
||||||
@@ -179,8 +185,7 @@ follows already), notification feeds (coop has none), muting/spam scoring.
|
|||||||
- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank
|
- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank
|
||||||
thresholds — revisit only if coop ever needs a network-scale gate.
|
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
|
- Persisting the graph or multi-hop (3+) scores — kind 3s are one LMDB query
|
||||||
away on restart.
|
away on restart, and negentropy keeps them current.
|
||||||
- Background jobs / WorkManager equivalents — hydration is two local queries.
|
|
||||||
|
|
||||||
## 5. Testing
|
## 5. Testing
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user