notecrumbs

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

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