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:
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,
);