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