diff --git a/Cargo.lock b/Cargo.lock index 59e72c25..a1fcf204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -933,9 +933,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -1066,7 +1066,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout 0.2.4", + "inout 0.2.5", ] [[package]] @@ -2013,9 +2013,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flatbuffers" @@ -2696,7 +2696,7 @@ dependencies = [ "log", "rustls", "rustls-platform-verifier", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -3392,7 +3392,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -3616,9 +3616,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] @@ -4352,7 +4352,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.45.5" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "aes", "base64 0.22.1", @@ -4378,7 +4378,7 @@ dependencies = [ [[package]] name = "nostr-blossom" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "base64 0.22.1", "bitcoin_hashes 1.2.0", @@ -4391,10 +4391,9 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "async-utility", - "futures-core", "nostr", "nostr-sdk", "opaquerr 0.2.0", @@ -4405,7 +4404,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "nostr", "opaquerr 0.2.0", @@ -4414,7 +4413,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "nostr", "opaquerr 0.2.0", @@ -4423,7 +4422,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "indexmap", "lru", @@ -4435,7 +4434,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.3" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "async-utility", "flatbuffers", @@ -4450,7 +4449,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "btreecap", "nostr", @@ -4461,7 +4460,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.4" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "async-utility", "async-wsocket", @@ -5943,7 +5942,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -7164,9 +7163,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "bytes", "libc", @@ -7656,14 +7655,14 @@ dependencies = [ "serde_json", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -8043,9 +8042,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -8056,14 +8055,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index ef1668c8..f306fd9b 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -14,6 +14,7 @@ use gpui::{ use instant::Duration; use nip4e::{DeviceEvent, DeviceRegistry}; use nostr_sdk::prelude::*; +use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; @@ -139,13 +140,13 @@ impl ChatRegistry { /// Create a new chat registry instance fn new(cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); + let device = DeviceRegistry::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let entity = cx.entity().downgrade(); let (tx, rx) = flume::unbounded::(); let mut subscriptions = smallvec![]; - let device = DeviceRegistry::global(cx); - subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { if event.signer_changed() { this.reset(cx); @@ -163,6 +164,10 @@ impl ChatRegistry { } })); + subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| { + this.promote_trusted(cx); + })); + cx.defer(move |cx| { let _ = entity.update(cx, |this, cx| { this.get_rooms(cx); @@ -567,7 +572,23 @@ impl ChatRegistry { .unwrap_or_default() } - /// Add a new room to the start of list. + /// Promote request rooms whose senders are inside the web of trust + fn promote_trusted(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + + for room in &self.rooms { + room.update(cx, |room, cx| { + if room.kind == RoomKind::Request && room.auto_trusted(me, cx) { + room.set_ongoing(cx); + } + }); + } + } + + /// Promote a room to ongoing when its latest message is from the current user pub(crate) fn add_room(&mut self, room: I, cx: &mut Context) where I: Into, @@ -577,7 +598,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 +678,11 @@ impl ChatRegistry { /// Extend the registry with new rooms. fn extend_rooms(&mut self, rooms: HashSet, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + let mut room_map: HashMap = self .rooms .iter() @@ -660,6 +691,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 +827,9 @@ impl ChatRegistry { match self.room_index.get(&message.room).cloned() { Some(room) => { room.update(cx, |this, cx| { - if this.kind == RoomKind::Request && message.rumor.pubkey == public_key { + if this.kind == RoomKind::Request + && (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx)) + { this.set_ongoing(cx); } this.push_message(message, cx); diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index c119f823..42454a69 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -8,8 +8,8 @@ use instant::Duration; use itertools::Itertools; use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; -use person::{Directory, Person}; -use settings::{RoomConfig, SignerKind}; +use person::{Directory, Person, PersonRegistry}; +use settings::{AppSettings, RoomConfig, SignerKind}; use state::{NostrRegistry, TIMEOUT, UniversalSigner}; use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; @@ -183,6 +183,61 @@ 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 = PersonRegistry::global(cx).read(cx).wot().read(cx); + + Self::trusted_by(&self.members, me, |key| wot.is_in_network(key)) + } + + /// Whether the room looks like spam: the web of trust is loaded and + /// everyone other than the current user is outside of it + pub fn is_spam(&self, cx: &App) -> bool { + let person = PersonRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + + let Some(me) = nostr.read(cx).current_user() else { + return false; + }; + + let wot = person.read(cx).wot().read(cx); + + // An unloaded graph cannot classify anything as spam + if !wot.is_loaded() { + return false; + } + + Self::trusted_by(&self.members, me, |key| wot.is_outside(key)) + } + + /// Whether every member other than `me` passes the check + 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.kind = RoomKind::Ongoing; diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index aec3cf1d..d885e079 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -11,9 +11,11 @@ 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); @@ -60,6 +62,8 @@ enum Dispatch { pub struct PersonRegistry { /// Public keys with a metadata request in flight in_flight: RwLock>, + /// Web of trust graph of the signed-in user + wot: Entity, sender: flume::Sender, tasks: SmallVec<[Task<()>; 4]>, _subscriptions: SmallVec<[Subscription; 1]>, @@ -80,6 +84,7 @@ impl PersonRegistry { fn new(cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let wot = cx.new(WebOfTrust::new); // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(100); @@ -122,17 +127,28 @@ impl PersonRegistry { let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| { if event.signer_changed() { this.get_emojis(cx); + + this.wot.update(cx, |wot, cx| { + wot.reset(cx); + wot.hydrate(cx); + }); } }); Self { in_flight: RwLock::new(HashSet::new()), + wot, sender: metadata_tx, tasks, _subscriptions: smallvec![subscription], } } + /// The web of trust graph of the signed-in user + pub fn wot(&self) -> Entity { + self.wot.clone() + } + /// Handle nostr notifications async fn handle_notifications(client: &Client, tx: &flume::Sender) { let mut notifications = client.notifications(); diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs new file mode 100644 index 00000000..f0475edb --- /dev/null +++ b/crates/person/src/wot.rs @@ -0,0 +1,298 @@ +use std::collections::{HashMap, HashSet}; + +use gpui::{Context, EventEmitter, 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; +/// Longest wait between retries while the graph has no data yet +const MAX_RETRY_WAIT: u64 = 60; + +/// The web of trust graph changed +#[derive(Debug, Clone)] +pub struct WoTEvent; + +/// A built graph: the follow set and, per public key, who follows it +#[derive(Debug)] +struct Snapshot { + follows: HashSet, + scores: HashMap, + mutuals: HashMap>, + disabled: bool, +} + +impl Snapshot { + /// Build the snapshot from the newest stored contact list of every author + fn build(self_key: PublicKey, follows: HashSet, events: Vec) -> Self { + let disabled = follows.len() > MAX_FOLLOWS; + + let mut snapshot = Self { + follows, + scores: HashMap::new(), + mutuals: HashMap::new(), + disabled, + }; + + if snapshot.disabled { + return snapshot; + } + + // Keep the newest contact list per author + let mut latest: HashMap = HashMap::new(); + + for event in events { + if event.pubkey == self_key || !snapshot.follows.contains(&event.pubkey) { + continue; + } + + match latest.get(&event.pubkey) { + Some(current) if current.created_at > event.created_at => continue, + _ => { + latest.insert(event.pubkey, event); + } + } + } + + for (author, event) in latest { + for key in event.tags.public_keys().take(MAX_TAGS_PER_LIST) { + if key == author || key == self_key { + continue; + } + + *snapshot.scores.entry(key).or_default() += 1; + snapshot.mutuals.entry(key).or_default().push(author); + } + } + + for contacts in snapshot.mutuals.values_mut() { + contacts.sort(); + } + + snapshot + } +} + +impl EventEmitter for WebOfTrust {} + +/// Web of trust +/// +/// Scores public keys by how many of the current user's contacts follow them. +#[derive(Debug)] +pub struct WebOfTrust { + /// Current user's public key + self_key: Option, + /// Latest built snapshot, none until the first refresh completes + snapshot: Option, + /// In-flight refresh, replaced by newer ones + refresh_task: Option>, + /// Keeps the periodic reconciliation loop alive + _tasks: SmallVec<[Task<()>; 4]>, +} + +impl WebOfTrust { + /// Create a new web of trust instance + pub(crate) fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); + let mut tasks = smallvec![]; + + tasks.push(cx.spawn(async move |this, cx| { + let mut delay = Duration::from_secs(5); + + loop { + cx.background_executor().timer(delay).await; + + let mut has_data = false; + + // Ignore errors, the next refresh will retry + let _ = this.update(cx, |this, cx| { + this.refresh(cx); + has_data = this.has_data(); + }); + + delay = if has_data { + Duration::from_secs(SYNC_INTERVAL) + } else { + (delay * 2).min(Duration::from_secs(MAX_RETRY_WAIT)) + }; + } + })); + + cx.defer(move |cx| { + let _ = entity.update(cx, |this, cx| { + this.hydrate(cx); + }); + }); + + Self { + self_key: None, + snapshot: None, + refresh_task: None, + _tasks: tasks, + } + } + + /// Load the graph for the current account + pub(crate) fn hydrate(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let Some(self_key) = nostr.read(cx).current_user() else { + return; + }; + + self.self_key = Some(self_key); + self.refresh(cx); + } + + /// Clear the graph on sign out or account switch + pub(crate) fn reset(&mut self, cx: &mut Context) { + self.self_key = None; + self.snapshot = None; + self.refresh_task = None; + + cx.emit(WoTEvent); + cx.notify(); + } + + /// Reconcile the follow set's contact lists with the relays and rebuild. + fn refresh(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(self_key) = nostr.read(cx).current_user() else { + return; + }; + + self.refresh_task = Some(cx.spawn(async move |this, cx| { + // Scope the reconcile to the follow set known so far + let known: HashSet = stored_contact_list(&client, self_key) + .await + .map(|event| event.tags.public_keys().take(MAX_FOLLOWS + 1).collect()) + .unwrap_or_default(); + + let mut authors: Vec = known.iter().copied().collect(); + authors.push(self_key); + + // Negentropy transfers only the lists the relays hold + // and the database is missing + let filter = Filter::new().kind(Kind::ContactList).authors(authors); + if let Err(error) = client.sync(filter).await { + log::warn!("Failed to reconcile contact lists: {error}"); + } + + // The follow set after the reconcile + let Some(own) = stored_contact_list(&client, self_key).await else { + return; + }; + + let follows: HashSet = + own.tags.public_keys().take(MAX_FOLLOWS + 1).collect(); + + let mut authors: Vec = follows.iter().copied().collect(); + authors.push(self_key); + + let events: Vec = if follows.is_empty() { + Vec::new() + } else { + let filter = Filter::new().kind(Kind::ContactList).authors(authors); + client + .database() + .query(filter) + .await + .unwrap_or_default() + .into_iter() + .collect() + }; + + let snapshot = Snapshot::build(self_key, follows.clone(), events); + log::debug!( + "Web of trust rebuilt: {} follows, {} scored keys", + snapshot.follows.len(), + snapshot.scores.len() + ); + + // Newly followed authors were not part of the reconcile + let grew = !known.is_superset(&follows); + + this.update(cx, |this, cx| { + this.snapshot = Some(snapshot); + cx.emit(WoTEvent); + cx.notify(); + }) + .ok(); + + if grew { + this.update(cx, |this, cx| { + this.refresh(cx); + }) + .ok(); + } + })); + } + + /// How many of the current user's contacts follow the public key + pub fn score(&self, public_key: &PublicKey) -> usize { + self.snapshot.as_ref().map_or(0, |snapshot| { + snapshot.scores.get(public_key).copied().unwrap_or(0) + }) + } + + /// Whether the public key is followed by the user or by any of their contacts + pub fn is_in_network(&self, public_key: &PublicKey) -> bool { + self.snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.follows.contains(public_key)) + || self.score(public_key) > 0 + } + + /// Whether the graph is loaded and the public key is outside the network + pub fn is_outside(&self, public_key: &PublicKey) -> bool { + self.is_loaded() && !self.is_in_network(public_key) + } + + /// The current user's contacts who follow the public key + pub fn mutuals(&self, public_key: &PublicKey) -> Vec { + self.snapshot.as_ref().map_or_else(Vec::new, |snapshot| { + snapshot + .mutuals + .get(public_key) + .cloned() + .unwrap_or_default() + }) + } + + /// Whether the graph is loaded and score queries are meaningful + pub fn is_loaded(&self) -> bool { + self.snapshot + .as_ref() + .is_some_and(|snapshot| !snapshot.disabled) + } + + /// Whether the snapshot covers a follow set + fn has_data(&self) -> bool { + self.snapshot + .as_ref() + .is_some_and(|snapshot| !snapshot.follows.is_empty()) + } +} + +/// The newest stored contact list of the public key +async fn stored_contact_list(client: &Client, self_key: PublicKey) -> Option { + let filter = Filter::new() + .author(self_key) + .kind(Kind::ContactList) + .limit(1); + + client + .database() + .query(filter) + .await + .unwrap_or_default() + .into_iter() + .next() +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 5b5b34aa..e50e4c7a 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -14,6 +14,7 @@ pub fn init(cx: &mut App) { const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const LEGACY_FILE_SERVER: &str = "blossom.band"; + /// Built-in image shown behind chat and group panels pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg"; @@ -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, - /// Trusted relays; Coop will automatically authenticate with these relays pub trusted_relays: Vec, - /// Server for blossom media attachments pub file_server: Url, - /// Pinned chat rooms, by conversation ID #[serde(default)] pub pinned_rooms: Vec, - /// Pinned groups, by cache tag #[serde(default)] pub pinned_groups: Vec, @@ -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 { - // Every field the persisted format has always required; `partial` - // contributes the fields under test. - serde_json::from_str(&format!( - r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false, - "trusted_relays": [], "file_server": "https://nostr.download/" }}"#, - partial - )) - } - - #[test] - fn old_theme_mode_key_keeps_its_pinned_choice() { - let settings = json_with(r#""theme_mode": "Dark", "#).unwrap(); - assert_eq!(settings.appearance_mode, AppearanceMode::Dark); - } - - #[test] - fn missing_appearance_mode_falls_back_to_dark() { - let settings = json_with("").unwrap(); - assert_eq!(settings.appearance_mode, AppearanceMode::Dark); - } -} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 673e65b1..5a53fcf6 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::Arc; use anyhow::{Error, anyhow}; #[cfg(not(target_arch = "wasm32"))] @@ -260,15 +261,9 @@ impl NostrRegistry { let uri = NostrConnectUri::parse(base)?; // Construct the nostr connect signer - let mut signer = NostrConnect::new( - uri, - keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - )?; - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); + let signer = NostrConnect::builder(uri, keys) + .auth_url_handler(Arc::new(CoopAuthUrlHandler)) + .build()?; this.update(cx, |this, cx| { this.set_signer(signer, cx); @@ -447,16 +442,9 @@ impl NostrRegistry { let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); cx.spawn(async move |this, cx| { - // Construct the nostr connect signer - let mut signer = NostrConnect::new( - connect_uri, - keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - )?; - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); + let signer = NostrConnect::builder(connect_uri, keys) + .auth_url_handler(Arc::new(CoopAuthUrlHandler)) + .build()?; // Verify the bunker responds before persisting the credential let public_key = signer.get_public_key_async().await?; diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index fc8313ec..9e4d950e 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -459,9 +459,24 @@ impl Render for ProfileDialog { .filter(|website| !website.trim().is_empty()) .map(SharedString::from); + let score = { + let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); + wot.is_loaded().then(|| wot.score(&self.public_key)) + } + .map(|score| { + SharedString::from(format!( + "{} mutual {}", + score, + if score == 1 { "contact" } else { "contacts" } + )) + }); + this.child(field("Name", name, "Unknown", cx)) .child(field("About", about, "No bio", cx)) .child(field("Website", website, "No website", cx)) + .when(!self.is_current_user, |this| { + this.child(field("Web of Trust", score, "Not loaded yet", cx)) + }) }) .child( h_flex() diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 52b84221..a8d315c9 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::{Directory, Person, shorten_pubkey}; +use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use smallvec::{SmallVec, smallvec}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -33,14 +33,19 @@ pub struct Screening { last_active: Option, /// All mutual contacts of the person being screened. mutual_contacts: Vec, + /// Whether the fallback lookup for mutual contacts has run. + mutuals_fallback: bool, /// Person directory for the person and their mutual contacts directory: Entity, tasks: SmallVec<[Task<()>; 3]>, - _subscriptions: SmallVec<[Subscription; 1]>, + _subscriptions: SmallVec<[Subscription; 2]>, } impl Screening { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let wot = PersonRegistry::global(cx).read(cx).wot(); + let directory = Directory::new(cx); + let mut subscriptions = smallvec![]; subscriptions.push(cx.on_release_in(window, move |this, window, cx| { @@ -48,22 +53,28 @@ impl Screening { window.close_all_dialogs(cx); })); + subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| { + cx.notify(); + })); + + 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); + this.fetch_fallback(cx); this.check_last_activity(cx); 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,42 +121,48 @@ impl Screening { })); } - fn check_wot(&mut self, cx: &mut Context) { + /// Fetch mutual contacts from the database while the web of trust loads + fn fetch_fallback(&mut self, cx: &mut Context) { + let persons = PersonRegistry::global(cx); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + + if self.mutuals_fallback || persons.read(cx).wot().read(cx).is_loaded() { + return; + } + self.mutuals_fallback = true; + let public_key = self.public_key; let Some(current_user) = nostr.read(cx).current_user() else { return; }; - let task: Task, Error>> = cx.background_spawn(async move { - // Check mutual contacts + let task: Task> = cx.background_spawn(async move { + // Check mutual contacts among the locally stored contact lists let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); - let mut mutual_contacts = vec![]; + let mut contacts = vec![]; if let Ok(events) = client.database().query(filter).await { for event in events.into_iter().filter(|ev| ev.pubkey != current_user) { - mutual_contacts.push(event.pubkey); + contacts.push(event.pubkey); } } - Ok(mutual_contacts) + contacts }); self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.mutual_contacts = contacts; - cx.notify(); - }) - .ok(); + let contacts = task.await; + + this.update(cx, |this, cx| { + if persons.read(cx).wot().read(cx).is_loaded() { + return; } - Err(e) => { - log::error!("Failed to fetch mutual contacts: {}", e); - } - }; + this.mutual_contacts = contacts; + cx.notify(); + }) + .ok(); })); } @@ -263,8 +280,12 @@ impl Screening { })); } - fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let contacts = self.mutual_contacts.clone(); + fn mutual_contacts( + &mut self, + contacts: Vec, + window: &mut Window, + cx: &mut Context, + ) { let directory = self.directory.clone(); window.open_dialog(cx, move |this, _window, _cx| { @@ -317,11 +338,23 @@ impl Render for Screening { const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; const NO_NIP05: &str = "This person has not set up their friendly address"; + let persons = PersonRegistry::global(cx); let profile = self.profile(cx); let shorten_pubkey = shorten_pubkey(self.public_key, 8); let last_active = self.last_active.map(|_| true); - let mutuals = self.mutual_contacts.len(); + + // Pull from the web of trust once loaded, else use the database fallback + let mutual_contacts = { + let wot = persons.read(cx).wot().read(cx); + if wot.is_loaded() { + wot.mutuals(&self.public_key) + } else { + self.mutual_contacts.clone() + } + }; + + let mutuals = mutual_contacts.len(); let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals); v_flex() @@ -365,7 +398,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 +409,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| { @@ -515,7 +547,11 @@ impl Render for Screening { .rounded() .disabled(mutuals == 0) .on_click(cx.listener(move |this, _, window, cx| { - this.mutual_contacts(window, cx); + this.mutual_contacts( + mutual_contacts.clone(), + window, + cx, + ); })), ), ), diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index 7bea2318..57b8d9c0 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -83,12 +83,14 @@ impl Preferences { impl Render for Preferences { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { const SCREENING: &str = "Show an screening dialog to verify the unknown sender."; + const WOT: &str = "Treat people followed by your contacts as known senders."; const AVATAR: &str = "Hide all avatar pictures to improve performance."; const MODE: &str = "Follow the OS, or always use light or dark."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages."; const BACKGROUND: &str = "Show an image behind chat and group panels."; let screening = AppSettings::get_screening(cx); + let wot_auto_trust = AppSettings::get_wot_auto_trust(cx); let hide_avatar = AppSettings::get_hide_avatar(cx); let nip4e = AppSettings::get_nip4e(cx); let appearance_mode = AppSettings::get_appearance_mode(cx); @@ -116,6 +118,15 @@ impl Render for Preferences { AppSettings::update_screening(!screening, cx); }), ) + .child( + Switch::new("wot-auto-trust") + .label("Web of Trust") + .description(WOT) + .checked(wot_auto_trust) + .on_click(move |_, _window, cx| { + AppSettings::update_wot_auto_trust(!wot_auto_trust, cx); + }), + ) .child( Switch::new("avatar") .label("Hide user avatar") diff --git a/crates/workspace/src/panels/greeter.rs b/crates/workspace/src/panels/greeter.rs index 60bb59e6..e51b2867 100644 --- a/crates/workspace/src/panels/greeter.rs +++ b/crates/workspace/src/panels/greeter.rs @@ -10,6 +10,7 @@ use gpui::{ StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, svg, }; use nostr_sdk::prelude::*; +use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, StateEvent}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -57,13 +58,14 @@ pub struct GreeterPanel { /// The coalesced reload scheduled after registry updates refresh_task: Option>>, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, + _subscriptions: SmallVec<[Subscription; 3]>, } impl GreeterPanel { fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let mut subscriptions = smallvec![]; @@ -76,6 +78,13 @@ impl GreeterPanel { }), ); + subscriptions.push( + // Redraw when the web of trust loads or changes + cx.subscribe(&wot, |_, _, _, cx| { + cx.notify(); + }), + ); + subscriptions.push( // Reload soon after rooms load, messages arrive, or requests change cx.observe(&chat, |this, _, cx| { @@ -176,120 +185,137 @@ impl GreeterPanel { })); } - fn render_requests(&mut self, cx: &mut Context) -> Option { - if self.requests.is_empty() { - return None; - } + fn render_requests(&mut self, cx: &mut Context) -> AnyElement { + // Spam requests stay out of the greeter entirely + let requests: Vec<_> = self + .requests + .iter() + .filter(|(room, _)| !room.read(cx).is_spam(cx)) + .cloned() + .collect(); let dock = self.dock.clone(); - let rows: Vec = self - .requests - .iter() - .take(SECTION_SIZE) - .enumerate() - .map(|(index, (room, preview))| { - let room = room.clone(); - let dock = dock.clone(); - let name = room.read(cx).display_name(cx); - let picture = room.read(cx).display_image(cx); - let seed = room.read(cx).display_image_seed(cx); - let created_at = room.read(cx).created_at.to_ago(); - let preview = preview.clone(); - + let rows: Vec = if requests.is_empty() { + vec![ h_flex() - .id(format!("request-{index}")) - .group("") + .h_16() .w_full() - .h_9() - .px_1p5() - .gap_2() + .justify_center() + .border_1() + .border_dashed() + .border_color(cx.theme().border) .rounded(px(button_radius())) - .cursor_pointer() - .hover(|this| this.bg(cx.theme().element_hover)) - .on_click({ - let room = room.clone(); - let dock = dock.clone(); - move |_, window, cx| { - open_room(&room, &dock, window, cx); - } - }) - .child( - Avatar::from_source(picture) - .seed(seed) - .small() - .flex_shrink_0(), - ) - .child( - div() - .min_w_0() - .truncate() - .text_style(TextStyle::Callout) - .child(name), - ) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_style(TextStyle::Callout) - .text_color(cx.theme().text_muted) - .italic() - .child(preview), - ) - .child( - div() - .relative() - .flex_shrink_0() - .h_6() - .flex() - .items_center() - .justify_end() - .child( - div() - .absolute() - .left_0() - .right_0() - .top_0() - .bottom_0() - .group_hover("", |this| this.invisible()) - .child( - div() - .text_align(gpui::TextAlign::Right) - .text_style(TextStyle::Caption) - .text_color(cx.theme().text_faint) - .child(created_at), - ), - ) - .child( - Button::new(format!("response-{index}")) - .label("Response") - .xsmall() - .ghost() - .invisible() - .group_hover("", |this| this.visible()) - .on_click({ - let room = room.clone(); - let dock = dock.clone(); - move |_, window, cx| { - cx.stop_propagation(); - open_room(&room, &dock, window, cx); - } - }), - ), - ) - .into_any_element() - }) - .collect(); + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child("No chat requests") + .into_any_element(), + ] + } else { + requests + .iter() + .enumerate() + .map(|(index, (room, preview))| { + let room = room.clone(); + let dock = dock.clone(); + let name = room.read(cx).display_name(cx); + let picture = room.read(cx).display_image(cx); + let seed = room.read(cx).display_image_seed(cx); + let created_at = room.read(cx).created_at.to_ago(); + let preview = preview.clone(); - Some( - GroupBox::new() - .id("requests") - .title("Requests") - .fill() - .child(v_flex().gap_0().w_full().children(rows)) - .into_any_element(), - ) + h_flex() + .id(format!("request-{index}")) + .group("") + .w_full() + .h_9() + .px_1p5() + .gap_2() + .rounded(px(button_radius())) + .cursor_pointer() + .hover(|this| this.bg(cx.theme().element_hover)) + .on_click({ + let room = room.clone(); + let dock = dock.clone(); + move |_, window, cx| { + open_room(&room, &dock, window, cx); + } + }) + .child( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) + .child( + div() + .min_w_0() + .truncate() + .text_style(TextStyle::Callout) + .child(name), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_style(TextStyle::Callout) + .text_color(cx.theme().text_muted) + .italic() + .child(preview), + ) + .child( + div() + .relative() + .flex_shrink_0() + .h_6() + .flex() + .items_center() + .justify_end() + .child( + div() + .absolute() + .left_0() + .right_0() + .top_0() + .bottom_0() + .group_hover("", |this| this.invisible()) + .child( + div() + .text_align(gpui::TextAlign::Right) + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child(created_at), + ), + ) + .child( + Button::new(format!("response-{index}")) + .label("Response") + .xsmall() + .ghost() + .invisible() + .group_hover("", |this| this.visible()) + .on_click({ + let room = room.clone(); + let dock = dock.clone(); + move |_, window, cx| { + cx.stop_propagation(); + open_room(&room, &dock, window, cx); + } + }), + ), + ) + .into_any_element() + }) + .collect() + }; + + GroupBox::new() + .id("requests") + .title("Requests") + .fill() + .child(v_flex().gap_0().w_full().children(rows)) + .into_any_element() } fn render_follow_ups(&mut self, cx: &mut Context) -> Option { @@ -503,7 +529,7 @@ impl Render for GreeterPanel { .size_full() .p_2() .gap_6() - .children(self.render_requests(cx)) + .child(self.render_requests(cx)) .children(self.render_follow_ups(cx)) .child(self.render_find_friends(cx)), ) diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index f5779955..b4e90d5f 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -120,7 +120,7 @@ impl Sidebar { .px_4() .text_style(TextStyle::Caption) .font_semibold() - .text_color(cx.theme().text_faint) + .text_color(cx.theme().text_muted) .child(list_title), ) }) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 46add454..08280709 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -13,7 +13,7 @@ use gpui::{ use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; -use person::Directory; +use person::{Directory, PersonRegistry}; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _}; @@ -103,6 +103,7 @@ impl Sidebar { let chat = ChatRegistry::global(cx); let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let find_input = cx.new(|cx| { InputState::new(window, cx) @@ -121,6 +122,12 @@ impl Sidebar { }), ); + subscriptions.push( + cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| { + cx.notify(); + }), + ); + subscriptions.push( cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { match event { diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index 986eb145..824316c8 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -118,7 +118,7 @@ impl Sidebar { .label(label) .text_style(TextStyle::Caption) .font_semibold() - .text_color(cx.theme().text_faint) + .text_color(cx.theme().text_muted) .into_any_element() } SidebarRow::Empty(text) => v_flex() @@ -136,17 +136,20 @@ impl Sidebar { .border_color(cx.theme().border) .rounded(px(button_radius())) .text_style(TextStyle::Caption) - .text_color(cx.theme().text_faint) + .text_color(cx.theme().text_muted) .child(*text), ) .into_any_element(), SidebarRow::Room { room } => { + let id = room.read(cx).id; let name = room.read(cx).display_name(cx); let picture = room.read(cx).display_image(cx); let seed = room.read(cx).display_image_seed(cx); let created_at = room.read(cx).created_at.to_ago(); - let id = room.read(cx).id; let member = room.read(cx).members().first().copied(); + let spam = + room.read(cx).is_spam(cx) && room.read(cx).kind == RoomKind::Request; + let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); let dock = self.dock.clone(); let room = room.clone(); @@ -163,11 +166,28 @@ impl Sidebar { ) }) .suffix( - div() - .font_normal() - .text_style(TextStyle::Caption) - .text_color(cx.theme().text_faint) - .child(created_at), + h_flex() + .gap_1() + .when(spam, |this| { + this.child( + div() + .px_1() + .rounded(px(button_radius())) + .bg(cx.theme().danger.opacity(0.15)) + .text_size(px(8.0)) + .text_color(cx.theme().danger) + .child("SPAM"), + ) + }) + .when(!spam, |this| { + this.child( + div() + .font_normal() + .text_style(TextStyle::Caption) + .text_color(cx.theme().text_faint) + .child(created_at), + ) + }), ) .on_click(move |_event, window, cx| { open_room(&room, &dock, window, cx);