notedeck

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

actionbar.rs (18218B)


      1 use std::collections::HashSet;
      2 
      3 use crate::{
      4     column::Columns,
      5     nav::{RouterAction, RouterType},
      6     route::Route,
      7     timeline::{
      8         thread::{selected_has_at_least_n_replies, NoteSeenFlags, ThreadNode, Threads},
      9         InsertionResponse, ThreadSelection, TimelineCache, TimelineKind,
     10     },
     11     view_state::ViewState,
     12 };
     13 
     14 use egui_nav::Percent;
     15 use enostr::{FilledKeypair, NoteId, Pubkey, RelayPool};
     16 use nostrdb::{IngestMetadata, Ndb, NoteBuilder, NoteKey, Transaction};
     17 use notedeck::{
     18     get_wallet_for, is_future_timestamp,
     19     note::{reaction_sent_id, ReactAction, ZapTargetAmount},
     20     unix_time_secs, Accounts, GlobalWallet, Images, MediaJobSender, NoteAction, NoteCache,
     21     NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps,
     22 };
     23 use notedeck_ui::media::MediaViewerFlags;
     24 use tracing::error;
     25 
     26 pub struct NewNotes {
     27     pub id: TimelineKind,
     28     pub notes: Vec<NoteKey>,
     29 }
     30 
     31 pub enum NotesOpenResult {
     32     Timeline(TimelineOpenResult),
     33     Thread(NewThreadNotes),
     34 }
     35 
     36 pub struct TimelineOpenResult {
     37     new_notes: Option<NewNotes>,
     38     new_pks: Option<HashSet<Pubkey>>,
     39 }
     40 
     41 struct NoteActionResponse {
     42     timeline_res: Option<NotesOpenResult>,
     43     router_action: Option<RouterAction>,
     44 }
     45 
     46 /// The note action executor for notedeck_columns
     47 #[allow(clippy::too_many_arguments)]
     48 fn execute_note_action(
     49     action: NoteAction,
     50     ndb: &mut Ndb,
     51     timeline_cache: &mut TimelineCache,
     52     threads: &mut Threads,
     53     note_cache: &mut NoteCache,
     54     pool: &mut RelayPool,
     55     txn: &Transaction,
     56     accounts: &mut Accounts,
     57     global_wallet: &mut GlobalWallet,
     58     zaps: &mut Zaps,
     59     images: &mut Images,
     60     view_state: &mut ViewState,
     61     router_type: RouterType,
     62     jobs: &MediaJobSender,
     63     ui: &mut egui::Ui,
     64     col: usize,
     65 ) -> NoteActionResponse {
     66     let mut timeline_res = None;
     67     let mut router_action = None;
     68     let can_post = accounts.get_selected_account().key.secret_key.is_some();
     69 
     70     match action {
     71         NoteAction::Scroll(ref scroll_info) => {
     72             tracing::trace!("timeline scroll {scroll_info:?}");
     73 
     74             // Update toolbar visibility based on scroll velocity
     75             let toolbar_visible_id = egui::Id::new("toolbar_visible");
     76             let velocity_threshold = 50.0; // pixels per second
     77 
     78             let viewable_content_height = scroll_info.viewable_content_rect.height();
     79             let scrollable_distance = scroll_info.full_content_size.y - viewable_content_height;
     80 
     81             // velocity.y > 0 means scrolling up (content moving down) - show toolbar
     82             // velocity.y < 0 means scrolling down (content moving up) - hide toolbar
     83             if scroll_info.velocity.y > velocity_threshold
     84                 || scrollable_distance < viewable_content_height
     85             {
     86                 ui.ctx()
     87                     .data_mut(|d| d.insert_temp(toolbar_visible_id, true));
     88             } else if scroll_info.velocity.y < -velocity_threshold {
     89                 ui.ctx()
     90                     .data_mut(|d| d.insert_temp(toolbar_visible_id, false));
     91             }
     92         }
     93 
     94         NoteAction::Reply(note_id) => {
     95             if can_post {
     96                 router_action = Some(RouterAction::route_to(Route::reply(note_id)));
     97             } else {
     98                 router_action = Some(RouterAction::route_to(Route::accounts()));
     99             }
    100         }
    101         NoteAction::React(react_action) => {
    102             if let Some(filled) = accounts.selected_filled() {
    103                 if let Err(err) = send_reaction_event(ndb, txn, pool, filled, &react_action) {
    104                     tracing::error!("Failed to send reaction: {err}");
    105                 }
    106                 ui.ctx().data_mut(|d| {
    107                     d.insert_temp(
    108                         reaction_sent_id(filled.pubkey, react_action.note_id.bytes()),
    109                         true,
    110                     )
    111                 });
    112             } else {
    113                 router_action = Some(RouterAction::route_to(Route::accounts()));
    114             }
    115         }
    116         NoteAction::Profile(pubkey) => {
    117             let kind = TimelineKind::Profile(pubkey);
    118             router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
    119             timeline_res = timeline_cache
    120                 .open(ndb, note_cache, txn, pool, &kind)
    121                 .map(NotesOpenResult::Timeline);
    122         }
    123         NoteAction::Note {
    124             note_id,
    125             preview,
    126             scroll_offset,
    127         } => 'ex: {
    128             let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id)
    129             else {
    130                 tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes()));
    131                 break 'ex;
    132             };
    133 
    134             timeline_res = threads
    135                 .open(
    136                     ndb,
    137                     txn,
    138                     pool,
    139                     &thread_selection,
    140                     preview,
    141                     col,
    142                     scroll_offset,
    143                 )
    144                 .map(NotesOpenResult::Thread);
    145 
    146             let route = Route::Thread(thread_selection);
    147 
    148             router_action = Some(RouterAction::Overlay {
    149                 route,
    150                 make_new: preview,
    151             });
    152         }
    153         NoteAction::Hashtag(htag) => {
    154             let kind = TimelineKind::Hashtag(vec![htag.clone()]);
    155             router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
    156             timeline_res = timeline_cache
    157                 .open(ndb, note_cache, txn, pool, &kind)
    158                 .map(NotesOpenResult::Timeline);
    159         }
    160         NoteAction::Repost(note_id) => {
    161             if can_post {
    162                 router_action = Some(RouterAction::route_to_sheet(
    163                     Route::RepostDecision(note_id),
    164                     egui_nav::Split::AbsoluteFromBottom(224.0),
    165                 ));
    166             } else {
    167                 router_action = Some(RouterAction::route_to(Route::accounts()));
    168             }
    169         }
    170         NoteAction::Zap(zap_action) => {
    171             let cur_acc = accounts.get_selected_account();
    172 
    173             let sender = cur_acc.key.pubkey;
    174 
    175             match &zap_action {
    176                 ZapAction::Send(target) => 'a: {
    177                     let Some(wallet) = get_wallet_for(accounts, global_wallet, sender.bytes())
    178                     else {
    179                         zaps.send_error(
    180                             sender.bytes(),
    181                             ZapTarget::Note((&target.target).into()),
    182                             ZappingError::SenderNoWallet,
    183                         );
    184                         break 'a;
    185                     };
    186 
    187                     if let RouterType::Sheet(_) = router_type {
    188                         router_action = Some(RouterAction::GoBack);
    189                     }
    190 
    191                     send_zap(
    192                         &sender,
    193                         zaps,
    194                         pool,
    195                         target,
    196                         wallet.default_zap.get_default_zap_msats(),
    197                     )
    198                 }
    199                 ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target),
    200                 ZapAction::CustomizeAmount(target) => {
    201                     let route = Route::CustomizeZapAmount(target.to_owned());
    202                     router_action = Some(RouterAction::route_to_sheet(
    203                         route,
    204                         egui_nav::Split::PercentFromTop(Percent::new(35).expect("35 <= 100")),
    205                     ));
    206                 }
    207             }
    208         }
    209         NoteAction::Context(context) => match ndb.get_note_by_key(txn, context.note_key) {
    210             Err(err) => tracing::error!("{err}"),
    211             Ok(note) => {
    212                 context.action.process_selection(ui, &note, pool, txn);
    213             }
    214         },
    215         NoteAction::Media(media_action) => {
    216             media_action.on_view_media(|medias| {
    217                 view_state.media_viewer.media_info = medias.clone();
    218                 tracing::debug!("on_view_media {:?}", &medias);
    219                 view_state
    220                     .media_viewer
    221                     .flags
    222                     .set(MediaViewerFlags::Open, true);
    223             });
    224 
    225             media_action.process_default_media_actions(images, jobs, ui.ctx())
    226         }
    227     }
    228 
    229     NoteActionResponse {
    230         timeline_res,
    231         router_action,
    232     }
    233 }
    234 
    235 /// Execute a NoteAction and process the result
    236 #[allow(clippy::too_many_arguments)]
    237 pub fn execute_and_process_note_action(
    238     action: NoteAction,
    239     ndb: &mut Ndb,
    240     columns: &mut Columns,
    241     col: usize,
    242     timeline_cache: &mut TimelineCache,
    243     threads: &mut Threads,
    244     note_cache: &mut NoteCache,
    245     pool: &mut RelayPool,
    246     txn: &Transaction,
    247     unknown_ids: &mut UnknownIds,
    248     accounts: &mut Accounts,
    249     global_wallet: &mut GlobalWallet,
    250     zaps: &mut Zaps,
    251     images: &mut Images,
    252     view_state: &mut ViewState,
    253     jobs: &MediaJobSender,
    254     ui: &mut egui::Ui,
    255 ) -> Option<RouterAction> {
    256     let router_type = {
    257         let sheet_router = &mut columns.column_mut(col).sheet_router;
    258 
    259         if sheet_router.route().is_some() {
    260             RouterType::Sheet(sheet_router.split)
    261         } else {
    262             RouterType::Stack
    263         }
    264     };
    265 
    266     let resp = execute_note_action(
    267         action,
    268         ndb,
    269         timeline_cache,
    270         threads,
    271         note_cache,
    272         pool,
    273         txn,
    274         accounts,
    275         global_wallet,
    276         zaps,
    277         images,
    278         view_state,
    279         router_type,
    280         jobs,
    281         ui,
    282         col,
    283     );
    284 
    285     if let Some(br) = resp.timeline_res {
    286         match br {
    287             NotesOpenResult::Timeline(timeline_open_result) => {
    288                 timeline_open_result.process(ndb, note_cache, txn, timeline_cache, unknown_ids);
    289             }
    290             NotesOpenResult::Thread(thread_open_result) => {
    291                 thread_open_result.process(threads, ndb, txn, unknown_ids, note_cache);
    292             }
    293         }
    294     }
    295 
    296     resp.router_action
    297 }
    298 
    299 fn send_reaction_event(
    300     ndb: &mut Ndb,
    301     txn: &Transaction,
    302     pool: &mut RelayPool,
    303     kp: FilledKeypair<'_>,
    304     reaction: &ReactAction,
    305 ) -> Result<(), String> {
    306     let Ok(note) = ndb.get_note_by_id(txn, reaction.note_id.bytes()) else {
    307         return Err(format!("noteid {:?} not found in ndb", reaction.note_id));
    308     };
    309 
    310     let target_pubkey = Pubkey::new(*note.pubkey());
    311     let relay_hint: Option<String> = note.relays(txn).next().map(|s| s.to_owned());
    312     let target_kind = note.kind();
    313     let d_tag_value = find_addressable_d_tag(&note);
    314 
    315     let mut builder = NoteBuilder::new().kind(7).content(reaction.content);
    316 
    317     builder = builder
    318         .start_tag()
    319         .tag_str("e")
    320         .tag_id(reaction.note_id.bytes())
    321         .tag_str(relay_hint.as_deref().unwrap_or(""))
    322         .tag_str(&target_pubkey.hex());
    323 
    324     builder = builder
    325         .start_tag()
    326         .tag_str("p")
    327         .tag_id(target_pubkey.bytes());
    328 
    329     if let Some(relay) = relay_hint.as_deref() {
    330         builder = builder.tag_str(relay);
    331     }
    332 
    333     // we don't support addressable events yet... but why not future proof it?
    334     if let Some(d_value) = d_tag_value.as_deref() {
    335         let coordinates = format!("{}:{}:{}", target_kind, target_pubkey.hex(), d_value);
    336 
    337         builder = builder.start_tag().tag_str("a").tag_str(&coordinates);
    338 
    339         if let Some(relay) = relay_hint.as_deref() {
    340             builder = builder.tag_str(relay);
    341         }
    342     }
    343 
    344     builder = builder
    345         .start_tag()
    346         .tag_str("k")
    347         .tag_str(&target_kind.to_string());
    348 
    349     let note = builder
    350         .sign(&kp.secret_key.secret_bytes())
    351         .build()
    352         .ok_or_else(|| "failed to build reaction event".to_owned())?;
    353 
    354     let Ok(event) = &enostr::ClientMessage::event(&note) else {
    355         return Err("failed to convert reaction note into client message".to_owned());
    356     };
    357 
    358     let Ok(json) = event.to_json() else {
    359         return Err("failed to serialize reaction event to json".to_owned());
    360     };
    361 
    362     let _ = ndb.process_event_with(&json, IngestMetadata::new().client(true));
    363 
    364     pool.send(event);
    365 
    366     Ok(())
    367 }
    368 
    369 fn find_addressable_d_tag(note: &nostrdb::Note<'_>) -> Option<String> {
    370     for tag in note.tags() {
    371         if tag.count() < 2 {
    372             continue;
    373         }
    374 
    375         if tag.get_unchecked(0).variant().str() != Some("d") {
    376             continue;
    377         }
    378 
    379         if let Some(value) = tag.get_unchecked(1).variant().str() {
    380             return Some(value.to_owned());
    381         }
    382     }
    383 
    384     None
    385 }
    386 
    387 fn send_zap(
    388     sender: &Pubkey,
    389     zaps: &mut Zaps,
    390     pool: &RelayPool,
    391     target_amount: &ZapTargetAmount,
    392     default_msats: u64,
    393 ) {
    394     let zap_target = ZapTarget::Note((&target_amount.target).into());
    395 
    396     let msats = target_amount.specified_msats.unwrap_or(default_msats);
    397 
    398     let sender_relays: Vec<String> = pool.relays.iter().map(|r| r.url().to_string()).collect();
    399     zaps.send_zap(sender.bytes(), sender_relays, zap_target, msats);
    400 }
    401 
    402 fn clear_zap_error(sender: &Pubkey, zaps: &mut Zaps, target: &NoteZapTargetOwned) {
    403     zaps.clear_error_for(sender.bytes(), ZapTarget::Note(target.into()));
    404 }
    405 
    406 impl TimelineOpenResult {
    407     pub fn new_notes(notes: Vec<NoteKey>, id: TimelineKind) -> Self {
    408         Self {
    409             new_notes: Some(NewNotes { id, notes }),
    410             new_pks: None,
    411         }
    412     }
    413 
    414     pub fn new_pks(pks: HashSet<Pubkey>) -> Self {
    415         Self {
    416             new_notes: None,
    417             new_pks: Some(pks),
    418         }
    419     }
    420 
    421     pub fn insert_pks(&mut self, pks: HashSet<Pubkey>) {
    422         match &mut self.new_pks {
    423             Some(cur_pks) => cur_pks.extend(pks),
    424             None => self.new_pks = Some(pks),
    425         }
    426     }
    427 
    428     pub fn process(
    429         &self,
    430         ndb: &Ndb,
    431         note_cache: &mut NoteCache,
    432         txn: &Transaction,
    433         storage: &mut TimelineCache,
    434         unknown_ids: &mut UnknownIds,
    435     ) {
    436         // update the thread for next render if we have new notes
    437         if let Some(new_notes) = &self.new_notes {
    438             new_notes.process(storage, ndb, txn, unknown_ids, note_cache);
    439         }
    440 
    441         let Some(pks) = &self.new_pks else {
    442             return;
    443         };
    444 
    445         for pk in pks {
    446             unknown_ids.add_pubkey_if_missing(ndb, txn, pk);
    447         }
    448     }
    449 }
    450 
    451 impl NewNotes {
    452     pub fn new(notes: Vec<NoteKey>, id: TimelineKind) -> Self {
    453         NewNotes { notes, id }
    454     }
    455 
    456     /// Simple helper for processing a NewThreadNotes result. It simply
    457     /// inserts/merges the notes into the corresponding timeline cache
    458     pub fn process(
    459         &self,
    460         timeline_cache: &mut TimelineCache,
    461         ndb: &Ndb,
    462         txn: &Transaction,
    463         unknown_ids: &mut UnknownIds,
    464         note_cache: &mut NoteCache,
    465     ) {
    466         let reversed = false;
    467 
    468         let timeline = if let Some(profile) = timeline_cache.get_mut(&self.id) {
    469             profile
    470         } else {
    471             error!("NewNotes: could not get timeline for key {:?}", self.id);
    472             return;
    473         };
    474 
    475         if let Err(err) = timeline.insert(&self.notes, ndb, txn, unknown_ids, note_cache, reversed)
    476         {
    477             error!("error inserting notes into profile timeline: {err}")
    478         }
    479     }
    480 }
    481 
    482 pub struct NewThreadNotes {
    483     pub selected_note_id: NoteId,
    484     pub notes: Vec<NoteKey>,
    485 }
    486 
    487 impl NewThreadNotes {
    488     pub fn process(
    489         &self,
    490         threads: &mut Threads,
    491         ndb: &Ndb,
    492         txn: &Transaction,
    493         unknown_ids: &mut UnknownIds,
    494         note_cache: &mut NoteCache,
    495     ) {
    496         let Some(node) = threads.threads.get_mut(&self.selected_note_id.bytes()) else {
    497             tracing::error!("Could not find thread node for {:?}", self.selected_note_id);
    498             return;
    499         };
    500 
    501         process_thread_notes(
    502             &self.notes,
    503             node,
    504             &mut threads.seen_flags,
    505             ndb,
    506             txn,
    507             unknown_ids,
    508             note_cache,
    509         );
    510     }
    511 }
    512 
    513 pub fn process_thread_notes(
    514     notes: &Vec<NoteKey>,
    515     thread: &mut ThreadNode,
    516     seen_flags: &mut NoteSeenFlags,
    517     ndb: &Ndb,
    518     txn: &Transaction,
    519     unknown_ids: &mut UnknownIds,
    520     note_cache: &mut NoteCache,
    521 ) {
    522     if notes.is_empty() {
    523         return;
    524     }
    525 
    526     let now = unix_time_secs();
    527     let mut has_spliced_resp = false;
    528     let mut num_new_notes = 0;
    529     for key in notes {
    530         let note = if let Ok(note) = ndb.get_note_by_key(txn, *key) {
    531             note
    532         } else {
    533             tracing::error!(
    534                 "hit race condition in poll_notes_into_view: https://github.com/damus-io/nostrdb/issues/35 note {:?} was not added to timeline",
    535                 key
    536             );
    537             continue;
    538         };
    539 
    540         if is_future_timestamp(note.created_at(), now) {
    541             continue;
    542         }
    543 
    544         // Ensure that unknown ids are captured when inserting notes
    545         UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &note);
    546 
    547         let created_at = note.created_at();
    548         let note_ref = notedeck::NoteRef {
    549             key: *key,
    550             created_at,
    551         };
    552 
    553         if thread.replies.contains_key(&note_ref.key) {
    554             continue;
    555         }
    556 
    557         let insertion_resp = thread.replies.insert(note_ref);
    558         if let InsertionResponse::Merged(crate::timeline::MergeKind::Spliced) = insertion_resp {
    559             has_spliced_resp = true;
    560         }
    561 
    562         if matches!(insertion_resp, InsertionResponse::Merged(_)) {
    563             num_new_notes += 1;
    564         }
    565 
    566         if !seen_flags.contains(note.id()) {
    567             let cached_note = note_cache.cached_note_or_insert_mut(*key, &note);
    568 
    569             let note_reply = cached_note.reply.borrow(note.tags());
    570 
    571             let has_reply = if let Some(root) = note_reply.root() {
    572                 selected_has_at_least_n_replies(ndb, txn, Some(note.id()), root.id, 1)
    573             } else {
    574                 selected_has_at_least_n_replies(ndb, txn, None, note.id(), 1)
    575             };
    576 
    577             seen_flags.mark_replies(note.id(), has_reply);
    578         }
    579     }
    580 
    581     if has_spliced_resp {
    582         tracing::debug!(
    583             "spliced when inserting {} new notes, resetting virtual list",
    584             num_new_notes
    585         );
    586         thread.list.reset();
    587     }
    588 }