notedeck

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

actionbar.rs (12563B)


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