notedeck

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

actionbar.rs (19903B)


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