render.rs (31808B)
1 use crate::timeout; 2 use crate::{abbrev::abbrev_str, error::Result, fonts, nip19, Error, Notecrumbs}; 3 use egui::epaint::Shadow; 4 use egui::{ 5 pos2, 6 text::{LayoutJob, TextFormat}, 7 Color32, FontFamily, FontId, Mesh, Rect, RichText, Rounding, Shape, TextureHandle, Vec2, 8 Visuals, 9 }; 10 use nostr::event::kind::Kind; 11 use nostr::types::{SingleLetterTag, Timestamp}; 12 use nostr_sdk::async_utility::futures_util::StreamExt; 13 use nostr_sdk::nips::nip19::Nip19; 14 use nostr_sdk::prelude::{Client, EventId, Keys, PublicKey}; 15 use nostrdb::{ 16 Block, BlockType, Blocks, FilterElement, FilterField, Mention, Ndb, Note, NoteKey, ProfileKey, 17 ProfileRecord, Transaction, 18 }; 19 use std::collections::{BTreeMap, BTreeSet}; 20 use tokio::time::{timeout, Duration}; 21 use tracing::{debug, error, warn}; 22 23 const PURPLE: Color32 = Color32::from_rgb(0xcc, 0x43, 0xc5); 24 25 pub enum NoteRenderData { 26 Missing([u8; 32]), 27 Address { 28 author: [u8; 32], 29 kind: u64, 30 identifier: String, 31 }, 32 Note(NoteKey), 33 } 34 35 impl NoteRenderData { 36 pub fn needs_note(&self) -> bool { 37 match self { 38 NoteRenderData::Missing(_) => true, 39 NoteRenderData::Address { .. } => true, 40 NoteRenderData::Note(_) => false, 41 } 42 } 43 44 pub fn lookup<'a>( 45 &self, 46 txn: &'a Transaction, 47 ndb: &Ndb, 48 ) -> std::result::Result<Note<'a>, nostrdb::Error> { 49 match self { 50 NoteRenderData::Missing(note_id) => ndb.get_note_by_id(txn, note_id), 51 NoteRenderData::Address { 52 author, 53 kind, 54 identifier, 55 } => query_note_by_address(ndb, txn, author, *kind, identifier), 56 NoteRenderData::Note(note_key) => ndb.get_note_by_key(txn, *note_key), 57 } 58 } 59 } 60 61 pub struct NoteAndProfileRenderData { 62 pub note_rd: NoteRenderData, 63 pub profile_rd: Option<ProfileRenderData>, 64 } 65 66 impl NoteAndProfileRenderData { 67 pub fn new(note_rd: NoteRenderData, profile_rd: Option<ProfileRenderData>) -> Self { 68 Self { 69 note_rd, 70 profile_rd, 71 } 72 } 73 } 74 75 pub enum ProfileRenderData { 76 Missing([u8; 32]), 77 Profile(ProfileKey), 78 } 79 80 impl ProfileRenderData { 81 pub fn lookup<'a>( 82 &self, 83 txn: &'a Transaction, 84 ndb: &Ndb, 85 ) -> std::result::Result<ProfileRecord<'a>, nostrdb::Error> { 86 match self { 87 ProfileRenderData::Missing(pk) => ndb.get_profile_by_pubkey(txn, pk), 88 ProfileRenderData::Profile(key) => ndb.get_profile_by_key(txn, *key), 89 } 90 } 91 92 pub fn needs_profile(&self) -> bool { 93 match self { 94 ProfileRenderData::Missing(_) => true, 95 ProfileRenderData::Profile(_) => false, 96 } 97 } 98 } 99 100 /// Primary keys for the data we're interested in rendering 101 pub enum RenderData { 102 Profile(Option<ProfileRenderData>), 103 Note(NoteAndProfileRenderData), 104 } 105 106 impl RenderData { 107 pub fn note(note_rd: NoteRenderData, profile_rd: Option<ProfileRenderData>) -> Self { 108 Self::Note(NoteAndProfileRenderData::new(note_rd, profile_rd)) 109 } 110 111 pub fn profile(profile_rd: Option<ProfileRenderData>) -> Self { 112 Self::Profile(profile_rd) 113 } 114 115 pub fn is_complete(&self) -> bool { 116 !(self.needs_profile() || self.needs_note()) 117 } 118 119 pub fn note_render_data(&self) -> Option<&NoteRenderData> { 120 match self { 121 Self::Note(nrd) => Some(&nrd.note_rd), 122 Self::Profile(_) => None, 123 } 124 } 125 126 pub fn profile_render_data(&self) -> Option<&ProfileRenderData> { 127 match self { 128 Self::Note(nrd) => nrd.profile_rd.as_ref(), 129 Self::Profile(prd) => prd.as_ref(), 130 } 131 } 132 133 pub fn needs_profile(&self) -> bool { 134 match self { 135 RenderData::Profile(profile_rd) => profile_rd 136 .as_ref() 137 .map(|prd| prd.needs_profile()) 138 .unwrap_or(true), 139 RenderData::Note(note) => note 140 .profile_rd 141 .as_ref() 142 .map(|prd| prd.needs_profile()) 143 .unwrap_or(true), 144 } 145 } 146 147 pub fn needs_note(&self) -> bool { 148 match self { 149 RenderData::Profile(_pkey) => false, 150 RenderData::Note(rd) => rd.note_rd.needs_note(), 151 } 152 } 153 } 154 155 fn renderdata_to_filter(render_data: &RenderData) -> Vec<nostrdb::Filter> { 156 if render_data.is_complete() { 157 return vec![]; 158 } 159 160 let mut filters = Vec::with_capacity(2); 161 162 match render_data.note_render_data() { 163 Some(NoteRenderData::Missing(note_id)) => { 164 filters.push(nostrdb::Filter::new().ids([note_id]).limit(1).build()); 165 } 166 Some(NoteRenderData::Address { 167 author, 168 kind, 169 identifier, 170 }) => { 171 filters.push(build_address_filter(author, *kind, identifier.as_str())); 172 } 173 None | Some(NoteRenderData::Note(_)) => {} 174 } 175 176 match render_data.profile_render_data() { 177 Some(ProfileRenderData::Missing(pubkey)) => { 178 filters.push( 179 nostrdb::Filter::new() 180 .authors([pubkey]) 181 .kinds([0]) 182 .limit(1) 183 .build(), 184 ); 185 } 186 None | Some(ProfileRenderData::Profile(_)) => {} 187 } 188 189 filters 190 } 191 192 fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter { 193 let mut filter = nostr::types::Filter::new(); 194 195 for element in ndb_filter { 196 match element { 197 FilterField::Ids(id_elems) => { 198 let event_ids = id_elems 199 .into_iter() 200 .map(|id| EventId::from_slice(id).expect("event id")); 201 filter = filter.ids(event_ids); 202 } 203 204 FilterField::Authors(authors) => { 205 let authors = authors 206 .into_iter() 207 .map(|id| PublicKey::from_slice(id).expect("ok")); 208 filter = filter.authors(authors); 209 } 210 211 FilterField::Kinds(int_elems) => { 212 let kinds = int_elems.into_iter().map(|knd| Kind::from_u16(knd as u16)); 213 filter = filter.kinds(kinds); 214 } 215 216 FilterField::Tags(chr, tag_elems) => { 217 let single_letter = if let Ok(single) = SingleLetterTag::from_char(chr) { 218 single 219 } else { 220 warn!("failed to adding char filter element: '{}", chr); 221 continue; 222 }; 223 224 let mut tags: BTreeMap<SingleLetterTag, BTreeSet<String>> = BTreeMap::new(); 225 let mut elems: BTreeSet<String> = BTreeSet::new(); 226 227 for elem in tag_elems { 228 if let FilterElement::Str(s) = elem { 229 elems.insert(s.to_string()); 230 } else { 231 warn!( 232 "not adding non-string element from filter tag '{}", 233 single_letter 234 ); 235 } 236 } 237 238 tags.insert(single_letter, elems); 239 240 filter.generic_tags = tags; 241 } 242 243 FilterField::Since(since) => { 244 filter.since = Some(Timestamp::from_secs(since)); 245 } 246 247 FilterField::Until(until) => { 248 filter.until = Some(Timestamp::from_secs(until)); 249 } 250 251 FilterField::Limit(limit) => { 252 filter.limit = Some(limit as usize); 253 } 254 } 255 } 256 257 filter 258 } 259 260 fn coordinate_tag(author: &[u8; 32], kind: u64, identifier: &str) -> String { 261 let pk_hex = hex::encode(author); 262 format!("{}:{}:{}", kind, pk_hex, identifier) 263 } 264 265 fn build_address_filter(author: &[u8; 32], kind: u64, identifier: &str) -> nostrdb::Filter { 266 let author_ref: [&[u8; 32]; 1] = [author]; 267 let mut filter = nostrdb::Filter::new().authors(author_ref).kinds([kind]); 268 if !identifier.is_empty() { 269 let ident = identifier.to_string(); 270 filter = filter.tags(vec![ident], 'd'); 271 } 272 filter.limit(1).build() 273 } 274 275 fn query_note_by_address<'a>( 276 ndb: &Ndb, 277 txn: &'a Transaction, 278 author: &[u8; 32], 279 kind: u64, 280 identifier: &str, 281 ) -> std::result::Result<Note<'a>, nostrdb::Error> { 282 let mut results = ndb.query(txn, &[build_address_filter(author, kind, identifier)], 1)?; 283 if results.is_empty() && !identifier.is_empty() { 284 let coord_filter = nostrdb::Filter::new() 285 .authors([author]) 286 .kinds([kind]) 287 .tags(vec![coordinate_tag(author, kind, identifier)], 'a') 288 .limit(1) 289 .build(); 290 results = ndb.query(txn, &[coord_filter], 1)?; 291 } 292 if let Some(result) = results.first() { 293 Ok(result.note.clone()) 294 } else { 295 Err(nostrdb::Error::NotFound) 296 } 297 } 298 299 pub async fn find_note( 300 ndb: Ndb, 301 keys: Keys, 302 filters: Vec<nostr::Filter>, 303 nip19: &Nip19, 304 ) -> Result<()> { 305 use nostr_sdk::JsonUtil; 306 307 let client = Client::builder().signer(keys).build(); 308 309 let _ = client.add_relay("wss://relay.damus.io").await; 310 let _ = client.add_relay("wss://nostr.wine").await; 311 let _ = client.add_relay("wss://nos.lol").await; 312 let expected_events = filters.len(); 313 314 let other_relays = nip19::nip19_relays(nip19); 315 for relay in other_relays { 316 let _ = client.add_relay(relay).await; 317 } 318 319 client 320 .connect_with_timeout(timeout::get_env_timeout()) 321 .await; 322 323 debug!("finding note(s) with filters: {:?}", filters); 324 325 let mut streamed_events = client 326 .stream_events(filters, Some(timeout::get_env_timeout())) 327 .await?; 328 329 let mut num_loops = 0; 330 while let Some(event) = streamed_events.next().await { 331 debug!("processing event {:?}", event); 332 if let Err(err) = ndb.process_event(&event.as_json()) { 333 error!("error processing event: {err}"); 334 } 335 336 num_loops += 1; 337 338 if num_loops == expected_events { 339 break; 340 } 341 } 342 343 Ok(()) 344 } 345 346 impl RenderData { 347 fn set_profile_key(&mut self, key: ProfileKey) { 348 match self { 349 RenderData::Profile(pk) => { 350 *pk = Some(ProfileRenderData::Profile(key)); 351 } 352 RenderData::Note(note_rd) => { 353 note_rd.profile_rd = Some(ProfileRenderData::Profile(key)); 354 } 355 }; 356 } 357 358 fn set_note_key(&mut self, key: NoteKey) { 359 match self { 360 RenderData::Profile(_pk) => {} 361 RenderData::Note(note) => { 362 note.note_rd = NoteRenderData::Note(key); 363 } 364 }; 365 } 366 367 pub async fn complete(&mut self, ndb: Ndb, keys: Keys, nip19: Nip19) -> Result<()> { 368 let mut stream = { 369 let filter = renderdata_to_filter(self); 370 if filter.is_empty() { 371 // should really never happen unless someone broke 372 // needs_note and needs_profile 373 return Err(Error::NothingToFetch); 374 } 375 let sub_id = ndb.subscribe(&filter)?; 376 377 let stream = sub_id.stream(&ndb).notes_per_await(2); 378 379 let filters = filter.iter().map(convert_filter).collect(); 380 let ndb = ndb.clone(); 381 tokio::spawn(async move { find_note(ndb, keys, filters, &nip19).await }); 382 stream 383 }; 384 385 let wait_for = Duration::from_secs(1); 386 let mut loops = 0; 387 388 loop { 389 if loops == 2 { 390 break; 391 } 392 393 let note_keys = if let Some(note_keys) = timeout(wait_for, stream.next()).await? { 394 note_keys 395 } else { 396 // end of stream? 397 break; 398 }; 399 400 let note_keys_len = note_keys.len(); 401 402 { 403 let txn = Transaction::new(&ndb)?; 404 405 for note_key in note_keys { 406 let note = if let Ok(note) = ndb.get_note_by_key(&txn, note_key) { 407 note 408 } else { 409 error!("race condition in RenderData::complete?"); 410 continue; 411 }; 412 413 if note.kind() == 0 { 414 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(&txn, note.pubkey()) { 415 self.set_profile_key(profile_key); 416 } 417 } else { 418 self.set_note_key(note_key); 419 } 420 } 421 } 422 423 if note_keys_len >= 2 { 424 break; 425 } 426 427 loops += 1; 428 } 429 430 Ok(()) 431 } 432 } 433 434 /// Attempt to locate the render data locally. Anything missing from 435 /// render data will be fetched. 436 pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<RenderData> { 437 match nip19 { 438 Nip19::Event(nevent) => { 439 let m_note = ndb.get_note_by_id(txn, nevent.event_id.as_bytes()).ok(); 440 441 let pk = if let Some(pk) = m_note.as_ref().map(|note| note.pubkey()) { 442 Some(*pk) 443 } else { 444 nevent.author.map(|a| a.serialize()) 445 }; 446 447 let profile_rd = pk.as_ref().map(|pubkey| { 448 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) { 449 ProfileRenderData::Profile(profile_key) 450 } else { 451 ProfileRenderData::Missing(*pubkey) 452 } 453 }); 454 455 let note_rd = if let Some(note_key) = m_note.and_then(|n| n.key()) { 456 NoteRenderData::Note(note_key) 457 } else { 458 NoteRenderData::Missing(*nevent.event_id.as_bytes()) 459 }; 460 461 Ok(RenderData::note(note_rd, profile_rd)) 462 } 463 464 Nip19::EventId(evid) => { 465 let m_note = ndb.get_note_by_id(txn, evid.as_bytes()).ok(); 466 let note_key = m_note.as_ref().and_then(|n| n.key()); 467 let pk = m_note.map(|note| note.pubkey()); 468 469 let profile_rd = pk.map(|pubkey| { 470 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) { 471 ProfileRenderData::Profile(profile_key) 472 } else { 473 ProfileRenderData::Missing(*pubkey) 474 } 475 }); 476 477 let note_rd = if let Some(note_key) = note_key { 478 NoteRenderData::Note(note_key) 479 } else { 480 NoteRenderData::Missing(*evid.as_bytes()) 481 }; 482 483 Ok(RenderData::note(note_rd, profile_rd)) 484 } 485 486 Nip19::Coordinate(coordinate) => { 487 let author = coordinate.public_key.serialize(); 488 let kind: u64 = u16::from(coordinate.kind) as u64; 489 let identifier = coordinate.identifier.clone(); 490 491 let note_rd = { 492 let filter = build_address_filter(&author, kind, identifier.as_str()); 493 let note_key = ndb 494 .query(txn, &[filter], 1) 495 .ok() 496 .and_then(|results| results.into_iter().next().map(|res| res.note_key)); 497 498 if let Some(note_key) = note_key { 499 NoteRenderData::Note(note_key) 500 } else { 501 NoteRenderData::Address { 502 author, 503 kind, 504 identifier, 505 } 506 } 507 }; 508 509 let profile_rd = { 510 if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &author) { 511 Some(ProfileRenderData::Profile(profile_key)) 512 } else { 513 Some(ProfileRenderData::Missing(author)) 514 } 515 }; 516 517 Ok(RenderData::note(note_rd, profile_rd)) 518 } 519 520 Nip19::Profile(nprofile) => { 521 let pubkey = nprofile.public_key.serialize(); 522 let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { 523 ProfileRenderData::Profile(profile_key) 524 } else { 525 ProfileRenderData::Missing(pubkey) 526 }; 527 528 Ok(RenderData::profile(Some(profile_rd))) 529 } 530 531 Nip19::Pubkey(public_key) => { 532 let pubkey = public_key.serialize(); 533 let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { 534 ProfileRenderData::Profile(profile_key) 535 } else { 536 ProfileRenderData::Missing(pubkey) 537 }; 538 539 Ok(RenderData::profile(Some(profile_rd))) 540 } 541 542 _ => Err(Error::CantRender), 543 } 544 } 545 546 fn render_username(ui: &mut egui::Ui, profile: Option<&ProfileRecord>) { 547 let name = format!( 548 "@{}", 549 profile 550 .and_then(|pr| pr.record().profile().and_then(|p| p.name())) 551 .unwrap_or("nostrich") 552 ); 553 ui.label(RichText::new(&name).size(40.0).color(Color32::LIGHT_GRAY)); 554 } 555 556 fn setup_visuals(font_data: &egui::FontData, ctx: &egui::Context) { 557 let mut visuals = Visuals::dark(); 558 visuals.override_text_color = Some(Color32::WHITE); 559 ctx.set_visuals(visuals); 560 fonts::setup_fonts(font_data, ctx); 561 } 562 563 fn push_job_text(job: &mut LayoutJob, s: &str, color: Color32) { 564 job.append( 565 s, 566 0.0, 567 TextFormat { 568 font_id: FontId::new(50.0, FontFamily::Proportional), 569 color, 570 ..Default::default() 571 }, 572 ) 573 } 574 575 fn push_job_user_mention( 576 job: &mut LayoutJob, 577 ndb: &Ndb, 578 block: &Block, 579 txn: &Transaction, 580 pk: &[u8; 32], 581 ) { 582 let record = ndb.get_profile_by_pubkey(txn, pk); 583 if let Ok(record) = record { 584 let profile = record.record().profile().unwrap(); 585 push_job_text( 586 job, 587 &format!("@{}", &abbrev_str(profile.name().unwrap_or("nostrich"))), 588 PURPLE, 589 ); 590 } else { 591 push_job_text(job, &format!("@{}", &abbrev_str(block.as_str())), PURPLE); 592 } 593 } 594 595 fn wrapped_body_blocks( 596 ui: &mut egui::Ui, 597 ndb: &Ndb, 598 note: &Note, 599 blocks: &Blocks, 600 txn: &Transaction, 601 ) { 602 let mut job = LayoutJob { 603 justify: false, 604 halign: egui::Align::LEFT, 605 wrap: egui::text::TextWrapping { 606 max_rows: 5, 607 break_anywhere: false, 608 overflow_character: Some('…'), 609 ..Default::default() 610 }, 611 ..Default::default() 612 }; 613 614 for block in blocks.iter(note) { 615 match block.blocktype() { 616 BlockType::Url => push_job_text(&mut job, block.as_str(), PURPLE), 617 618 BlockType::Hashtag => { 619 push_job_text(&mut job, "#", PURPLE); 620 push_job_text(&mut job, block.as_str(), PURPLE); 621 } 622 623 BlockType::MentionBech32 => { 624 match block.as_mention().unwrap() { 625 Mention::Event(_ev) => push_job_text( 626 &mut job, 627 &format!("@{}", &abbrev_str(block.as_str())), 628 PURPLE, 629 ), 630 Mention::Note(_ev) => { 631 push_job_text( 632 &mut job, 633 &format!("@{}", &abbrev_str(block.as_str())), 634 PURPLE, 635 ); 636 } 637 Mention::Profile(nprofile) => { 638 push_job_user_mention(&mut job, ndb, &block, txn, nprofile.pubkey()) 639 } 640 Mention::Pubkey(npub) => { 641 push_job_user_mention(&mut job, ndb, &block, txn, npub.pubkey()) 642 } 643 Mention::Secret(_sec) => push_job_text(&mut job, "--redacted--", PURPLE), 644 Mention::Relay(_relay) => { 645 push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE) 646 } 647 Mention::Addr(_addr) => { 648 push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE) 649 } 650 }; 651 } 652 653 _ => push_job_text(&mut job, block.as_str(), Color32::WHITE), 654 }; 655 } 656 657 ui.label(job); 658 } 659 660 fn wrapped_body_text(ui: &mut egui::Ui, text: &str) { 661 let format = TextFormat { 662 font_id: FontId::proportional(52.0), 663 color: Color32::WHITE, 664 extra_letter_spacing: 0.0, 665 line_height: Some(50.0), 666 ..Default::default() 667 }; 668 669 let job = LayoutJob::single_section(text.to_owned(), format); 670 ui.label(job); 671 } 672 673 fn right_aligned() -> egui::Layout { 674 use egui::{Align, Direction, Layout}; 675 676 Layout { 677 main_dir: Direction::RightToLeft, 678 main_wrap: false, 679 main_align: Align::Center, 680 main_justify: false, 681 cross_align: Align::Center, 682 cross_justify: false, 683 } 684 } 685 686 fn note_frame_align() -> egui::Layout { 687 use egui::{Align, Direction, Layout}; 688 689 Layout { 690 main_dir: Direction::TopDown, 691 main_wrap: false, 692 main_align: Align::Center, 693 main_justify: false, 694 cross_align: Align::Center, 695 cross_justify: false, 696 } 697 } 698 699 fn note_ui(app: &Notecrumbs, ctx: &egui::Context, rd: &NoteAndProfileRenderData) -> Result<()> { 700 setup_visuals(&app.font_data, ctx); 701 702 let outer_margin = 60.0; 703 let inner_margin = 40.0; 704 let canvas_width = 1200.0; 705 let canvas_height = 600.0; 706 //let canvas_size = Vec2::new(canvas_width, canvas_height); 707 708 let total_margin = outer_margin + inner_margin; 709 let txn = Transaction::new(&app.ndb)?; 710 let profile_record = rd 711 .profile_rd 712 .as_ref() 713 .and_then(|profile_rd| match profile_rd { 714 ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(), 715 ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(), 716 }); 717 //let _profile = profile_record.and_then(|pr| pr.record().profile()); 718 //let pfp_url = profile.and_then(|p| p.picture()); 719 720 // TODO: async pfp loading using notedeck browser context? 721 let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default()); 722 let bg = ctx.load_texture("background", app.background.clone(), Default::default()); 723 724 egui::CentralPanel::default() 725 .frame( 726 egui::Frame::default() 727 //.fill(Color32::from_rgb(0x43, 0x20, 0x62) 728 .fill(Color32::from_rgb(0x00, 0x00, 0x00)), 729 ) 730 .show(ctx, |ui| { 731 background_texture(ui, &bg); 732 egui::Frame::none() 733 .fill(Color32::from_rgb(0x0F, 0x0F, 0x0F)) 734 .shadow(Shadow { 735 extrusion: 50.0, 736 color: Color32::from_black_alpha(60), 737 }) 738 .rounding(Rounding::same(20.0)) 739 .outer_margin(outer_margin) 740 .inner_margin(inner_margin) 741 .show(ui, |ui| { 742 let desired_height = canvas_height - total_margin * 2.0; 743 let desired_width = canvas_width - total_margin * 2.0; 744 let desired_size = Vec2::new(desired_width, desired_height); 745 ui.set_max_size(desired_size); 746 747 ui.with_layout(note_frame_align(), |ui| { 748 //egui::ScrollArea::vertical().show(ui, |ui| { 749 ui.spacing_mut().item_spacing = Vec2::new(10.0, 50.0); 750 751 ui.vertical(|ui| { 752 let desired = Vec2::new(desired_width, desired_height / 1.5); 753 ui.set_max_size(desired); 754 ui.set_min_size(desired); 755 756 if let Ok(note) = rd.note_rd.lookup(&txn, &app.ndb) { 757 if let Some(blocks) = note 758 .key() 759 .and_then(|nk| app.ndb.get_blocks_by_key(&txn, nk).ok()) 760 { 761 wrapped_body_blocks(ui, &app.ndb, ¬e, &blocks, &txn); 762 } else { 763 wrapped_body_text(ui, note.content()); 764 } 765 } 766 }); 767 768 ui.horizontal(|ui| { 769 ui.image(&pfp); 770 render_username(ui, profile_record.as_ref()); 771 ui.with_layout(right_aligned(), discuss_on_damus); 772 }); 773 }); 774 }); 775 }); 776 777 Ok(()) 778 } 779 780 fn background_texture(ui: &mut egui::Ui, texture: &TextureHandle) { 781 // Get the size of the panel 782 let size = ui.available_size(); 783 784 // Create a rectangle for the texture 785 let rect = Rect::from_min_size(ui.min_rect().min, size); 786 787 // Get the current layer ID 788 let layer_id = ui.layer_id(); 789 790 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); 791 //let uv_skewed = Rect::from_min_max(uv.min, pos2(uv.max.x, uv.max.y * 0.5)); 792 793 // Get the painter and draw the texture 794 let painter = ui.ctx().layer_painter(layer_id); 795 //let tint = Color32::WHITE; 796 797 let mut mesh = Mesh::with_texture(texture.into()); 798 799 // Define vertices for a rectangle 800 mesh.add_rect_with_uv(rect, uv, Color32::WHITE); 801 802 //let origin = pos2(600.0, 300.0); 803 //let angle = Rot2::from_angle(45.0); 804 //mesh.rotate(angle, origin); 805 806 // Draw the mesh 807 painter.add(Shape::mesh(mesh)); 808 809 //painter.image(texture.into(), rect, uv_skewed, tint); 810 } 811 812 fn discuss_on_damus(ui: &mut egui::Ui) { 813 let button = egui::Button::new( 814 RichText::new("Discuss on Damus ➡") 815 .size(30.0) 816 .color(Color32::BLACK), 817 ) 818 .rounding(50.0) 819 .min_size(Vec2::new(330.0, 75.0)) 820 .fill(Color32::WHITE); 821 822 ui.add(button); 823 } 824 825 fn profile_ui(app: &Notecrumbs, ctx: &egui::Context, profile_rd: Option<&ProfileRenderData>) { 826 let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default()); 827 setup_visuals(&app.font_data, ctx); 828 829 egui::CentralPanel::default().show(ctx, |ui| { 830 ui.vertical(|ui| { 831 ui.horizontal(|ui| { 832 ui.image(&pfp); 833 if let Ok(txn) = Transaction::new(&app.ndb) { 834 let profile = profile_rd.and_then(|prd| prd.lookup(&txn, &app.ndb).ok()); 835 render_username(ui, profile.as_ref()); 836 } 837 }); 838 //body(ui, &profile.about); 839 }); 840 }); 841 } 842 843 #[cfg(test)] 844 mod tests { 845 use super::*; 846 use nostr::nips::nip01::Coordinate; 847 use nostr::prelude::{EventBuilder, Keys, Tag}; 848 use nostrdb::{Config, Filter}; 849 use std::fs; 850 use std::path::PathBuf; 851 use std::time::{SystemTime, UNIX_EPOCH}; 852 853 fn temp_db_dir(prefix: &str) -> PathBuf { 854 let base = PathBuf::from("target/test-dbs"); 855 let _ = fs::create_dir_all(&base); 856 let nanos = SystemTime::now() 857 .duration_since(UNIX_EPOCH) 858 .expect("time went backwards") 859 .as_nanos(); 860 let dir = base.join(format!("{}-{}", prefix, nanos)); 861 let _ = fs::create_dir_all(&dir); 862 dir 863 } 864 865 #[test] 866 fn build_address_filter_includes_only_d_tags() { 867 let author = [1u8; 32]; 868 let identifier = "article-slug"; 869 let kind = Kind::LongFormTextNote.as_u16() as u64; 870 871 let filter = build_address_filter(&author, kind, identifier); 872 let mut saw_d_tag = false; 873 874 for field in &filter { 875 if let FilterField::Tags(tag, elements) = field { 876 assert_eq!(tag, 'd', "unexpected tag '{}' in filter", tag); 877 let mut values: Vec<String> = Vec::new(); 878 for element in elements { 879 match element { 880 FilterElement::Str(value) => values.push(value.to_owned()), 881 other => panic!("unexpected tag element {:?}", other), 882 } 883 } 884 assert_eq!(values, vec![identifier.to_owned()]); 885 saw_d_tag = true; 886 } 887 } 888 889 assert!(saw_d_tag, "expected filter to include a 'd' tag constraint"); 890 } 891 892 #[tokio::test] 893 async fn query_note_by_address_uses_d_and_a_tag_filters() { 894 let keys = Keys::generate(); 895 let author = keys.public_key().to_bytes(); 896 let kind = Kind::LongFormTextNote.as_u16() as u64; 897 let identifier_with_d = "with-d-tag"; 898 let identifier_with_a = "only-a-tag"; 899 900 let db_dir = temp_db_dir("address-filters"); 901 let db_path = db_dir.to_string_lossy().to_string(); 902 let cfg = Config::new().skip_validation(true); 903 let ndb = Ndb::new(&db_path, &cfg).expect("failed to open nostrdb"); 904 905 let event_with_d = EventBuilder::long_form_text_note("content with d tag") 906 .tags([Tag::identifier(identifier_with_d)]) 907 .sign_with_keys(&keys) 908 .expect("sign long-form event with d tag"); 909 910 let coordinate = Coordinate::new(Kind::LongFormTextNote, keys.public_key()) 911 .identifier(identifier_with_a); 912 let event_with_a_only = EventBuilder::long_form_text_note("content with a tag only") 913 .tags([Tag::coordinate(coordinate)]) 914 .sign_with_keys(&keys) 915 .expect("sign long-form event with coordinate tag"); 916 917 let wait_filter = Filter::new().ids([event_with_d.id.as_bytes()]).build(); 918 let wait_filter_2 = Filter::new().ids([event_with_a_only.id.as_bytes()]).build(); 919 920 ndb.process_event(&serde_json::to_string(&event_with_d).unwrap()) 921 .expect("ingest event with d tag"); 922 ndb.process_event(&serde_json::to_string(&event_with_a_only).unwrap()) 923 .expect("ingest event with a tag"); 924 925 let sub_id = ndb.subscribe(&[wait_filter, wait_filter_2]).expect("sub"); 926 let _r = ndb.wait_for_notes(sub_id, 2).await; 927 928 { 929 let txn = Transaction::new(&ndb).expect("transaction for d-tag lookup"); 930 let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_d) 931 .expect("should find event by d tag"); 932 assert_eq!(note.id(), event_with_d.id.as_bytes()); 933 } 934 935 { 936 let txn = Transaction::new(&ndb).expect("transaction for a-tag lookup"); 937 let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_a) 938 .expect("should find event via a-tag fallback"); 939 assert_eq!(note.id(), event_with_a_only.id.as_bytes()); 940 } 941 942 drop(ndb); 943 let _ = fs::remove_dir_all(&db_dir); 944 } 945 } 946 947 pub fn render_note(ndb: &Notecrumbs, render_data: &RenderData) -> Vec<u8> { 948 use egui_skia::{rasterize, RasterizeOptions}; 949 use skia_safe::EncodedImageFormat; 950 951 let options = RasterizeOptions { 952 pixels_per_point: 1.0, 953 frames_before_screenshot: 1, 954 }; 955 956 let mut surface = match render_data { 957 RenderData::Note(note_render_data) => rasterize( 958 (1200, 600), 959 |ctx| { 960 let _ = note_ui(ndb, ctx, note_render_data); 961 }, 962 Some(options), 963 ), 964 965 RenderData::Profile(profile_rd) => rasterize( 966 (1200, 600), 967 |ctx| profile_ui(ndb, ctx, profile_rd.as_ref()), 968 Some(options), 969 ), 970 }; 971 972 surface 973 .image_snapshot() 974 .encode_to_data(EncodedImageFormat::PNG) 975 .expect("expected image") 976 .as_bytes() 977 .into() 978 }