notecrumbs

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

render.rs (49901B)


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