notecrumbs

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

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:
Msrc/html.rs | 367++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Asrc/relay_pool.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/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, &note, &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(&note_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, &note_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, &note, &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 }