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, ¬e, 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(¬e); 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(¬e) 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, ¬e); 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(¬e_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, ¬e); 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 }