commit 0df6b17f18656a7362dfc5ca84e233c32dbc85d1
parent 60546331d5ec2cdbc598924722b1b903dc1425e5
Author: alltheseas <alltheseas@users.noreply.github.com>
Date: Wed, 22 Oct 2025 10:11:50 -0500
Improve profile feed freshness and ordering
Diffstat:
| M | src/html.rs | | | 41 | ++++++++++++++++++++++++----------------- |
| M | src/render.rs | | | 1013 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
2 files changed, 991 insertions(+), 63 deletions(-)
diff --git a/src/html.rs b/src/html.rs
@@ -759,17 +759,18 @@ pub fn serve_profile_html(
let author_display_html = html_escape::encode_text(&display_name).into_owned();
let mut recent_notes_markup = String::new();
- let mut has_recent_notes = false;
if let Some(pubkey) = profile_pubkey {
let author_ref = [&pubkey];
let note_filter = nostrdb::Filter::new()
.authors(author_ref)
.kinds([1])
- .limit(6)
+ .limit(20)
.build();
- if let Ok(results) = app.ndb.query(&txn, &[note_filter], 6) {
+ if let Ok(results) = app.ndb.query(&txn, &[note_filter], 20) {
+ let mut entries = Vec::new();
+
for res in results {
if let Ok(note) = app.ndb.get_note_by_key(&txn, res.note_key) {
let mut note_body = Vec::new();
@@ -792,9 +793,17 @@ pub fn serve_profile_html(
let note_link_attr =
html_escape::encode_double_quoted_attribute(¬e_link).into_owned();
- let _ = write!(
- recent_notes_markup,
- r#"<div class="note profile-note">
+ entries.push((timestamp_value, note_body_html, note_link_attr));
+ }
+ }
+
+ entries.sort_by(|a, b| b.0.cmp(&a.0));
+ entries.truncate(6);
+
+ for (timestamp_value, note_body_html, note_link_attr) in entries {
+ let _ = write!(
+ recent_notes_markup,
+ r#"<div class="note profile-note">
<div class="note-header">
<img src="{pfp}" class="note-author-avatar" />
<div class="note-author-name">{author}</div>
@@ -807,14 +816,12 @@ pub fn serve_profile_html(
</div>
</div>
"#,
- pfp = pfp_attr,
- author = author_display_html,
- ts = timestamp_value,
- body = note_body_html,
- href = note_link_attr,
- );
- has_recent_notes = true;
- }
+ pfp = pfp_attr,
+ author = author_display_html,
+ ts = timestamp_value,
+ body = note_body_html,
+ href = note_link_attr,
+ );
}
}
}
@@ -874,7 +881,9 @@ pub fn serve_profile_html(
})
.unwrap_or_default();
- let recent_section = if has_recent_notes {
+ let recent_section = if recent_notes_markup.is_empty() {
+ String::new()
+ } else {
format!(
r#"<div class="profile-section">
<h4 class="section-heading">Recent notes</h4>
@@ -882,8 +891,6 @@ pub fn serve_profile_html(
</div>"#,
recent_notes_markup
)
- } else {
- String::new()
};
let _ = write!(
diff --git a/src/render.rs b/src/render.rs
@@ -1,4 +1,3 @@
-use crate::timeout;
use crate::{
abbrev::abbrev_str, error::Result, fonts, nip19, relay_pool::RelayPool, Error, Notecrumbs,
};
@@ -18,8 +17,8 @@ use nostrdb::{
Block, BlockType, Blocks, FilterElement, FilterField, Mention, Ndb, Note, NoteKey, ProfileKey,
ProfileRecord, Transaction,
};
-use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
+use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::time::SystemTime;
use tokio::time::{timeout, Duration};
use tracing::{debug, error, warn};
@@ -28,9 +27,8 @@ const PURPLE: Color32 = Color32::from_rgb(0xcc, 0x43, 0xc5);
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
const MAX_IMAGE_WIDTH: f32 = 900.0;
const MAX_IMAGE_HEIGHT: f32 = 260.0;
-const SECONDS_PER_DAY: u64 = 60 * 60 * 24;
-pub const PROFILE_FEED_LOOKBACK_DAYS: u64 = 30;
pub const PROFILE_FEED_RECENT_LIMIT: usize = 12;
+pub const PROFILE_FEED_LOOKBACK_DAYS: u64 = 30;
pub enum NoteRenderData {
Missing([u8; 32]),
@@ -223,77 +221,1000 @@ fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
filter = filter.kinds(kinds);
}
- FilterField::Tag { .. } => {}
+ FilterField::Tags(chr, tag_elems) => {
+ let single_letter = if let Ok(single) = SingleLetterTag::from_char(chr) {
+ single
+ } else {
+ warn!("failed to adding char filter element: '{}", chr);
+ continue;
+ };
+
+ let mut tags: BTreeMap<SingleLetterTag, BTreeSet<String>> = BTreeMap::new();
+ let mut elems: BTreeSet<String> = BTreeSet::new();
+
+ for elem in tag_elems {
+ if let FilterElement::Str(s) = elem {
+ elems.insert(s.to_string());
+ } else {
+ warn!(
+ "not adding non-string element from filter tag '{}",
+ single_letter
+ );
+ }
+ }
+
+ tags.insert(single_letter, elems);
+
+ filter.generic_tags = tags;
+ }
+
+ FilterField::Since(since) => {
+ filter.since = Some(Timestamp::from_secs(since));
+ }
+
+ FilterField::Until(until) => {
+ filter.until = Some(Timestamp::from_secs(until));
+ }
- FilterField::Search { .. } => {}
+ FilterField::Limit(limit) => {
+ filter.limit = Some(limit as usize);
+ }
}
}
filter
}
-fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
- let mut filter = nostr::types::Filter::new();
+fn coordinate_tag(author: &[u8; 32], kind: u64, identifier: &str) -> String {
+ let pk_hex = hex::encode(author);
+ format!("{}:{}:{}", kind, pk_hex, identifier)
+}
- for element in ndb_filter {
- match element {
- FilterField::Ids(id_elems) => {
- let event_ids = id_elems
- .into_iter()
- .map(|id| EventId::from_slice(id).expect("event id"));
- filter = filter.ids(event_ids);
+fn build_address_filter(author: &[u8; 32], kind: u64, identifier: &str) -> nostrdb::Filter {
+ let author_ref: [&[u8; 32]; 1] = [author];
+ let mut filter = nostrdb::Filter::new().authors(author_ref).kinds([kind]);
+ if !identifier.is_empty() {
+ let ident = identifier.to_string();
+ filter = filter.tags(vec![ident], 'd');
+ }
+ filter.limit(1).build()
+}
+
+fn query_note_by_address<'a>(
+ ndb: &Ndb,
+ txn: &'a Transaction,
+ author: &[u8; 32],
+ kind: u64,
+ identifier: &str,
+) -> std::result::Result<Note<'a>, nostrdb::Error> {
+ let mut results = ndb.query(txn, &[build_address_filter(author, kind, identifier)], 1)?;
+ if results.is_empty() && !identifier.is_empty() {
+ let coord_filter = nostrdb::Filter::new()
+ .authors([author])
+ .kinds([kind])
+ .tags(vec![coordinate_tag(author, kind, identifier)], 'a')
+ .limit(1)
+ .build();
+ results = ndb.query(txn, &[coord_filter], 1)?;
+ }
+ if let Some(result) = results.first() {
+ ndb.get_note_by_key(txn, result.note_key)
+ } else {
+ Err(nostrdb::Error::NotFound)
+ }
+}
+
+pub async fn find_note(
+ relay_pool: Arc<RelayPool>,
+ ndb: Ndb,
+ filters: Vec<nostr::Filter>,
+ nip19: &Nip19,
+) -> Result<()> {
+ use nostr_sdk::JsonUtil;
+
+ let mut relay_targets = nip19::nip19_relays(nip19);
+ if relay_targets.is_empty() {
+ relay_targets = relay_pool.default_relays().to_vec();
+ }
+
+ relay_pool.ensure_relays(relay_targets.clone()).await?;
+
+ debug!("finding note(s) with filters: {:?}", filters);
+
+ let expected_events = filters.len();
+
+ let mut streamed_events = relay_pool
+ .stream_events(
+ filters,
+ &relay_targets,
+ std::time::Duration::from_millis(2000),
+ )
+ .await?;
+
+ let mut num_loops = 0;
+ while let Some(event) = streamed_events.next().await {
+ if let Err(err) = ensure_relay_hints(&relay_pool, &event).await {
+ warn!("failed to apply relay hints: {err}");
+ }
+
+ debug!("processing event {:?}", event);
+ if let Err(err) = ndb.process_event(&event.as_json()) {
+ error!("error processing event: {err}");
+ }
+
+ num_loops += 1;
+
+ if num_loops == expected_events {
+ break;
+ }
+ }
+
+ Ok(())
+}
+
+pub async fn fetch_profile_feed(
+ relay_pool: Arc<RelayPool>,
+ ndb: Ndb,
+ pubkey: [u8; 32],
+) -> Result<()> {
+ use nostr_sdk::JsonUtil;
+
+ let relay_targets = collect_profile_relays(relay_pool.clone(), ndb.clone(), pubkey).await?;
+
+ let relay_targets_arc = Arc::new(relay_targets);
+
+ let cutoff = SystemTime::now()
+ .checked_sub(Duration::from_secs(60 * 60 * 24 * PROFILE_FEED_LOOKBACK_DAYS))
+ .and_then(|ts| ts.duration_since(SystemTime::UNIX_EPOCH).ok())
+ .map(|dur| dur.as_secs());
+
+ let mut fetched = stream_profile_feed_once(
+ relay_pool.clone(),
+ ndb.clone(),
+ relay_targets_arc.clone(),
+ pubkey,
+ cutoff,
+ )
+ .await?;
+
+ if fetched == 0 {
+ fetched = stream_profile_feed_once(
+ relay_pool.clone(),
+ ndb.clone(),
+ relay_targets_arc.clone(),
+ pubkey,
+ None,
+ )
+ .await?;
+ }
+
+ if fetched == 0 {
+ warn!(
+ "no profile notes fetched for {} even after fallback",
+ hex::encode(pubkey)
+ );
+ }
+
+ Ok(())
+}
+
+impl RenderData {
+ fn set_profile_key(&mut self, key: ProfileKey) {
+ match self {
+ RenderData::Profile(pk) => {
+ *pk = Some(ProfileRenderData::Profile(key));
+ }
+ RenderData::Note(note_rd) => {
+ note_rd.profile_rd = Some(ProfileRenderData::Profile(key));
}
+ };
+ }
- FilterField::Authors(authors) => {
- let authors = authors
- .into_iter()
- .map(|id| PublicKey::from_slice(id).expect("ok"));
- filter = filter.authors(authors);
+ fn set_note_key(&mut self, key: NoteKey) {
+ match self {
+ RenderData::Profile(_pk) => {}
+ RenderData::Note(note) => {
+ note.note_rd = NoteRenderData::Note(key);
}
+ };
+ }
- FilterField::Kinds(int_elems) => {
- let kinds = int_elems.into_iter().map(|knd| Kind::from_u16(knd as u16));
- filter = filter.kinds(kinds);
+ pub async fn complete(
+ &mut self,
+ ndb: Ndb,
+ relay_pool: Arc<RelayPool>,
+ nip19: Nip19,
+ ) -> Result<()> {
+ let (mut stream, fetch_handle) = {
+ let filter = renderdata_to_filter(self);
+ if filter.is_empty() {
+ // should really never happen unless someone broke
+ // needs_note and needs_profile
+ return Err(Error::NothingToFetch);
+ }
+ let sub_id = ndb.subscribe(&filter)?;
+
+ let stream = sub_id.stream(&ndb).notes_per_await(2);
+
+ let filters = filter.iter().map(convert_filter).collect();
+ let ndb = ndb.clone();
+ let pool = relay_pool.clone();
+ let handle = tokio::spawn(async move { find_note(pool, ndb, filters, &nip19).await });
+ (stream, handle)
+ };
+
+ let wait_for = Duration::from_secs(1);
+ let mut consecutive_timeouts = 0;
+
+ loop {
+ if !self.needs_note() && !self.needs_profile() {
+ break;
+ }
+
+ if consecutive_timeouts >= 5 {
+ warn!("render completion timed out waiting for remaining data");
+ break;
}
- FilterField::Tag { .. } => {}
+ let note_keys = match timeout(wait_for, stream.next()).await {
+ Ok(Some(note_keys)) => {
+ consecutive_timeouts = 0;
+ note_keys
+ }
+ Ok(None) => {
+ // end of stream
+ break;
+ }
+ Err(_) => {
+ consecutive_timeouts += 1;
+ continue;
+ }
+ };
- FilterField::Search { .. } => {}
+ let note_keys_len = note_keys.len();
+
+ {
+ let txn = Transaction::new(&ndb)?;
+
+ for note_key in note_keys {
+ let note = if let Ok(note) = ndb.get_note_by_key(&txn, note_key) {
+ note
+ } else {
+ error!("race condition in RenderData::complete?");
+ continue;
+ };
+
+ if note.kind() == 0 {
+ if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(&txn, note.pubkey()) {
+ self.set_profile_key(profile_key);
+ }
+ } else {
+ self.set_note_key(note_key);
+ }
+ }
+ }
+
+ if note_keys_len >= 2 && !self.needs_note() && !self.needs_profile() {
+ break;
+ }
+ }
+
+ match fetch_handle.await {
+ Ok(Ok(())) => Ok(()),
+ Ok(Err(err)) => Err(err),
+ Err(join_err) => Err(Error::Generic(format!(
+ "relay fetch task failed: {}",
+ join_err
+ ))),
}
}
+}
- filter
+fn collect_relay_hints(event: &Event) -> Vec<RelayUrl> {
+ let mut relays = Vec::new();
+ for tag in event.tags.as_slice() {
+ let parts = tag.as_slice();
+ if parts.is_empty() {
+ continue;
+ }
+ let tag_name = parts[0].as_str();
+ let candidate = if matches!(tag_name, "r" | "relay" | "relays") {
+ tag.content()
+ } else if event.kind == Kind::ContactList {
+ parts.get(2).map(|s| s.as_str())
+ } else {
+ None
+ };
+
+ let Some(url) = candidate else {
+ continue;
+ };
+ if url.is_empty() {
+ continue;
+ }
+
+ match RelayUrl::parse(url) {
+ Ok(relay) => relays.push(relay),
+ Err(err) => warn!("ignoring invalid relay hint {}: {}", url, err),
+ }
+ }
+ relays
}
-fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
- let mut filter = nostr::types::Filter::new();
+async fn ensure_relay_hints(relay_pool: &Arc<RelayPool>, event: &Event) -> Result<()> {
+ let hints = collect_relay_hints(event);
+ if hints.is_empty() {
+ return Ok(());
+ }
+ relay_pool.ensure_relays(hints).await
+}
- for element in ndb_filter {
- match element {
- FilterField::Ids(id_elems) => {
- let event_ids = id_elems
- .into_iter()
- .map(|id| EventId::from_slice(id).expect("event id"));
- filter = filter.ids(event_ids);
+async fn collect_profile_relays(
+ relay_pool: Arc<RelayPool>,
+ ndb: Ndb,
+ pubkey: [u8; 32],
+) -> Result<Vec<RelayUrl>> {
+ use nostr_sdk::JsonUtil;
+
+ relay_pool
+ .ensure_relays(relay_pool.default_relays().iter().cloned())
+ .await?;
+
+ let mut known: HashSet<String> = relay_pool
+ .default_relays()
+ .iter()
+ .map(|url| url.to_string())
+ .collect();
+ let mut targets = relay_pool.default_relays().to_vec();
+
+ let author_ref = [&pubkey];
+
+ let relay_filter = convert_filter(
+ &nostrdb::Filter::new()
+ .authors(author_ref)
+ .kinds([Kind::RelayList.as_u16() as u64])
+ .limit(1)
+ .build(),
+ );
+
+ let contact_filter = convert_filter(
+ &nostrdb::Filter::new()
+ .authors(author_ref)
+ .kinds([Kind::ContactList.as_u16() as u64])
+ .limit(1)
+ .build(),
+ );
+
+ for filter in [relay_filter, contact_filter] {
+ let mut stream = relay_pool
+ .stream_events(vec![filter.clone()], &[], Duration::from_millis(2000))
+ .await?;
+ while let Some(event) = stream.next().await {
+ if let Err(err) = ndb.process_event(&event.as_json()) {
+ error!("error processing relay discovery event: {err}");
}
- FilterField::Authors(authors) => {
- let authors = authors
- .into_iter()
- .map(|id| PublicKey::from_slice(id).expect("ok"));
- filter = filter.authors(authors);
+ let hints = collect_relay_hints(&event);
+ if hints.is_empty() {
+ continue;
}
- FilterField::Kinds(int_elems) => {
- let kinds = int_elems.into_iter().map(|knd| Kind::from_u16(knd as u16));
- filter = filter.kinds(kinds);
+ let mut fresh = Vec::new();
+ for hint in hints {
+ let key = hint.to_string();
+ if known.insert(key) {
+ targets.push(hint.clone());
+ fresh.push(hint);
+ }
+ }
+
+ if !fresh.is_empty() {
+ relay_pool.ensure_relays(fresh).await?;
+ }
+ }
+ }
+
+ Ok(targets)
+}
+
+async fn stream_profile_feed_once(
+ relay_pool: Arc<RelayPool>,
+ ndb: Ndb,
+ relays: Arc<Vec<RelayUrl>>,
+ pubkey: [u8; 32],
+ since: Option<u64>,
+) -> Result<usize> {
+ use nostr_sdk::JsonUtil;
+
+ let author_ref = [&pubkey];
+ let mut filter_builder = nostrdb::Filter::new()
+ .authors(author_ref)
+ .kinds([1])
+ .limit(PROFILE_FEED_RECENT_LIMIT as u64);
+
+ if let Some(since) = since {
+ filter_builder = filter_builder.since(since);
+ }
+
+ let filter = convert_filter(&filter_builder.build());
+ let mut stream = relay_pool
+ .stream_events(vec![filter], &relays, Duration::from_millis(2000))
+ .await?;
+
+ let mut fetched = 0usize;
+
+ while let Some(event) = stream.next().await {
+ if let Err(err) = ensure_relay_hints(&relay_pool, &event).await {
+ warn!("failed to apply relay hints: {err}");
+ }
+ if let Err(err) = ndb.process_event(&event.as_json()) {
+ error!("error processing profile feed event: {err}");
+ } else {
+ fetched += 1;
+ }
+ }
+
+ Ok(fetched)
+}
+
+/// Attempt to locate the render data locally. Anything missing from
+/// render data will be fetched.
+pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<RenderData> {
+ match nip19 {
+ Nip19::Event(nevent) => {
+ let m_note = ndb.get_note_by_id(txn, nevent.event_id.as_bytes()).ok();
+
+ let pk = if let Some(pk) = m_note.as_ref().map(|note| note.pubkey()) {
+ Some(*pk)
+ } else {
+ nevent.author.map(|a| a.serialize())
+ };
+
+ let profile_rd = pk.as_ref().map(|pubkey| {
+ if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) {
+ ProfileRenderData::Profile(profile_key)
+ } else {
+ ProfileRenderData::Missing(*pubkey)
+ }
+ });
+
+ let note_rd = if let Some(note_key) = m_note.and_then(|n| n.key()) {
+ NoteRenderData::Note(note_key)
+ } else {
+ NoteRenderData::Missing(*nevent.event_id.as_bytes())
+ };
+
+ Ok(RenderData::note(note_rd, profile_rd))
+ }
+
+ Nip19::EventId(evid) => {
+ let m_note = ndb.get_note_by_id(txn, evid.as_bytes()).ok();
+ let note_key = m_note.as_ref().and_then(|n| n.key());
+ let pk = m_note.map(|note| note.pubkey());
+
+ let profile_rd = pk.map(|pubkey| {
+ if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, pubkey) {
+ ProfileRenderData::Profile(profile_key)
+ } else {
+ ProfileRenderData::Missing(*pubkey)
+ }
+ });
+
+ let note_rd = if let Some(note_key) = note_key {
+ NoteRenderData::Note(note_key)
+ } else {
+ NoteRenderData::Missing(*evid.as_bytes())
+ };
+
+ Ok(RenderData::note(note_rd, profile_rd))
+ }
+
+ Nip19::Coordinate(coordinate) => {
+ let author = coordinate.public_key.serialize();
+ let kind: u64 = u16::from(coordinate.kind) as u64;
+ let identifier = coordinate.identifier.clone();
+
+ let note_rd = {
+ let filter = build_address_filter(&author, kind, identifier.as_str());
+ let note_key = ndb
+ .query(txn, &[filter], 1)
+ .ok()
+ .and_then(|results| results.into_iter().next().map(|res| res.note_key));
+
+ if let Some(note_key) = note_key {
+ NoteRenderData::Note(note_key)
+ } else {
+ NoteRenderData::Address {
+ author,
+ kind,
+ identifier: identifier.clone(),
+ }
+ }
+ };
+
+ let profile_rd = {
+ if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &author) {
+ Some(ProfileRenderData::Profile(profile_key))
+ } else {
+ Some(ProfileRenderData::Missing(author))
+ }
+ };
+
+ Ok(RenderData::note(note_rd, profile_rd))
+ }
+
+ Nip19::Profile(nprofile) => {
+ let pubkey = nprofile.public_key.serialize();
+ let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) {
+ ProfileRenderData::Profile(profile_key)
+ } else {
+ ProfileRenderData::Missing(pubkey)
+ };
+
+ Ok(RenderData::profile(Some(profile_rd)))
+ }
+
+ Nip19::Pubkey(public_key) => {
+ let pubkey = public_key.serialize();
+ let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) {
+ ProfileRenderData::Profile(profile_key)
+ } else {
+ ProfileRenderData::Missing(pubkey)
+ };
+
+ Ok(RenderData::profile(Some(profile_rd)))
+ }
+
+ _ => Err(Error::CantRender),
+ }
+}
+
+fn render_username(ui: &mut egui::Ui, profile: Option<&ProfileRecord>) {
+ let name = format!(
+ "@{}",
+ profile
+ .and_then(|pr| pr.record().profile().and_then(|p| p.name()))
+ .unwrap_or("nostrich")
+ );
+ ui.label(RichText::new(&name).size(40.0).color(Color32::LIGHT_GRAY));
+}
+
+fn setup_visuals(font_data: &egui::FontData, ctx: &egui::Context) {
+ let mut visuals = Visuals::dark();
+ visuals.override_text_color = Some(Color32::WHITE);
+ ctx.set_visuals(visuals);
+ fonts::setup_fonts(font_data, ctx);
+}
+
+fn push_job_text(job: &mut LayoutJob, s: &str, color: Color32) {
+ job.append(
+ s,
+ 0.0,
+ TextFormat {
+ font_id: FontId::new(50.0, FontFamily::Proportional),
+ color,
+ ..Default::default()
+ },
+ )
+}
+
+fn push_job_user_mention(
+ job: &mut LayoutJob,
+ ndb: &Ndb,
+ block: &Block,
+ txn: &Transaction,
+ pk: &[u8; 32],
+) {
+ let record = ndb.get_profile_by_pubkey(txn, pk);
+ if let Ok(record) = record {
+ let profile = record.record().profile().unwrap();
+ push_job_text(
+ job,
+ &format!("@{}", &abbrev_str(profile.name().unwrap_or("nostrich"))),
+ PURPLE,
+ );
+ } else {
+ push_job_text(job, &format!("@{}", &abbrev_str(block.as_str())), PURPLE);
+ }
+}
+
+fn wrapped_body_blocks(
+ ui: &mut egui::Ui,
+ ndb: &Ndb,
+ note: &Note,
+ blocks: &Blocks,
+ txn: &Transaction,
+) {
+ let mut job = LayoutJob {
+ justify: false,
+ halign: egui::Align::LEFT,
+ wrap: egui::text::TextWrapping {
+ max_rows: 5,
+ break_anywhere: false,
+ overflow_character: Some('…'),
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+
+ for block in blocks.iter(note) {
+ match block.blocktype() {
+ BlockType::Url => push_job_text(&mut job, block.as_str(), PURPLE),
+
+ BlockType::Hashtag => {
+ push_job_text(&mut job, "#", PURPLE);
+ push_job_text(&mut job, block.as_str(), PURPLE);
+ }
+
+ BlockType::MentionBech32 => {
+ match block.as_mention().unwrap() {
+ Mention::Event(_ev) => push_job_text(
+ &mut job,
+ &format!("@{}", &abbrev_str(block.as_str())),
+ PURPLE,
+ ),
+ Mention::Note(_ev) => {
+ push_job_text(
+ &mut job,
+ &format!("@{}", &abbrev_str(block.as_str())),
+ PURPLE,
+ );
+ }
+ Mention::Profile(nprofile) => {
+ push_job_user_mention(&mut job, ndb, &block, txn, nprofile.pubkey())
+ }
+ Mention::Pubkey(npub) => {
+ push_job_user_mention(&mut job, ndb, &block, txn, npub.pubkey())
+ }
+ Mention::Secret(_sec) => push_job_text(&mut job, "--redacted--", PURPLE),
+ Mention::Relay(_relay) => {
+ push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE)
+ }
+ Mention::Addr(_addr) => {
+ push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE)
+ }
+ };
}
- FilterField::Tag { .. } => {}
+ _ => push_job_text(&mut job, block.as_str(), Color32::WHITE),
+ };
+ }
+
+ ui.label(job);
+}
+
+fn wrapped_body_text(ui: &mut egui::Ui, text: &str) {
+ let format = TextFormat {
+ font_id: FontId::proportional(52.0),
+ color: Color32::WHITE,
+ extra_letter_spacing: 0.0,
+ line_height: Some(50.0),
+ ..Default::default()
+ };
+
+ let job = LayoutJob::single_section(text.to_owned(), format);
+ ui.label(job);
+}
+
+fn right_aligned() -> egui::Layout {
+ use egui::{Align, Direction, Layout};
+
+ Layout {
+ main_dir: Direction::RightToLeft,
+ main_wrap: false,
+ main_align: Align::Center,
+ main_justify: false,
+ cross_align: Align::Center,
+ cross_justify: false,
+ }
+}
+
+fn note_frame_align() -> egui::Layout {
+ use egui::{Align, Direction, Layout};
- FilterField::Search { .. } => {}
+ Layout {
+ main_dir: Direction::TopDown,
+ main_wrap: false,
+ main_align: Align::Center,
+ main_justify: false,
+ cross_align: Align::Center,
+ cross_justify: false,
+ }
+}
+
+fn note_ui(app: &Notecrumbs, ctx: &egui::Context, rd: &NoteAndProfileRenderData) -> Result<()> {
+ setup_visuals(&app.font_data, ctx);
+
+ let outer_margin = 60.0;
+ let inner_margin = 40.0;
+ let canvas_width = 1200.0;
+ let canvas_height = 600.0;
+ //let canvas_size = Vec2::new(canvas_width, canvas_height);
+
+ let total_margin = outer_margin + inner_margin;
+ let txn = Transaction::new(&app.ndb)?;
+ let profile_record = rd
+ .profile_rd
+ .as_ref()
+ .and_then(|profile_rd| match profile_rd {
+ ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(),
+ ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(),
+ });
+ //let _profile = profile_record.and_then(|pr| pr.record().profile());
+ //let pfp_url = profile.and_then(|p| p.picture());
+
+ // TODO: async pfp loading using notedeck browser context?
+ let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default());
+ let bg = ctx.load_texture("background", app.background.clone(), Default::default());
+
+ egui::CentralPanel::default()
+ .frame(
+ egui::Frame::default()
+ //.fill(Color32::from_rgb(0x43, 0x20, 0x62)
+ .fill(Color32::from_rgb(0x00, 0x00, 0x00)),
+ )
+ .show(ctx, |ui| {
+ background_texture(ui, &bg);
+ egui::Frame::none()
+ .fill(Color32::from_rgb(0x0F, 0x0F, 0x0F))
+ .shadow(Shadow {
+ extrusion: 50.0,
+ color: Color32::from_black_alpha(60),
+ })
+ .rounding(Rounding::same(20.0))
+ .outer_margin(outer_margin)
+ .inner_margin(inner_margin)
+ .show(ui, |ui| {
+ let desired_height = canvas_height - total_margin * 2.0;
+ let desired_width = canvas_width - total_margin * 2.0;
+ let desired_size = Vec2::new(desired_width, desired_height);
+ ui.set_max_size(desired_size);
+
+ ui.with_layout(note_frame_align(), |ui| {
+ //egui::ScrollArea::vertical().show(ui, |ui| {
+ ui.spacing_mut().item_spacing = Vec2::new(10.0, 50.0);
+
+ ui.vertical(|ui| {
+ let desired = Vec2::new(desired_width, desired_height / 1.5);
+ ui.set_max_size(desired);
+ ui.set_min_size(desired);
+
+ if let Ok(note) = rd.note_rd.lookup(&txn, &app.ndb) {
+ if let Some(blocks) = note
+ .key()
+ .and_then(|nk| app.ndb.get_blocks_by_key(&txn, nk).ok())
+ {
+ wrapped_body_blocks(ui, &app.ndb, ¬e, &blocks, &txn);
+ } else {
+ wrapped_body_text(ui, note.content());
+ }
+ }
+ });
+
+ ui.horizontal(|ui| {
+ ui.image(&pfp);
+ render_username(ui, profile_record.as_ref());
+ ui.with_layout(right_aligned(), discuss_on_damus);
+ });
+ });
+ });
+ });
+
+ Ok(())
+}
+
+fn background_texture(ui: &mut egui::Ui, texture: &TextureHandle) {
+ // Get the size of the panel
+ let size = ui.available_size();
+
+ // Create a rectangle for the texture
+ let rect = Rect::from_min_size(ui.min_rect().min, size);
+
+ // Get the current layer ID
+ let layer_id = ui.layer_id();
+
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+ //let uv_skewed = Rect::from_min_max(uv.min, pos2(uv.max.x, uv.max.y * 0.5));
+
+ // Get the painter and draw the texture
+ let painter = ui.ctx().layer_painter(layer_id);
+ //let tint = Color32::WHITE;
+
+ let mut mesh = Mesh::with_texture(texture.into());
+
+ // Define vertices for a rectangle
+ mesh.add_rect_with_uv(rect, uv, Color32::WHITE);
+
+ //let origin = pos2(600.0, 300.0);
+ //let angle = Rot2::from_angle(45.0);
+ //mesh.rotate(angle, origin);
+
+ // Draw the mesh
+ painter.add(Shape::mesh(mesh));
+
+ //painter.image(texture.into(), rect, uv_skewed, tint);
+}
+
+fn discuss_on_damus(ui: &mut egui::Ui) {
+ let button = egui::Button::new(
+ RichText::new("Discuss on Damus ➡")
+ .size(30.0)
+ .color(Color32::BLACK),
+ )
+ .rounding(50.0)
+ .min_size(Vec2::new(330.0, 75.0))
+ .fill(Color32::WHITE);
+
+ ui.add(button);
+}
+
+fn profile_ui(app: &Notecrumbs, ctx: &egui::Context, profile_rd: Option<&ProfileRenderData>) {
+ let pfp = ctx.load_texture("pfp", app.default_pfp.clone(), Default::default());
+ setup_visuals(&app.font_data, ctx);
+
+ egui::CentralPanel::default().show(ctx, |ui| {
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ ui.image(&pfp);
+ if let Ok(txn) = Transaction::new(&app.ndb) {
+ let profile = profile_rd.and_then(|prd| prd.lookup(&txn, &app.ndb).ok());
+ render_username(ui, profile.as_ref());
+ }
+ });
+ //body(ui, &profile.about);
+ });
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use nostr::nips::nip01::Coordinate;
+ use nostr::prelude::{EventBuilder, Keys, Tag};
+ use nostrdb::Config;
+ use std::fs;
+ use std::path::PathBuf;
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+ fn temp_db_dir(prefix: &str) -> PathBuf {
+ let base = PathBuf::from("target/test-dbs");
+ let _ = fs::create_dir_all(&base);
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("time went backwards")
+ .as_nanos();
+ let dir = base.join(format!("{}-{}", prefix, nanos));
+ let _ = fs::create_dir_all(&dir);
+ dir
+ }
+
+ fn wait_for_note(ndb: &Ndb, note_id: &[u8; 32]) {
+ let deadline = Instant::now() + Duration::from_millis(500);
+ loop {
+ if let Ok(txn) = Transaction::new(ndb) {
+ if ndb.get_note_by_id(&txn, note_id).is_ok() {
+ return;
+ }
+ }
+
+ if Instant::now() >= deadline {
+ panic!("timed out waiting for note ingestion");
+ }
+
+ std::thread::sleep(Duration::from_millis(10));
}
}
- filter
+ #[test]
+ fn build_address_filter_includes_only_d_tags() {
+ let author = [1u8; 32];
+ let identifier = "article-slug";
+ let kind = Kind::LongFormTextNote.as_u16() as u64;
+
+ let filter = build_address_filter(&author, kind, identifier);
+ let mut saw_d_tag = false;
+
+ for field in &filter {
+ if let FilterField::Tags(tag, elements) = field {
+ assert_eq!(tag, 'd', "unexpected tag '{}' in filter", tag);
+ let mut values: Vec<String> = Vec::new();
+ for element in elements {
+ match element {
+ FilterElement::Str(value) => values.push(value.to_owned()),
+ other => panic!("unexpected tag element {:?}", other),
+ }
+ }
+ assert_eq!(values, vec![identifier.to_owned()]);
+ saw_d_tag = true;
+ }
+ }
+
+ assert!(saw_d_tag, "expected filter to include a 'd' tag constraint");
+ }
+
+ #[test]
+ fn query_note_by_address_uses_d_and_a_tag_filters() {
+ let keys = Keys::generate();
+ let author = keys.public_key().to_bytes();
+ let kind = Kind::LongFormTextNote.as_u16() as u64;
+ let identifier_with_d = "with-d-tag";
+ let identifier_with_a = "only-a-tag";
+
+ let db_dir = temp_db_dir("address-filters");
+ let db_path = db_dir.to_string_lossy().to_string();
+ let cfg = Config::new().skip_validation(true);
+ let ndb = Ndb::new(&db_path, &cfg).expect("failed to open nostrdb");
+
+ let event_with_d = EventBuilder::long_form_text_note("content with d tag")
+ .tags([Tag::identifier(identifier_with_d)])
+ .sign_with_keys(&keys)
+ .expect("sign long-form event with d tag");
+
+ let coordinate = Coordinate::new(Kind::LongFormTextNote, keys.public_key())
+ .identifier(identifier_with_a);
+ let event_with_a_only = EventBuilder::long_form_text_note("content with a tag only")
+ .tags([Tag::coordinate(coordinate)])
+ .sign_with_keys(&keys)
+ .expect("sign long-form event with coordinate tag");
+
+ ndb.process_event(&serde_json::to_string(&event_with_d).unwrap())
+ .expect("ingest event with d tag");
+ ndb.process_event(&serde_json::to_string(&event_with_a_only).unwrap())
+ .expect("ingest event with a tag");
+
+ let event_with_d_id = event_with_d.id.to_bytes();
+ let event_with_a_only_id = event_with_a_only.id.to_bytes();
+ wait_for_note(&ndb, &event_with_d_id);
+ wait_for_note(&ndb, &event_with_a_only_id);
+
+ {
+ let txn = Transaction::new(&ndb).expect("transaction for d-tag lookup");
+ let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_d)
+ .expect("should find event by d tag");
+ assert_eq!(note.id(), &event_with_d_id);
+ }
+
+ {
+ let txn = Transaction::new(&ndb).expect("transaction for a-tag lookup");
+ let note = query_note_by_address(&ndb, &txn, &author, kind, identifier_with_a)
+ .expect("should find event via a-tag fallback");
+ assert_eq!(note.id(), &event_with_a_only_id);
+ }
+
+ drop(ndb);
+ let _ = fs::remove_dir_all(&db_dir);
+ }
+}
+
+pub fn render_note(ndb: &Notecrumbs, render_data: &RenderData) -> Vec<u8> {
+ use egui_skia::{rasterize, RasterizeOptions};
+ use skia_safe::EncodedImageFormat;
+
+ let options = RasterizeOptions {
+ pixels_per_point: 1.0,
+ frames_before_screenshot: 1,
+ };
+
+ let mut surface = match render_data {
+ RenderData::Note(note_render_data) => rasterize(
+ (1200, 600),
+ |ctx| {
+ let _ = note_ui(ndb, ctx, note_render_data);
+ },
+ Some(options),
+ ),
+
+ RenderData::Profile(profile_rd) => rasterize(
+ (1200, 600),
+ |ctx| profile_ui(ndb, ctx, profile_rd.as_ref()),
+ Some(options),
+ ),
+ };
+
+ surface
+ .image_snapshot()
+ .encode_to_data(EncodedImageFormat::PNG)
+ .expect("expected image")
+ .as_bytes()
+ .into()
}