render.rs (39285B)
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::types::{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, Mention, Ndb, Note, NoteKey, ProfileKey, 23 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 35 pub enum NoteRenderData { 36 Missing([u8; 32]), 37 Address { 38 author: [u8; 32], 39 kind: u64, 40 identifier: String, 41 }, 42 Note(NoteKey), 43 } 44 45 impl NoteRenderData { 46 pub fn needs_note(&self) -> bool { 47 match self { 48 NoteRenderData::Missing(_) => true, 49 NoteRenderData::Address { .. } => true, 50 NoteRenderData::Note(_) => false, 51 } 52 } 53 54 pub fn lookup<'a>( 55 &self, 56 txn: &'a Transaction, 57 ndb: &Ndb, 58 ) -> std::result::Result<Note<'a>, nostrdb::Error> { 59 match self { 60 NoteRenderData::Missing(note_id) => ndb.get_note_by_id(txn, note_id), 61 NoteRenderData::Address { 62 author, 63 kind, 64 identifier, 65 } => query_note_by_address(ndb, txn, author, *kind, identifier), 66 NoteRenderData::Note(note_key) => ndb.get_note_by_key(txn, *note_key), 67 } 68 } 69 } 70 71 pub struct NoteAndProfileRenderData { 72 pub note_rd: NoteRenderData, 73 pub profile_rd: Option<ProfileRenderData>, 74 } 75 76 impl NoteAndProfileRenderData { 77 pub fn new(note_rd: NoteRenderData, profile_rd: Option<ProfileRenderData>) -> Self { 78 Self { 79 note_rd, 80 profile_rd, 81 } 82 } 83 } 84 85 pub enum ProfileRenderData { 86 Missing([u8; 32]), 87 Profile(ProfileKey), 88 } 89 90 impl ProfileRenderData { 91 pub fn lookup<'a>( 92 &self, 93 txn: &'a Transaction, 94 ndb: &Ndb, 95 ) -> std::result::Result<ProfileRecord<'a>, nostrdb::Error> { 96 match self { 97 ProfileRenderData::Missing(pk) => ndb.get_profile_by_pubkey(txn, pk), 98 ProfileRenderData::Profile(key) => ndb.get_profile_by_key(txn, *key), 99 } 100 } 101 102 pub fn needs_profile(&self) -> bool { 103 match self { 104 ProfileRenderData::Missing(_) => true, 105 ProfileRenderData::Profile(_) => false, 106 } 107 } 108 } 109 110 /// Primary keys for the data we're interested in rendering 111 pub enum RenderData { 112 Profile(Option<ProfileRenderData>), 113 Note(NoteAndProfileRenderData), 114 } 115 116 impl RenderData { 117 pub fn note(note_rd: NoteRenderData, profile_rd: Option<ProfileRenderData>) -> Self { 118 Self::Note(NoteAndProfileRenderData::new(note_rd, profile_rd)) 119 } 120 121 pub fn profile(profile_rd: Option<ProfileRenderData>) -> Self { 122 Self::Profile(profile_rd) 123 } 124 125 pub fn is_complete(&self) -> bool { 126 !(self.needs_profile() || self.needs_note()) 127 } 128 129 pub fn note_render_data(&self) -> Option<&NoteRenderData> { 130 match self { 131 Self::Note(nrd) => Some(&nrd.note_rd), 132 Self::Profile(_) => None, 133 } 134 } 135 136 pub fn profile_render_data(&self) -> Option<&ProfileRenderData> { 137 match self { 138 Self::Note(nrd) => nrd.profile_rd.as_ref(), 139 Self::Profile(prd) => prd.as_ref(), 140 } 141 } 142 143 pub fn needs_profile(&self) -> bool { 144 match self { 145 RenderData::Profile(profile_rd) => profile_rd 146 .as_ref() 147 .map(|prd| prd.needs_profile()) 148 .unwrap_or(true), 149 RenderData::Note(note) => note 150 .profile_rd 151 .as_ref() 152 .map(|prd| prd.needs_profile()) 153 .unwrap_or(true), 154 } 155 } 156 157 pub fn needs_note(&self) -> bool { 158 match self { 159 RenderData::Profile(_pkey) => false, 160 RenderData::Note(rd) => rd.note_rd.needs_note(), 161 } 162 } 163 } 164 165 fn renderdata_to_filter(render_data: &RenderData) -> Vec<nostrdb::Filter> { 166 if render_data.is_complete() { 167 return vec![]; 168 } 169 170 let mut filters = Vec::with_capacity(2); 171 172 match render_data.note_render_data() { 173 Some(NoteRenderData::Missing(note_id)) => { 174 filters.push(nostrdb::Filter::new().ids([note_id]).limit(1).build()); 175 } 176 Some(NoteRenderData::Address { 177 author, 178 kind, 179 identifier, 180 }) => { 181 filters.push(build_address_filter(author, *kind, identifier.as_str())); 182 } 183 None | Some(NoteRenderData::Note(_)) => {} 184 } 185 186 match render_data.profile_render_data() { 187 Some(ProfileRenderData::Missing(pubkey)) => { 188 filters.push( 189 nostrdb::Filter::new() 190 .authors([pubkey]) 191 .kinds([0]) 192 .limit(1) 193 .build(), 194 ); 195 } 196 None | Some(ProfileRenderData::Profile(_)) => {} 197 } 198 199 filters 200 } 201 202 pub(crate) fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter { 203 let mut filter = nostr::types::Filter::new(); 204 205 for element in ndb_filter { 206 match element { 207 FilterField::Ids(id_elems) => { 208 let event_ids = id_elems 209 .into_iter() 210 .map(|id| EventId::from_slice(id).expect("event id")); 211 filter = filter.ids(event_ids); 212 } 213 214 FilterField::Authors(authors) => { 215 let authors = authors 216 .into_iter() 217 .map(|id| PublicKey::from_slice(id).expect("ok")); 218 filter = filter.authors(authors); 219 } 220 221 FilterField::Kinds(int_elems) => { 222 let kinds = int_elems.into_iter().map(|knd| Kind::from_u16(knd as u16)); 223 filter = filter.kinds(kinds); 224 } 225 226 FilterField::Tags(chr, tag_elems) => { 227 let single_letter = if let Ok(single) = SingleLetterTag::from_char(chr) { 228 single 229 } else { 230 warn!("failed to adding char filter element: '{}", chr); 231 continue; 232 }; 233 234 let mut tags: BTreeMap<SingleLetterTag, BTreeSet<String>> = BTreeMap::new(); 235 let mut elems: BTreeSet<String> = BTreeSet::new(); 236 237 for elem in tag_elems { 238 if let FilterElement::Str(s) = elem { 239 elems.insert(s.to_string()); 240 } else { 241 warn!( 242 "not adding non-string element from filter tag '{}", 243 single_letter 244 ); 245 } 246 } 247 248 tags.insert(single_letter, elems); 249 250 filter.generic_tags = tags; 251 } 252 253 FilterField::Since(since) => { 254 filter.since = Some(Timestamp::from_secs(since)); 255 } 256 257 FilterField::Until(until) => { 258 filter.until = Some(Timestamp::from_secs(until)); 259 } 260 261 FilterField::Limit(limit) => { 262 filter.limit = Some(limit as usize); 263 } 264 } 265 } 266 267 filter 268 } 269 270 fn coordinate_tag(author: &[u8; 32], kind: u64, identifier: &str) -> String { 271 let Ok(public_key) = PublicKey::from_slice(author) else { 272 return String::new(); 273 }; 274 let nostr_kind = Kind::from_u16(kind as u16); 275 let coordinate = Coordinate::new(nostr_kind, public_key).identifier(identifier); 276 coordinate.to_string() 277 } 278 279 fn build_address_filter(author: &[u8; 32], kind: u64, identifier: &str) -> nostrdb::Filter { 280 let author_ref: [&[u8; 32]; 1] = [author]; 281 let mut filter = nostrdb::Filter::new().authors(author_ref).kinds([kind]); 282 if !identifier.is_empty() { 283 let ident = identifier.to_string(); 284 filter = filter.tags(vec![ident], 'd'); 285 } 286 filter.limit(1).build() 287 } 288 289 fn query_note_by_address<'a>( 290 ndb: &Ndb, 291 txn: &'a Transaction, 292 author: &[u8; 32], 293 kind: u64, 294 identifier: &str, 295 ) -> std::result::Result<Note<'a>, nostrdb::Error> { 296 let mut results = ndb.query(txn, &[build_address_filter(author, kind, identifier)], 1)?; 297 if results.is_empty() && !identifier.is_empty() { 298 let coord_filter = nostrdb::Filter::new() 299 .authors([author]) 300 .kinds([kind]) 301 .tags(vec![coordinate_tag(author, kind, identifier)], 'a') 302 .limit(1) 303 .build(); 304 results = ndb.query(txn, &[coord_filter], 1)?; 305 } 306 if let Some(result) = results.first() { 307 ndb.get_note_by_key(txn, result.note_key) 308 } else { 309 Err(nostrdb::Error::NotFound) 310 } 311 } 312 313 pub async fn find_note( 314 relay_pool: Arc<RelayPool>, 315 ndb: Ndb, 316 filters: Vec<nostr::Filter>, 317 nip19: &Nip19, 318 ) -> Result<()> { 319 use nostr_sdk::JsonUtil; 320 321 let mut relay_targets = nip19::nip19_relays(nip19); 322 if relay_targets.is_empty() { 323 relay_targets = relay_pool.default_relays().to_vec(); 324 } 325 326 relay_pool.ensure_relays(relay_targets.clone()).await?; 327 328 debug!("finding note(s) with filters: {:?}", filters); 329 330 let expected_events = filters.len(); 331 332 let mut streamed_events = relay_pool 333 .stream_events( 334 filters, 335 &relay_targets, 336 std::time::Duration::from_millis(2000), 337 ) 338 .await?; 339 340 let mut num_loops = 0; 341 while let Some(event) = streamed_events.next().await { 342 if let Err(err) = ensure_relay_hints(&relay_pool, &event).await { 343 warn!("failed to apply relay hints: {err}"); 344 } 345 346 debug!("processing event {:?}", event); 347 if let Err(err) = ndb.process_event(&event.as_json()) { 348 error!("error processing event: {err}"); 349 } 350 351 num_loops += 1; 352 353 if num_loops == expected_events { 354 break; 355 } 356 } 357 358 Ok(()) 359 } 360 361 pub async fn fetch_profile_feed( 362 relay_pool: Arc<RelayPool>, 363 ndb: Ndb, 364 pubkey: [u8; 32], 365 ) -> Result<()> { 366 let relay_targets = collect_profile_relays(relay_pool.clone(), ndb.clone(), pubkey).await?; 367 368 let relay_targets_arc = Arc::new(relay_targets); 369 370 let cutoff = SystemTime::now() 371 .checked_sub(Duration::from_secs( 372 60 * 60 * 24 * PROFILE_FEED_LOOKBACK_DAYS, 373 )) 374 .and_then(|ts| ts.duration_since(SystemTime::UNIX_EPOCH).ok()) 375 .map(|dur| dur.as_secs()); 376 377 let mut fetched = stream_profile_feed_once( 378 relay_pool.clone(), 379 ndb.clone(), 380 relay_targets_arc.clone(), 381 pubkey, 382 cutoff, 383 ) 384 .await?; 385 386 if fetched == 0 { 387 fetched = stream_profile_feed_once( 388 relay_pool.clone(), 389 ndb.clone(), 390 relay_targets_arc.clone(), 391 pubkey, 392 None, 393 ) 394 .await?; 395 } 396 397 if fetched == 0 { 398 warn!( 399 "no profile notes fetched for {} even after fallback", 400 hex::encode(pubkey) 401 ); 402 } 403 404 Ok(()) 405 } 406 407 impl RenderData { 408 fn set_profile_key(&mut self, key: ProfileKey) { 409 match self { 410 RenderData::Profile(pk) => { 411 *pk = Some(ProfileRenderData::Profile(key)); 412 } 413 RenderData::Note(note_rd) => { 414 note_rd.profile_rd = Some(ProfileRenderData::Profile(key)); 415 } 416 }; 417 } 418 419 fn set_note_key(&mut self, key: NoteKey) { 420 match self { 421 RenderData::Profile(_pk) => {} 422 RenderData::Note(note) => { 423 note.note_rd = NoteRenderData::Note(key); 424 } 425 }; 426 } 427 428 fn hydrate_from_note_key(&mut self, ndb: &Ndb, note_key: NoteKey) -> Result<bool> { 429 let txn = Transaction::new(ndb)?; 430 let note = match ndb.get_note_by_key(&txn, note_key) { 431 Ok(note) => note, 432 Err(err) => { 433 debug!(?note_key, "note key not yet visible in transaction: {err}"); 434 return Ok(false); 435 } 436 }; 437 438 if note.kind() == 0 { 439 match ndb.get_profilekey_by_pubkey(&txn, note.pubkey()) { 440 Ok(profile_key) => self.set_profile_key(profile_key), 441 Err(err) => { 442 debug!( 443 pubkey = %hex::encode(note.pubkey()), 444 "profile key not ready after note ingestion: {err}" 445 ); 446 } 447 } 448 } else { 449 self.set_note_key(note_key); 450 } 451 452 Ok(true) 453 } 454 455 pub async fn complete( 456 &mut self, 457 ndb: Ndb, 458 relay_pool: Arc<RelayPool>, 459 nip19: Nip19, 460 ) -> Result<()> { 461 let (mut stream, fetch_handle) = { 462 let filter = renderdata_to_filter(self); 463 if filter.is_empty() { 464 // should really never happen unless someone broke 465 // needs_note and needs_profile 466 return Err(Error::NothingToFetch); 467 } 468 let sub_id = ndb.subscribe(&filter)?; 469 470 let stream = sub_id.stream(&ndb).notes_per_await(2); 471 472 let filters = filter.iter().map(convert_filter).collect(); 473 let ndb = ndb.clone(); 474 let pool = relay_pool.clone(); 475 let handle = tokio::spawn(async move { find_note(pool, ndb, filters, &nip19).await }); 476 (stream, handle) 477 }; 478 479 let wait_for = Duration::from_secs(1); 480 let mut consecutive_timeouts = 0; 481 482 loop { 483 if !self.needs_note() && !self.needs_profile() { 484 break; 485 } 486 487 if consecutive_timeouts >= 5 { 488 warn!("render completion timed out waiting for remaining data"); 489 break; 490 } 491 492 let note_keys = match timeout(wait_for, stream.next()).await { 493 Ok(Some(note_keys)) => { 494 consecutive_timeouts = 0; 495 note_keys 496 } 497 Ok(None) => { 498 // end of stream 499 break; 500 } 501 Err(_) => { 502 consecutive_timeouts += 1; 503 continue; 504 } 505 }; 506 507 let note_keys_len = note_keys.len(); 508 509 for note_key in note_keys { 510 match self.hydrate_from_note_key(&ndb, note_key) { 511 Ok(true) => {} 512 Ok(false) => { 513 // keep waiting; the outer loop will retry on the next batch 514 } 515 Err(err) => { 516 error!(?note_key, "failed to hydrate note from key: {err}"); 517 } 518 } 519 } 520 521 if note_keys_len >= 2 && !self.needs_note() && !self.needs_profile() { 522 break; 523 } 524 } 525 526 match fetch_handle.await { 527 Ok(Ok(())) => Ok(()), 528 Ok(Err(err)) => Err(err), 529 Err(join_err) => Err(Error::Generic(format!( 530 "relay fetch task failed: {}", 531 join_err 532 ))), 533 } 534 } 535 } 536 537 fn collect_relay_hints(event: &Event) -> Vec<RelayUrl> { 538 let mut relays = Vec::new(); 539 for tag in event.tags.iter() { 540 let candidate = match tag.kind() { 541 TagKind::Relay | TagKind::Relays => tag.content(), 542 TagKind::SingleLetter(letter) if letter.as_char() == 'r' => tag.content(), 543 _ if event.kind == Kind::ContactList => { 544 if let Some(TagStandard::PublicKey { 545 relay_url: Some(url), 546 .. 547 }) = tag.as_standardized() 548 { 549 Some(url.as_str()) 550 } else { 551 tag.as_slice().get(2).map(|value| value.as_str()) 552 } 553 } 554 _ => None, 555 }; 556 557 let Some(url) = candidate else { 558 continue; 559 }; 560 561 if url.is_empty() { 562 continue; 563 } 564 565 match RelayUrl::parse(url) { 566 Ok(relay) => relays.push(relay), 567 Err(err) => warn!("ignoring invalid relay hint {}: {}", url, err), 568 } 569 } 570 relays 571 } 572 573 async fn ensure_relay_hints(relay_pool: &Arc<RelayPool>, event: &Event) -> Result<()> { 574 let hints = collect_relay_hints(event); 575 if hints.is_empty() { 576 return Ok(()); 577 } 578 relay_pool.ensure_relays(hints).await 579 } 580 581 async fn collect_profile_relays( 582 relay_pool: Arc<RelayPool>, 583 ndb: Ndb, 584 pubkey: [u8; 32], 585 ) -> Result<Vec<RelayUrl>> { 586 relay_pool 587 .ensure_relays(relay_pool.default_relays().iter().cloned()) 588 .await?; 589 590 let mut known: HashSet<String> = relay_pool 591 .default_relays() 592 .iter() 593 .map(|url| url.to_string()) 594 .collect(); 595 let mut targets = relay_pool.default_relays().to_vec(); 596 597 let author_ref = [&pubkey]; 598 599 let relay_filter = convert_filter( 600 &nostrdb::Filter::new() 601 .authors(author_ref) 602 .kinds([Kind::RelayList.as_u16() as u64]) 603 .limit(1) 604 .build(), 605 ); 606 607 let contact_filter = convert_filter( 608 &nostrdb::Filter::new() 609 .authors(author_ref) 610 .kinds([Kind::ContactList.as_u16() as u64]) 611 .limit(1) 612 .build(), 613 ); 614 615 let mut stream = relay_pool 616 .stream_events( 617 vec![relay_filter, contact_filter], 618 &[], 619 Duration::from_millis(2000), 620 ) 621 .await?; 622 while let Some(event) = stream.next().await { 623 if let Err(err) = ndb.process_event(&event.as_json()) { 624 error!("error processing relay discovery event: {err}"); 625 } 626 627 let hints = collect_relay_hints(&event); 628 if hints.is_empty() { 629 continue; 630 } 631 632 let mut fresh = Vec::new(); 633 for hint in hints { 634 let key = hint.to_string(); 635 if known.insert(key) { 636 targets.push(hint.clone()); 637 fresh.push(hint); 638 } 639 } 640 641 if !fresh.is_empty() { 642 relay_pool.ensure_relays(fresh).await?; 643 } 644 } 645 646 Ok(targets) 647 } 648 649 async fn stream_profile_feed_once( 650 relay_pool: Arc<RelayPool>, 651 ndb: Ndb, 652 relays: Arc<Vec<RelayUrl>>, 653 pubkey: [u8; 32], 654 since: Option<u64>, 655 ) -> Result<usize> { 656 let filter = { 657 let author_ref = [&pubkey]; 658 let mut builder = nostrdb::Filter::new() 659 .authors(author_ref) 660 .kinds([1]) 661 .limit(PROFILE_FEED_RECENT_LIMIT as u64); 662 663 if let Some(since) = since { 664 builder = builder.since(since); 665 } 666 667 convert_filter(&builder.build()) 668 }; 669 let mut stream = relay_pool 670 .stream_events(vec![filter], &relays, Duration::from_millis(2000)) 671 .await?; 672 673 let mut fetched = 0usize; 674 675 while let Some(event) = stream.next().await { 676 if let Err(err) = ensure_relay_hints(&relay_pool, &event).await { 677 warn!("failed to apply relay hints: {err}"); 678 } 679 if let Err(err) = ndb.process_event(&event.as_json()) { 680 error!("error processing profile feed event: {err}"); 681 } else { 682 fetched += 1; 683 } 684 } 685 686 Ok(fetched) 687 } 688 689 /// Attempt to locate the render data locally. Anything missing from 690 /// render data will be fetched. 691 pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<RenderData> { 692 match nip19 { 693 Nip19::Event(nevent) => { 694 let m_note = ndb.get_note_by_id(txn, nevent.event_id.as_bytes()).ok(); 695 696 let pk = if let Some(pk) = m_note.as_ref().map(|note| note.pubkey()) { 697 Some(*pk) 698 } else { 699 nevent.author.map(|a| a.serialize()) 700 }; 701 702 let profile_rd = pk.as_ref().map(|pubkey| { 703 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) { 704 ProfileRenderData::Profile(profile_key) 705 } else { 706 ProfileRenderData::Missing(*pubkey) 707 } 708 }); 709 710 let note_rd = if let Some(note_key) = m_note.and_then(|n| n.key()) { 711 NoteRenderData::Note(note_key) 712 } else { 713 NoteRenderData::Missing(*nevent.event_id.as_bytes()) 714 }; 715 716 Ok(RenderData::note(note_rd, profile_rd)) 717 } 718 719 Nip19::EventId(evid) => { 720 let m_note = ndb.get_note_by_id(txn, evid.as_bytes()).ok(); 721 let note_key = m_note.as_ref().and_then(|n| n.key()); 722 let pk = m_note.map(|note| note.pubkey()); 723 724 let profile_rd = pk.map(|pubkey| { 725 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) { 726 ProfileRenderData::Profile(profile_key) 727 } else { 728 ProfileRenderData::Missing(*pubkey) 729 } 730 }); 731 732 let note_rd = if let Some(note_key) = note_key { 733 NoteRenderData::Note(note_key) 734 } else { 735 NoteRenderData::Missing(*evid.as_bytes()) 736 }; 737 738 Ok(RenderData::note(note_rd, profile_rd)) 739 } 740 741 Nip19::Coordinate(coordinate) => { 742 let author = coordinate.public_key.serialize(); 743 let kind: u64 = u16::from(coordinate.kind) as u64; 744 let identifier = coordinate.identifier.clone(); 745 746 let note_rd = { 747 let filter = build_address_filter(&author, kind, identifier.as_str()); 748 let note_key = ndb 749 .query(txn, &[filter], 1) 750 .ok() 751 .and_then(|results| results.into_iter().next().map(|res| res.note_key)); 752 753 if let Some(note_key) = note_key { 754 NoteRenderData::Note(note_key) 755 } else { 756 NoteRenderData::Address { 757 author, 758 kind, 759 identifier, 760 } 761 } 762 }; 763 764 let profile_rd = { 765 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &author) { 766 Some(ProfileRenderData::Profile(profile_key)) 767 } else { 768 Some(ProfileRenderData::Missing(author)) 769 } 770 }; 771 772 Ok(RenderData::note(note_rd, profile_rd)) 773 } 774 775 Nip19::Profile(nprofile) => { 776 let pubkey = nprofile.public_key.serialize(); 777 let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { 778 ProfileRenderData::Profile(profile_key) 779 } else { 780 ProfileRenderData::Missing(pubkey) 781 }; 782 783 Ok(RenderData::profile(Some(profile_rd))) 784 } 785 786 Nip19::Pubkey(public_key) => { 787 let pubkey = public_key.serialize(); 788 let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { 789 ProfileRenderData::Profile(profile_key) 790 } else { 791 ProfileRenderData::Missing(pubkey) 792 }; 793 794 Ok(RenderData::profile(Some(profile_rd))) 795 } 796 797 _ => Err(Error::CantRender), 798 } 799 } 800 801 fn render_username(ui: &mut egui::Ui, profile: Option<&ProfileRecord>) { 802 let name = format!( 803 "@{}", 804 profile 805 .and_then(|pr| pr.record().profile().and_then(|p| p.name())) 806 .unwrap_or("nostrich") 807 ); 808 ui.label(RichText::new(&name).size(40.0).color(Color32::LIGHT_GRAY)); 809 } 810 811 fn setup_visuals(font_data: &egui::FontData, ctx: &egui::Context) { 812 let mut visuals = Visuals::dark(); 813 visuals.override_text_color = Some(Color32::WHITE); 814 ctx.set_visuals(visuals); 815 fonts::setup_fonts(font_data, ctx); 816 } 817 818 fn push_job_text(job: &mut LayoutJob, s: &str, color: Color32) { 819 job.append( 820 s, 821 0.0, 822 TextFormat { 823 font_id: FontId::new(50.0, FontFamily::Proportional), 824 color, 825 ..Default::default() 826 }, 827 ) 828 } 829 830 fn push_job_user_mention( 831 job: &mut LayoutJob, 832 ndb: &Ndb, 833 block: &Block, 834 txn: &Transaction, 835 pk: &[u8; 32], 836 ) { 837 let record = ndb.get_profile_by_pubkey(txn, pk); 838 if let Ok(record) = record { 839 let profile = record.record().profile().unwrap(); 840 push_job_text( 841 job, 842 &format!("@{}", &abbrev_str(profile.name().unwrap_or("nostrich"))), 843 PURPLE, 844 ); 845 } else { 846 push_job_text(job, &format!("@{}", &abbrev_str(block.as_str())), PURPLE); 847 } 848 } 849 850 fn wrapped_body_blocks( 851 ui: &mut egui::Ui, 852 ndb: &Ndb, 853 note: &Note, 854 blocks: &Blocks, 855 txn: &Transaction, 856 ) { 857 let mut job = LayoutJob { 858 justify: false, 859 halign: egui::Align::LEFT, 860 wrap: egui::text::TextWrapping { 861 max_rows: 5, 862 break_anywhere: false, 863 overflow_character: Some('…'), 864 ..Default::default() 865 }, 866 ..Default::default() 867 }; 868 869 for block in blocks.iter(note) { 870 match block.blocktype() { 871 BlockType::Url => push_job_text(&mut job, block.as_str(), PURPLE), 872 873 BlockType::Hashtag => { 874 push_job_text(&mut job, "#", PURPLE); 875 push_job_text(&mut job, block.as_str(), PURPLE); 876 } 877 878 BlockType::MentionBech32 => { 879 match block.as_mention().unwrap() { 880 Mention::Event(_ev) => push_job_text( 881 &mut job, 882 &format!("@{}", &abbrev_str(block.as_str())), 883 PURPLE, 884 ), 885 Mention::Note(_ev) => { 886 push_job_text( 887 &mut job, 888 &format!("@{}", &abbrev_str(block.as_str())), 889 PURPLE, 890 ); 891 } 892 Mention::Profile(nprofile) => { 893 push_job_user_mention(&mut job, ndb, &block, txn, nprofile.pubkey()) 894 } 895 Mention::Pubkey(npub) => { 896 push_job_user_mention(&mut job, ndb, &block, txn, npub.pubkey()) 897 } 898 Mention::Secret(_sec) => push_job_text(&mut job, "--redacted--", PURPLE), 899 Mention::Relay(_relay) => { 900 push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE) 901 } 902 Mention::Addr(_addr) => { 903 push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE) 904 } 905 }; 906 } 907 908 _ => push_job_text(&mut job, block.as_str(), Color32::WHITE), 909 }; 910 } 911 912 ui.label(job); 913 } 914 915 fn wrapped_body_text(ui: &mut egui::Ui, text: &str) { 916 let format = TextFormat { 917 font_id: FontId::proportional(52.0), 918 color: Color32::WHITE, 919 extra_letter_spacing: 0.0, 920 line_height: Some(50.0), 921 ..Default::default() 922 }; 923 924 let job = LayoutJob::single_section(text.to_owned(), format); 925 ui.label(job); 926 } 927 928 fn right_aligned() -> egui::Layout { 929 use egui::{Align, Direction, Layout}; 930 931 Layout { 932 main_dir: Direction::RightToLeft, 933 main_wrap: false, 934 main_align: Align::Center, 935 main_justify: false, 936 cross_align: Align::Center, 937 cross_justify: false, 938 } 939 } 940 941 fn note_frame_align() -> egui::Layout { 942 use egui::{Align, Direction, Layout}; 943 944 Layout { 945 main_dir: Direction::TopDown, 946 main_wrap: false, 947 main_align: Align::Center, 948 main_justify: false, 949 cross_align: Align::Center, 950 cross_justify: false, 951 } 952 } 953 954 fn note_ui(app: &Notecrumbs, ctx: &egui::Context, rd: &NoteAndProfileRenderData) -> Result<()> { 955 setup_visuals(&app.font_data, ctx); 956 957 let outer_margin = 60.0; 958 let inner_margin = 40.0; 959 let canvas_width = 1200.0; 960 let canvas_height = 600.0; 961 //let canvas_size = Vec2::new(canvas_width, canvas_height); 962 963 let total_margin = outer_margin + inner_margin; 964 let txn = Transaction::new(&app.ndb)?; 965 let profile_record = rd 966 .profile_rd 967 .as_ref() 968 .and_then(|profile_rd| match profile_rd { 969 ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(), 970 ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(), 971 }); 972 //let _profile = profile_record.and_then(|pr| pr.record().profile()); 973 //let pfp_url = profile.and_then(|p| p.picture()); 974 975 // TODO: async pfp loading using notedeck browser context? 976 let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default()); 977 let bg = ctx.load_texture("background", app.background.clone(), Default::default()); 978 979 egui::CentralPanel::default() 980 .frame( 981 egui::Frame::default() 982 //.fill(Color32::from_rgb(0x43, 0x20, 0x62) 983 .fill(Color32::from_rgb(0x00, 0x00, 0x00)), 984 ) 985 .show(ctx, |ui| { 986 background_texture(ui, &bg); 987 egui::Frame::none() 988 .fill(Color32::from_rgb(0x0F, 0x0F, 0x0F)) 989 .shadow(Shadow { 990 extrusion: 50.0, 991 color: Color32::from_black_alpha(60), 992 }) 993 .rounding(Rounding::same(20.0)) 994 .outer_margin(outer_margin) 995 .inner_margin(inner_margin) 996 .show(ui, |ui| { 997 let desired_height = canvas_height - total_margin * 2.0; 998 let desired_width = canvas_width - total_margin * 2.0; 999 let desired_size = Vec2::new(desired_width, desired_height); 1000 ui.set_max_size(desired_size); 1001 1002 ui.with_layout(note_frame_align(), |ui| { 1003 //egui::ScrollArea::vertical().show(ui, |ui| { 1004 ui.spacing_mut().item_spacing = Vec2::new(10.0, 50.0); 1005 1006 ui.vertical(|ui| { 1007 let desired = Vec2::new(desired_width, desired_height / 1.5); 1008 ui.set_max_size(desired); 1009 ui.set_min_size(desired); 1010 1011 if let Ok(note) = rd.note_rd.lookup(&txn, &app.ndb) { 1012 if let Some(blocks) = note 1013 .key() 1014 .and_then(|nk| app.ndb.get_blocks_by_key(&txn, nk).ok()) 1015 { 1016 wrapped_body_blocks(ui, &app.ndb, ¬e, &blocks, &txn); 1017 } else { 1018 wrapped_body_text(ui, note.content()); 1019 } 1020 } 1021 }); 1022 1023 ui.horizontal(|ui| { 1024 ui.image(&pfp); 1025 render_username(ui, profile_record.as_ref()); 1026 ui.with_layout(right_aligned(), discuss_on_damus); 1027 }); 1028 }); 1029 }); 1030 }); 1031 1032 Ok(()) 1033 } 1034 1035 fn background_texture(ui: &mut egui::Ui, texture: &TextureHandle) { 1036 // Get the size of the panel 1037 let size = ui.available_size(); 1038 1039 // Create a rectangle for the texture 1040 let rect = Rect::from_min_size(ui.min_rect().min, size); 1041 1042 // Get the current layer ID 1043 let layer_id = ui.layer_id(); 1044 1045 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); 1046 //let uv_skewed = Rect::from_min_max(uv.min, pos2(uv.max.x, uv.max.y * 0.5)); 1047 1048 // Get the painter and draw the texture 1049 let painter = ui.ctx().layer_painter(layer_id); 1050 //let tint = Color32::WHITE; 1051 1052 let mut mesh = Mesh::with_texture(texture.into()); 1053 1054 // Define vertices for a rectangle 1055 mesh.add_rect_with_uv(rect, uv, Color32::WHITE); 1056 1057 //let origin = pos2(600.0, 300.0); 1058 //let angle = Rot2::from_angle(45.0); 1059 //mesh.rotate(angle, origin); 1060 1061 // Draw the mesh 1062 painter.add(Shape::mesh(mesh)); 1063 1064 //painter.image(texture.into(), rect, uv_skewed, tint); 1065 } 1066 1067 fn discuss_on_damus(ui: &mut egui::Ui) { 1068 let button = egui::Button::new( 1069 RichText::new("Discuss on Damus ➡") 1070 .size(30.0) 1071 .color(Color32::BLACK), 1072 ) 1073 .rounding(50.0) 1074 .min_size(Vec2::new(330.0, 75.0)) 1075 .fill(Color32::WHITE); 1076 1077 ui.add(button); 1078 } 1079 1080 fn profile_ui(app: &Notecrumbs, ctx: &egui::Context, profile_rd: Option<&ProfileRenderData>) { 1081 let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default()); 1082 setup_visuals(&app.font_data, ctx); 1083 1084 egui::CentralPanel::default().show(ctx, |ui| { 1085 ui.vertical(|ui| { 1086 ui.horizontal(|ui| { 1087 ui.image(&pfp); 1088 if let Ok(txn) = Transaction::new(&app.ndb) { 1089 let profile = profile_rd.and_then(|prd| prd.lookup(&txn, &app.ndb).ok()); 1090 render_username(ui, profile.as_ref()); 1091 } 1092 }); 1093 //body(ui, &profile.about); 1094 }); 1095 }); 1096 } 1097 1098 #[cfg(test)] 1099 mod tests { 1100 use super::*; 1101 use nostr::nips::nip01::Coordinate; 1102 use nostr::prelude::{EventBuilder, Keys, Tag}; 1103 use nostrdb::{Config, Filter}; 1104 use std::fs; 1105 use std::path::PathBuf; 1106 use std::time::{SystemTime, UNIX_EPOCH}; 1107 1108 fn temp_db_dir(prefix: &str) -> PathBuf { 1109 let base = PathBuf::from("target/test-dbs"); 1110 let _ = fs::create_dir_all(&base); 1111 let nanos = SystemTime::now() 1112 .duration_since(UNIX_EPOCH) 1113 .expect("time went backwards") 1114 .as_nanos(); 1115 let dir = base.join(format!("{}-{}", prefix, nanos)); 1116 let _ = fs::create_dir_all(&dir); 1117 dir 1118 } 1119 1120 #[test] 1121 fn build_address_filter_includes_only_d_tags() { 1122 let author = [1u8; 32]; 1123 let identifier = "article-slug"; 1124 let kind = Kind::LongFormTextNote.as_u16() as u64; 1125 1126 let filter = build_address_filter(&author, kind, identifier); 1127 let mut saw_d_tag = false; 1128 1129 for field in &filter { 1130 if let FilterField::Tags(tag, elements) = field { 1131 assert_eq!(tag, 'd', "unexpected tag '{}' in filter", tag); 1132 let mut values: Vec<String> = Vec::new(); 1133 for element in elements { 1134 match element { 1135 FilterElement::Str(value) => values.push(value.to_owned()), 1136 other => panic!("unexpected tag element {:?}", other), 1137 } 1138 } 1139 assert_eq!(values, vec![identifier.to_owned()]); 1140 saw_d_tag = true; 1141 } 1142 } 1143 1144 assert!(saw_d_tag, "expected filter to include a 'd' tag constraint"); 1145 } 1146 1147 #[tokio::test] 1148 async fn query_note_by_address_uses_d_and_a_tag_filters() { 1149 let keys = Keys::generate(); 1150 let author = keys.public_key().to_bytes(); 1151 let kind = Kind::LongFormTextNote.as_u16() as u64; 1152 let identifier_with_d = "with-d-tag"; 1153 let identifier_with_a = "only-a-tag"; 1154 1155 let db_dir = temp_db_dir("address-filters"); 1156 let db_path = db_dir.to_string_lossy().to_string(); 1157 let cfg = Config::new().skip_validation(true); 1158 let ndb = Ndb::new(&db_path, &cfg).expect("failed to open nostrdb"); 1159 1160 let event_with_d = EventBuilder::long_form_text_note("content with d tag") 1161 .tags([Tag::identifier(identifier_with_d)]) 1162 .sign_with_keys(&keys) 1163 .expect("sign long-form event with d tag"); 1164 1165 let coordinate = Coordinate::new(Kind::LongFormTextNote, keys.public_key()) 1166 .identifier(identifier_with_a); 1167 let event_with_a_only = EventBuilder::long_form_text_note("content with a tag only") 1168 .tags([Tag::coordinate(coordinate)]) 1169 .sign_with_keys(&keys) 1170 .expect("sign long-form event with coordinate tag"); 1171 1172 let event_with_d_id = event_with_d.id.to_bytes(); 1173 let event_with_a_only_id = event_with_a_only.id.to_bytes(); 1174 1175 let wait_filter = Filter::new() 1176 .ids([&event_with_d_id, &event_with_a_only_id]) 1177 .limit(2) 1178 .build(); 1179 let subscription = ndb 1180 .subscribe(&[wait_filter]) 1181 .expect("subscribe for note ingestion"); 1182 1183 ndb.process_event(&serde_json::to_string(&event_with_d).unwrap()) 1184 .expect("ingest event with d tag"); 1185 ndb.process_event(&serde_json::to_string(&event_with_a_only).unwrap()) 1186 .expect("ingest event with a tag"); 1187 1188 let _ = ndb 1189 .wait_for_notes(subscription, 2) 1190 .await 1191 .expect("wait for note ingestion to complete"); 1192 1193 tokio::time::sleep(Duration::from_millis(100)).await; 1194 1195 { 1196 let txn = Transaction::new(&ndb).expect("transaction for d-tag lookup"); 1197 let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_d) 1198 .expect("should find event by d tag"); 1199 assert_eq!(note.id(), &event_with_d_id); 1200 } 1201 1202 { 1203 let txn = Transaction::new(&ndb).expect("transaction for a-tag lookup"); 1204 let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_a) 1205 .expect("should find event via a-tag fallback"); 1206 assert_eq!(note.id(), &event_with_a_only_id); 1207 } 1208 1209 drop(ndb); 1210 let _ = fs::remove_dir_all(&db_dir); 1211 } 1212 } 1213 1214 pub fn render_note(ndb: &Notecrumbs, render_data: &RenderData) -> Vec<u8> { 1215 use egui_skia::{rasterize, RasterizeOptions}; 1216 use skia_safe::EncodedImageFormat; 1217 1218 let options = RasterizeOptions { 1219 pixels_per_point: 1.0, 1220 frames_before_screenshot: 1, 1221 }; 1222 1223 let mut surface = match render_data { 1224 RenderData::Note(note_render_data) => rasterize( 1225 (1200, 600), 1226 |ctx| { 1227 let _ = note_ui(ndb, ctx, note_render_data); 1228 }, 1229 Some(options), 1230 ), 1231 1232 RenderData::Profile(profile_rd) => rasterize( 1233 (1200, 600), 1234 |ctx| profile_ui(ndb, ctx, profile_rd.as_ref()), 1235 Some(options), 1236 ), 1237 }; 1238 1239 surface 1240 .image_snapshot() 1241 .encode_to_data(EncodedImageFormat::PNG) 1242 .expect("expected image") 1243 .as_bytes() 1244 .into() 1245 }