notedeck

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

commit 98c28b5735327eaf50df382f12920050268d146d
parent 8387a6683aa06d5a0bd089a35a523cb71173d9f4
Author: kernelkind <kernelkind@gmail.com>
Date:   Wed, 25 Feb 2026 19:42:28 -0500

feat(outbox-int): migrate onboarding to use outbox

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck/src/filter.rs | 3++-
Mcrates/notedeck/src/nip51_set.rs | 53+++++++++++++++++++++++++++++++++++++----------------
Mcrates/notedeck_columns/src/accounts/mod.rs | 25++++++++++++++++++++-----
Mcrates/notedeck_columns/src/app.rs | 6+++---
Mcrates/notedeck_columns/src/nav.rs | 1+
Mcrates/notedeck_columns/src/onboarding.rs | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/notedeck_columns/src/route.rs | 7+++++++
Mcrates/notedeck_columns/src/toolbar.rs | 1+
Mcrates/notedeck_columns/src/ui/add_column.rs | 13++-----------
9 files changed, 207 insertions(+), 66 deletions(-)

diff --git a/crates/notedeck/src/filter.rs b/crates/notedeck/src/filter.rs @@ -1,5 +1,6 @@ use crate::error::{Error, FilterError}; use crate::note::NoteRef; +use enostr::OutboxSubId; use nostrdb::{Filter, FilterBuilder, Note, Subscription}; use tracing::{debug, warn}; @@ -8,7 +9,7 @@ use tracing::{debug, warn}; #[derive(Debug, Clone)] pub struct UnifiedSubscription { pub local: Subscription, - pub remote: String, + pub remote: OutboxSubId, // abstracted ID to a remote subscription } /// We may need to fetch some data from relays before our filter is ready. diff --git a/crates/notedeck/src/nip51_set.rs b/crates/notedeck/src/nip51_set.rs @@ -1,9 +1,8 @@ -use enostr::{Pubkey, RelayPool}; +use enostr::{OutboxSubId, Pubkey, RelayUrlPkgs}; use indexmap::IndexMap; -use nostrdb::{Filter, Ndb, Note, Transaction}; -use uuid::Uuid; +use nostrdb::{Filter, Ndb, Note, Subscription, Transaction}; -use crate::{UnifiedSubscription, UnknownIds}; +use crate::{Accounts, Outbox, UnifiedSubscription, UnknownIds}; /// Keeps track of most recent NIP-51 sets #[derive(Debug)] @@ -15,27 +14,49 @@ pub struct Nip51SetCache { type PackId = String; impl Nip51SetCache { - pub fn new( - pool: &mut RelayPool, + pub fn new_accounts_read( + pool: &mut Outbox<'_>, + accounts: &Accounts, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds, nip51_set_filter: Vec<Filter>, ) -> Option<Self> { - let subid = Uuid::new_v4().to_string(); - let (cached_notes, sub) = + let (cached_notes, local) = load_cached_notes_and_local_sub(ndb, txn, unknown_ids, &nip51_set_filter)?; - pool.subscribe(subid.clone(), nip51_set_filter); + let remote = pool.subscribe( + nip51_set_filter.clone(), + RelayUrlPkgs::new(accounts.selected_account_read_relays()), + ); Some(Self { - sub: UnifiedSubscription { - local: sub, - remote: subid, - }, + sub: UnifiedSubscription { local, remote }, cached_notes, }) } + pub fn new_local( + ndb: &Ndb, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + nip51_set_filter: Vec<Filter>, + ) -> Option<Self> { + let (cached_notes, local) = + load_cached_notes_and_local_sub(ndb, txn, unknown_ids, &nip51_set_filter)?; + + // Local-only constructor used when remote relay management is handled elsewhere. + let remote = OutboxSubId(0); + + Some(Self { + sub: UnifiedSubscription { local, remote }, + cached_notes, + }) + } + + pub fn local_sub(&self) -> Subscription { + self.sub.local + } + #[profiling::function] pub fn poll_for_notes(&mut self, ndb: &Ndb, unknown_ids: &mut UnknownIds) { let new_notes = ndb.poll_for_notes(self.sub.local, 5); @@ -75,7 +96,7 @@ fn load_cached_notes_and_local_sub( txn: &Transaction, unknown_ids: &mut UnknownIds, nip51_set_filter: &[Filter], -) -> Option<(IndexMap<PackId, Nip51Set>, nostrdb::Subscription)> { +) -> Option<(IndexMap<PackId, Nip51Set>, Subscription)> { let mut cached_notes = IndexMap::default(); let notes: Option<Vec<Note>> = if let Ok(results) = ndb.query(txn, nip51_set_filter, 500) { @@ -88,7 +109,7 @@ fn load_cached_notes_and_local_sub( add(notes, &mut cached_notes, ndb, txn, unknown_ids); } - let sub = match ndb.subscribe(nip51_set_filter) { + let local = match ndb.subscribe(nip51_set_filter) { Ok(sub) => sub, Err(e) => { tracing::error!("Could not ndb subscribe: {e}"); @@ -96,7 +117,7 @@ fn load_cached_notes_and_local_sub( } }; - Some((cached_notes, sub)) + Some((cached_notes, local)) } #[profiling::function] diff --git a/crates/notedeck_columns/src/accounts/mod.rs b/crates/notedeck_columns/src/accounts/mod.rs @@ -7,8 +7,9 @@ use notedeck_ui::nip51_set::Nip51SetUiCache; pub use crate::accounts::route::AccountsResponse; use crate::app::get_active_columns_mut; use crate::decks::DecksCache; -use crate::onboarding::Onboarding; +use crate::onboarding::{Onboarding, OnboardingEffect}; use crate::profile::{send_default_dms_relay_list, send_new_contact_list}; +use crate::scoped_sub_owner_keys::onboarding_owner_key; use crate::subscriptions::Subscriptions; use crate::ui::onboarding::{FollowPackOnboardingView, FollowPacksResponse, OnboardingResponse}; use crate::{ @@ -168,14 +169,13 @@ pub fn process_login_view_response( } AccountLoginResponse::CreatingNew => { cur_router.route_to(Route::Accounts(AccountsRoute::Onboarding)); - - onboarding.process(app_ctx.legacy_pool, app_ctx.ndb, subs, app_ctx.unknown_ids); + process_onboarding_step(app_ctx, onboarding, col); None } AccountLoginResponse::Onboarding(onboarding_response) => match onboarding_response { FollowPacksResponse::NoFollowPacks => { - onboarding.process(app_ctx.legacy_pool, app_ctx.ndb, subs, app_ctx.unknown_ids); + process_onboarding_step(app_ctx, onboarding, col); None } FollowPacksResponse::UserSelectedPacks(nip51_sets_ui_state) => { @@ -194,7 +194,9 @@ pub fn process_login_view_response( send_default_dms_relay_list(kp.to_filled(), app_ctx.ndb, &mut publisher); } cur_router.go_back(); - onboarding.end_onboarding(app_ctx.legacy_pool, app_ctx.ndb); + onboarding.end_onboarding(app_ctx.ndb); + let mut scoped_subs = app_ctx.remote.scoped_subs(app_ctx.accounts); + let _ = scoped_subs.drop_owner(onboarding_owner_key(col)); app_ctx.accounts.add_account(kp.to_keypair()) } @@ -218,6 +220,19 @@ pub fn process_login_view_response( } } +fn process_onboarding_step(app_ctx: &mut AppContext, onboarding: &mut Onboarding, col: usize) { + let owner = onboarding_owner_key(col); + let effect = { + let mut scoped_subs = app_ctx.remote.scoped_subs(app_ctx.accounts); + onboarding.process(&mut scoped_subs, owner, app_ctx.ndb, app_ctx.unknown_ids) + }; + + if let Some(OnboardingEffect::Oneshot(filters)) = effect { + let mut oneshot = app_ctx.remote.oneshot(app_ctx.accounts); + oneshot.oneshot(filters); + } +} + impl AccountsRouteResponse { pub fn process( self, diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -252,10 +252,10 @@ fn try_process_event( _ => {} } } - } - if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() { - follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids); + if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() { + follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids); + } } Ok(()) diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -281,6 +281,7 @@ fn process_nav_resp( route, &mut app.timeline_cache, &mut app.threads, + &mut app.onboarding, &mut app.view_state, ctx.ndb, &mut ctx.remote.scoped_subs(ctx.accounts), diff --git a/crates/notedeck_columns/src/onboarding.rs b/crates/notedeck_columns/src/onboarding.rs @@ -1,30 +1,38 @@ use std::{cell::RefCell, rc::Rc}; use egui_virtual_list::VirtualList; -use enostr::{Pubkey, RelayPool}; +use enostr::Pubkey; use nostrdb::{Filter, Ndb, NoteKey, Transaction}; -use notedeck::{create_nip51_set, filter::default_limit, Nip51SetCache, UnknownIds}; -use uuid::Uuid; - -use crate::subscriptions::Subscriptions; +use notedeck::{ + create_nip51_set, filter::default_limit, Nip51SetCache, RelaySelection, ScopedSubApi, + ScopedSubIdentity, SubConfig, SubKey, SubOwnerKey, UnknownIds, +}; #[derive(Debug)] enum OnboardingState { AwaitingTrustedPksList(Vec<Filter>), - HaveFollowPacks(Nip51SetCache), + HaveFollowPacks { packs: Nip51SetCache }, } -/// Manages the onboarding process. Responsible for retriving the kind 30000 list of trusted pubkeys -/// and then retrieving all follow packs from the trusted pks updating when new ones arrive +/// Manages onboarding discovery of trusted follow packs. +/// +/// This first requests the trusted-author list (kind `30000`) and then +/// installs a scoped account subscription for follow packs from those authors. #[derive(Default)] pub struct Onboarding { state: Option<Result<OnboardingState, OnboardingError>>, pub list: Rc<RefCell<VirtualList>>, } +/// Side effects emitted by one `Onboarding::process` pass. +pub enum OnboardingEffect { + /// Request a one-shot fetch for the provided filters. + Oneshot(Vec<Filter>), +} + impl Onboarding { pub fn get_follow_packs(&self) -> Option<&Nip51SetCache> { - let Some(Ok(OnboardingState::HaveFollowPacks(packs))) = &self.state else { + let Some(Ok(OnboardingState::HaveFollowPacks { packs, .. })) = &self.state else { return None; }; @@ -32,71 +40,79 @@ impl Onboarding { } pub fn get_follow_packs_mut(&mut self) -> Option<&mut Nip51SetCache> { - let Some(Ok(OnboardingState::HaveFollowPacks(packs))) = &mut self.state else { + let Some(Ok(OnboardingState::HaveFollowPacks { packs, .. })) = &mut self.state else { return None; }; Some(packs) } + #[allow(clippy::too_many_arguments)] pub fn process( &mut self, - pool: &mut RelayPool, + scoped_subs: &mut ScopedSubApi<'_, '_>, + owner: SubOwnerKey, ndb: &Ndb, - subs: &mut Subscriptions, unknown_ids: &mut UnknownIds, - ) { + ) -> Option<OnboardingEffect> { match &self.state { Some(res) => { let Ok(OnboardingState::AwaitingTrustedPksList(filter)) = res else { - return; + return None; }; let txn = Transaction::new(ndb).expect("txns"); let Ok(res) = ndb.query(&txn, filter, 1) else { - return; + return None; }; if res.is_empty() { - return; + return None; } let key = res.first().expect("checked empty").note_key; let new_state = get_trusted_authors(ndb, &txn, key).and_then(|trusted_pks| { let pks: Vec<&[u8; 32]> = trusted_pks.iter().map(|f| f.bytes()).collect(); - Nip51SetCache::new(pool, ndb, &txn, unknown_ids, vec![follow_packs_filter(pks)]) - .map(OnboardingState::HaveFollowPacks) + let follow_filter = follow_packs_filter(pks); + let sub_key = follow_packs_sub_key(); + let identity = ScopedSubIdentity::account(owner, sub_key); + let sub_config = SubConfig { + relays: RelaySelection::AccountsRead, + filters: vec![follow_filter.clone()], + use_transparent: false, + }; + let _ = scoped_subs.ensure_sub(identity, sub_config); + + Nip51SetCache::new_local(ndb, &txn, unknown_ids, vec![follow_filter]) + .map(|packs| OnboardingState::HaveFollowPacks { packs }) .ok_or(OnboardingError::InvalidNip51Set) }); self.state = Some(new_state); + None } None => { let filter = vec![trusted_pks_list_filter()]; - - let subid = Uuid::new_v4().to_string(); - pool.subscribe(subid.clone(), filter.clone()); - subs.subs - .insert(subid, crate::subscriptions::SubKind::OneShot); - let new_state = Some(Ok(OnboardingState::AwaitingTrustedPksList(filter))); self.state = new_state; + let Some(Ok(OnboardingState::AwaitingTrustedPksList(filters))) = &self.state else { + return None; + }; + + Some(OnboardingEffect::Oneshot(filters.clone())) } } } // Unsubscribe and clear state - pub fn end_onboarding(&mut self, pool: &mut RelayPool, ndb: &mut Ndb) { - let Some(Ok(OnboardingState::HaveFollowPacks(state))) = &mut self.state else { + pub fn end_onboarding(&mut self, ndb: &mut Ndb) { + let Some(Ok(OnboardingState::HaveFollowPacks { packs })) = &mut self.state else { self.state = None; return; }; - let unified = &state.sub; - - pool.unsubscribe(unified.remote.clone()); - let _ = ndb.unsubscribe(unified.local); + let _ = ndb.unsubscribe(packs.local_sub()); self.state = None; } @@ -104,11 +120,19 @@ impl Onboarding { #[derive(Debug)] pub enum OnboardingError { + /// Follow-pack note could not be parsed as a valid NIP-51 set. InvalidNip51Set, + /// Trusted-author note exists but is not kind `30000`. InvalidTrustedPksListKind, + /// Trusted-author note key could not be resolved from NostrDB. NdbCouldNotFindNote, } +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum OnboardingScopedSub { + FollowPacks, +} + // author providing the list of trusted follow pack authors const FOLLOW_PACK_AUTHOR: [u8; 32] = [ 0x89, 0x5c, 0x2a, 0x90, 0xa8, 0x60, 0xac, 0x18, 0x43, 0x4a, 0xa6, 0x9e, 0x7b, 0x0d, 0xa8, 0x46, @@ -132,6 +156,10 @@ pub fn follow_packs_filter(pks: Vec<&[u8; 32]>) -> Filter { .build() } +fn follow_packs_sub_key() -> SubKey { + SubKey::builder(OnboardingScopedSub::FollowPacks).finish() +} + /// gets the pubkeys from a kind 30000 follow set fn get_trusted_authors( ndb: &Ndb, @@ -152,3 +180,79 @@ fn get_trusted_authors( Ok(nip51set.pks) } + +#[cfg(test)] +mod tests { + use super::*; + use enostr::{OutboxPool, OutboxSessionHandler}; + use nostrdb::Config; + use notedeck::{Accounts, EguiWakeup, ScopedSubsState, FALLBACK_PUBKEY}; + use tempfile::TempDir; + + fn test_harness() -> ( + TempDir, + Ndb, + Accounts, + UnknownIds, + ScopedSubsState, + OutboxPool, + ) { + let tmp = TempDir::new().expect("tmp dir"); + let mut ndb = Ndb::new(tmp.path().to_str().expect("path"), &Config::new()).expect("ndb"); + let txn = Transaction::new(&ndb).expect("txn"); + let mut unknown_ids = UnknownIds::default(); + let accounts = Accounts::new( + None, + vec!["wss://relay-onboarding.example.com".to_owned()], + FALLBACK_PUBKEY(), + &mut ndb, + &txn, + &mut unknown_ids, + ); + + ( + tmp, + ndb, + accounts, + unknown_ids, + ScopedSubsState::default(), + OutboxPool::default(), + ) + } + + /// Verifies onboarding emits a one-time oneshot effect on first process call + /// and does not emit duplicate oneshot effects on subsequent calls. + #[test] + fn process_initially_emits_oneshot_effect_once() { + let (_tmp, ndb, accounts, mut unknown_ids, mut scoped_sub_state, mut pool) = test_harness(); + let owner = SubOwnerKey::new(("onboarding", 1usize)); + let mut onboarding = Onboarding::default(); + + let first = { + let mut outbox = + OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())); + let mut scoped_subs = scoped_sub_state.api(&mut outbox, &accounts); + onboarding.process(&mut scoped_subs, owner, &ndb, &mut unknown_ids) + }; + + match first { + Some(OnboardingEffect::Oneshot(filters)) => { + assert_eq!(filters.len(), 1); + assert_eq!( + filters[0].json().expect("json"), + trusted_pks_list_filter().json().expect("json") + ); + } + None => panic!("expected onboarding oneshot effect"), + } + + let second = { + let mut outbox = + OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())); + let mut scoped_subs = scoped_sub_state.api(&mut outbox, &accounts); + onboarding.process(&mut scoped_subs, owner, &ndb, &mut unknown_ids) + }; + + assert!(second.is_none()); + } +} diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -9,6 +9,8 @@ use std::ops::Range; use crate::{ accounts::AccountsRoute, + onboarding::Onboarding, + scoped_sub_owner_keys::onboarding_owner_key, timeline::{kind::ColumnTitle, thread::Threads, ThreadSelection, TimelineCache, TimelineKind}, ui::add_column::{AddAlgoRoute, AddColumnRoute}, view_state::ViewState, @@ -797,6 +799,7 @@ pub fn cleanup_popped_route( route: &Route, timeline_cache: &mut TimelineCache, threads: &mut Threads, + onboarding: &mut Onboarding, view_state: &mut ViewState, ndb: &mut Ndb, scoped_subs: &mut ScopedSubApi, @@ -815,6 +818,10 @@ pub fn cleanup_popped_route( Route::EditProfile(pk) => { view_state.pubkey_to_profile_state.remove(pk); } + Route::Accounts(AccountsRoute::Onboarding) => { + onboarding.end_onboarding(ndb); + let _ = scoped_subs.drop_owner(onboarding_owner_key(col_index)); + } _ => {} } } diff --git a/crates/notedeck_columns/src/toolbar.rs b/crates/notedeck_columns/src/toolbar.rs @@ -100,6 +100,7 @@ fn pop_to_root(app: &mut Damus, ctx: &mut AppContext, col_index: usize) { &popped, &mut app.timeline_cache, &mut app.threads, + &mut app.onboarding, &mut app.view_state, ctx.ndb, &mut ctx.remote.scoped_subs(ctx.accounts), diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -199,7 +199,6 @@ pub struct AddColumnView<'a> { contacts: &'a ContactState, i18n: &'a mut Localization, jobs: &'a MediaJobSender, - pool: &'a mut enostr::RelayPool, unknown_ids: &'a mut notedeck::UnknownIds, people_lists: &'a mut Option<notedeck::Nip51SetCache>, } @@ -215,7 +214,6 @@ impl<'a> AddColumnView<'a> { contacts: &'a ContactState, i18n: &'a mut Localization, jobs: &'a MediaJobSender, - pool: &'a mut enostr::RelayPool, unknown_ids: &'a mut notedeck::UnknownIds, people_lists: &'a mut Option<notedeck::Nip51SetCache>, ) -> Self { @@ -228,7 +226,6 @@ impl<'a> AddColumnView<'a> { contacts, i18n, jobs, - pool, unknown_ids, people_lists, } @@ -308,13 +305,8 @@ impl<'a> AddColumnView<'a> { .kinds([30000]) .limit(50) .build(); - *self.people_lists = notedeck::Nip51SetCache::new( - self.pool, - self.ndb, - &txn, - self.unknown_ids, - vec![filter], - ); + *self.people_lists = + notedeck::Nip51SetCache::new_local(self.ndb, &txn, self.unknown_ids, vec![filter]); } // Poll for newly arrived notes each frame @@ -969,7 +961,6 @@ pub fn render_add_column_routes( contacts, ctx.i18n, ctx.media_jobs.sender(), - ctx.legacy_pool, ctx.unknown_ids, &mut app.view_state.people_lists, );