notedeck

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

commit 38b2077a8d1a03715869d8210cc9faf1f5663eb7
parent c94a4184747755274980a3c1d9c35ef953d1dca8
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 20 Jul 2025 17:13:34 -0700

Merge kernel's "can't remove damoose fixes" and more! #1001

kernelkind (9):
      appease clippy
      use `NwcError` instead of nwc::Error
      make `UserAccount` cloneable
      allow removal of Damoose account
      expose `AccountCache::falback`
      move select account logic to own method
      bugfix: properly sub to new selected acc after removal of selected
      bugfix: unsubscribe from timelines on deck deletion
      bugfix: unsubscribe all decks when log out account

Diffstat:
Mcrates/notedeck/src/account/accounts.rs | 40++++++++++++++++++++++++++++++++++------
Mcrates/notedeck/src/account/cache.rs | 38+++++++++++++++++++++++++++++++-------
Mcrates/notedeck/src/account/contacts.rs | 2++
Mcrates/notedeck/src/account/mute.rs | 1+
Mcrates/notedeck/src/account/relay.rs | 1+
Mcrates/notedeck/src/user_account.rs | 1+
Mcrates/notedeck/src/wallet.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/notedeck/src/zaps/default_zap.rs | 6+++---
Mcrates/notedeck_columns/src/column.rs | 4++--
Mcrates/notedeck_columns/src/decks.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/notedeck_columns/src/nav.rs | 18++++++++++++++++--
Mcrates/notedeck_columns/src/storage/decks.rs | 4++--
12 files changed, 215 insertions(+), 33 deletions(-)

