feat: add support for basic web of trust #65
@@ -8,7 +8,7 @@ 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, WebOfTrust};
|
use person::{Directory, Person, PersonRegistry};
|
||||||
use settings::{AppSettings, RoomConfig, SignerKind};
|
use settings::{AppSettings, RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ impl Room {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let wot = WebOfTrust::global(cx).read(cx);
|
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
|
||||||
|
|
||||||
Self::trusted_by(&self.members, me, |key| wot.is_in_network(key))
|
Self::trusted_by(&self.members, me, |key| wot.is_in_network(key))
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ impl Room {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
let wot = WebOfTrust::global(cx).read(cx);
|
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
|
||||||
|
|
||||||
Self::trusted_by(&self.members, me, |key| !wot.is_outside(key))
|
Self::trusted_by(&self.members, me, |key| !wot.is_outside(key))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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>);
|
||||||
@@ -63,6 +62,8 @@ enum Dispatch {
|
|||||||
pub struct PersonRegistry {
|
pub struct PersonRegistry {
|
||||||
/// Public keys with a metadata request in flight
|
/// Public keys with a metadata request in flight
|
||||||
in_flight: RwLock<HashSet<PublicKey>>,
|
in_flight: RwLock<HashSet<PublicKey>>,
|
||||||
|
/// Web of trust graph of the signed-in user
|
||||||
|
wot: Entity<WebOfTrust>,
|
||||||
sender: flume::Sender<PublicKey>,
|
sender: flume::Sender<PublicKey>,
|
||||||
tasks: SmallVec<[Task<()>; 4]>,
|
tasks: SmallVec<[Task<()>; 4]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
@@ -83,6 +84,7 @@ impl PersonRegistry {
|
|||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
let wot = cx.new(WebOfTrust::new);
|
||||||
|
|
||||||
// Channel for communication between nostr and gpui
|
// Channel for communication between nostr and gpui
|
||||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||||
@@ -125,17 +127,28 @@ impl PersonRegistry {
|
|||||||
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||||
if event.signer_changed() {
|
if event.signer_changed() {
|
||||||
this.get_emojis(cx);
|
this.get_emojis(cx);
|
||||||
|
|
||||||
|
this.wot.update(cx, |wot, cx| {
|
||||||
|
wot.reset(cx);
|
||||||
|
wot.hydrate(cx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
in_flight: RwLock::new(HashSet::new()),
|
in_flight: RwLock::new(HashSet::new()),
|
||||||
|
wot,
|
||||||
sender: metadata_tx,
|
sender: metadata_tx,
|
||||||
tasks,
|
tasks,
|
||||||
_subscriptions: smallvec![subscription],
|
_subscriptions: smallvec![subscription],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The web of trust graph of the signed-in user
|
||||||
|
pub fn wot(&self) -> Entity<WebOfTrust> {
|
||||||
|
self.wot.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle nostr notifications
|
/// Handle nostr notifications
|
||||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
|
|||||||
+14
-65
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
use gpui::{AppContext, Context, EventEmitter, Task};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -11,20 +11,11 @@ const MAX_FOLLOWS: usize = 2000;
|
|||||||
/// Cap a followed author's contact list to bound hostile publishers
|
/// Cap a followed author's contact list to bound hostile publishers
|
||||||
const MAX_TAGS_PER_LIST: usize = 5000;
|
const MAX_TAGS_PER_LIST: usize = 5000;
|
||||||
/// Time between reconciliations with the relays
|
/// Time between reconciliations with the relays
|
||||||
const SYNC_INTERVAL: u64 = 15 * 60;
|
const INTERVAL: u64 = 15 * 60;
|
||||||
|
|
||||||
struct GlobalWebOfTrust(Entity<WebOfTrust>);
|
/// The web of trust graph changed
|
||||||
|
|
||||||
impl Global for GlobalWebOfTrust {}
|
|
||||||
|
|
||||||
/// A change to the web of trust
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WoTEvent {
|
pub struct WoTEvent;
|
||||||
/// The graph changed
|
|
||||||
Updated,
|
|
||||||
/// The initial load finished
|
|
||||||
Ready,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<WoTEvent> for WebOfTrust {}
|
impl EventEmitter<WoTEvent> for WebOfTrust {}
|
||||||
|
|
||||||
@@ -48,38 +39,22 @@ pub struct WebOfTrust {
|
|||||||
disabled: bool,
|
disabled: bool,
|
||||||
/// Whether the initial load has finished
|
/// Whether the initial load has finished
|
||||||
ready: bool,
|
ready: bool,
|
||||||
/// Whether the next refresh completes the initial load
|
|
||||||
initial: bool,
|
|
||||||
/// Task loading the graph, replaced by newer loads
|
/// Task loading the graph, replaced by newer loads
|
||||||
hydration: Option<Task<()>>,
|
hydration: Option<Task<()>>,
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
tasks: SmallVec<[Task<()>; 4]>,
|
tasks: SmallVec<[Task<()>; 4]>,
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebOfTrust {
|
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
|
/// Create a new web of trust instance
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
tasks.push(cx.spawn(async move |this, cx| {
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
loop {
|
loop {
|
||||||
cx.background_executor()
|
cx.background_executor()
|
||||||
.timer(Duration::from_secs(SYNC_INTERVAL))
|
.timer(Duration::from_secs(INTERVAL))
|
||||||
.await;
|
.await;
|
||||||
// Ignore errors, the next refresh will retry
|
// Ignore errors, the next refresh will retry
|
||||||
let _ = this.update(cx, |this, cx| {
|
let _ = this.update(cx, |this, cx| {
|
||||||
@@ -88,13 +63,6 @@ impl WebOfTrust {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
|
||||||
if event.signer_changed() {
|
|
||||||
this.reset(cx);
|
|
||||||
this.hydrate(cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
let _ = entity.update(cx, |this, cx| {
|
let _ = entity.update(cx, |this, cx| {
|
||||||
this.hydrate(cx);
|
this.hydrate(cx);
|
||||||
@@ -109,23 +77,21 @@ impl WebOfTrust {
|
|||||||
reverse: HashMap::new(),
|
reverse: HashMap::new(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
ready: false,
|
ready: false,
|
||||||
initial: false,
|
|
||||||
hydration: None,
|
hydration: None,
|
||||||
tasks,
|
tasks,
|
||||||
_subscriptions: smallvec![subscription],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the graph for the current account
|
/// Load the graph for the current account
|
||||||
fn hydrate(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let Some(self_key) = nostr.read(cx).current_user() else {
|
let Some(self_key) = nostr.read(cx).current_user() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.self_key = Some(self_key);
|
self.self_key = Some(self_key);
|
||||||
self.initial = true;
|
|
||||||
|
|
||||||
// The locally stored contact list, applied before the relays respond
|
// The locally stored contact list, applied before the relays respond
|
||||||
let local: Task<Option<Event>> = cx.background_spawn(async move {
|
let local: Task<Option<Event>> = cx.background_spawn(async move {
|
||||||
@@ -155,7 +121,7 @@ impl WebOfTrust {
|
|||||||
// Credit the local graph before the relays respond
|
// Credit the local graph before the relays respond
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
if this.self_key == Some(self_key) && this.store_follows(at, snapshot.clone()) {
|
if this.self_key == Some(self_key) && this.store_follows(at, snapshot.clone()) {
|
||||||
cx.emit(WoTEvent::Updated);
|
cx.emit(WoTEvent);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -206,16 +172,12 @@ impl WebOfTrust {
|
|||||||
let mut grew = false;
|
let mut grew = false;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
let was_ready = this.ready;
|
||||||
let changed = this.apply_latest(self_key, events, cx);
|
let changed = this.apply_latest(self_key, events, cx);
|
||||||
|
this.ready = true;
|
||||||
|
|
||||||
if this.initial {
|
if changed || !was_ready {
|
||||||
this.initial = false;
|
cx.emit(WoTEvent);
|
||||||
this.ready = true;
|
|
||||||
cx.emit(WoTEvent::Ready);
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
cx.emit(WoTEvent::Updated);
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +369,7 @@ impl WebOfTrust {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the graph on sign out or account switch
|
/// Clear the graph on sign out or account switch
|
||||||
fn reset(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.self_key = None;
|
self.self_key = None;
|
||||||
self.follows.clear();
|
self.follows.clear();
|
||||||
self.follows_at = None;
|
self.follows_at = None;
|
||||||
@@ -415,10 +377,9 @@ impl WebOfTrust {
|
|||||||
self.reverse.clear();
|
self.reverse.clear();
|
||||||
self.disabled = false;
|
self.disabled = false;
|
||||||
self.ready = false;
|
self.ready = false;
|
||||||
self.initial = false;
|
|
||||||
self.hydration = None;
|
self.hydration = None;
|
||||||
|
|
||||||
cx.emit(WoTEvent::Updated);
|
cx.emit(WoTEvent);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,20 +411,10 @@ impl WebOfTrust {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the initial load has finished
|
|
||||||
pub fn is_ready(&self) -> bool {
|
|
||||||
self.ready
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the graph is loaded and score queries are meaningful
|
/// Whether the graph is loaded and score queries are meaningful
|
||||||
pub fn is_loaded(&self) -> bool {
|
pub fn is_loaded(&self) -> bool {
|
||||||
self.ready && !self.disabled
|
self.ready && !self.disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether scoring is disabled due to an oversized follow set
|
|
||||||
pub fn is_disabled(&self) -> bool {
|
|
||||||
self.disabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -487,10 +438,8 @@ mod tests {
|
|||||||
reverse: HashMap::new(),
|
reverse: HashMap::new(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
ready: false,
|
ready: false,
|
||||||
initial: false,
|
|
||||||
hydration: None,
|
hydration: None,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
_subscriptions: smallvec![],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Directory, Person, PersonRegistry, WebOfTrust, shorten_pubkey};
|
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::{NostrRegistry, upload};
|
use state::{NostrRegistry, upload};
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||||
@@ -460,7 +460,7 @@ impl Render for ProfileDialog {
|
|||||||
.map(SharedString::from);
|
.map(SharedString::from);
|
||||||
|
|
||||||
let score = {
|
let score = {
|
||||||
let wot = WebOfTrust::global(cx).read(cx);
|
let wot = PersonRegistry::global(cx).read(cx).wot().read(cx);
|
||||||
wot.is_loaded().then(|| wot.score(&self.public_key))
|
wot.is_loaded().then(|| wot.score(&self.public_key))
|
||||||
}
|
}
|
||||||
.map(|score| {
|
.map(|score| {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Directory, Person, WebOfTrust, shorten_pubkey};
|
use person::{Directory, Person, PersonRegistry, 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,7 +33,7 @@ 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.
|
/// Whether the fallback lookup for mutual contacts has run.
|
||||||
mutuals_fallback: bool,
|
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>,
|
||||||
@@ -43,7 +43,7 @@ pub struct Screening {
|
|||||||
|
|
||||||
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 wot = PersonRegistry::global(cx).read(cx).wot();
|
||||||
let directory = Directory::new(cx);
|
let directory = Directory::new(cx);
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
@@ -53,8 +53,8 @@ impl Screening {
|
|||||||
window.close_all_dialogs(cx);
|
window.close_all_dialogs(cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| {
|
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
|
||||||
this.check_wot(cx);
|
cx.notify();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
directory.update(cx, |directory, cx| {
|
directory.update(cx, |directory, cx| {
|
||||||
@@ -63,7 +63,7 @@ impl Screening {
|
|||||||
|
|
||||||
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.fetch_fallback(cx);
|
||||||
this.check_last_activity(cx);
|
this.check_last_activity(cx);
|
||||||
this.verify_identifier(cx);
|
this.verify_identifier(cx);
|
||||||
});
|
});
|
||||||
@@ -121,27 +121,17 @@ impl Screening {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check mutual contacts through the web of trust,
|
/// Fetch mutual contacts from the database while the web of trust loads
|
||||||
/// falling back to the database while it loads
|
fn fetch_fallback(&mut self, cx: &mut Context<Self>) {
|
||||||
fn check_wot(&mut self, cx: &mut Context<Self>) {
|
let persons = PersonRegistry::global(cx);
|
||||||
let trusted = {
|
let nostr = NostrRegistry::global(cx);
|
||||||
let wot = WebOfTrust::global(cx).read(cx);
|
let client = nostr.read(cx).client();
|
||||||
wot.is_loaded().then(|| wot.mutuals(&self.public_key))
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(contacts) = trusted {
|
if self.mutuals_fallback || persons.read(cx).wot().read(cx).is_loaded() {
|
||||||
self.mutual_contacts = contacts;
|
|
||||||
cx.notify();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.mutuals_fallback {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.mutuals_fallback = true;
|
self.mutuals_fallback = true;
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
let public_key = self.public_key;
|
let public_key = self.public_key;
|
||||||
|
|
||||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||||
@@ -166,11 +156,9 @@ impl Screening {
|
|||||||
let contacts = task.await;
|
let contacts = task.await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
// The web of trust wins once its load has finished
|
if persons.read(cx).wot().read(cx).is_loaded() {
|
||||||
if WebOfTrust::global(cx).read(cx).is_loaded() {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mutual_contacts = contacts;
|
this.mutual_contacts = contacts;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
@@ -292,8 +280,12 @@ impl Screening {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn mutual_contacts(
|
||||||
let contacts = self.mutual_contacts.clone();
|
&mut self,
|
||||||
|
contacts: Vec<PublicKey>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let directory = self.directory.clone();
|
let directory = self.directory.clone();
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, _cx| {
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
@@ -346,11 +338,23 @@ impl Render for Screening {
|
|||||||
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
||||||
const NO_NIP05: &str = "This person has not set up their friendly address";
|
const NO_NIP05: &str = "This person has not set up their friendly address";
|
||||||
|
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = self.profile(cx);
|
let profile = self.profile(cx);
|
||||||
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
||||||
|
|
||||||
let last_active = self.last_active.map(|_| true);
|
let last_active = self.last_active.map(|_| true);
|
||||||
let mutuals = self.mutual_contacts.len();
|
|
||||||
|
// Pull from the web of trust once loaded, else use the database fallback
|
||||||
|
let mutual_contacts = {
|
||||||
|
let wot = persons.read(cx).wot().read(cx);
|
||||||
|
if wot.is_loaded() {
|
||||||
|
wot.mutuals(&self.public_key)
|
||||||
|
} else {
|
||||||
|
self.mutual_contacts.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mutuals = mutual_contacts.len();
|
||||||
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
|
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -543,7 +547,11 @@ impl Render for Screening {
|
|||||||
.rounded()
|
.rounded()
|
||||||
.disabled(mutuals == 0)
|
.disabled(mutuals == 0)
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
this.mutual_contacts(window, cx);
|
this.mutual_contacts(
|
||||||
|
mutual_contacts.clone(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
# Web of Trust — implementation plan
|
|
||||||
|
|
||||||
A plan for scoring strangers by the user's follow graph, based on how Amethyst
|
|
||||||
implements web of trust. The goal is to make the screening dialog and the chat
|
|
||||||
requests list more useful: "3 of your contacts follow this person" instead of a
|
|
||||||
bare npub, and a way to let trusted strangers skip screening.
|
|
||||||
|
|
||||||
## 1. How Amethyst does it
|
|
||||||
|
|
||||||
Amethyst (vitorpamplona/amethyst) has two independent WoT layers:
|
|
||||||
|
|
||||||
**Layer 1 — follow-graph score (`WoTService`)**, in
|
|
||||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`:
|
|
||||||
|
|
||||||
- For every pubkey `X`, the score is the number of people the user follows who
|
|
||||||
also follow `X` (friends-of-friends, computed only from kind 3 contact lists).
|
|
||||||
- The user's own follow set and each followed user's kind 3 feed the graph; the
|
|
||||||
service keeps a reverse index `target -> {followers}` and derives scores from
|
|
||||||
set sizes.
|
|
||||||
- Purely in memory, rebuilt on start; a single writer coroutine serializes
|
|
||||||
updates via an op channel and diffs each follower's list on republish, so a
|
|
||||||
changed kind 3 only touches the entries it added/removed.
|
|
||||||
- Guardrails: WoT is disabled when the user follows more than `MAX_FOLLOWS`
|
|
||||||
(2000) accounts, and each ingested contact list is capped
|
|
||||||
(`MAX_FOLLOWS_PER_EVENT`, 5000) to bound hostile publishers.
|
|
||||||
- A `ready` flag (set on first EOSE) tells the UI when scores are usable.
|
|
||||||
- Consumers sort people follows-first, then by score (`PeopleOrder.kt`) and show
|
|
||||||
a desktop badge.
|
|
||||||
|
|
||||||
**Layer 2 — NIP-85 trust-provider index**, in
|
|
||||||
`commons/.../wot/network/` and `quartz/.../nip85TrustedAssertions/users/index/`:
|
|
||||||
|
|
||||||
- The user names a "trust provider" (e.g. Brainstorm/GrapeRank) in a kind 10040
|
|
||||||
event; the provider publishes a signed rank card (kind 30382) for every member
|
|
||||||
of the user's extended network (~150–300k events).
|
|
||||||
- The client downloads the whole set once, verifies every signature, and stores
|
|
||||||
a compact binary index (~6 MB: 128-bit key prefixes + rank/hops/followers
|
|
||||||
arrays) with cursor-based incremental sync (NIP-45 COUNT + NIP-77 negentropy).
|
|
||||||
- The gate is `accepted(pk) = pk == me || pk in follows || rank(pk) >= minScore`
|
|
||||||
and it drives the DM Known/New split, the "Curated" notification filter, push
|
|
||||||
filtering, and collapsing out-of-network replies. Exemptions live in one
|
|
||||||
predicate (`TrustVerdicts.kt` / `CuratedTrustFilter.kt`) so in-app and push
|
|
||||||
can't drift: zaps always pass, chats follow the DM rule, everything else is
|
|
||||||
gated on network membership.
|
|
||||||
|
|
||||||
### What we take, what we skip
|
|
||||||
|
|
||||||
| Amethyst piece | Take? |
|
|
||||||
|---|---|
|
|
||||||
| Friends-of-friends score, reverse index, diffing, caps, ready flag | Yes — this is the whole feature for coop |
|
|
||||||
| Follows-first ordering helper | Only if we ever list non-contacts; not needed now |
|
|
||||||
| NIP-85 provider onboarding, kind 30382 index, negentropy sync, binary files | No — massive sync + storage complexity for a DM client |
|
|
||||||
| One shared exemption predicate per surface | Yes as a principle: gate requests in one place |
|
|
||||||
|
|
||||||
Layer 2 exists because Amethyst gates a 300k-user social graph. Coop gates
|
|
||||||
incoming DMs; the follow graph (one hop of contact lists) is enough signal and
|
|
||||||
needs no new events, relays, or storage formats.
|
|
||||||
|
|
||||||
## 2. Design: `WebOfTrust` in the `person` crate
|
|
||||||
|
|
||||||
One new logical component, one new file: `crates/person/src/wot.rs`, re-exported
|
|
||||||
from `crates/person/src/lib.rs` and initialized in `person::init` as a global
|
|
||||||
entity, exactly like `PersonRegistry`.
|
|
||||||
|
|
||||||
### State
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct WebOfTrust {
|
|
||||||
/// The signed-in user's contacts (latest kind 3)
|
|
||||||
follows: HashSet<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 profile dialog shows the score as a
|
|
||||||
"Web of Trust" field (`crates/workspace/src/dialogs/profile.rs`, other users
|
|
||||||
only). The mutual-count caption below is still open and will follow a
|
|
||||||
different flow.
|
|
||||||
|
|
||||||
Where request rooms are rendered, show the mutual count for the sender:
|
|
||||||
|
|
||||||
- `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and
|
|
||||||
`crates/workspace/src/panels/greeter.rs` (`load`): one caption
|
|
||||||
`"N mutual contacts"` under the request preview, via the room's other member.
|
|
||||||
- Subscribe to `WoTEvent::Updated` where `PersonEvent` is already subscribed so
|
|
||||||
rows re-render when the graph changes.
|
|
||||||
|
|
||||||
### Phase 3 — opt-in auto-trust (behind a setting)
|
|
||||||
|
|
||||||
Status: **implemented** (Phase 2 skipped for now; it will follow a different
|
|
||||||
flow).
|
|
||||||
|
|
||||||
The payoff: strangers vouched for by the user's contacts can skip screening.
|
|
||||||
|
|
||||||
- `pub wot_auto_trust: bool` in `setting_accessors!` in
|
|
||||||
`crates/settings/src/lib.rs` (enabled out of the box: `Default` is `true` and
|
|
||||||
a `#[serde(default = "default_true")]` fallback covers settings files saved
|
|
||||||
before the field existed) and a "Web of trust" toggle in
|
|
||||||
the preferences screen (`crates/workspace/src/dialogs/settings.rs`).
|
|
||||||
- The rule lives in one predicate — `Room::auto_trusted(me, cx)` in
|
|
||||||
`crates/chat/src/room.rs`: the setting is on and every member other than the
|
|
||||||
current user is inside the web of trust (`is_in_network`, so followed
|
|
||||||
senders always pass and strangers need at least one mutual contact).
|
|
||||||
It's applied at every point a room's kind is decided so the states can't
|
|
||||||
drift: `ChatRegistry::add_room` (incoming first message),
|
|
||||||
`ChatRegistry::new_message` (live promotion next to the existing
|
|
||||||
I-sent-it rule), and `ChatRegistry::extend_rooms` (rooms loaded from the
|
|
||||||
database, so the split survives a restart).
|
|
||||||
|
|
||||||
Deliberately not planned: sorting contacts (the new-chat dialog lists only
|
|
||||||
follows already), notification feeds (coop has none), muting/spam scoring.
|
|
||||||
|
|
||||||
## 4. Non-goals
|
|
||||||
|
|
||||||
- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank
|
|
||||||
thresholds — revisit only if coop ever needs a network-scale gate.
|
|
||||||
- Persisting the graph or multi-hop (3+) scores — kind 3s are one LMDB query
|
|
||||||
away on restart, and negentropy keeps them current.
|
|
||||||
|
|
||||||
## 5. Testing
|
|
||||||
|
|
||||||
- Unit tests for the pure graph logic in `wot.rs` (no `cx` needed): seeding,
|
|
||||||
per-follower diffing, follow-set removal un-crediting, self/follower
|
|
||||||
exclusion, both caps and the disabled transition.
|
|
||||||
- One GPUI test for hydration ordering (newest kind 3 wins) using
|
|
||||||
`TestAppContext`; use `cx.background_executor().timer(...)` for any waits per
|
|
||||||
the project guidelines.
|
|
||||||
|
|
||||||
## 6. References
|
|
||||||
|
|
||||||
- `WoTService.kt` — the model this plan ports:
|
|
||||||
https://github.com/vitorpamplona/amethyst/blob/main/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt
|
|
||||||
- `PeopleOrder.kt`, `CuratedTrustFilter.kt`, `TrustVerdicts.kt` — consumers and
|
|
||||||
the shared-predicate pattern.
|
|
||||||
- Design doc for Amethyst's NIP-85 index (the layer we skip, with the reasoning
|
|
||||||
and measurements): `commons/plans/2026-10-07-wot-network-index.md` on main.
|
|
||||||
Reference in New Issue
Block a user