commit 60546331d5ec2cdbc598924722b1b903dc1425e5
parent a0a2a5126fa01aa09be1f8ebfcf7221e4c968640
Author: alltheseas <alltheseas@users.noreply.github.com>
Date: Wed, 22 Oct 2025 09:56:21 -0500
Add persistent relay pool and richer npub profile rendering
Diffstat:
| M | src/html.rs | | | 367 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/main.rs | | | 143 | +++++++++++++++++++++++++++++++++++++++++++++++++------------------------------- |
| A | src/relay_pool.rs | | | 113 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/render.rs | | | 791 | ++++++------------------------------------------------------------------------- |
4 files changed, 622 insertions(+), 792 deletions(-)
diff --git a/src/html.rs b/src/html.rs
@@ -7,8 +7,8 @@ use crate::{
use ammonia::Builder as HtmlSanitizer;
use http_body_util::Full;
use hyper::{body::Bytes, header, Request, Response, StatusCode};
-use nostr_sdk::prelude::{Nip19, ToBech32};
-use nostrdb::{BlockType, Blocks, Filter, Mention, Ndb, Note, Transaction};
+use nostr_sdk::prelude::{EventId, Nip19, ToBech32};
+use nostrdb::{BlockType, Blocks, Filter, Mention, Ndb, Note, NoteKey, Transaction};
use pulldown_cmark::{html, Options, Parser};
use std::fmt::Write as _;
use std::io::Write;
@@ -623,3 +623,366 @@ pub fn serve_note_html(
.status(StatusCode::OK)
.body(Full::new(Bytes::from(data)))?)
}
+
+pub fn serve_profile_html(
+ app: &Notecrumbs,
+ nip19: &Nip19,
+ profile_rd: Option<&ProfileRenderData>,
+ _r: Request<hyper::body::Incoming>,
+) -> Result<Response<Full<Bytes>>, Error> {
+ let mut data = Vec::new();
+
+ let Some(profile_rd) = profile_rd else {
+ let _ = write!(data, "Profile not found :(");
+ return Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::NOT_FOUND)
+ .body(Full::new(Bytes::from(data)))?);
+ };
+
+ let txn = Transaction::new(&app.ndb)?;
+
+ let (profile_rec, profile_pubkey) = match profile_rd {
+ ProfileRenderData::Profile(profile_key) => {
+ let rec = match app.ndb.get_profile_by_key(&txn, *profile_key) {
+ Ok(rec) => rec,
+ Err(_) => {
+ let _ = write!(data, "Profile not found :(");
+ return Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::NOT_FOUND)
+ .body(Full::new(Bytes::from(data)))?);
+ }
+ };
+
+ let mut pubkey = None;
+ if let Ok(profile_note) = app
+ .ndb
+ .get_note_by_key(&txn, NoteKey::new(rec.record().note_key()))
+ {
+ pubkey = Some(*profile_note.pubkey());
+ }
+
+ (rec, pubkey)
+ }
+ ProfileRenderData::Missing(pk) => {
+ let rec = match app.ndb.get_profile_by_pubkey(&txn, pk) {
+ Ok(rec) => rec,
+ Err(_) => {
+ let _ = write!(data, "Profile not found :(");
+ return Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::NOT_FOUND)
+ .body(Full::new(Bytes::from(data)))?);
+ }
+ };
+
+ (rec, Some(*pk))
+ }
+ };
+
+ let profile_data = profile_rec.record().profile();
+ let mut display_name = String::new();
+ let mut username = String::new();
+ let mut about_html = None;
+ let mut nip05 = None;
+ let mut website = None;
+ let mut lud16 = None;
+ let mut banner = None;
+ let mut picture = None;
+
+ if let Some(profile) = profile_data {
+ if let Some(name) = profile.name() {
+ username = name.to_owned();
+ }
+ if let Some(display) = profile.display_name() {
+ display_name = display.to_owned();
+ }
+ if let Some(about) = profile.about() {
+ let escaped = html_escape::encode_text(about).into_owned();
+ about_html = Some(escaped.replace('\n', "<br />"));
+ }
+ if let Some(n) = profile.nip05() {
+ if !n.is_empty() {
+ nip05 = Some(html_escape::encode_text(n).into_owned());
+ }
+ }
+ if let Some(site) = profile.website() {
+ let trimmed = site.trim();
+ if !trimmed.is_empty() {
+ let href = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
+ trimmed.to_owned()
+ } else {
+ format!("https://{}", trimmed)
+ };
+ website = Some((
+ html_escape::encode_double_quoted_attribute(&href).into_owned(),
+ html_escape::encode_text(trimmed).into_owned(),
+ ));
+ }
+ }
+ if let Some(pay) = profile.lud16() {
+ if !pay.is_empty() {
+ lud16 = Some(html_escape::encode_text(pay).into_owned());
+ }
+ }
+ if let Some(pic) = profile.picture() {
+ if !pic.is_empty() {
+ picture = Some(pic.to_owned());
+ }
+ }
+ if let Some(b) = profile.banner() {
+ if !b.is_empty() {
+ banner = Some(b.to_owned());
+ }
+ }
+ }
+
+ if display_name.is_empty() {
+ if !username.is_empty() {
+ display_name = username.clone();
+ } else {
+ display_name = "nostrich".to_string();
+ }
+ }
+
+ let default_pfp_url = "https://damus.io/img/no-profile.svg";
+ let pfp_url = picture.unwrap_or_else(|| default_pfp_url.to_string());
+ let pfp_attr = html_escape::encode_double_quoted_attribute(&pfp_url).into_owned();
+
+ let username_display = if username.is_empty() {
+ String::new()
+ } else {
+ format!("@{}", html_escape::encode_text(&username))
+ };
+
+ 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)
+ .build();
+
+ if let Ok(results) = app.ndb.query(&txn, &[note_filter], 6) {
+ for res in results {
+ if let Ok(note) = app.ndb.get_note_by_key(&txn, res.note_key) {
+ let mut note_body = Vec::new();
+ if let Some(blocks) = note
+ .key()
+ .and_then(|nk| app.ndb.get_blocks_by_key(&txn, nk).ok())
+ {
+ render_note_content(&mut note_body, ¬e, &blocks);
+ } else {
+ let _ = write!(note_body, "{}", html_escape::encode_text(note.content()));
+ }
+
+ let note_body_html = String::from_utf8(note_body).unwrap_or_default();
+ let timestamp_value = note.created_at();
+ let note_link = EventId::from_slice(note.id())
+ .ok()
+ .and_then(|id| id.to_bech32().ok())
+ .map(|bech| format!("/{bech}"))
+ .unwrap_or_default();
+ 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">
+ <div class="note-header">
+ <img src="{pfp}" class="note-author-avatar" />
+ <div class="note-author-name">{author}</div>
+ <div class="note-header-separator">·</div>
+ <time class="note-timestamp" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
+ </div>
+ <div class="note-content">{body}</div>
+ <div class="note-actions-footer">
+ <a class="muted-link" href={href}>Open note</a>
+ </div>
+</div>
+"#,
+ pfp = pfp_attr,
+ author = author_display_html,
+ ts = timestamp_value,
+ body = note_body_html,
+ href = note_link_attr,
+ );
+ has_recent_notes = true;
+ }
+ }
+ }
+ }
+
+ let hostname = "https://damus.io";
+ let bech32 = nip19.to_bech32().unwrap();
+ let canonical_url = format!("{}/{}", hostname, bech32);
+ let fallback_image_url = format!("{}/{}.png", hostname, bech32);
+
+ let og_image_url = if pfp_url == default_pfp_url {
+ fallback_image_url.clone()
+ } else {
+ pfp_url.clone()
+ };
+
+ let page_heading = "Profile";
+ let og_type = "website";
+
+ let about_for_meta = about_html
+ .as_ref()
+ .map(|html| html.replace("<br />", " "))
+ .unwrap_or_default();
+ let og_description_raw = if !about_for_meta.is_empty() {
+ collapse_whitespace(&about_for_meta)
+ } else {
+ format!("{} on nostr", &display_name)
+ };
+
+ let about_block = about_html
+ .as_ref()
+ .map(|html| format!(r#"<p class="profile-about">{}</p>"#, html))
+ .unwrap_or_default();
+
+ let nip05_block = nip05
+ .as_ref()
+ .map(|val| format!(r#"<div class="profile-nip05">✅ {}</div>"#, val))
+ .unwrap_or_default();
+
+ let lud16_block = lud16
+ .as_ref()
+ .map(|val| format!(r#"<div class="profile-lnurl">⚡ {}</div>"#, val))
+ .unwrap_or_default();
+
+ let website_block = website
+ .as_ref()
+ .map(|(href, label)| format!(r#"<a class="profile-website" href={}>{}</a>"#, href, label))
+ .unwrap_or_default();
+
+ let banner_block = banner
+ .as_ref()
+ .map(|url| {
+ let attr = html_escape::encode_double_quoted_attribute(url).into_owned();
+ format!(
+ r#"<img src="{}" class="profile-banner" alt="Profile banner image" />"#,
+ attr
+ )
+ })
+ .unwrap_or_default();
+
+ let recent_section = if has_recent_notes {
+ format!(
+ r#"<div class="profile-section">
+ <h4 class="section-heading">Recent notes</h4>
+ {}
+</div>"#,
+ recent_notes_markup
+ )
+ } else {
+ String::new()
+ };
+
+ let _ = write!(
+ data,
+ r#"
+ <html>
+ <head>
+ <title>{title} on nostr</title>
+ <link rel="stylesheet" href="https://damus.io/css/notecrumbs.css" type="text/css" />
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="apple-itunes-app" content="app-id=1628663131, app-argument=damus:nostr:{bech32}"/>
+ <meta charset="UTF-8">
+
+ <meta property="og:description" content="{og_description}" />
+ <meta property="og:image" content="{og_image}"/>
+ <meta property="og:image:alt" content="{title}: {og_description}" />
+ <meta property="og:image:height" content="600" />
+ <meta property="og:image:width" content="1200" />
+ <meta property="og:image:type" content="image/png" />
+ <meta property="og:site_name" content="Damus" />
+ <meta property="og:title" content="{title} on nostr" />
+ <meta property="og:url" content="{canonical}"/>
+ <meta name="og:type" content="{og_type}"/>
+ <meta name="twitter:image:src" content="{og_image}" />
+ <meta name="twitter:site" content="@damusapp" />
+ <meta name="twitter:card" content="summary_large_image" />
+ <meta name="twitter:title" content="{title} on nostr" />
+ <meta name="twitter:description" content="{og_description}" />
+
+ </head>
+ <body>
+ <main>
+ <div class="container">
+ <div class="top-menu">
+ <a href="https://damus.io" target="_blank">
+ <img src="https://damus.io/logo_icon.png" class="logo" />
+ </a>
+ </div>
+ <h3 class="page-heading">{page_heading}</h3>
+ <div class="note profile-card">
+ {banner}
+ <div class="profile-header">
+ <img src="{pfp}" class="note-author-avatar" />
+ <div class="profile-author-meta">
+ <div class="note-author-name">{author}</div>
+ {username}
+ {nip05}
+ {lud16}
+ {website}
+ </div>
+ </div>
+ {about}
+ </div>
+ {recent_section}
+ </div>
+ <div class="note-actions-footer">
+ <a href="nostr:{bech32}" class="muted-link">Open with default Nostr client</a>
+ </div>
+ </main>
+ <footer>
+ <span class="footer-note">
+ <a href="https://damus.io">Damus</a> is a decentralized social network app built on the Nostr protocol.
+ </span>
+ <span class="copyright-note">
+ © Damus Nostr Inc.
+ </span>
+ </footer>
+ {time_script}
+ </body>
+ </html>
+ "#,
+ title = html_escape::encode_text(&display_name),
+ og_description = html_escape::encode_double_quoted_attribute(&og_description_raw),
+ og_image = html_escape::encode_double_quoted_attribute(&og_image_url),
+ canonical = html_escape::encode_double_quoted_attribute(&canonical_url),
+ og_type = og_type,
+ banner = banner_block,
+ pfp = pfp_attr,
+ author = author_display_html,
+ username = if username_display.is_empty() {
+ String::new()
+ } else {
+ format!(
+ r#"<div class="profile-username">{}</div>"#,
+ username_display
+ )
+ },
+ about = about_block,
+ nip05 = nip05_block,
+ lud16 = lud16_block,
+ website = website_block,
+ recent_section = recent_section,
+ page_heading = page_heading,
+ bech32 = bech32,
+ time_script = LOCAL_TIME_SCRIPT,
+ );
+
+ Ok(Response::builder()
+ .header(header::CONTENT_TYPE, "text/html")
+ .status(StatusCode::OK)
+ .body(Full::new(Bytes::from(data)))?)
+}
diff --git a/src/main.rs b/src/main.rs
@@ -7,7 +7,7 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
-use std::io::Write;
+use metrics_exporter_prometheus::PrometheusHandle;
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing::{error, info};
@@ -17,7 +17,7 @@ use crate::{
render::{ProfileRenderData, RenderData},
};
use nostr_sdk::prelude::*;
-use nostrdb::{Config, Ndb, Transaction};
+use nostrdb::{Config, Ndb, NoteKey, Transaction};
use std::time::Duration;
use lru::LruCache;
@@ -29,10 +29,12 @@ mod gradient;
mod html;
mod nip19;
mod pfp;
+mod relay_pool;
mod render;
mod timeout;
use crate::secp256k1::XOnlyPublicKey;
+use relay_pool::RelayPool;
type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>;
@@ -40,10 +42,12 @@ type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>;
pub struct Notecrumbs {
pub ndb: Ndb,
keys: Keys,
+ relay_pool: Arc<RelayPool>,
font_data: egui::FontData,
_img_cache: Arc<ImageCache>,
default_pfp: egui::ImageData,
background: egui::ImageData,
+ prometheus_handle: PrometheusHandle,
/// How long do we wait for remote note requests
_timeout: Duration,
@@ -70,58 +74,18 @@ fn is_utf8_char_boundary(c: u8) -> bool {
(c as i8) >= -0x40
}
-fn serve_profile_html(
- app: &Notecrumbs,
- _nip: &Nip19,
- profile_rd: Option<&ProfileRenderData>,
- _r: Request<hyper::body::Incoming>,
-) -> Result<Response<Full<Bytes>>, Error> {
- let mut data = Vec::new();
-
- let profile_key = match profile_rd {
- None | Some(ProfileRenderData::Missing(_)) => {
- let _ = write!(data, "Profile not found :(");
- return Ok(Response::builder()
- .header(header::CONTENT_TYPE, "text/html")
- .status(StatusCode::NOT_FOUND)
- .body(Full::new(Bytes::from(data)))?);
- }
-
- Some(ProfileRenderData::Profile(profile_key)) => *profile_key,
- };
-
- let txn = Transaction::new(&app.ndb)?;
-
- let profile_rec = if let Ok(profile_rec) = app.ndb.get_profile_by_key(&txn, profile_key) {
- profile_rec
- } else {
- let _ = write!(data, "Profile not found :(");
- return Ok(Response::builder()
- .header(header::CONTENT_TYPE, "text/html")
- .status(StatusCode::NOT_FOUND)
- .body(Full::new(Bytes::from(data)))?);
- };
-
- let _ = write!(
- data,
- "{}",
- profile_rec
- .record()
- .profile()
- .and_then(|p| p.name())
- .unwrap_or("nostrich")
- );
-
- Ok(Response::builder()
- .header(header::CONTENT_TYPE, "text/html")
- .status(StatusCode::OK)
- .body(Full::new(Bytes::from(data)))?)
-}
-
async fn serve(
app: &Notecrumbs,
r: Request<hyper::body::Incoming>,
) -> Result<Response<Full<Bytes>>, Error> {
+ if r.uri().path() == "/metrics" {
+ let body = app.prometheus_handle.render();
+ return Ok(Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/plain; version=0.0.4")
+ .body(Full::new(Bytes::from(body)))?);
+ }
+
let is_png = r.uri().path().ends_with(".png");
let is_json = r.uri().path().ends_with(".json");
let until = if is_png {
@@ -160,13 +124,43 @@ async fn serve(
// fetch extra data if we are missing it
if !render_data.is_complete() {
if let Err(err) = render_data
- .complete(app.ndb.clone(), app.keys.clone(), nip19.clone())
+ .complete(app.ndb.clone(), app.relay_pool.clone(), nip19.clone())
.await
{
error!("Error fetching completion data: {err}");
}
}
+ if let RenderData::Profile(profile_opt) = &render_data {
+ let maybe_pubkey = {
+ let txn = Transaction::new(&app.ndb)?;
+ match profile_opt {
+ Some(ProfileRenderData::Profile(profile_key)) => {
+ if let Ok(profile_rec) = app.ndb.get_profile_by_key(&txn, *profile_key) {
+ let note_key = NoteKey::new(profile_rec.record().note_key());
+ if let Ok(profile_note) = app.ndb.get_note_by_key(&txn, note_key) {
+ Some(*profile_note.pubkey())
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ }
+ Some(ProfileRenderData::Missing(pk)) => Some(*pk),
+ None => None,
+ }
+ };
+
+ if let Some(pubkey) = maybe_pubkey {
+ if let Err(err) =
+ render::fetch_profile_feed(app.relay_pool.clone(), app.ndb.clone(), pubkey).await
+ {
+ error!("Error fetching profile feed: {err}");
+ }
+ }
+ }
+
if is_png {
let data = render::render_note(app, &render_data);
@@ -187,12 +181,18 @@ async fn serve(
match render_data {
RenderData::Note(note_rd) => html::serve_note_html(app, &nip19, ¬e_rd, r),
RenderData::Profile(profile_rd) => {
- serve_profile_html(app, &nip19, profile_rd.as_ref(), r)
+ html::serve_profile_html(app, &nip19, profile_rd.as_ref(), r)
}
}
}
}
+fn get_env_timeout() -> Duration {
+ let timeout_env = std::env::var("TIMEOUT_MS").unwrap_or("2000".to_string());
+ let timeout_ms: u64 = timeout_env.parse().unwrap_or(2000);
+ Duration::from_millis(timeout_ms)
+}
+
fn get_gradient() -> egui::ColorImage {
use egui::{Color32, ColorImage};
//use egui::pos2;
@@ -247,6 +247,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ndb = Ndb::new(".", &cfg).expect("ndb failed to open");
let keys = Keys::generate();
let timeout = timeout::get_env_timeout();
+ let prometheus_handle = metrics_exporter_prometheus::PrometheusBuilder::new()
+ .install_recorder()
+ .expect("install prometheus recorder");
+ let relay_pool = Arc::new(
+ RelayPool::new(
+ keys.clone(),
+ &["wss://relay.damus.io", "wss://nostr.wine", "wss://nos.lol"],
+ timeout,
+ )
+ .await?,
+ );
+ spawn_relay_pool_metrics_logger(relay_pool.clone());
let img_cache = Arc::new(LruCache::new(std::num::NonZeroUsize::new(64).unwrap()));
let default_pfp = egui::ImageData::Color(Arc::new(get_default_pfp()));
let background = egui::ImageData::Color(Arc::new(get_gradient()));
@@ -255,11 +267,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let app = Notecrumbs {
ndb,
keys,
- _timeout: timeout,
- _img_cache: img_cache,
- background,
+ relay_pool,
font_data,
+ _img_cache: img_cache,
default_pfp,
+ background,
+ prometheus_handle,
+ _timeout: timeout,
};
// We start a loop to continuously accept incoming connections
@@ -285,3 +299,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
});
}
}
+
+fn spawn_relay_pool_metrics_logger(pool: Arc<RelayPool>) {
+ tokio::spawn(async move {
+ let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
+ loop {
+ ticker.tick().await;
+ let (stats, tracked) = pool.relay_stats().await;
+ metrics::gauge!("relay_pool_known_relays", tracked as f64);
+ info!(
+ total_relays = tracked,
+ ensure_calls = stats.ensure_calls,
+ relays_added = stats.relays_added,
+ connect_successes = stats.connect_successes,
+ connect_failures = stats.connect_failures,
+ "relay pool metrics snapshot"
+ );
+ }
+ });
+}
diff --git a/src/relay_pool.rs b/src/relay_pool.rs
@@ -0,0 +1,113 @@
+use crate::Error;
+use nostr::prelude::RelayUrl;
+use nostr_sdk::prelude::{Client, Event, Filter, Keys, ReceiverStream};
+use std::collections::HashSet;
+use std::sync::Arc;
+use tokio::sync::Mutex;
+use tokio::time::Duration;
+use tracing::{debug, warn};
+
+/// Persistent relay pool responsible for maintaining long-lived connections.
+#[derive(Clone)]
+pub struct RelayPool {
+ client: Client,
+ known_relays: Arc<Mutex<HashSet<String>>>,
+ default_relays: Arc<Vec<RelayUrl>>,
+ connect_timeout: Duration,
+}
+
+impl RelayPool {
+ pub async fn new(
+ keys: Keys,
+ default_relays: &[&str],
+ connect_timeout: Duration,
+ ) -> Result<Self, Error> {
+ let client = Client::builder().signer(keys).build();
+ let parsed_defaults: Vec<RelayUrl> = default_relays
+ .iter()
+ .filter_map(|url| match RelayUrl::parse(url) {
+ Ok(relay) => Some(relay),
+ Err(err) => {
+ warn!("failed to parse default relay {url}: {err}");
+ None
+ }
+ })
+ .collect();
+
+ let pool = Self {
+ client,
+ known_relays: Arc::new(Mutex::new(HashSet::new())),
+ default_relays: Arc::new(parsed_defaults),
+ connect_timeout,
+ };
+
+ pool.ensure_relays(pool.default_relays()).await?;
+ pool.connect_known_relays().await?;
+
+ Ok(pool)
+ }
+
+ pub fn default_relays(&self) -> Vec<RelayUrl> {
+ self.default_relays.as_ref().clone()
+ }
+
+ pub async fn ensure_relays<I>(&self, relays: I) -> Result<(), Error>
+ where
+ I: IntoIterator<Item = RelayUrl>,
+ {
+ let mut new_relays = Vec::new();
+ {
+ let mut guard = self.known_relays.lock().await;
+ for relay in relays {
+ let key = relay.to_string();
+ if guard.insert(key) {
+ new_relays.push(relay);
+ }
+ }
+ }
+
+ for relay in new_relays {
+ debug!("adding relay {}", relay);
+ self.client.add_relay(relay.clone()).await?;
+ if let Err(err) = self.client.connect_relay(relay.clone()).await {
+ warn!("failed to connect relay {}: {}", relay, err);
+ }
+ }
+
+ Ok(())
+ }
+
+ pub async fn stream_events(
+ &self,
+ filters: Vec<Filter>,
+ relays: &[RelayUrl],
+ timeout: Duration,
+ ) -> Result<ReceiverStream<Event>, Error> {
+ self.client.connect_with_timeout(self.connect_timeout).await;
+
+ if relays.is_empty() {
+ Ok(self.client.stream_events(filters, Some(timeout)).await?)
+ } else {
+ let urls: Vec<String> = relays.iter().map(|r| r.to_string()).collect();
+ Ok(self
+ .client
+ .stream_events_from(urls, filters, Some(timeout))
+ .await?)
+ }
+ }
+
+ async fn connect_known_relays(&self) -> Result<(), Error> {
+ let relays = {
+ let guard = self.known_relays.lock().await;
+ guard.iter().cloned().collect::<Vec<_>>()
+ };
+
+ if relays.is_empty() {
+ return Ok(());
+ }
+
+ self.client.connect_with_timeout(self.connect_timeout).await;
+
+ Ok(())
+ }
+}
diff --git a/src/render.rs b/src/render.rs
@@ -1,5 +1,7 @@
use crate::timeout;
-use crate::{abbrev::abbrev_str, error::Result, fonts, nip19, Error, Notecrumbs};
+use crate::{
+ abbrev::abbrev_str, error::Result, fonts, nip19, relay_pool::RelayPool, Error, Notecrumbs,
+};
use egui::epaint::Shadow;
use egui::{
pos2,
@@ -8,19 +10,27 @@ use egui::{
Visuals,
};
use nostr::event::kind::Kind;
-use nostr::types::{SingleLetterTag, Timestamp};
+use nostr::types::{RelayUrl, SingleLetterTag, Timestamp};
use nostr_sdk::async_utility::futures_util::StreamExt;
use nostr_sdk::nips::nip19::Nip19;
-use nostr_sdk::prelude::{Client, EventId, Keys, PublicKey};
+use nostr_sdk::prelude::{Event, EventId, PublicKey};
use nostrdb::{
Block, BlockType, Blocks, FilterElement, FilterField, Mention, Ndb, Note, NoteKey, ProfileKey,
ProfileRecord, Transaction,
};
-use std::collections::{BTreeMap, BTreeSet};
+use std::collections::{BTreeMap, BTreeSet, HashSet};
+use std::sync::Arc;
+use std::time::SystemTime;
use tokio::time::{timeout, Duration};
use tracing::{debug, error, warn};
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 enum NoteRenderData {
Missing([u8; 32]),
@@ -213,766 +223,77 @@ fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
filter = filter.kinds(kinds);
}
- 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::Tag { .. } => {}
- FilterField::Since(since) => {
- filter.since = Some(Timestamp::from_secs(since));
- }
-
- FilterField::Until(until) => {
- filter.until = Some(Timestamp::from_secs(until));
- }
-
- FilterField::Limit(limit) => {
- filter.limit = Some(limit as usize);
- }
+ FilterField::Search { .. } => {}
}
}
filter
}
-fn coordinate_tag(author: &[u8; 32], kind: u64, identifier: &str) -> String {
- let pk_hex = hex::encode(author);
- format!("{}:{}:{}", kind, pk_hex, identifier)
-}
-
-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() {
- Ok(result.note.clone())
- } else {
- Err(nostrdb::Error::NotFound)
- }
-}
-
-pub async fn find_note(
- ndb: Ndb,
- keys: Keys,
- filters: Vec<nostr::Filter>,
- nip19: &Nip19,
-) -> Result<()> {
- use nostr_sdk::JsonUtil;
-
- let client = Client::builder().signer(keys).build();
-
- let _ = client.add_relay("wss://relay.damus.io").await;
- let _ = client.add_relay("wss://nostr.wine").await;
- let _ = client.add_relay("wss://nos.lol").await;
- let expected_events = filters.len();
-
- let other_relays = nip19::nip19_relays(nip19);
- for relay in other_relays {
- let _ = client.add_relay(relay).await;
- }
-
- client
- .connect_with_timeout(timeout::get_env_timeout())
- .await;
-
- debug!("finding note(s) with filters: {:?}", filters);
-
- let mut streamed_events = client
- .stream_events(filters, Some(timeout::get_env_timeout()))
- .await?;
-
- let mut num_loops = 0;
- while let Some(event) = streamed_events.next().await {
- 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(())
-}
-
-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));
- }
- };
- }
-
- fn set_note_key(&mut self, key: NoteKey) {
- match self {
- RenderData::Profile(_pk) => {}
- RenderData::Note(note) => {
- note.note_rd = NoteRenderData::Note(key);
- }
- };
- }
+fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
+ let mut filter = nostr::types::Filter::new();
- pub async fn complete(&mut self, ndb: Ndb, keys: Keys, nip19: Nip19) -> Result<()> {
- let mut stream = {
- 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);
+ 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);
}
- 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();
- tokio::spawn(async move { find_note(ndb, keys, filters, &nip19).await });
- stream
- };
-
- let wait_for = Duration::from_secs(1);
- let mut loops = 0;
-
- loop {
- if loops == 2 {
- break;
+ FilterField::Authors(authors) => {
+ let authors = authors
+ .into_iter()
+ .map(|id| PublicKey::from_slice(id).expect("ok"));
+ filter = filter.authors(authors);
}
- let note_keys = if let Some(note_keys) = timeout(wait_for, stream.next()).await? {
- note_keys
- } else {
- // end of stream?
- break;
- };
-
- 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);
- }
- }
+ FilterField::Kinds(int_elems) => {
+ let kinds = int_elems.into_iter().map(|knd| Kind::from_u16(knd as u16));
+ filter = filter.kinds(kinds);
}
- if note_keys_len >= 2 {
- break;
- }
+ FilterField::Tag { .. } => {}
- loops += 1;
+ FilterField::Search { .. } => {}
}
-
- Ok(())
}
-}
-
-/// 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,
- }
- }
- };
-
- 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()
- },
- )
+ filter
}
-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 convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter {
+ let mut filter = nostr::types::Filter::new();
-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);
+ 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);
}
- 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::Authors(authors) => {
+ let authors = authors
+ .into_iter()
+ .map(|id| PublicKey::from_slice(id).expect("ok"));
+ filter = filter.authors(authors);
}
- _ => 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};
-
- 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, Filter};
- use std::fs;
- use std::path::PathBuf;
- use std::time::{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
- }
-
- #[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;
+ FilterField::Kinds(int_elems) => {
+ let kinds = int_elems.into_iter().map(|knd| Kind::from_u16(knd as u16));
+ filter = filter.kinds(kinds);
}
- }
-
- assert!(saw_d_tag, "expected filter to include a 'd' tag constraint");
- }
- #[tokio::test]
- async 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");
-
- let wait_filter = Filter::new().ids([event_with_d.id.as_bytes()]).build();
- let wait_filter_2 = Filter::new().ids([event_with_a_only.id.as_bytes()]).build();
-
- 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 sub_id = ndb.subscribe(&[wait_filter, wait_filter_2]).expect("sub");
- let _r = ndb.wait_for_notes(sub_id, 2).await;
-
- {
- 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.as_bytes());
- }
+ FilterField::Tag { .. } => {}
- {
- 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.as_bytes());
+ FilterField::Search { .. } => {}
}
-
- 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()
+ filter
}