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