notedeck

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

actionbar.rs (17234B)


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