notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit f00a67ab2cd83049af4d4862e12b5609af301459
parent 575d469aa0917b16bdbd0f8392ada2cf3584441e
Author: Ken Sedgwick <ken@bonsai.com>
Date:   Fri, 15 Nov 2024 08:35:12 -0800

Update user relay-list via polling

Diffstat:
MCargo.lock | 5+++--
MCargo.toml | 1+
Menostr/src/relay/pool.rs | 24++++++++++++++++++++++++
Msrc/accounts/mod.rs | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/app.rs | 58++++++++++------------------------------------------------
5 files changed, 298 insertions(+), 54 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ab_glyph" @@ -2599,7 +2599,7 @@ dependencies = [ [[package]] name = "notedeck" -version = "0.1.0" +version = "0.2.0" dependencies = [ "android-activity 0.4.3", "android_logger", @@ -2638,6 +2638,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tracing-wasm", + "url", "urlencoding", "uuid", "wasm-bindgen-futures", diff --git a/Cargo.toml b/Cargo.toml @@ -55,6 +55,7 @@ dirs = "5.0.1" tracing-appender = "0.2.3" urlencoding = "2.1.3" open = "5.3.0" +url = "2.5" [dev-dependencies] tempfile = "3.13.0" diff --git a/enostr/src/relay/pool.rs b/enostr/src/relay/pool.rs @@ -2,6 +2,7 @@ use crate::relay::{Relay, RelayStatus}; use crate::{ClientMessage, Result}; use nostrdb::Filter; +use std::collections::BTreeSet; use std::time::{Duration, Instant}; use url::Url; @@ -89,6 +90,13 @@ impl RelayPool { false } + pub fn urls(&self) -> BTreeSet<String> { + self.relays + .iter() + .map(|pool_relay| pool_relay.relay.url.clone()) + .collect() + } + pub fn send(&mut self, cmd: &ClientMessage) { for relay in &mut self.relays { relay.relay.send(cmd); @@ -181,6 +189,22 @@ impl RelayPool { Ok(()) } + pub fn add_urls( + &mut self, + urls: BTreeSet<String>, + wakeup: impl Fn() + Send + Sync + Clone + 'static, + ) -> Result<()> { + for url in urls { + self.add_url(url, wakeup.clone())?; + } + Ok(()) + } + + pub fn remove_urls(&mut self, urls: &BTreeSet<String>) { + self.relays + .retain(|pool_relay| !urls.contains(&pool_relay.relay.url)); + } + // standardize the format (ie, trailing slashes) fn canonicalize_url(url: String) -> String { match Url::parse(&url) { diff --git a/src/accounts/mod.rs b/src/accounts/mod.rs @@ -1,7 +1,11 @@ use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; -use enostr::{FilledKeypair, FullKeypair, Keypair}; -use nostrdb::{Ndb, Transaction}; +use url::Url; +use uuid::Uuid; + +use enostr::{ClientMessage, FilledKeypair, FullKeypair, Keypair, RelayPool}; +use nostrdb::{Filter, Ndb, NoteKey, Subscription, Transaction}; use crate::{ column::Columns, @@ -17,18 +21,115 @@ use crate::{ unknowns::UnknownIds, user_account::UserAccount, }; -use tracing::{error, info}; +use tracing::{debug, error, info}; mod route; pub use route::{AccountsRoute, AccountsRouteResponse}; +pub struct AccountRelayData { + filter: Filter, + subid: String, + sub: Option<Subscription>, + local: BTreeSet<String>, // used locally but not advertised + advertised: BTreeSet<String>, // advertised via NIP-65 +} + +impl AccountRelayData { + pub fn new(ndb: &Ndb, pool: &mut RelayPool, pubkey: &[u8; 32]) -> Self { + // Construct a filter for the user's NIP-65 relay list + let filter = Filter::new() + .authors([pubkey]) + .kinds([10002]) + .limit(1) + .build(); + + // Local ndb subscription + let ndbsub = ndb + .subscribe(&[filter.clone()]) + .expect("ndb relay list subscription"); + + // Query the ndb immediately to see if the user list is already there + let txn = Transaction::new(ndb).expect("transaction"); + let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32; + let nks = ndb + .query(&txn, &[filter.clone()], lim) + .expect("query user relays results") + .iter() + .map(|qr| qr.note_key) + .collect::<Vec<NoteKey>>(); + let relays = Self::harvest_nip65_relays(ndb, &txn, &nks); + debug!( + "pubkey {}: initial relays {:?}", + hex::encode(pubkey), + relays + ); + + // Id for future remote relay subscriptions + let subid = Uuid::new_v4().to_string(); + + // Add remote subscription to existing relays + pool.subscribe(subid.clone(), vec![filter.clone()]); + + AccountRelayData { + filter, + subid, + sub: Some(ndbsub), + local: BTreeSet::new(), + advertised: relays.into_iter().collect(), + } + } + + // standardize the format (ie, trailing slashes) to avoid dups + pub fn canonicalize_url(url: &str) -> String { + match Url::parse(url) { + Ok(parsed_url) => parsed_url.to_string(), + Err(_) => url.to_owned(), // If parsing fails, return the original URL. + } + } + + fn harvest_nip65_relays(ndb: &Ndb, txn: &Transaction, nks: &[NoteKey]) -> Vec<String> { + let mut relays = Vec::new(); + for nk in nks.iter() { + if let Ok(note) = ndb.get_note_by_key(txn, *nk) { + for tag in note.tags() { + match tag.get(0).and_then(|t| t.variant().str()) { + Some("r") => { + if let Some(url) = tag.get(1).and_then(|f| f.variant().str()) { + relays.push(Self::canonicalize_url(url)); + } + } + Some("alt") => { + // ignore for now + } + Some(x) => { + error!("harvest_nip65_relays: unexpected tag type: {}", x); + } + None => { + error!("harvest_nip65_relays: invalid tag"); + } + } + } + } + } + relays + } +} + +pub struct AccountData { + relay: AccountRelayData, +} + /// The interface for managing the user's accounts. /// Represents all user-facing operations related to account management. pub struct Accounts { currently_selected_account: Option<usize>, accounts: Vec<UserAccount>, key_store: KeyStorageType, + account_data: BTreeMap<[u8; 32], AccountData>, + forced_relays: BTreeSet<String>, + bootstrap_relays: BTreeSet<String>, + needs_relay_config: bool, } /// Render account management views from a route @@ -93,7 +194,7 @@ pub fn process_accounts_view_response( } impl Accounts { - pub fn new(key_store: KeyStorageType) -> Self { + pub fn new(key_store: KeyStorageType, forced_relays: Vec<String>) -> Self { let accounts = if let KeyStorageResponse::ReceivedResult(res) = key_store.get_keys() { res.unwrap_or_default() } else { @@ -101,10 +202,31 @@ impl Accounts { }; let currently_selected_account = get_selected_index(&accounts, &key_store); + let account_data = BTreeMap::new(); + let forced_relays: BTreeSet<String> = forced_relays + .into_iter() + .map(|u| AccountRelayData::canonicalize_url(&u)) + .collect(); + let bootstrap_relays = [ + "wss://relay.damus.io", + // "wss://pyramid.fiatjaf.com", // Uncomment if needed + "wss://nos.lol", + "wss://nostr.wine", + "wss://purplepag.es", + ] + .iter() + .map(|&url| url.to_string()) + .map(|u| AccountRelayData::canonicalize_url(&u)) + .collect(); + Accounts { currently_selected_account, accounts, key_store, + account_data, + forced_relays, + bootstrap_relays, + needs_relay_config: true, } } @@ -226,6 +348,140 @@ impl Accounts { self.currently_selected_account = None; self.key_store.select_key(None); } + + pub fn send_initial_filters(&mut self, pool: &mut RelayPool, relay_url: &str) { + for data in self.account_data.values() { + pool.send_to( + &ClientMessage::req(data.relay.subid.clone(), vec![data.relay.filter.clone()]), + relay_url, + ); + } + } + + // Returns added and removed accounts + fn delta_accounts(&self) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) { + let mut added = Vec::new(); + for pubkey in self.accounts.iter().map(|a| a.pubkey.bytes()) { + if !self.account_data.contains_key(pubkey) { + added.push(*pubkey); + } + } + let mut removed = Vec::new(); + for pubkey in self.account_data.keys() { + if self.contains_account(pubkey).is_none() { + removed.push(*pubkey); + } + } + (added, removed) + } + + fn handle_added_account(&mut self, ndb: &Ndb, pool: &mut RelayPool, pubkey: &[u8; 32]) { + debug!("handle_added_account {}", hex::encode(pubkey)); + + // Create the user account data + let new_account_data = AccountData { + relay: AccountRelayData::new(ndb, pool, pubkey), + }; + self.account_data.insert(*pubkey, new_account_data); + } + + fn handle_removed_account(&mut self, pubkey: &[u8; 32]) { + debug!("handle_removed_account {}", hex::encode(pubkey)); + // FIXME - we need to unsubscribe here + self.account_data.remove(pubkey); + } + + fn poll_for_updates(&mut self, ndb: &Ndb) -> bool { + let mut changed = false; + for (pubkey, data) in &mut self.account_data { + if let Some(sub) = data.relay.sub { + let nks = ndb.poll_for_notes(sub, 1); + if !nks.is_empty() { + let txn = Transaction::new(ndb).expect("txn"); + let relays = AccountRelayData::harvest_nip65_relays(ndb, &txn, &nks); + debug!( + "pubkey {}: updated relays {:?}", + hex::encode(pubkey), + relays + ); + data.relay.advertised = relays.into_iter().collect(); + changed = true; + } + } + } + changed + } + + fn update_relay_configuration( + &mut self, + pool: &mut RelayPool, + wakeup: impl Fn() + Send + Sync + Clone + 'static, + ) { + // If forced relays are set use them only + let mut desired_relays = self.forced_relays.clone(); + + // Compose the desired relay lists from the accounts + if desired_relays.is_empty() { + for data in self.account_data.values() { + desired_relays.extend(data.relay.local.iter().cloned()); + desired_relays.extend(data.relay.advertised.iter().cloned()); + } + } + + // If no relays are specified at this point use the bootstrap list + if desired_relays.is_empty() { + desired_relays = self.bootstrap_relays.clone(); + } + + debug!("current relays: {:?}", pool.urls()); + debug!("desired relays: {:?}", desired_relays); + + let add: BTreeSet<String> = desired_relays.difference(&pool.urls()).cloned().collect(); + let sub: BTreeSet<String> = pool.urls().difference(&desired_relays).cloned().collect(); + if !add.is_empty() { + debug!("configuring added relays: {:?}", add); + let _ = pool.add_urls(add, wakeup); + } + if !sub.is_empty() { + debug!("removing unwanted relays: {:?}", sub); + pool.remove_urls(&sub); + } + + debug!("current relays: {:?}", pool.urls()); + } + + pub fn update(&mut self, ndb: &Ndb, pool: &mut RelayPool, ctx: &egui::Context) { + // IMPORTANT - This function is called in the UI update loop, + // make sure it is fast when idle + + // On the initial update the relays need config even if nothing changes below + let mut relays_changed = self.needs_relay_config; + + let ctx2 = ctx.clone(); + let wakeup = move || { + ctx2.request_repaint(); + }; + + // Were any accounts added or removed? + let (added, removed) = self.delta_accounts(); + for pk in added { + self.handle_added_account(ndb, pool, &pk); + relays_changed = true; + } + for pk in removed { + self.handle_removed_account(&pk); + relays_changed = true; + } + + // Did any accounts receive updates (ie NIP-65 relay lists) + relays_changed = self.poll_for_updates(ndb) || relays_changed; + + // If needed, update the relay configuration + if relays_changed { + self.update_relay_configuration(pool, wakeup); + self.needs_relay_config = false; + } + } } fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Option<usize> { diff --git a/src/app.rs b/src/app.rs @@ -71,31 +71,6 @@ pub struct Damus { pub textmode: bool, } -fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) { - let ctx = ctx.clone(); - let wakeup = move || { - ctx.request_repaint(); - }; - if let Err(e) = pool.add_url("ws://localhost:8080".to_string(), wakeup.clone()) { - error!("{:?}", e) - } - if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup.clone()) { - error!("{:?}", e) - } - //if let Err(e) = pool.add_url("wss://pyramid.fiatjaf.com".to_string(), wakeup.clone()) { - //error!("{:?}", e) - //} - if let Err(e) = pool.add_url("wss://nos.lol".to_string(), wakeup.clone()) { - error!("{:?}", e) - } - if let Err(e) = pool.add_url("wss://nostr.wine".to_string(), wakeup.clone()) { - error!("{:?}", e) - } - if let Err(e) = pool.add_url("wss://purplepag.es".to_string(), wakeup) { - error!("{:?}", e) - } -} - fn handle_key_events(input: &egui::InputState, _pixels_per_point: f32, columns: &mut Columns) { for event in &input.raw.events { if let egui::Event::Key { @@ -142,6 +117,10 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { match (&ev.event).into() { RelayEvent::Opened => { + damus + .accounts + .send_initial_filters(&mut damus.pool, &ev.relay); + timeline::send_initial_timeline_filters( &damus.ndb, damus.since_optimize, @@ -213,6 +192,8 @@ fn setup_profiling() { } fn update_damus(damus: &mut Damus, ctx: &egui::Context) { + damus.accounts.update(&damus.ndb, &mut damus.pool, ctx); // update user relay and mute lists + match damus.state { DamusState::Initializing => { #[cfg(feature = "profiling")] @@ -422,7 +403,7 @@ impl Damus { KeyStorageType::None }; - let mut accounts = Accounts::new(keystore); + let mut accounts = Accounts::new(keystore, parsed_args.relays); let num_keys = parsed_args.keys.len(); @@ -443,27 +424,8 @@ impl Damus { accounts.select_account(0); } - // setup relays if we have them - let pool = if parsed_args.relays.is_empty() { - let mut pool = RelayPool::new(); - relay_setup(&mut pool, ctx); - pool - } else { - let wakeup = { - let ctx = ctx.clone(); - move || { - ctx.request_repaint(); - } - }; - - let mut pool = RelayPool::new(); - for relay in parsed_args.relays { - if let Err(e) = pool.add_url(relay.clone(), wakeup.clone()) { - error!("error adding relay {}: {}", relay, e); - } - } - pool - }; + // AccountManager will setup the pool on first update + let pool = RelayPool::new(); let account = accounts .get_selected_account() @@ -613,7 +575,7 @@ impl Damus { &config, ) .expect("ndb"), - accounts: Accounts::new(KeyStorageType::None), + accounts: Accounts::new(KeyStorageType::None, vec![]), frame_history: FrameHistory::default(), view_state: ViewState::default(),