diff --git a/crates/notedeck/src/account/accounts.rs b/crates/notedeck/src/account/accounts.rs @@ -97,16 +97,31 @@ impl Accounts { } } - pub fn remove_account(&mut self, pk: &Pubkey) { - let Some(removed) = self.cache.remove(pk) else { - return; + pub fn remove_account( + &mut self, + pk: &Pubkey, + ndb: &mut Ndb, + pool: &mut RelayPool, + ctx: &egui::Context, + ) -> bool { + let Some(resp) = self.cache.remove(pk) else { + return false; }; - if let Some(key_store) = &self.storage_writer { - if let Err(e) = key_store.remove_key(&removed.key) { - tracing::error!("Could not remove account {pk}: {e}"); + if pk != self.cache.fallback() { + if let Some(key_store) = &self.storage_writer { + if let Err(e) = key_store.remove_key(&resp.deleted) { + tracing::error!("Could not remove account {pk}: {e}"); + } } } + + if let Some(swap_to) = resp.swap_to { + let txn = Transaction::new(ndb).expect("txn"); + self.select_account_internal(&swap_to, ndb, &txn, pool, ctx); + } + + true } pub fn contains_full_kp(&self, pubkey: &enostr::Pubkey) -> bool { @@ -212,6 +227,18 @@ impl Accounts { return; } + self.select_account_internal(pk_to_select, ndb, txn, pool, ctx); + } + + /// Have already selected in `AccountCache`, updating other things + fn select_account_internal( + &mut self, + pk_to_select: &Pubkey, + ndb: &mut Ndb, + txn: &Transaction, + pool: &mut RelayPool, + ctx: &egui::Context, + ) { if let Some(key_store) = &self.storage_writer { if let Err(e) = key_store.select_key(Some(*pk_to_select)) { tracing::error!("Could not select key {:?}: {e}", pk_to_select); @@ -375,6 +402,7 @@ fn get_acc_from_storage(user_account_serializable: UserAccountSerializable) -> O }) } +#[derive(Clone)] pub struct AccountData { pub(crate) relay: AccountRelayData, pub(crate) muted: AccountMutedData, diff --git a/crates/notedeck/src/account/cache.rs b/crates/notedeck/src/account/cache.rs @@ -6,6 +6,7 @@ use crate::{SingleUnkIdAction, UserAccount}; pub struct AccountCache { selected: Pubkey, fallback: Pubkey, + fallback_account: UserAccount, // never empty at rest accounts: HashMap<Pubkey, UserAccount>, @@ -16,12 +17,13 @@ impl AccountCache { let mut accounts = HashMap::with_capacity(1); let pk = fallback.key.pubkey; - accounts.insert(pk, fallback); + accounts.insert(pk, fallback.clone()); ( Self { selected: pk, fallback: pk, + fallback_account: fallback, accounts, }, SingleUnkIdAction::pubkey(pk), @@ -48,15 +50,20 @@ impl AccountCache { self.accounts.entry(pk).insert(account) } - pub(super) fn remove(&mut self, pk: &Pubkey) -> Option<UserAccount> { - // fallback account should never be removed - if *pk == self.fallback { + pub(super) fn remove(&mut self, pk: &Pubkey) -> Option<AccountDeletionResponse> { + if *pk == self.fallback && self.accounts.len() == 1 { + // no point in removing it since it'll just get re-added anyway return None; } - let removed = self.accounts.remove(pk); + let removed = self.accounts.remove(pk)?; - if removed.is_some() && self.selected == *pk { + if self.accounts.is_empty() { + self.accounts + .insert(self.fallback, self.fallback_account.clone()); + } + + if self.selected == *pk { // TODO(kernelkind): choose next better let (next, _) = self .accounts @@ -64,9 +71,17 @@ impl AccountCache { .next() .expect("accounts can never be empty"); self.selected = *next; + + return Some(AccountDeletionResponse { + deleted: removed.key, + swap_to: Some(*next), + }); } - removed + Some(AccountDeletionResponse { + deleted: removed.key, + swap_to: None, + }) } /// guarenteed that all selected exist in accounts @@ -90,6 +105,10 @@ impl AccountCache { .get_mut(&self.selected) .expect("guarenteed that selected exists in accounts") } + + pub fn fallback(&self) -> &Pubkey { + &self.fallback + } } impl<'a> IntoIterator for &'a AccountCache { @@ -100,3 +119,8 @@ impl<'a> IntoIterator for &'a AccountCache { self.accounts.iter() } } + +pub struct AccountDeletionResponse { + pub deleted: enostr::Keypair, + pub swap_to: Option<Pubkey>, +} diff --git a/crates/notedeck/src/account/contacts.rs b/crates/notedeck/src/account/contacts.rs @@ -3,11 +3,13 @@ use std::collections::HashSet; use enostr::Pubkey; use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction}; +#[derive(Clone)] pub struct Contacts { pub filter: Filter, pub(super) state: ContactState, } +#[derive(Clone)] pub enum ContactState { Unreceived, Received { diff --git a/crates/notedeck/src/account/mute.rs b/crates/notedeck/src/account/mute.rs @@ -5,6 +5,7 @@ use tracing::{debug, error}; use crate::Muted; +#[derive(Clone)] pub(crate) struct AccountMutedData { pub filter: Filter, pub muted: Arc<Muted>, diff --git a/crates/notedeck/src/account/relay.rs b/crates/notedeck/src/account/relay.rs @@ -7,6 +7,7 @@ use url::Url; use crate::{AccountData, RelaySpec}; +#[derive(Clone)] pub(crate) struct AccountRelayData { pub filter: Filter, pub local: BTreeSet<RelaySpec>, // used locally but not advertised diff --git a/crates/notedeck/src/user_account.rs b/crates/notedeck/src/user_account.rs @@ -6,6 +6,7 @@ use crate::{ AccountData, IsFollowing, }; +#[derive(Clone)] pub struct UserAccount { pub key: Keypair, pub wallet: Option<ZapWallet>, diff --git a/crates/notedeck/src/wallet.rs b/crates/notedeck/src/wallet.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{fmt::Display, sync::Arc}; use nwc::{ nostr::nips::nip47::{NostrWalletConnectURI, PayInvoiceRequest, PayInvoiceResponse}, @@ -57,7 +57,17 @@ pub enum WalletError { pub struct Wallet { pub uri: String, wallet: Arc<RwLock<NWC>>, - balance: Option<Promise<Result<u64, nwc::Error>>>, + balance: Option<Promise<Result<u64, NwcError>>>, +} + +impl Clone for Wallet { + fn clone(&self) -> Self { + Self { + uri: self.uri.clone(), + wallet: self.wallet.clone(), + balance: None, + } + } } #[derive(Clone)] @@ -95,7 +105,7 @@ impl Wallet { }) } - pub fn get_balance(&mut self) -> Option<&Result<u64, nwc::Error>> { + pub fn get_balance(&mut self) -> Option<&Result<u64, NwcError>> { if self.balance.is_none() { self.balance = Some(get_balance(self.wallet.clone())); return None; @@ -117,11 +127,51 @@ impl Wallet { } } -fn get_balance(nwc: Arc<RwLock<NWC>>) -> Promise<Result<u64, nwc::Error>> { +#[derive(Clone)] +pub enum NwcError { + /// NIP47 error + NIP47(String), + /// Relay + Relay(String), + /// Premature exit + PrematureExit, + /// Request timeout + Timeout, +} + +impl From<nwc::Error> for NwcError { + fn from(value: nwc::Error) -> Self { + match value { + nwc::error::Error::NIP47(error) => NwcError::NIP47(error.to_string()), + nwc::error::Error::Relay(error) => NwcError::Relay(error.to_string()), + nwc::error::Error::PrematureExit => NwcError::PrematureExit, + nwc::error::Error::Timeout => NwcError::Timeout, + } + } +} + +impl Display for NwcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NwcError::NIP47(err) => write!(f, "NIP47 error: {}", err), + NwcError::Relay(err) => write!(f, "Relay error: {}", err), + NwcError::PrematureExit => write!(f, "Premature exit"), + NwcError::Timeout => write!(f, "Request timed out"), + } + } +} + +fn get_balance(nwc: Arc<RwLock<NWC>>) -> Promise<Result<u64, NwcError>> { let (sender, promise) = Promise::new(); tokio::spawn(async move { - sender.send(nwc.read().await.get_balance().await); + sender.send( + nwc.read() + .await + .get_balance() + .await + .map_err(nwc::Error::into), + ); }); promise @@ -196,7 +246,7 @@ fn construct_global_wallet(wallet_handler: &TokenHandler) -> Option<ZapWallet> { Some(wallet) } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ZapWallet { pub wallet: Wallet, pub default_zap: DefaultZapMsats, diff --git a/crates/notedeck/src/zaps/default_zap.rs b/crates/notedeck/src/zaps/default_zap.rs @@ -4,7 +4,7 @@ use crate::get_current_wallet; const DEFAULT_ZAP_MSATS: u64 = 10_000; -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct DefaultZapMsats { pub msats: Option<u64>, pub pending: PendingDefaultZapState, @@ -83,7 +83,7 @@ impl TokenSerializable for UserZapMsats { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PendingDefaultZapState { pub amount_sats: String, pub error_message: Option<DefaultZapError>, @@ -110,7 +110,7 @@ fn msats_to_sats_string(msats: u64) -> String { (msats / 1000).to_string() } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum DefaultZapError { InvalidUserInput, } diff --git a/crates/notedeck_columns/src/column.rs b/crates/notedeck_columns/src/column.rs @@ -124,7 +124,7 @@ impl Columns { IntermediaryRoute::Timeline(mut timeline) => { let route = Route::timeline(timeline.kind.clone()); timeline.subscription.increment(); - timeline_cache.insert(timeline.kind.clone(), timeline); + timeline_cache.insert(timeline.kind.clone(), *timeline); route } IntermediaryRoute::Route(route) => route, @@ -247,7 +247,7 @@ impl Columns { } pub enum IntermediaryRoute { - Timeline(Timeline), + Timeline(Box<Timeline>), Route(Route), } diff --git a/crates/notedeck_columns/src/decks.rs b/crates/notedeck_columns/src/decks.rs @@ -1,6 +1,6 @@ use std::collections::{hash_map::ValuesMut, HashMap}; -use enostr::Pubkey; +use enostr::{Pubkey, RelayPool}; use nostrdb::Transaction; use notedeck::{AppContext, FALLBACK_PUBKEY}; use tracing::{error, info}; @@ -155,9 +155,24 @@ impl DecksCache { } } - pub fn remove_for(&mut self, key: &Pubkey) { + pub fn remove( + &mut self, + key: &Pubkey, + timeline_cache: &mut TimelineCache, + ndb: &mut nostrdb::Ndb, + pool: &mut RelayPool, + ) { + let Some(decks) = self.account_to_decks.remove(key) else { + return; + }; info!("Removing decks for {:?}", key); - self.account_to_decks.remove(key); + + decks.unsubscribe_all(timeline_cache, ndb, pool); + + if !self.account_to_decks.contains_key(&self.fallback_pubkey) { + self.account_to_decks + .insert(self.fallback_pubkey, Decks::default()); + } } pub fn get_fallback_pubkey(&self) -> &Pubkey { @@ -265,10 +280,25 @@ impl Decks { } } - pub fn remove_deck(&mut self, index: usize) { + pub fn remove_deck( + &mut self, + index: usize, + timeline_cache: &mut TimelineCache, + ndb: &mut nostrdb::Ndb, + pool: &mut enostr::RelayPool, + ) { + let Some(deck) = self.remove_deck_internal(index) else { + return; + }; + + delete_deck(deck, timeline_cache, ndb, pool); + } + + fn remove_deck_internal(&mut self, index: usize) -> Option<Deck> { + let mut res = None; if index < self.decks.len() { if self.decks.len() > 1 { - self.decks.remove(index); + res = Some(self.decks.remove(index)); let info_prefix = format!("Removed deck at index {index}"); match index.cmp(&self.active_deck) { @@ -311,6 +341,37 @@ impl Decks { } else { error!("index was out of bounds"); } + res + } + + pub fn unsubscribe_all( + self, + timeline_cache: &mut TimelineCache, + ndb: &mut nostrdb::Ndb, + pool: &mut enostr::RelayPool, + ) { + for deck in self.decks { + delete_deck(deck, timeline_cache, ndb, pool); + } + } +} + +fn delete_deck( + mut deck: Deck, + timeline_cache: &mut TimelineCache, + ndb: &mut nostrdb::Ndb, + pool: &mut enostr::RelayPool, +) { + let cols = deck.columns_mut(); + let num_cols = cols.num_columns(); + for i in (0..num_cols).rev() { + let kinds_to_pop = cols.delete_column(i); + + for kind in &kinds_to_pop { + if let Err(err) = timeline_cache.pop(kind, ndb, pool) { + error!("error popping timeline: {err}"); + } + } } } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -94,7 +94,16 @@ impl SwitchingAction { .router_mut() .go_back(); } - AccountsAction::Remove(to_remove) => ctx.accounts.remove_account(to_remove), + AccountsAction::Remove(to_remove) => 's: { + if !ctx + .accounts + .remove_account(to_remove, ctx.ndb, ctx.pool, ui_ctx) + { + break 's; + } + + decks_cache.remove(to_remove, timeline_cache, ctx.ndb, ctx.pool); + } }, SwitchingAction::Columns(columns_action) => match *columns_action { ColumnsAction::Remove(index) => { @@ -116,7 +125,12 @@ impl SwitchingAction { get_decks_mut(ctx.accounts, decks_cache).set_active(index) } DecksAction::Removing(index) => { - get_decks_mut(ctx.accounts, decks_cache).remove_deck(index) + get_decks_mut(ctx.accounts, decks_cache).remove_deck( + index, + timeline_cache, + ctx.ndb, + ctx.pool, + ); } }, } diff --git a/crates/notedeck_columns/src/storage/decks.rs b/crates/notedeck_columns/src/storage/decks.rs @@ -351,9 +351,9 @@ impl CleanIntermediaryRoute { match self { CleanIntermediaryRoute::ToTimeline(timeline_kind) => { let txn = Transaction::new(ndb).unwrap(); - Some(IntermediaryRoute::Timeline( + Some(IntermediaryRoute::Timeline(Box::new( timeline_kind.into_timeline(&txn, ndb)?, - )) + ))) } CleanIntermediaryRoute::ToRoute(route) => Some(IntermediaryRoute::Route(route)), }