notedeck

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

actionbar.rs (12045B)


      1 use crate::{
      2     column::Columns,
      3     nav::{RouterAction, RouterType},
      4     route::Route,
      5     timeline::{
      6         thread::{
      7             selected_has_at_least_n_replies, InsertionResponse, NoteSeenFlags, ThreadNode, Threads,
      8         },
      9         ThreadSelection, TimelineCache, TimelineKind,
     10     },
     11 };
     12 
     13 use enostr::{NoteId, Pubkey, RelayPool};
     14 use nostrdb::{Ndb, NoteKey, Transaction};
     15 use notedeck::{
     16     get_wallet_for, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, NoteCache,
     17     NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps,
     18 };
     19 use tracing::error;
     20 
     21 pub struct NewNotes {
     22     pub id: TimelineKind,
     23     pub notes: Vec<NoteKey>,
     24 }
     25 
     26 pub enum NotesOpenResult {
     27     Timeline(TimelineOpenResult),
     28     Thread(NewThreadNotes),
     29 }
     30 
     31 pub enum TimelineOpenResult {
     32     NewNotes(NewNotes),
     33 }
     34 
     35 struct NoteActionResponse {
     36     timeline_res: Option<NotesOpenResult>,
     37     router_action: Option<RouterAction>,
     38 }
     39 
     40 /// The note action executor for notedeck_columns
     41 #[allow(clippy::too_many_arguments)]
     42 fn execute_note_action(
     43     action: NoteAction,
     44     ndb: &mut Ndb,
     45     timeline_cache: &mut TimelineCache,
     46     threads: &mut Threads,
     47     note_cache: &mut NoteCache,
     48     pool: &mut RelayPool,
     49     txn: &Transaction,
     50     accounts: &mut Accounts,
     51     global_wallet: &mut GlobalWallet,
     52     zaps: &mut Zaps,
     53     images: &mut Images,
     54     router_type: RouterType,
     55     ui: &mut egui::Ui,
     56     col: usize,
     57 ) -> NoteActionResponse {
     58     let mut timeline_res = None;
     59     let mut router_action = None;
     60     let can_post = accounts.get_selected_account().key.secret_key.is_some();
     61 
     62     match action {
     63         NoteAction::Scroll(ref scroll_info) => {
     64             tracing::trace!("timeline scroll {scroll_info:?}")
     65         }
     66 
     67         NoteAction::Reply(note_id) => {
     68             if can_post {
     69                 router_action = Some(RouterAction::route_to(Route::reply(note_id)));
     70             } else {
     71                 router_action = Some(RouterAction::route_to(Route::accounts()));
     72             }
     73         }
     74         NoteAction::Profile(pubkey) => {
     75             let kind = TimelineKind::Profile(pubkey);
     76             router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
     77             timeline_res = timeline_cache
     78                 .open(ndb, note_cache, txn, pool, &kind)
     79                 .map(NotesOpenResult::Timeline);
     80         }
     81         NoteAction::Note { note_id, preview } => 'ex: {
     82             let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id)
     83             else {
     84                 tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes()));
     85                 break 'ex;
     86             };
     87 
     88             timeline_res = threads
     89                 .open(ndb, txn, pool, &thread_selection, preview, col)
     90                 .map(NotesOpenResult::Thread);
     91 
     92             let route = Route::Thread(thread_selection);
     93 
     94             router_action = Some(RouterAction::Overlay {
     95                 route,
     96                 make_new: preview,
     97             });
     98         }
     99         NoteAction::Hashtag(htag) => {
    100             let kind = TimelineKind::Hashtag(vec![htag.clone()]);
    101             router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
    102             timeline_res = timeline_cache
    103                 .open(ndb, note_cache, txn, pool, &kind)
    104                 .map(NotesOpenResult::Timeline);
    105         }
    106         NoteAction::Quote(note_id) => {
    107             if can_post {
    108                 router_action = Some(RouterAction::route_to(Route::quote(note_id)));
    109             } else {
    110                 router_action = Some(RouterAction::route_to(Route::accounts()));
    111             }
    112         }
    113         NoteAction::Zap(zap_action) => {
    114             let cur_acc = accounts.get_selected_account();
    115 
    116             let sender = cur_acc.key.pubkey;
    117 
    118             match &zap_action {
    119                 ZapAction::Send(target) => 'a: {
    120                     let Some(wallet) = get_wallet_for(accounts, global_wallet, sender.bytes())
    121                     else {
    122                         zaps.send_error(
    123                             sender.bytes(),
    124                             ZapTarget::Note((&target.target).into()),
    125                             ZappingError::SenderNoWallet,
    126                         );
    127                         break 'a;
    128                     };
    129 
    130                     if let RouterType::Sheet = router_type {
    131                         router_action = Some(RouterAction::GoBack);
    132                     }
    133 
    134                     send_zap(
    135                         &sender,
    136                         zaps,
    137                         pool,
    138                         target,
    139                         wallet.default_zap.get_default_zap_msats(),
    140                     )
    141                 }
    142                 ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target),
    143                 ZapAction::CustomizeAmount(target) => {
    144                     let route = Route::CustomizeZapAmount(target.to_owned());
    145                     router_action = Some(RouterAction::route_to_sheet(route));
    146                 }
    147             }
    148         }
    149         NoteAction::Context(context) => match ndb.get_note_by_key(txn, context.note_key) {
    150             Err(err) => tracing::error!("{err}"),
    151             Ok(note) => {
    152                 context.action.process(ui, &note, pool);
    153             }
    154         },
    155         NoteAction::Media(media_action) => {
    156             media_action.process(images);
    157         }
    158     }
    159 
    160     NoteActionResponse {
    161         timeline_res,
    162         router_action,
    163     }
    164 }
    165 
    166 /// Execute a NoteAction and process the result
    167 #[allow(clippy::too_many_arguments)]
    168 pub fn execute_and_process_note_action(
    169     action: NoteAction,
    170     ndb: &mut Ndb,
    171     columns: &mut Columns,
    172     col: usize,
    173     timeline_cache: &mut TimelineCache,
    174     threads: &mut Threads,
    175     note_cache: &mut NoteCache,
    176     pool: &mut RelayPool,
    177     txn: &Transaction,
    178     unknown_ids: &mut UnknownIds,
    179     accounts: &mut Accounts,
    180     global_wallet: &mut GlobalWallet,
    181     zaps: &mut Zaps,
    182     images: &mut Images,
    183     ui: &mut egui::Ui,
    184 ) -> Option<RouterAction> {
    185     let router_type = {
    186         let sheet_router = &mut columns.column_mut(col).sheet_router;
    187 
    188         if sheet_router.route().is_some() {
    189             RouterType::Sheet
    190         } else {
    191             RouterType::Stack
    192         }
    193     };
    194 
    195     let resp = execute_note_action(
    196         action,
    197         ndb,
    198         timeline_cache,
    199         threads,
    200         note_cache,
    201         pool,
    202         txn,
    203         accounts,
    204         global_wallet,
    205         zaps,
    206         images,
    207         router_type,
    208         ui,
    209         col,
    210     );
    211 
    212     if let Some(br) = resp.timeline_res {
    213         match br {
    214             NotesOpenResult::Timeline(timeline_open_result) => {
    215                 timeline_open_result.process(ndb, note_cache, txn, timeline_cache, unknown_ids);
    216             }
    217             NotesOpenResult::Thread(thread_open_result) => {
    218                 thread_open_result.process(threads, ndb, txn, unknown_ids, note_cache);
    219             }
    220         }
    221     }
    222 
    223     resp.router_action
    224 }
    225 
    226 fn send_zap(
    227     sender: &Pubkey,
    228     zaps: &mut Zaps,
    229     pool: &RelayPool,
    230     target_amount: &ZapTargetAmount,
    231     default_msats: u64,
    232 ) {
    233     let zap_target = ZapTarget::Note((&target_amount.target).into());
    234 
    235     let msats = target_amount.specified_msats.unwrap_or(default_msats);
    236 
    237     let sender_relays: Vec<String> = pool.relays.iter().map(|r| r.url().to_string()).collect();
    238     zaps.send_zap(sender.bytes(), sender_relays, zap_target, msats);
    239 }
    240 
    241 fn clear_zap_error(sender: &Pubkey, zaps: &mut Zaps, target: &NoteZapTargetOwned) {
    242     zaps.clear_error_for(sender.bytes(), ZapTarget::Note(target.into()));
    243 }
    244 
    245 impl TimelineOpenResult {
    246     pub fn new_notes(notes: Vec<NoteKey>, id: TimelineKind) -> Self {
    247         Self::NewNotes(NewNotes::new(notes, id))
    248     }
    249 
    250     pub fn process(
    251         &self,
    252         ndb: &Ndb,
    253         note_cache: &mut NoteCache,
    254         txn: &Transaction,
    255         storage: &mut TimelineCache,
    256         unknown_ids: &mut UnknownIds,
    257     ) {
    258         match self {
    259             // update the thread for next render if we have new notes
    260             TimelineOpenResult::NewNotes(new_notes) => {
    261                 new_notes.process(storage, ndb, txn, unknown_ids, note_cache);
    262             }
    263         }
    264     }
    265 }
    266 
    267 impl NewNotes {
    268     pub fn new(notes: Vec<NoteKey>, id: TimelineKind) -> Self {
    269         NewNotes { notes, id }
    270     }
    271 
    272     /// Simple helper for processing a NewThreadNotes result. It simply
    273     /// inserts/merges the notes into the corresponding timeline cache
    274     pub fn process(
    275         &self,
    276         timeline_cache: &mut TimelineCache,
    277         ndb: &Ndb,
    278         txn: &Transaction,
    279         unknown_ids: &mut UnknownIds,
    280         note_cache: &mut NoteCache,
    281     ) {
    282         let reversed = false;
    283 
    284         let timeline = if let Some(profile) = timeline_cache.get_mut(&self.id) {
    285             profile
    286         } else {
    287             error!("NewNotes: could not get timeline for key {:?}", self.id);
    288             return;
    289         };
    290 
    291         if let Err(err) = timeline.insert(&self.notes, ndb, txn, unknown_ids, note_cache, reversed)
    292         {
    293             error!("error inserting notes into profile timeline: {err}")
    294         }
    295     }
    296 }
    297 
    298 pub struct NewThreadNotes {
    299     pub selected_note_id: NoteId,
    300     pub notes: Vec<NoteKey>,
    301 }
    302 
    303 impl NewThreadNotes {
    304     pub fn process(
    305         &self,
    306         threads: &mut Threads,
    307         ndb: &Ndb,
    308         txn: &Transaction,
    309         unknown_ids: &mut UnknownIds,
    310         note_cache: &mut NoteCache,
    311     ) {
    312         let Some(node) = threads.threads.get_mut(&self.selected_note_id.bytes()) else {
    313             tracing::error!("Could not find thread node for {:?}", self.selected_note_id);
    314             return;
    315         };
    316 
    317         process_thread_notes(
    318             &self.notes,
    319             node,
    320             &mut threads.seen_flags,
    321             ndb,
    322             txn,
    323             unknown_ids,
    324             note_cache,
    325         );
    326     }
    327 }
    328 
    329 pub fn process_thread_notes(
    330     notes: &Vec<NoteKey>,
    331     thread: &mut ThreadNode,
    332     seen_flags: &mut NoteSeenFlags,
    333     ndb: &Ndb,
    334     txn: &Transaction,
    335     unknown_ids: &mut UnknownIds,
    336     note_cache: &mut NoteCache,
    337 ) {
    338     if notes.is_empty() {
    339         return;
    340     }
    341 
    342     let mut has_spliced_resp = false;
    343     let mut num_new_notes = 0;
    344     for key in notes {
    345         let note = if let Ok(note) = ndb.get_note_by_key(txn, *key) {
    346             note
    347         } else {
    348             tracing::error!(
    349                 "hit race condition in poll_notes_into_view: https://github.com/damus-io/nostrdb/issues/35 note {:?} was not added to timeline",
    350                 key
    351             );
    352             continue;
    353         };
    354 
    355         // Ensure that unknown ids are captured when inserting notes
    356         UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &note);
    357 
    358         let created_at = note.created_at();
    359         let note_ref = notedeck::NoteRef {
    360             key: *key,
    361             created_at,
    362         };
    363 
    364         if thread.replies.contains(&note_ref) {
    365             continue;
    366         }
    367 
    368         let insertion_resp = thread.replies.insert(note_ref);
    369         if let InsertionResponse::Merged(crate::timeline::MergeKind::Spliced) = insertion_resp {
    370             has_spliced_resp = true;
    371         }
    372 
    373         if matches!(insertion_resp, InsertionResponse::Merged(_)) {
    374             num_new_notes += 1;
    375         }
    376 
    377         if !seen_flags.contains(note.id()) {
    378             let cached_note = note_cache.cached_note_or_insert_mut(*key, &note);
    379 
    380             let note_reply = cached_note.reply.borrow(note.tags());
    381 
    382             let has_reply = if let Some(root) = note_reply.root() {
    383                 selected_has_at_least_n_replies(ndb, txn, Some(note.id()), root.id, 1)
    384             } else {
    385                 selected_has_at_least_n_replies(ndb, txn, None, note.id(), 1)
    386             };
    387 
    388             seen_flags.mark_replies(note.id(), has_reply);
    389         }
    390     }
    391 
    392     if has_spliced_resp {
    393         tracing::debug!(
    394             "spliced when inserting {} new notes, resetting virtual list",
    395             num_new_notes
    396         );
    397         thread.list.reset();
    398     }
    399 }