notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

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, &note, &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 }