render.rs (49901B)
1 use crate::{ 2 abbrev::abbrev_str, error::Result, fonts, nip19, relay_pool::RelayPool, Error, Notecrumbs, 3 }; 4 use egui::epaint::Shadow; 5 use egui::{ 6 pos2, 7 text::{LayoutJob, TextFormat}, 8 Color32, FontFamily, FontId, Mesh, Rect, RichText, Rounding, Shape, TextureHandle, Vec2, 9 Visuals, 10 }; 11 use nostr::event::{ 12 kind::Kind, 13 tag::{TagKind, TagStandard}, 14 }; 15 use nostr::nips::nip01::Coordinate; 16 use nostr::{RelayUrl, SingleLetterTag, Timestamp}; 17 use nostr_sdk::async_utility::futures_util::StreamExt; 18 use nostr_sdk::nips::nip19::Nip19; 19 use nostr_sdk::prelude::{Event, EventId, PublicKey}; 20 use nostr_sdk::JsonUtil; 21 use nostrdb::{ 22 Block, BlockType, Blocks, FilterElement, FilterField, IngestMetadata, Mention, Ndb, Note, 23 NoteKey, ProfileKey, ProfileRecord, Transaction, 24 }; 25 use std::collections::{BTreeMap, BTreeSet, HashSet}; 26 use std::sync::Arc; 27 use std::time::SystemTime; 28 use tokio::time::{timeout, Duration}; 29 use tracing::{debug, error, warn}; 30 31 const PURPLE: Color32 = Color32::from_rgb(0xcc, 0x43, 0xc5); 32 pub const PROFILE_FEED_RECENT_LIMIT: usize = 12; 33 pub const PROFILE_FEED_LOOKBACK_DAYS: u64 = 30; 34 pub const DIRECT_REPLY_LIMIT: i32 = 50; 35 36 #[derive(Clone)] 37 pub enum NoteRenderData { 38 Missing([u8; 32]), 39 Address { 40 author: [u8; 32], 41 kind: u64, 42 identifier: String, 43 }, 44 Note(NoteKey), 45 } 46 47 impl NoteRenderData { 48 pub fn needs_note(&self) -> bool { 49 match self { 50 NoteRenderData::Missing(_) => true, 51 NoteRenderData::Address { .. } => true, 52 NoteRenderData::Note(_) => false, 53 } 54 } 55 56 pub fn lookup<'a>( 57 &self, 58 txn: &'a Transaction, 59 ndb: &Ndb, 60 ) -> std::result::Result<Note<'a>, nostrdb::Error> { 61 match self { 62 NoteRenderData::Missing(note_id) => ndb.get_note_by_id(txn, note_id), 63 NoteRenderData::Address { 64 author, 65 kind, 66 identifier, 67 } => query_note_by_address(ndb, txn, author, *kind, identifier), 68 NoteRenderData::Note(note_key) => ndb.get_note_by_key(txn, *note_key), 69 } 70 } 71 } 72 73 pub struct NoteAndProfileRenderData { 74 pub note_rd: NoteRenderData, 75 pub profile_rd: Option<ProfileRenderData>, 76 /// Source relay URL(s) where the note was fetched from. 77 /// Used for generating bech32 links with relay hints. 78 pub source_relays: Vec<RelayUrl>, 79 } 80 81 impl NoteAndProfileRenderData { 82 pub fn new(note_rd: NoteRenderData, profile_rd: Option<ProfileRenderData>) -> Self { 83 Self { 84 note_rd, 85 profile_rd, 86 source_relays: Vec::new(), 87 } 88 } 89 90 pub fn add_source_relay(&mut self, relay: RelayUrl) { 91 if !self.source_relays.contains(&relay) { 92 self.source_relays.push(relay); 93 } 94 } 95 } 96 97 pub enum ProfileRenderData { 98 Missing([u8; 32]), 99 Profile(ProfileKey), 100 } 101 102 impl ProfileRenderData { 103 pub fn lookup<'a>( 104 &self, 105 txn: &'a Transaction, 106 ndb: &Ndb, 107 ) -> std::result::Result<ProfileRecord<'a>, nostrdb::Error> { 108 match self { 109 ProfileRenderData::Missing(pk) => ndb.get_profile_by_pubkey(txn, pk), 110 ProfileRenderData::Profile(key) => ndb.get_profile_by_key(txn, *key), 111 } 112 } 113 114 pub fn needs_profile(&self) -> bool { 115 match self { 116 ProfileRenderData::Missing(_) => true, 117 ProfileRenderData::Profile(_) => false, 118 } 119 } 120 } 121 122 /// Primary keys for the data we're interested in rendering 123 pub enum RenderData { 124 Profile(Option<ProfileRenderData>), 125 Note(NoteAndProfileRenderData), 126 } 127 128 impl RenderData { 129 pub fn note(note_rd: NoteRenderData, profile_rd: Option<ProfileRenderData>) -> Self { 130 Self::Note(NoteAndProfileRenderData::new(note_rd, profile_rd)) 131 } 132 133 pub fn profile(profile_rd: Option<ProfileRenderData>) -> Self { 134 Self::Profile(profile_rd) 135 } 136 137 pub fn is_complete(&self) -> bool { 138 !(self.needs_profile() || self.needs_note()) 139 } 140 141 pub fn note_render_data(&self) -> Option<&NoteRenderData> { 142 match self { 143 Self::Note(nrd) => Some(&nrd.note_rd), 144 Self::Profile(_) => None, 145 } 146 } 147 148 pub fn profile_render_data(&self) -> Option<&ProfileRenderData> { 149 match self { 150 Self::Note(nrd) => nrd.profile_rd.as_ref(), 151 Self::Profile(prd) => prd.as_ref(), 152 } 153 } 154 155 pub fn needs_profile(&self) -> bool { 156 match self { 157 RenderData::Profile(profile_rd) => profile_rd 158 .as_ref() 159 .map(|prd| prd.needs_profile()) 160 .unwrap_or(true), 161 RenderData::Note(note) => note 162 .profile_rd 163 .as_ref() 164 .map(|prd| prd.needs_profile()) 165 .unwrap_or(true), 166 } 167 } 168 169 pub fn needs_note(&self) -> bool { 170 match self { 171 RenderData::Profile(_pkey) => false, 172 RenderData::Note(rd) => rd.note_rd.needs_note(), 173 } 174 } 175 } 176 177 fn renderdata_to_filter(render_data: &RenderData) -> Vec<nostrdb::Filter> { 178 if render_data.is_complete() { 179 return vec![]; 180 } 181 182 let mut filters = Vec::with_capacity(2); 183 184 match render_data.note_render_data() { 185 Some(NoteRenderData::Missing(note_id)) => { 186 filters.push(nostrdb::Filter::new().ids([note_id]).limit(1).build()); 187 } 188 Some(NoteRenderData::Address { 189 author, 190 kind, 191 identifier, 192 }) => { 193 filters.push(build_address_filter(author, *kind, identifier.as_str())); 194 } 195 None | Some(NoteRenderData::Note(_)) => {} 196 } 197 198 match render_data.profile_render_data() { 199 Some(ProfileRenderData::Missing(pubkey)) => { 200 filters.push( 201 nostrdb::Filter::new() 202 .authors([pubkey]) 203 .kinds([0]) 204 .limit(1) 205 .build(), 206 ); 207 } 208 None | Some(ProfileRenderData::Profile(_)) => {} 209 } 210 211 filters 212 } 213 214 pub(crate) fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::Filter { 215 let mut filter = nostr::Filter::new(); 216 217 for element in ndb_filter { 218 match element { 219 FilterField::Ids(id_elems) => { 220 let event_ids = id_elems 221 .into_iter() 222 .map(|id| EventId::from_slice(id).expect("event id")); 223 filter = filter.ids(event_ids); 224 } 225 226 FilterField::Authors(authors) => { 227 let authors = authors 228 .into_iter() 229 .map(|id| PublicKey::from_slice(id).expect("ok")); 230 filter = filter.authors(authors); 231 } 232 233 FilterField::Kinds(int_elems) => { 234 let kinds = int_elems.into_iter().map(|knd| Kind::from_u16(knd as u16)); 235 filter = filter.kinds(kinds); 236 } 237 238 FilterField::Tags(chr, tag_elems) => { 239 let single_letter = if let Ok(single) = SingleLetterTag::from_char(chr) { 240 single 241 } else { 242 warn!("failed to adding char filter element: '{}", chr); 243 continue; 244 }; 245 246 let mut tags: BTreeMap<SingleLetterTag, BTreeSet<String>> = BTreeMap::new(); 247 let mut elems: BTreeSet<String> = BTreeSet::new(); 248 249 for elem in tag_elems { 250 match elem { 251 FilterElement::Str(s) => { 252 elems.insert(s.to_string()); 253 } 254 FilterElement::Id(id) => { 255 elems.insert(hex::encode(id)); 256 } 257 _ => { 258 warn!( 259 "not adding non-string element from filter tag '{}", 260 single_letter 261 ); 262 } 263 } 264 } 265 266 tags.insert(single_letter, elems); 267 268 filter.generic_tags = tags; 269 } 270 271 FilterField::Since(since) => { 272 filter.since = Some(Timestamp::from_secs(since)); 273 } 274 275 FilterField::Until(until) => { 276 filter.until = Some(Timestamp::from_secs(until)); 277 } 278 279 FilterField::Limit(limit) => { 280 filter.limit = Some(limit as usize); 281 } 282 283 // Ignore new filter fields we don't handle 284 FilterField::Search(_) | FilterField::Relays(_) | FilterField::Custom(_) => {} 285 } 286 } 287 288 filter 289 } 290 291 fn coordinate_tag(author: &[u8; 32], kind: u64, identifier: &str) -> String { 292 let Ok(public_key) = PublicKey::from_slice(author) else { 293 return String::new(); 294 }; 295 let nostr_kind = Kind::from_u16(kind as u16); 296 let coordinate = Coordinate::new(nostr_kind, public_key).identifier(identifier); 297 coordinate.to_string() 298 } 299 300 fn build_address_filter(author: &[u8; 32], kind: u64, identifier: &str) -> nostrdb::Filter { 301 let author_ref: [&[u8; 32]; 1] = [author]; 302 let mut filter = nostrdb::Filter::new().authors(author_ref).kinds([kind]); 303 if !identifier.is_empty() { 304 filter = filter.tags([identifier], 'd'); 305 } 306 filter.limit(1).build() 307 } 308 309 fn query_note_by_address<'a>( 310 ndb: &Ndb, 311 txn: &'a Transaction, 312 author: &[u8; 32], 313 kind: u64, 314 identifier: &str, 315 ) -> std::result::Result<Note<'a>, nostrdb::Error> { 316 let mut results = ndb.query(txn, &[build_address_filter(author, kind, identifier)], 1)?; 317 if results.is_empty() && !identifier.is_empty() { 318 let coord_tag = coordinate_tag(author, kind, identifier); 319 let coord_filter = nostrdb::Filter::new() 320 .authors([author]) 321 .kinds([kind]) 322 .tags([coord_tag.as_str()], 'a') 323 .limit(1) 324 .build(); 325 results = ndb.query(txn, &[coord_filter], 1)?; 326 } 327 if let Some(result) = results.first() { 328 ndb.get_note_by_key(txn, result.note_key) 329 } else { 330 Err(nostrdb::Error::NotFound) 331 } 332 } 333 334 /// Fetches notes from relays and returns the source relay URLs. 335 /// The source relays are used to generate bech32 links with relay hints. 336 /// Prioritizes default relays in the returned list for better reliability. 337 pub async fn find_note( 338 relay_pool: Arc<RelayPool>, 339 ndb: Ndb, 340 filters: Vec<nostr::Filter>, 341 nip19: &Nip19, 342 ) -> Result<Vec<RelayUrl>> { 343 use nostr_sdk::JsonUtil; 344 345 let mut relay_targets = nip19::nip19_relays(nip19); 346 if relay_targets.is_empty() { 347 relay_targets = relay_pool.default_relays().to_vec(); 348 } 349 350 relay_pool.ensure_relays(relay_targets.clone()).await?; 351 352 debug!("finding note(s) with filters: {:?}", filters); 353 354 let mut all_source_relays = Vec::new(); 355 let default_relays = relay_pool.default_relays(); 356 357 for filter in filters { 358 let mut streamed_events = relay_pool 359 .stream_events( 360 filter, 361 &relay_targets, 362 std::time::Duration::from_millis(2000), 363 ) 364 .await?; 365 366 // Collect all responding relays, then prioritize after stream exhausts. 367 while let Some(relay_event) = streamed_events.next().await { 368 if let Err(err) = ensure_relay_hints(&relay_pool, &relay_event.event).await { 369 warn!("failed to apply relay hints: {err}"); 370 } 371 372 debug!("processing event {:?}", relay_event.event); 373 let relay_url = relay_event.relay_url(); 374 let ingest_meta = relay_url 375 .map(|url| IngestMetadata::new().relay(url.as_str())) 376 .unwrap_or_else(IngestMetadata::new); 377 if let Err(err) = ndb.process_event_with(&relay_event.event.as_json(), ingest_meta) { 378 error!("error processing event: {err}"); 379 } 380 381 // Skip profile events - their relays shouldn't be used as note hints 382 if relay_event.event.kind == Kind::Metadata { 383 continue; 384 } 385 386 let Some(relay_url) = relay_url else { 387 continue; 388 }; 389 390 if all_source_relays.contains(relay_url) { 391 continue; 392 } 393 394 all_source_relays.push(relay_url.clone()); 395 } 396 } 397 398 // Sort relays: default relays first (more reliable), then others. 399 // Take up to 3 for the final result. 400 const MAX_SOURCE_RELAYS: usize = 3; 401 all_source_relays.sort_by(|a, b| { 402 let a_is_default = default_relays.contains(a); 403 let b_is_default = default_relays.contains(b); 404 b_is_default.cmp(&a_is_default) // true > false, so defaults come first 405 }); 406 all_source_relays.truncate(MAX_SOURCE_RELAYS); 407 408 Ok(all_source_relays) 409 } 410 411 /// Fetch the latest profile metadata (kind 0) from relays and update nostrdb. 412 /// 413 /// Profile metadata is a replaceable event (NIP-01) - nostrdb keeps only the 414 /// newest version by `created_at` timestamp. This function queries relays for 415 /// the latest kind 0 event to ensure cached profile data stays fresh during 416 /// background refreshes. 417 async fn fetch_profile_metadata( 418 relay_pool: Arc<RelayPool>, 419 ndb: Ndb, 420 relays: Vec<RelayUrl>, 421 pubkey: [u8; 32], 422 ) { 423 use nostr_sdk::JsonUtil; 424 425 if relays.is_empty() { 426 return; 427 } 428 429 let filter = { 430 let author_ref = [&pubkey]; 431 convert_filter( 432 &nostrdb::Filter::new() 433 .authors(author_ref) 434 .kinds([0]) 435 .limit(1) 436 .build(), 437 ) 438 }; 439 440 let stream = relay_pool 441 .stream_events(filter, &relays, Duration::from_millis(2000)) 442 .await; 443 444 let mut stream = match stream { 445 Ok(s) => s, 446 Err(err) => { 447 warn!("failed to stream profile metadata: {err}"); 448 return; 449 } 450 }; 451 452 // Process all returned events - nostrdb handles deduplication and keeps newest. 453 // Note: we skip ensure_relay_hints here because kind 0 profile metadata doesn't 454 // contain relay hints (unlike kind 1 notes which may have 'r' tags). 455 while let Some(relay_event) = stream.next().await { 456 let ingest_meta = relay_event 457 .relay_url() 458 .map(|url| IngestMetadata::new().relay(url.as_str())) 459 .unwrap_or_else(IngestMetadata::new); 460 if let Err(err) = ndb.process_event_with(&relay_event.event.as_json(), ingest_meta) { 461 error!("error processing profile metadata event: {err}"); 462 } 463 } 464 } 465 466 pub async fn fetch_profile_feed( 467 relay_pool: Arc<RelayPool>, 468 ndb: Ndb, 469 pubkey: [u8; 32], 470 ) -> Result<()> { 471 let relay_targets = collect_profile_relays(relay_pool.clone(), ndb.clone(), pubkey).await?; 472 473 // Spawn metadata fetch in parallel - best-effort, don't block note refresh 474 tokio::spawn(fetch_profile_metadata( 475 relay_pool.clone(), 476 ndb.clone(), 477 relay_targets.clone(), 478 pubkey, 479 )); 480 481 let cutoff = SystemTime::now() 482 .checked_sub(Duration::from_secs( 483 60 * 60 * 24 * PROFILE_FEED_LOOKBACK_DAYS, 484 )) 485 .and_then(|ts| ts.duration_since(SystemTime::UNIX_EPOCH).ok()) 486 .map(|dur| dur.as_secs()); 487 488 let mut fetched = stream_profile_feed_once( 489 relay_pool.clone(), 490 ndb.clone(), 491 &relay_targets, 492 pubkey, 493 cutoff, 494 ) 495 .await?; 496 497 if fetched == 0 { 498 fetched = stream_profile_feed_once( 499 relay_pool.clone(), 500 ndb.clone(), 501 &relay_targets, 502 pubkey, 503 None, 504 ) 505 .await?; 506 } 507 508 if fetched == 0 { 509 warn!( 510 "no profile notes fetched for {} even after fallback", 511 hex::encode(pubkey) 512 ); 513 } 514 515 Ok(()) 516 } 517 518 impl RenderData { 519 fn set_profile_key(&mut self, key: ProfileKey) { 520 match self { 521 RenderData::Profile(pk) => { 522 *pk = Some(ProfileRenderData::Profile(key)); 523 } 524 RenderData::Note(note_rd) => { 525 note_rd.profile_rd = Some(ProfileRenderData::Profile(key)); 526 } 527 }; 528 } 529 530 fn set_note_key(&mut self, key: NoteKey) { 531 match self { 532 RenderData::Profile(_pk) => {} 533 RenderData::Note(note) => { 534 note.note_rd = NoteRenderData::Note(key); 535 } 536 }; 537 } 538 539 fn hydrate_from_note_key(&mut self, ndb: &Ndb, note_key: NoteKey) -> Result<bool> { 540 let txn = Transaction::new(ndb)?; 541 let note = match ndb.get_note_by_key(&txn, note_key) { 542 Ok(note) => note, 543 Err(err) => { 544 debug!(?note_key, "note key not yet visible in transaction: {err}"); 545 return Ok(false); 546 } 547 }; 548 549 if note.kind() == 0 { 550 match ndb.get_profilekey_by_pubkey(&txn, note.pubkey()) { 551 Ok(profile_key) => self.set_profile_key(profile_key), 552 Err(err) => { 553 debug!( 554 pubkey = %hex::encode(note.pubkey()), 555 "profile key not ready after note ingestion: {err}" 556 ); 557 } 558 } 559 } else { 560 self.set_note_key(note_key); 561 } 562 563 Ok(true) 564 } 565 566 pub async fn complete( 567 &mut self, 568 ndb: Ndb, 569 relay_pool: Arc<RelayPool>, 570 nip19: Nip19, 571 ) -> Result<()> { 572 let (mut stream, fetch_handle) = { 573 let filter = renderdata_to_filter(self); 574 if filter.is_empty() { 575 // should really never happen unless someone broke 576 // needs_note and needs_profile 577 return Err(Error::NothingToFetch); 578 } 579 let sub_id = ndb.subscribe(&filter)?; 580 581 let stream = sub_id.stream(&ndb).notes_per_await(2); 582 583 let filters = filter.iter().map(convert_filter).collect(); 584 let ndb = ndb.clone(); 585 let pool = relay_pool.clone(); 586 let handle = tokio::spawn(async move { find_note(pool, ndb, filters, &nip19).await }); 587 (stream, handle) 588 }; 589 590 let wait_for = Duration::from_secs(1); 591 let mut consecutive_timeouts = 0; 592 593 loop { 594 if !self.needs_note() && !self.needs_profile() { 595 break; 596 } 597 598 if consecutive_timeouts >= 5 { 599 warn!("render completion timed out waiting for remaining data"); 600 break; 601 } 602 603 let note_keys = match timeout(wait_for, stream.next()).await { 604 Ok(Some(note_keys)) => { 605 consecutive_timeouts = 0; 606 note_keys 607 } 608 Ok(None) => { 609 // end of stream 610 break; 611 } 612 Err(_) => { 613 consecutive_timeouts += 1; 614 continue; 615 } 616 }; 617 618 let note_keys_len = note_keys.len(); 619 620 for note_key in note_keys { 621 match self.hydrate_from_note_key(&ndb, note_key) { 622 Ok(true) => {} 623 Ok(false) => { 624 // keep waiting; the outer loop will retry on the next batch 625 } 626 Err(err) => { 627 error!(?note_key, "failed to hydrate note from key: {err}"); 628 } 629 } 630 } 631 632 if note_keys_len >= 2 && !self.needs_note() && !self.needs_profile() { 633 break; 634 } 635 } 636 637 // Capture source relay URLs from the fetch task 638 match fetch_handle.await { 639 Ok(Ok(source_relays)) => { 640 // Store source relays in the render data for bech32 link generation 641 if let RenderData::Note(ref mut note_data) = self { 642 for relay in source_relays { 643 note_data.add_source_relay(relay); 644 } 645 } 646 Ok(()) 647 } 648 Ok(Err(err)) => Err(err), 649 Err(join_err) => Err(Error::Generic(format!( 650 "relay fetch task failed: {}", 651 join_err 652 ))), 653 } 654 } 655 } 656 657 /// Collect all unknown IDs from a note - author, mentions, quotes, reply chain. 658 pub fn collect_note_unknowns( 659 ndb: &Ndb, 660 note_rd: &NoteRenderData, 661 ) -> Option<crate::unknowns::UnknownIds> { 662 let txn = Transaction::new(ndb).ok()?; 663 let note = note_rd.lookup(&txn, ndb).ok()?; 664 665 let mut unknowns = crate::unknowns::UnknownIds::new(); 666 667 // Collect from note content, author, reply chain, mentioned profiles/events 668 unknowns.collect_from_note(ndb, &txn, ¬e); 669 670 // Also collect from quote refs (q tags and inline nevent/naddr for embedded quotes) 671 let quote_refs = crate::html::collect_all_quote_refs(ndb, &txn, ¬e); 672 if !quote_refs.is_empty() { 673 debug!("found {} quote refs in note", quote_refs.len()); 674 unknowns.collect_from_quote_refs(ndb, &txn, "e_refs); 675 } 676 677 debug!("collected {} total unknowns from note", unknowns.ids_len()); 678 679 if unknowns.is_empty() { 680 None 681 } else { 682 Some(unknowns) 683 } 684 } 685 686 /// Fetch unknown IDs (quoted events, profiles) from relays using relay hints. 687 pub async fn fetch_unknowns( 688 relay_pool: &Arc<RelayPool>, 689 ndb: &Ndb, 690 unknowns: crate::unknowns::UnknownIds, 691 ) -> Result<()> { 692 use nostr_sdk::JsonUtil; 693 694 // Collect relay hints before consuming unknowns 695 let relay_hints = unknowns.relay_hints(); 696 let relay_targets: Vec<RelayUrl> = if relay_hints.is_empty() { 697 relay_pool.default_relays().to_vec() 698 } else { 699 relay_hints.into_iter().collect() 700 }; 701 702 // Build and convert filters in one go (nostrdb::Filter is not Send) 703 let nostr_filters: Vec<nostr::Filter> = { 704 let filters = unknowns.to_filters(); 705 if filters.is_empty() { 706 return Ok(()); 707 } 708 filters.iter().map(convert_filter).collect() 709 }; 710 711 // Now we can await - nostrdb::Filter has been dropped 712 relay_pool.ensure_relays(relay_targets.clone()).await?; 713 714 debug!( 715 "fetching {} unknowns from {:?}", 716 nostr_filters.len(), 717 relay_targets 718 ); 719 720 // Stream with shorter timeout since these are secondary fetches 721 for filter in nostr_filters { 722 let mut stream = relay_pool 723 .stream_events(filter, &relay_targets, Duration::from_millis(1500)) 724 .await?; 725 726 while let Some(relay_event) = stream.next().await { 727 let ingest_meta = relay_event 728 .relay_url() 729 .map(|url| IngestMetadata::new().relay(url.as_str())) 730 .unwrap_or_else(IngestMetadata::new); 731 if let Err(err) = ndb.process_event_with(&relay_event.event.as_json(), ingest_meta) { 732 warn!("error processing quoted event: {err}"); 733 } 734 } 735 } 736 737 Ok(()) 738 } 739 740 /// Fetch kind:7 reactions for a note from relays and ingest into ndb. 741 /// Collect unknown profiles from reply notes (already ingested into ndb). 742 /// Call this after fetch_note_stats so reply authors can be resolved. 743 pub fn collect_reply_unknowns( 744 ndb: &Ndb, 745 note_rd: &NoteRenderData, 746 ) -> Option<crate::unknowns::UnknownIds> { 747 let txn = Transaction::new(ndb).ok()?; 748 let note = note_rd.lookup(&txn, ndb).ok()?; 749 750 let filter = nostrdb::Filter::new() 751 .kinds([1]) 752 .event(note.id()) 753 .limit(DIRECT_REPLY_LIMIT as u64) 754 .build(); 755 let results = ndb.query(&txn, &[filter], DIRECT_REPLY_LIMIT).ok()?; 756 757 let mut unknowns = crate::unknowns::UnknownIds::new(); 758 759 for result in &results { 760 unknowns.add_profile_if_missing(ndb, &txn, result.note.pubkey()); 761 } 762 763 if unknowns.is_empty() { 764 None 765 } else { 766 Some(unknowns) 767 } 768 } 769 770 /// Fetch note stats (reactions, replies, reposts) from relays and ingest into ndb. 771 pub async fn fetch_note_stats( 772 relay_pool: &Arc<RelayPool>, 773 ndb: &Ndb, 774 note_rd: &NoteRenderData, 775 source_relays: &[RelayUrl], 776 ) -> Result<()> { 777 use nostr_sdk::JsonUtil; 778 779 // Build filters for reactions (kind:7), replies (kind:1), and reposts (kind:6) 780 // nostrdb::Filter is not Send, so convert before await 781 let filters: Vec<nostr::Filter> = { 782 let txn = Transaction::new(ndb)?; 783 let note = note_rd.lookup(&txn, ndb)?; 784 let id = note.id(); 785 vec![ 786 convert_filter(&nostrdb::Filter::new().kinds([7]).event(id).build()), 787 convert_filter(&nostrdb::Filter::new().kinds([1]).event(id).build()), 788 convert_filter(&nostrdb::Filter::new().kinds([6]).event(id).build()), 789 ] 790 }; 791 792 let relay_targets: Vec<RelayUrl> = if source_relays.is_empty() { 793 relay_pool.default_relays().to_vec() 794 } else { 795 source_relays.to_vec() 796 }; 797 798 relay_pool.ensure_relays(relay_targets.clone()).await?; 799 800 debug!("fetching note stats from {:?}", relay_targets); 801 802 for filter in filters { 803 let mut stream = relay_pool 804 .stream_events(filter, &relay_targets, Duration::from_millis(1500)) 805 .await?; 806 807 while let Some(relay_event) = stream.next().await { 808 let ingest_meta = relay_event 809 .relay_url() 810 .map(|url| IngestMetadata::new().relay(url.as_str())) 811 .unwrap_or_else(IngestMetadata::new); 812 if let Err(err) = ndb.process_event_with(&relay_event.event.as_json(), ingest_meta) { 813 warn!("error processing event: {err}"); 814 } 815 } 816 } 817 818 Ok(()) 819 } 820 821 fn collect_relay_hints(event: &Event) -> Vec<RelayUrl> { 822 let mut relays = Vec::new(); 823 for tag in event.tags.iter() { 824 let candidate = match tag.kind() { 825 TagKind::Relay | TagKind::Relays => tag.content(), 826 TagKind::SingleLetter(letter) if letter.as_char() == 'r' => tag.content(), 827 _ if event.kind == Kind::ContactList => { 828 if let Some(TagStandard::PublicKey { 829 relay_url: Some(url), 830 .. 831 }) = tag.as_standardized() 832 { 833 Some(url.as_str()) 834 } else { 835 tag.as_slice().get(2).map(|value| value.as_str()) 836 } 837 } 838 _ => None, 839 }; 840 841 let Some(url) = candidate else { 842 continue; 843 }; 844 845 if url.is_empty() { 846 continue; 847 } 848 849 match RelayUrl::parse(url) { 850 Ok(relay) => relays.push(relay), 851 Err(err) => warn!("ignoring invalid relay hint {}: {}", url, err), 852 } 853 } 854 relays 855 } 856 857 async fn ensure_relay_hints(relay_pool: &Arc<RelayPool>, event: &Event) -> Result<()> { 858 let hints = collect_relay_hints(event); 859 if hints.is_empty() { 860 return Ok(()); 861 } 862 relay_pool.ensure_relays(hints).await 863 } 864 865 async fn collect_profile_relays( 866 relay_pool: Arc<RelayPool>, 867 ndb: Ndb, 868 pubkey: [u8; 32], 869 ) -> Result<Vec<RelayUrl>> { 870 relay_pool 871 .ensure_relays(relay_pool.default_relays().iter().cloned()) 872 .await?; 873 874 let mut known: HashSet<String> = relay_pool 875 .default_relays() 876 .iter() 877 .map(|url| url.to_string()) 878 .collect(); 879 let mut targets = relay_pool.default_relays().to_vec(); 880 881 let author_ref = [&pubkey]; 882 883 let relay_filter = convert_filter( 884 &nostrdb::Filter::new() 885 .authors(author_ref) 886 .kinds([Kind::RelayList.as_u16() as u64]) 887 .limit(1) 888 .build(), 889 ); 890 891 let contact_filter = convert_filter( 892 &nostrdb::Filter::new() 893 .authors(author_ref) 894 .kinds([Kind::ContactList.as_u16() as u64]) 895 .limit(1) 896 .build(), 897 ); 898 899 // Process each filter separately since stream_events now takes a single filter 900 for filter in [relay_filter, contact_filter] { 901 let mut stream = relay_pool 902 .stream_events(filter, &[], Duration::from_millis(2000)) 903 .await?; 904 905 while let Some(relay_event) = stream.next().await { 906 let ingest_meta = relay_event 907 .relay_url() 908 .map(|url| IngestMetadata::new().relay(url.as_str())) 909 .unwrap_or_else(IngestMetadata::new); 910 if let Err(err) = ndb.process_event_with(&relay_event.event.as_json(), ingest_meta) { 911 error!("error processing relay discovery event: {err}"); 912 } 913 914 let hints = collect_relay_hints(&relay_event.event); 915 if hints.is_empty() { 916 continue; 917 } 918 919 let mut fresh = Vec::new(); 920 for hint in hints { 921 let key = hint.to_string(); 922 if known.insert(key) { 923 targets.push(hint.clone()); 924 fresh.push(hint); 925 } 926 } 927 928 if !fresh.is_empty() { 929 relay_pool.ensure_relays(fresh).await?; 930 } 931 } 932 } 933 934 Ok(targets) 935 } 936 937 async fn stream_profile_feed_once( 938 relay_pool: Arc<RelayPool>, 939 ndb: Ndb, 940 relays: &[RelayUrl], 941 pubkey: [u8; 32], 942 since: Option<u64>, 943 ) -> Result<usize> { 944 let filter = { 945 let author_ref = [&pubkey]; 946 let mut builder = nostrdb::Filter::new() 947 .authors(author_ref) 948 .kinds([1]) 949 .limit(PROFILE_FEED_RECENT_LIMIT as u64); 950 951 if let Some(since) = since { 952 builder = builder.since(since); 953 } 954 955 convert_filter(&builder.build()) 956 }; 957 let mut stream = relay_pool 958 .stream_events(filter, relays, Duration::from_millis(2000)) 959 .await?; 960 961 let mut fetched = 0usize; 962 963 while let Some(relay_event) = stream.next().await { 964 if let Err(err) = ensure_relay_hints(&relay_pool, &relay_event.event).await { 965 warn!("failed to apply relay hints: {err}"); 966 } 967 let ingest_meta = relay_event 968 .relay_url() 969 .map(|url| IngestMetadata::new().relay(url.as_str())) 970 .unwrap_or_else(IngestMetadata::new); 971 if let Err(err) = ndb.process_event_with(&relay_event.event.as_json(), ingest_meta) { 972 error!("error processing profile feed event: {err}"); 973 } else { 974 fetched += 1; 975 } 976 } 977 978 Ok(fetched) 979 } 980 981 /// Attempt to locate the render data locally. Anything missing from 982 /// render data will be fetched. 983 pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<RenderData> { 984 match nip19 { 985 Nip19::Event(nevent) => { 986 let m_note = ndb.get_note_by_id(txn, nevent.event_id.as_bytes()).ok(); 987 988 let pk = if let Some(pk) = m_note.as_ref().map(|note| note.pubkey()) { 989 Some(*pk) 990 } else { 991 nevent.author.map(|a| a.to_bytes()) 992 }; 993 994 let profile_rd = pk.as_ref().map(|pubkey| { 995 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) { 996 ProfileRenderData::Profile(profile_key) 997 } else { 998 ProfileRenderData::Missing(*pubkey) 999 } 1000 }); 1001 1002 let note_rd = if let Some(note_key) = m_note.and_then(|n| n.key()) { 1003 NoteRenderData::Note(note_key) 1004 } else { 1005 NoteRenderData::Missing(*nevent.event_id.as_bytes()) 1006 }; 1007 1008 Ok(RenderData::note(note_rd, profile_rd)) 1009 } 1010 1011 Nip19::EventId(evid) => { 1012 let m_note = ndb.get_note_by_id(txn, evid.as_bytes()).ok(); 1013 let note_key = m_note.as_ref().and_then(|n| n.key()); 1014 let pk = m_note.map(|note| note.pubkey()); 1015 1016 let profile_rd = pk.map(|pubkey| { 1017 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) { 1018 ProfileRenderData::Profile(profile_key) 1019 } else { 1020 ProfileRenderData::Missing(*pubkey) 1021 } 1022 }); 1023 1024 let note_rd = if let Some(note_key) = note_key { 1025 NoteRenderData::Note(note_key) 1026 } else { 1027 NoteRenderData::Missing(*evid.as_bytes()) 1028 }; 1029 1030 Ok(RenderData::note(note_rd, profile_rd)) 1031 } 1032 1033 Nip19::Coordinate(coordinate) => { 1034 let author = coordinate.public_key.to_bytes(); 1035 let kind: u64 = u16::from(coordinate.kind) as u64; 1036 let identifier = coordinate.identifier.clone(); 1037 1038 let note_rd = { 1039 let filter = build_address_filter(&author, kind, identifier.as_str()); 1040 let note_key = ndb 1041 .query(txn, &[filter], 1) 1042 .ok() 1043 .and_then(|results| results.into_iter().next().map(|res| res.note_key)); 1044 1045 if let Some(note_key) = note_key { 1046 NoteRenderData::Note(note_key) 1047 } else { 1048 NoteRenderData::Address { 1049 author, 1050 kind, 1051 identifier, 1052 } 1053 } 1054 }; 1055 1056 let profile_rd = { 1057 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &author) { 1058 Some(ProfileRenderData::Profile(profile_key)) 1059 } else { 1060 Some(ProfileRenderData::Missing(author)) 1061 } 1062 }; 1063 1064 Ok(RenderData::note(note_rd, profile_rd)) 1065 } 1066 1067 Nip19::Profile(nprofile) => { 1068 let pubkey = nprofile.public_key.to_bytes(); 1069 let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { 1070 ProfileRenderData::Profile(profile_key) 1071 } else { 1072 ProfileRenderData::Missing(pubkey) 1073 }; 1074 1075 Ok(RenderData::profile(Some(profile_rd))) 1076 } 1077 1078 Nip19::Pubkey(public_key) => { 1079 let pubkey = public_key.to_bytes(); 1080 let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { 1081 ProfileRenderData::Profile(profile_key) 1082 } else { 1083 ProfileRenderData::Missing(pubkey) 1084 }; 1085 1086 Ok(RenderData::profile(Some(profile_rd))) 1087 } 1088 1089 _ => Err(Error::CantRender), 1090 } 1091 } 1092 1093 fn render_username(ui: &mut egui::Ui, profile: Option<&ProfileRecord>) { 1094 let name = format!( 1095 "@{}", 1096 profile 1097 .and_then(|pr| pr.record().profile().and_then(|p| p.name())) 1098 .unwrap_or("nostrich") 1099 ); 1100 ui.label(RichText::new(&name).size(40.0).color(Color32::LIGHT_GRAY)); 1101 } 1102 1103 fn setup_visuals(font_data: &egui::FontData, ctx: &egui::Context) { 1104 let mut visuals = Visuals::dark(); 1105 visuals.override_text_color = Some(Color32::WHITE); 1106 ctx.set_visuals(visuals); 1107 fonts::setup_fonts(font_data, ctx); 1108 } 1109 1110 fn push_job_text(job: &mut LayoutJob, s: &str, color: Color32) { 1111 job.append( 1112 s, 1113 0.0, 1114 TextFormat { 1115 font_id: FontId::new(50.0, FontFamily::Proportional), 1116 color, 1117 ..Default::default() 1118 }, 1119 ) 1120 } 1121 1122 fn push_job_user_mention( 1123 job: &mut LayoutJob, 1124 ndb: &Ndb, 1125 block: &Block, 1126 txn: &Transaction, 1127 pk: &[u8; 32], 1128 ) { 1129 let record = ndb.get_profile_by_pubkey(txn, pk); 1130 if let Ok(record) = record { 1131 let profile = record.record().profile().unwrap(); 1132 push_job_text( 1133 job, 1134 &format!("@{}", &abbrev_str(profile.name().unwrap_or("nostrich"))), 1135 PURPLE, 1136 ); 1137 } else { 1138 push_job_text(job, &format!("@{}", &abbrev_str(block.as_str())), PURPLE); 1139 } 1140 } 1141 1142 fn wrapped_body_blocks( 1143 ui: &mut egui::Ui, 1144 ndb: &Ndb, 1145 note: &Note, 1146 blocks: &Blocks, 1147 txn: &Transaction, 1148 ) { 1149 let mut job = LayoutJob { 1150 justify: false, 1151 halign: egui::Align::LEFT, 1152 wrap: egui::text::TextWrapping { 1153 max_rows: 5, 1154 break_anywhere: false, 1155 overflow_character: Some('…'), 1156 ..Default::default() 1157 }, 1158 ..Default::default() 1159 }; 1160 1161 for block in blocks.iter(note) { 1162 match block.blocktype() { 1163 BlockType::Url => push_job_text(&mut job, block.as_str(), PURPLE), 1164 1165 BlockType::Hashtag => { 1166 push_job_text(&mut job, "#", PURPLE); 1167 push_job_text(&mut job, block.as_str(), PURPLE); 1168 } 1169 1170 BlockType::MentionBech32 => { 1171 match block.as_mention().unwrap() { 1172 Mention::Event(_ev) => push_job_text( 1173 &mut job, 1174 &format!("@{}", &abbrev_str(block.as_str())), 1175 PURPLE, 1176 ), 1177 Mention::Note(_ev) => { 1178 push_job_text( 1179 &mut job, 1180 &format!("@{}", &abbrev_str(block.as_str())), 1181 PURPLE, 1182 ); 1183 } 1184 Mention::Profile(nprofile) => { 1185 push_job_user_mention(&mut job, ndb, &block, txn, nprofile.pubkey()) 1186 } 1187 Mention::Pubkey(npub) => { 1188 push_job_user_mention(&mut job, ndb, &block, txn, npub.pubkey()) 1189 } 1190 Mention::Secret(_sec) => push_job_text(&mut job, "--redacted--", PURPLE), 1191 Mention::Relay(_relay) => { 1192 push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE) 1193 } 1194 Mention::Addr(_addr) => { 1195 push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE) 1196 } 1197 }; 1198 } 1199 1200 _ => push_job_text(&mut job, block.as_str(), Color32::WHITE), 1201 }; 1202 } 1203 1204 ui.label(job); 1205 } 1206 1207 fn wrapped_body_text(ui: &mut egui::Ui, text: &str) { 1208 let format = TextFormat { 1209 font_id: FontId::proportional(52.0), 1210 color: Color32::WHITE, 1211 extra_letter_spacing: 0.0, 1212 line_height: Some(50.0), 1213 ..Default::default() 1214 }; 1215 1216 let job = LayoutJob::single_section(text.to_owned(), format); 1217 ui.label(job); 1218 } 1219 1220 fn right_aligned() -> egui::Layout { 1221 use egui::{Align, Direction, Layout}; 1222 1223 Layout { 1224 main_dir: Direction::RightToLeft, 1225 main_wrap: false, 1226 main_align: Align::Center, 1227 main_justify: false, 1228 cross_align: Align::Center, 1229 cross_justify: false, 1230 } 1231 } 1232 1233 fn note_frame_align() -> egui::Layout { 1234 use egui::{Align, Direction, Layout}; 1235 1236 Layout { 1237 main_dir: Direction::TopDown, 1238 main_wrap: false, 1239 main_align: Align::Center, 1240 main_justify: false, 1241 cross_align: Align::Center, 1242 cross_justify: false, 1243 } 1244 } 1245 1246 fn note_ui(app: &Notecrumbs, ctx: &egui::Context, rd: &NoteAndProfileRenderData) -> Result<()> { 1247 setup_visuals(&app.font_data, ctx); 1248 1249 let outer_margin = 60.0; 1250 let inner_margin = 40.0; 1251 let canvas_width = 1200.0; 1252 let canvas_height = 600.0; 1253 //let canvas_size = Vec2::new(canvas_width, canvas_height); 1254 1255 let total_margin = outer_margin + inner_margin; 1256 let txn = Transaction::new(&app.ndb)?; 1257 let profile_record = rd 1258 .profile_rd 1259 .as_ref() 1260 .and_then(|profile_rd| match profile_rd { 1261 ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(), 1262 ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(), 1263 }); 1264 //let _profile = profile_record.and_then(|pr| pr.record().profile()); 1265 //let pfp_url = profile.and_then(|p| p.picture()); 1266 1267 // TODO: async pfp loading using notedeck browser context? 1268 let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default()); 1269 let bg = ctx.load_texture("background", app.background.clone(), Default::default()); 1270 1271 egui::CentralPanel::default() 1272 .frame( 1273 egui::Frame::default() 1274 //.fill(Color32::from_rgb(0x43, 0x20, 0x62) 1275 .fill(Color32::from_rgb(0x00, 0x00, 0x00)), 1276 ) 1277 .show(ctx, |ui| { 1278 background_texture(ui, &bg); 1279 egui::Frame::none() 1280 .fill(Color32::from_rgb(0x0F, 0x0F, 0x0F)) 1281 .shadow(Shadow { 1282 extrusion: 50.0, 1283 color: Color32::from_black_alpha(60), 1284 }) 1285 .rounding(Rounding::same(20.0)) 1286 .outer_margin(outer_margin) 1287 .inner_margin(inner_margin) 1288 .show(ui, |ui| { 1289 let desired_height = canvas_height - total_margin * 2.0; 1290 let desired_width = canvas_width - total_margin * 2.0; 1291 let desired_size = Vec2::new(desired_width, desired_height); 1292 ui.set_max_size(desired_size); 1293 1294 ui.with_layout(note_frame_align(), |ui| { 1295 //egui::ScrollArea::vertical().show(ui, |ui| { 1296 ui.spacing_mut().item_spacing = Vec2::new(10.0, 50.0); 1297 1298 ui.vertical(|ui| { 1299 let desired = Vec2::new(desired_width, desired_height / 1.5); 1300 ui.set_max_size(desired); 1301 ui.set_min_size(desired); 1302 1303 if let Ok(note) = rd.note_rd.lookup(&txn, &app.ndb) { 1304 if let Some(blocks) = note 1305 .key() 1306 .and_then(|nk| app.ndb.get_blocks_by_key(&txn, nk).ok()) 1307 { 1308 wrapped_body_blocks(ui, &app.ndb, ¬e, &blocks, &txn); 1309 } else { 1310 wrapped_body_text(ui, note.content()); 1311 } 1312 } 1313 }); 1314 1315 ui.horizontal(|ui| { 1316 ui.image(&pfp); 1317 render_username(ui, profile_record.as_ref()); 1318 ui.with_layout(right_aligned(), discuss_on_damus); 1319 }); 1320 }); 1321 }); 1322 }); 1323 1324 Ok(()) 1325 } 1326 1327 fn background_texture(ui: &mut egui::Ui, texture: &TextureHandle) { 1328 // Get the size of the panel 1329 let size = ui.available_size(); 1330 1331 // Create a rectangle for the texture 1332 let rect = Rect::from_min_size(ui.min_rect().min, size); 1333 1334 // Get the current layer ID 1335 let layer_id = ui.layer_id(); 1336 1337 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); 1338 //let uv_skewed = Rect::from_min_max(uv.min, pos2(uv.max.x, uv.max.y * 0.5)); 1339 1340 // Get the painter and draw the texture 1341 let painter = ui.ctx().layer_painter(layer_id); 1342 //let tint = Color32::WHITE; 1343 1344 let mut mesh = Mesh::with_texture(texture.into()); 1345 1346 // Define vertices for a rectangle 1347 mesh.add_rect_with_uv(rect, uv, Color32::WHITE); 1348 1349 //let origin = pos2(600.0, 300.0); 1350 //let angle = Rot2::from_angle(45.0); 1351 //mesh.rotate(angle, origin); 1352 1353 // Draw the mesh 1354 painter.add(Shape::mesh(mesh)); 1355 1356 //painter.image(texture.into(), rect, uv_skewed, tint); 1357 } 1358 1359 fn discuss_on_damus(ui: &mut egui::Ui) { 1360 let button = egui::Button::new( 1361 RichText::new("Discuss on Damus ➡") 1362 .size(30.0) 1363 .color(Color32::BLACK), 1364 ) 1365 .rounding(50.0) 1366 .min_size(Vec2::new(330.0, 75.0)) 1367 .fill(Color32::WHITE); 1368 1369 ui.add(button); 1370 } 1371 1372 fn profile_ui(app: &Notecrumbs, ctx: &egui::Context, profile_rd: Option<&ProfileRenderData>) { 1373 let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default()); 1374 setup_visuals(&app.font_data, ctx); 1375 1376 egui::CentralPanel::default().show(ctx, |ui| { 1377 ui.vertical(|ui| { 1378 ui.horizontal(|ui| { 1379 ui.image(&pfp); 1380 if let Ok(txn) = Transaction::new(&app.ndb) { 1381 let profile = profile_rd.and_then(|prd| prd.lookup(&txn, &app.ndb).ok()); 1382 render_username(ui, profile.as_ref()); 1383 } 1384 }); 1385 //body(ui, &profile.about); 1386 }); 1387 }); 1388 } 1389 1390 #[cfg(test)] 1391 mod tests { 1392 use super::*; 1393 use nostr::nips::nip01::Coordinate; 1394 use nostr::prelude::{EventBuilder, Keys, Tag}; 1395 use nostrdb::{Config, Filter}; 1396 use std::fs; 1397 use std::path::PathBuf; 1398 use std::time::{SystemTime, UNIX_EPOCH}; 1399 1400 fn temp_db_dir(prefix: &str) -> PathBuf { 1401 let base = PathBuf::from("target/test-dbs"); 1402 let _ = fs::create_dir_all(&base); 1403 let nanos = SystemTime::now() 1404 .duration_since(UNIX_EPOCH) 1405 .expect("time went backwards") 1406 .as_nanos(); 1407 let dir = base.join(format!("{}-{}", prefix, nanos)); 1408 let _ = fs::create_dir_all(&dir); 1409 dir 1410 } 1411 1412 #[test] 1413 fn build_address_filter_includes_only_d_tags() { 1414 let author = [1u8; 32]; 1415 let identifier = "article-slug"; 1416 let kind = Kind::LongFormTextNote.as_u16() as u64; 1417 1418 let filter = build_address_filter(&author, kind, identifier); 1419 let mut saw_d_tag = false; 1420 1421 for field in &filter { 1422 if let FilterField::Tags(tag, elements) = field { 1423 assert_eq!(tag, 'd', "unexpected tag '{}' in filter", tag); 1424 let mut values: Vec<String> = Vec::new(); 1425 for element in elements { 1426 match element { 1427 FilterElement::Str(value) => values.push(value.to_owned()), 1428 other => panic!("unexpected tag element {:?}", other), 1429 } 1430 } 1431 assert_eq!(values, vec![identifier.to_owned()]); 1432 saw_d_tag = true; 1433 } 1434 } 1435 1436 assert!(saw_d_tag, "expected filter to include a 'd' tag constraint"); 1437 } 1438 1439 #[tokio::test] 1440 async fn query_note_by_address_uses_d_and_a_tag_filters() { 1441 let keys = Keys::generate(); 1442 let author = keys.public_key().to_bytes(); 1443 let kind = Kind::LongFormTextNote.as_u16() as u64; 1444 let identifier_with_d = "with-d-tag"; 1445 let identifier_with_a = "only-a-tag"; 1446 1447 let db_dir = temp_db_dir("address-filters"); 1448 let db_path = db_dir.to_string_lossy().to_string(); 1449 let cfg = Config::new().skip_validation(true); 1450 let ndb = Ndb::new(&db_path, &cfg).expect("failed to open nostrdb"); 1451 1452 let event_with_d = EventBuilder::long_form_text_note("content with d tag") 1453 .tags([Tag::identifier(identifier_with_d)]) 1454 .sign_with_keys(&keys) 1455 .expect("sign long-form event with d tag"); 1456 1457 let coordinate = Coordinate::new(Kind::LongFormTextNote, keys.public_key()) 1458 .identifier(identifier_with_a); 1459 let event_with_a_only = EventBuilder::long_form_text_note("content with a tag only") 1460 .tags([Tag::coordinate(coordinate, None)]) 1461 .sign_with_keys(&keys) 1462 .expect("sign long-form event with coordinate tag"); 1463 1464 let event_with_d_id = event_with_d.id.to_bytes(); 1465 let event_with_a_only_id = event_with_a_only.id.to_bytes(); 1466 1467 let wait_filter = Filter::new() 1468 .ids([&event_with_d_id, &event_with_a_only_id]) 1469 .limit(2) 1470 .build(); 1471 let subscription = ndb 1472 .subscribe(&[wait_filter]) 1473 .expect("subscribe for note ingestion"); 1474 1475 ndb.process_event(&serde_json::to_string(&event_with_d).unwrap()) 1476 .expect("ingest event with d tag"); 1477 ndb.process_event(&serde_json::to_string(&event_with_a_only).unwrap()) 1478 .expect("ingest event with a tag"); 1479 1480 let _ = ndb 1481 .wait_for_notes(subscription, 2) 1482 .await 1483 .expect("wait for note ingestion to complete"); 1484 1485 tokio::time::sleep(Duration::from_millis(100)).await; 1486 1487 { 1488 let txn = Transaction::new(&ndb).expect("transaction for d-tag lookup"); 1489 let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_d) 1490 .expect("should find event by d tag"); 1491 assert_eq!(note.id(), &event_with_d_id); 1492 } 1493 1494 { 1495 let txn = Transaction::new(&ndb).expect("transaction for a-tag lookup"); 1496 let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_a) 1497 .expect("should find event via a-tag fallback"); 1498 assert_eq!(note.id(), &event_with_a_only_id); 1499 } 1500 1501 drop(ndb); 1502 let _ = fs::remove_dir_all(&db_dir); 1503 } 1504 } 1505 1506 pub fn render_note(ndb: &Notecrumbs, render_data: &RenderData) -> Vec<u8> { 1507 use egui_skia::{rasterize, RasterizeOptions}; 1508 use skia_safe::EncodedImageFormat; 1509 1510 let options = RasterizeOptions { 1511 pixels_per_point: 1.0, 1512 frames_before_screenshot: 1, 1513 }; 1514 1515 let mut surface = match render_data { 1516 RenderData::Note(note_render_data) => rasterize( 1517 (1200, 600), 1518 |ctx| { 1519 let _ = note_ui(ndb, ctx, note_render_data); 1520 }, 1521 Some(options), 1522 ), 1523 1524 RenderData::Profile(profile_rd) => rasterize( 1525 (1200, 600), 1526 |ctx| profile_ui(ndb, ctx, profile_rd.as_ref()), 1527 Some(options), 1528 ), 1529 }; 1530 1531 surface 1532 .image_snapshot() 1533 .encode_to_data(EncodedImageFormat::PNG) 1534 .expect("expected image") 1535 .as_bytes() 1536 .into() 1537 }