onboarding.rs (8414B)
1 use std::{cell::RefCell, rc::Rc}; 2 3 use egui_virtual_list::VirtualList; 4 use enostr::Pubkey; 5 use nostrdb::{Filter, Ndb, NoteKey, Transaction}; 6 use notedeck::{ 7 create_nip51_set, filter::default_limit, Nip51SetCache, RelaySelection, ScopedSubApi, 8 ScopedSubIdentity, SubConfig, SubKey, SubOwnerKey, UnknownIds, 9 }; 10 11 #[derive(Debug)] 12 enum OnboardingState { 13 AwaitingTrustedPksList(Vec<Filter>), 14 HaveFollowPacks { packs: Nip51SetCache }, 15 } 16 17 /// Manages onboarding discovery of trusted follow packs. 18 /// 19 /// This first requests the trusted-author list (kind `30000`) and then 20 /// installs a scoped account subscription for follow packs from those authors. 21 #[derive(Default)] 22 pub struct Onboarding { 23 state: Option<Result<OnboardingState, OnboardingError>>, 24 pub list: Rc<RefCell<VirtualList>>, 25 } 26 27 /// Side effects emitted by one `Onboarding::process` pass. 28 pub enum OnboardingEffect { 29 /// Request a one-shot fetch for the provided filters. 30 Oneshot(Vec<Filter>), 31 } 32 33 impl Onboarding { 34 pub fn get_follow_packs(&self) -> Option<&Nip51SetCache> { 35 let Some(Ok(OnboardingState::HaveFollowPacks { packs, .. })) = &self.state else { 36 return None; 37 }; 38 39 Some(packs) 40 } 41 42 pub fn get_follow_packs_mut(&mut self) -> Option<&mut Nip51SetCache> { 43 let Some(Ok(OnboardingState::HaveFollowPacks { packs, .. })) = &mut self.state else { 44 return None; 45 }; 46 47 Some(packs) 48 } 49 50 #[allow(clippy::too_many_arguments)] 51 pub fn process( 52 &mut self, 53 scoped_subs: &mut ScopedSubApi<'_, '_>, 54 owner: SubOwnerKey, 55 ndb: &Ndb, 56 unknown_ids: &mut UnknownIds, 57 ) -> Option<OnboardingEffect> { 58 match &self.state { 59 Some(res) => { 60 let Ok(OnboardingState::AwaitingTrustedPksList(filter)) = res else { 61 return None; 62 }; 63 64 let txn = Transaction::new(ndb).expect("txns"); 65 let Ok(res) = ndb.query(&txn, filter, 1) else { 66 return None; 67 }; 68 69 if res.is_empty() { 70 return None; 71 } 72 73 let key = res.first().expect("checked empty").note_key; 74 75 let new_state = get_trusted_authors(ndb, &txn, key).and_then(|trusted_pks| { 76 let pks: Vec<&[u8; 32]> = trusted_pks.iter().map(|f| f.bytes()).collect(); 77 let follow_filter = follow_packs_filter(pks); 78 let sub_key = follow_packs_sub_key(); 79 let identity = ScopedSubIdentity::account(owner, sub_key); 80 let sub_config = SubConfig { 81 relays: RelaySelection::AccountsRead, 82 filters: vec![follow_filter.clone()], 83 use_transparent: false, 84 }; 85 let _ = scoped_subs.ensure_sub(identity, sub_config); 86 87 Nip51SetCache::new_local(ndb, &txn, unknown_ids, vec![follow_filter]) 88 .map(|packs| OnboardingState::HaveFollowPacks { packs }) 89 .ok_or(OnboardingError::InvalidNip51Set) 90 }); 91 92 self.state = Some(new_state); 93 None 94 } 95 None => { 96 let filter = vec![trusted_pks_list_filter()]; 97 let new_state = Some(Ok(OnboardingState::AwaitingTrustedPksList(filter))); 98 self.state = new_state; 99 let Some(Ok(OnboardingState::AwaitingTrustedPksList(filters))) = &self.state else { 100 return None; 101 }; 102 103 Some(OnboardingEffect::Oneshot(filters.clone())) 104 } 105 } 106 } 107 108 // Unsubscribe and clear state 109 pub fn end_onboarding(&mut self, ndb: &mut Ndb) { 110 let Some(Ok(OnboardingState::HaveFollowPacks { packs })) = &mut self.state else { 111 self.state = None; 112 return; 113 }; 114 115 let _ = ndb.unsubscribe(packs.local_sub()); 116 117 self.state = None; 118 } 119 } 120 121 #[derive(Debug)] 122 pub enum OnboardingError { 123 /// Follow-pack note could not be parsed as a valid NIP-51 set. 124 InvalidNip51Set, 125 /// Trusted-author note exists but is not kind `30000`. 126 InvalidTrustedPksListKind, 127 /// Trusted-author note key could not be resolved from NostrDB. 128 NdbCouldNotFindNote, 129 } 130 131 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] 132 enum OnboardingScopedSub { 133 FollowPacks, 134 } 135 136 // author providing the list of trusted follow pack authors 137 const FOLLOW_PACK_AUTHOR: [u8; 32] = [ 138 0x89, 0x5c, 0x2a, 0x90, 0xa8, 0x60, 0xac, 0x18, 0x43, 0x4a, 0xa6, 0x9e, 0x7b, 0x0d, 0xa8, 0x46, 139 0x57, 0x21, 0x21, 0x6f, 0xa3, 0x6e, 0x42, 0xc0, 0x22, 0xe3, 0x93, 0x57, 0x9c, 0x48, 0x6c, 0xba, 140 ]; 141 142 fn trusted_pks_list_filter() -> Filter { 143 Filter::new() 144 .kinds([30000]) 145 .limit(1) 146 .authors(&[FOLLOW_PACK_AUTHOR]) 147 .tags(["trusted-follow-pack-authors"], 'd') 148 .build() 149 } 150 151 pub fn follow_packs_filter(pks: Vec<&[u8; 32]>) -> Filter { 152 Filter::new() 153 .kinds([39089]) 154 .limit(default_limit()) 155 .authors(pks) 156 .build() 157 } 158 159 fn follow_packs_sub_key() -> SubKey { 160 SubKey::builder(OnboardingScopedSub::FollowPacks).finish() 161 } 162 163 /// gets the pubkeys from a kind 30000 follow set 164 fn get_trusted_authors( 165 ndb: &Ndb, 166 txn: &Transaction, 167 key: NoteKey, 168 ) -> Result<Vec<Pubkey>, OnboardingError> { 169 let Ok(note) = ndb.get_note_by_key(txn, key) else { 170 return Result::Err(OnboardingError::NdbCouldNotFindNote); 171 }; 172 173 if note.kind() != 30000 { 174 return Result::Err(OnboardingError::InvalidTrustedPksListKind); 175 } 176 177 let Some(nip51set) = create_nip51_set(note) else { 178 return Result::Err(OnboardingError::InvalidNip51Set); 179 }; 180 181 Ok(nip51set.pks) 182 } 183 184 #[cfg(test)] 185 mod tests { 186 use super::*; 187 use enostr::{OutboxPool, OutboxSessionHandler}; 188 use nostrdb::Config; 189 use notedeck::{Accounts, EguiWakeup, ScopedSubsState, FALLBACK_PUBKEY}; 190 use tempfile::TempDir; 191 192 fn test_harness() -> ( 193 TempDir, 194 Ndb, 195 Accounts, 196 UnknownIds, 197 ScopedSubsState, 198 OutboxPool, 199 ) { 200 let tmp = TempDir::new().expect("tmp dir"); 201 let mut ndb = Ndb::new(tmp.path().to_str().expect("path"), &Config::new()).expect("ndb"); 202 let txn = Transaction::new(&ndb).expect("txn"); 203 let mut unknown_ids = UnknownIds::default(); 204 let accounts = Accounts::new( 205 None, 206 vec!["wss://relay-onboarding.example.com".to_owned()], 207 FALLBACK_PUBKEY(), 208 &mut ndb, 209 &txn, 210 &mut unknown_ids, 211 ); 212 213 ( 214 tmp, 215 ndb, 216 accounts, 217 unknown_ids, 218 ScopedSubsState::default(), 219 OutboxPool::default(), 220 ) 221 } 222 223 /// Verifies onboarding emits a one-time oneshot effect on first process call 224 /// and does not emit duplicate oneshot effects on subsequent calls. 225 #[test] 226 fn process_initially_emits_oneshot_effect_once() { 227 let (_tmp, ndb, accounts, mut unknown_ids, mut scoped_sub_state, mut pool) = test_harness(); 228 let owner = SubOwnerKey::new(("onboarding", 1usize)); 229 let mut onboarding = Onboarding::default(); 230 231 let first = { 232 let mut outbox = 233 OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())); 234 let mut scoped_subs = scoped_sub_state.api(&mut outbox, &accounts); 235 onboarding.process(&mut scoped_subs, owner, &ndb, &mut unknown_ids) 236 }; 237 238 match first { 239 Some(OnboardingEffect::Oneshot(filters)) => { 240 assert_eq!(filters.len(), 1); 241 assert_eq!( 242 filters[0].json().expect("json"), 243 trusted_pks_list_filter().json().expect("json") 244 ); 245 } 246 None => panic!("expected onboarding oneshot effect"), 247 } 248 249 let second = { 250 let mut outbox = 251 OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())); 252 let mut scoped_subs = scoped_sub_state.api(&mut outbox, &accounts); 253 onboarding.process(&mut scoped_subs, owner, &ndb, &mut unknown_ids) 254 }; 255 256 assert!(second.is_none()); 257 } 258 }