notedeck

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

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 }