commit 6cc648652abf8b36cbc10ca04fab6b0c109d51f0
parent 7a41982f1496ce9d63bcbf3c53ebd93a6cffce52
Author: William Casarin <jb55@jb55.com>
Date: Wed, 20 Dec 2023 08:36:28 -0800
refactor data completion, add initial design from karnage
Diffstat:
M | src/error.rs | | | 53 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ |
M | src/fonts.rs | | | 4 | +--- |
M | src/main.rs | | | 138 | +++++++++++++++++++++++++++++++++++++++++-------------------------------------- |
M | src/nip19.rs | | | 10 | ---------- |
M | src/pfp.rs | | | 97 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
M | src/render.rs | | | 429 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
6 files changed, 639 insertions(+), 92 deletions(-)
diff --git a/src/error.rs b/src/error.rs
@@ -7,20 +7,65 @@ use tokio::sync::broadcast::error::RecvError;
pub enum Error {
Nip19(nip19::Error),
Http(hyper::http::Error),
+ Hyper(hyper::Error),
Nostrdb(nostrdb::Error),
NostrClient(nostr_sdk::client::Error),
Recv(RecvError),
+ Io(std::io::Error),
+ Generic(String),
+ Image(image::error::ImageError),
+ Secp(nostr_sdk::secp256k1::Error),
+ InvalidUri,
NotFound,
+ /// Profile picture is too big
+ TooBig,
InvalidNip19,
+ InvalidProfilePic,
SliceErr,
}
+impl From<image::error::ImageError> for Error {
+ fn from(err: image::error::ImageError) -> Self {
+ Error::Image(err)
+ }
+}
+
+impl From<http::uri::InvalidUri> for Error {
+ fn from(err: http::uri::InvalidUri) -> Self {
+ Error::InvalidUri
+ }
+}
+
+impl From<nostr_sdk::secp256k1::Error> for Error {
+ fn from(err: nostr_sdk::secp256k1::Error) -> Self {
+ Error::Secp(err)
+ }
+}
+
+impl From<std::io::Error> for Error {
+ fn from(err: std::io::Error) -> Self {
+ Error::Io(err)
+ }
+}
+
+impl From<String> for Error {
+ fn from(err: String) -> Self {
+ Error::Generic(err)
+ }
+}
+
impl From<RecvError> for Error {
fn from(err: RecvError) -> Self {
Error::Recv(err)
}
}
+impl From<hyper::Error> for Error {
+ fn from(err: hyper::Error) -> Self {
+ Error::Hyper(err)
+ }
+}
+
impl From<TryFromSliceError> for Error {
fn from(_: TryFromSliceError) -> Self {
Error::SliceErr
@@ -63,6 +108,14 @@ impl fmt::Display for Error {
Error::Recv(e) => write!(f, "Recieve error: {}", e),
Error::InvalidNip19 => write!(f, "Invalid nip19 object"),
Error::SliceErr => write!(f, "Array slice error"),
+ Error::TooBig => write!(f, "Profile picture is too big"),
+ Error::InvalidProfilePic => write!(f, "Profile picture is corrupt"),
+ Error::Image(err) => write!(f, "Image error: {}", err),
+ Error::InvalidUri => write!(f, "Invalid url"),
+ Error::Hyper(err) => write!(f, "Hyper error: {}", err),
+ Error::Generic(err) => write!(f, "Generic error: {}", err),
+ Error::Io(err) => write!(f, "Io error: {}", err),
+ Error::Secp(err) => write!(f, "Signature error: {}", err),
}
}
}
diff --git a/src/fonts.rs b/src/fonts.rs
@@ -1,7 +1,6 @@
// TODO: figure out the custom font situation
-/*
-fn setup_fonts(font_data: &egui::FontData, ctx: &egui::Context) {
+pub fn setup_fonts(font_data: &egui::FontData, ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
// Install my own font (maybe supporting non-latin characters).
@@ -20,4 +19,3 @@ fn setup_fonts(font_data: &egui::FontData, ctx: &egui::Context) {
// Tell egui to use these fonts:
ctx.set_fonts(fonts);
}
-*/
diff --git a/src/main.rs b/src/main.rs
@@ -12,6 +12,7 @@ use std::sync::Arc;
use tokio::net::TcpListener;
use crate::error::Error;
+use crate::render::NoteRenderData;
use nostr_sdk::prelude::*;
use nostrdb::{Config, Ndb, Transaction};
use std::time::Duration;
@@ -19,28 +20,32 @@ use std::time::Duration;
use lru::LruCache;
mod error;
+mod fonts;
+mod gradient;
mod nip19;
mod pfp;
mod render;
-pub enum Target {
- Profile(XOnlyPublicKey),
- Event(EventId),
-}
-
type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>;
-#[derive(Debug, Clone)]
+#[derive(Clone)]
pub struct Notecrumbs {
ndb: Ndb,
keys: Keys,
+ font_data: egui::FontData,
img_cache: Arc<ImageCache>,
+ default_pfp: egui::ImageData,
/// How long do we wait for remote note requests
timeout: Duration,
}
-async fn find_note(app: &Notecrumbs, nip19: &Nip19) -> Result<nostr_sdk::Event, Error> {
+pub struct FindNoteResult {
+ note: Option<Event>,
+ profile: Option<Event>,
+}
+
+pub async fn find_note(app: &Notecrumbs, nip19: &Nip19) -> Result<FindNoteResult, Error> {
let opts = Options::new().shutdown_on_drop(true);
let client = Client::with_opts(&app.keys, opts);
@@ -59,16 +64,28 @@ async fn find_note(app: &Notecrumbs, nip19: &Nip19) -> Result<nostr_sdk::Event,
.req_events_of(filters.clone(), Some(app.timeout))
.await;
+ let mut note: Option<Event> = None;
+ let mut profile: Option<Event> = None;
+
loop {
match client.notifications().recv().await? {
RelayPoolNotification::Event(_url, ev) => {
- info!("got ev: {:?}", ev);
- return Ok(ev);
+ debug!("got event 1 {:?}", ev);
+ note = Some(ev);
+ return Ok(FindNoteResult { note, profile });
}
RelayPoolNotification::RelayStatus { .. } => continue,
RelayPoolNotification::Message(_url, msg) => match msg {
- RelayMessage::Event { event, .. } => return Ok(*event),
- RelayMessage::EndOfStoredEvents(_) => return Err(Error::NotFound),
+ RelayMessage::Event { event, .. } => {
+ if event.kind == Kind::Metadata {
+ debug!("got profile {:?}", event);
+ profile = Some(*event);
+ } else {
+ debug!("got event {:?}", event);
+ note = Some(*event);
+ }
+ }
+ RelayMessage::EndOfStoredEvents(_) => return Ok(FindNoteResult { note, profile }),
_ => continue,
},
RelayPoolNotification::Stop | RelayPoolNotification::Shutdown => {
@@ -91,66 +108,22 @@ async fn serve(
}
};
- let target = match nip19::to_target(&nip19) {
- Some(target) => target,
- None => {
- return Ok(Response::builder()
- .status(StatusCode::NOT_FOUND)
- .body(Full::new(Bytes::from("\n")))?)
- }
- };
-
- let content = {
- let mut txn = Transaction::new(&app.ndb)?;
- match target {
- Target::Profile(pk) => app
- .ndb
- .get_profile_by_pubkey(&mut txn, &pk.serialize())
- .and_then(|n| {
- info!("profile cache hit {:?}", nip19);
- Ok(n.record
- .profile()
- .ok_or(nostrdb::Error::NotFound)?
- .name()
- .ok_or(nostrdb::Error::NotFound)?
- .to_string())
- }),
- Target::Event(evid) => app
- .ndb
- .get_note_by_id(&mut txn, evid.as_bytes().try_into()?)
- .map(|n| {
- info!("event cache hit {:?}", nip19);
- n.content().to_string()
- }),
- }
- };
-
- let content = match content {
- Ok(content) => content,
- Err(nostrdb::Error::NotFound) => {
- debug!("Finding {:?}", nip19);
- match find_note(app, &nip19).await {
- Ok(note) => {
- let _ = app
- .ndb
- .process_event(&json!(["EVENT", "s", note]).to_string());
- note.content
- }
- Err(_err) => {
- return Ok(Response::builder().status(StatusCode::NOT_FOUND).body(
- Full::new(Bytes::from(format!("noteid {:?} not found\n", nip19))),
- )?);
- }
- }
- }
+ // render_data is always returned, it just might be empty
+ let partial_render_data = match render::get_render_data(&app, &nip19) {
Err(err) => {
return Ok(Response::builder()
- .status(StatusCode::INTERNAL_SERVER_ERROR)
- .body(Full::new(Bytes::from(format!("{}\n", err))))?);
+ .status(StatusCode::BAD_REQUEST)
+ .body(Full::new(Bytes::from(
+ "nsecs are not supported, what were you thinking!?\n",
+ )))?);
}
+ Ok(render_data) => render_data,
};
- let data = render::render_note(&app, &content);
+ // fetch extra data if we are missing it
+ let render_data = partial_render_data.complete(&app, &nip19).await;
+
+ let data = render::render_note(&app, &render_data);
Ok(Response::builder()
.header(header::CONTENT_TYPE, "image/png")
@@ -164,6 +137,33 @@ fn get_env_timeout() -> Duration {
Duration::from_millis(timeout_ms)
}
+fn get_gradient() -> egui::ColorImage {
+ use egui::{pos2, Color32, ColorImage};
+ use gradient::Gradient;
+
+ //let gradient = Gradient::linear(Color32::LIGHT_GRAY, Color32::DARK_GRAY);
+ let size = pfp::PFP_SIZE as usize;
+ let radius = (pfp::PFP_SIZE as f32) / 2.0;
+ let center = pos2(radius, radius);
+ let start_color = Color32::from_rgb(0x1E, 0x55, 0xFF);
+ let end_color = Color32::from_rgb(0xFA, 0x0D, 0xD4);
+
+ let gradient = Gradient::radial_alpha_gradient(center, radius, start_color, end_color);
+ let pixels = gradient.to_pixel_row();
+
+ assert_eq!(pixels.len(), size * size);
+ ColorImage {
+ size: [size, size],
+ pixels,
+ }
+}
+
+fn get_default_pfp() -> egui::ColorImage {
+ let img = std::fs::read("assets/default_pfp_2.png").expect("default pfp missing");
+ let mut dyn_image = image::load_from_memory(&img).expect("failed to load default pfp");
+ pfp::process_pfp_bitmap(&mut dyn_image)
+}
+
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
env_logger::init();
@@ -181,11 +181,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let keys = Keys::generate();
let timeout = get_env_timeout();
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 default_pfp = egui::ImageData::Color(get_gradient());
+ let font_data = egui::FontData::from_static(include_bytes!("../fonts/NotoSans-Regular.ttf"));
+
let app = Notecrumbs {
ndb,
keys,
timeout,
img_cache,
+ font_data,
+ default_pfp,
};
// We start a loop to continuously accept incoming connections
diff --git a/src/nip19.rs b/src/nip19.rs
@@ -3,16 +3,6 @@ use crate::Target;
use nostr_sdk::nips::nip19::Nip19;
use nostr_sdk::prelude::*;
-pub fn to_target(nip19: &Nip19) -> Option<Target> {
- match nip19 {
- Nip19::Event(ev) => Some(Target::Event(ev.event_id)),
- Nip19::EventId(evid) => Some(Target::Event(*evid)),
- Nip19::Profile(prof) => Some(Target::Profile(prof.public_key)),
- Nip19::Pubkey(pk) => Some(Target::Profile(*pk)),
- Nip19::Secret(_) => None,
- }
-}
-
pub fn to_filters(nip19: &Nip19) -> Result<Vec<Filter>, Error> {
match nip19 {
Nip19::Event(ev) => {
diff --git a/src/pfp.rs b/src/pfp.rs
@@ -1,6 +1,11 @@
+use crate::Error;
+use bytes::Bytes;
use egui::{Color32, ColorImage};
+use hyper::body::Incoming;
use image::imageops::FilterType;
+pub const PFP_SIZE: u32 = 64;
+
// Thank to gossip for this one!
pub fn round_image(image: &mut ColorImage) {
#[cfg(feature = "profiling")]
@@ -49,10 +54,12 @@ pub fn round_image(image: &mut ColorImage) {
}
}
-fn process_pfp_bitmap(size: u32, image: &mut image::DynamicImage) -> ColorImage {
+pub fn process_pfp_bitmap(image: &mut image::DynamicImage) -> ColorImage {
#[cfg(features = "profiling")]
puffin::profile_function!();
+ let size = PFP_SIZE;
+
// Crop square
let smaller = image.width().min(image.height());
@@ -75,3 +82,91 @@ fn process_pfp_bitmap(size: u32, image: &mut image::DynamicImage) -> ColorImage
round_image(&mut color_image);
color_image
}
+
+async fn fetch_url(url: &str) -> Result<(Vec<u8>, hyper::Response<Incoming>), Error> {
+ use http_body_util::BodyExt;
+ use http_body_util::Empty;
+ use hyper::Request;
+ use hyper_util::rt::tokio::TokioIo;
+ use tokio::net::TcpStream;
+
+ let mut data: Vec<u8> = vec![];
+ let url = url.parse::<hyper::Uri>()?;
+ let host = url.host().expect("uri has no host");
+ let port = url.port_u16().unwrap_or(80);
+ let addr = format!("{}:{}", host, port);
+ let stream = TcpStream::connect(addr).await?;
+ let io = TokioIo::new(stream);
+
+ let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
+ tokio::task::spawn(async move {
+ if let Err(err) = conn.await {
+ println!("Connection failed: {:?}", err);
+ }
+ });
+
+ let authority = url.authority().unwrap().clone();
+
+ let req = Request::builder()
+ .uri(url)
+ .header(hyper::header::HOST, authority.as_str())
+ .body(Empty::<Bytes>::new())?;
+
+ let mut res: hyper::Response<Incoming> = sender.send_request(req).await?;
+
+ // Stream the body, writing each chunk to stdout as we get it
+ // (instead of buffering and printing at the end).
+ while let Some(next) = res.frame().await {
+ let frame = next?;
+ if let Some(chunk) = frame.data_ref() {
+ if data.len() + chunk.len() > 52428800
+ /* 50 MiB */
+ {
+ return Err(Error::TooBig);
+ }
+ data.extend(chunk);
+ }
+ }
+
+ Ok((data, res))
+}
+
+pub async fn fetch_pfp(url: &str) -> Result<ColorImage, Error> {
+ let (data, res) = fetch_url(url).await?;
+ parse_img_response(data, res)
+}
+
+fn parse_img_response(
+ data: Vec<u8>,
+ response: hyper::Response<Incoming>,
+) -> Result<ColorImage, Error> {
+ use egui_extras::image::FitTo;
+
+ #[cfg(feature = "profiling")]
+ puffin::profile_function!();
+
+ let content_type = response.headers()["content-type"]
+ .to_str()
+ .unwrap_or_default();
+
+ let size = PFP_SIZE;
+
+ if content_type.starts_with("image/svg") {
+ #[cfg(feature = "profiling")]
+ puffin::profile_scope!("load_svg");
+
+ let mut color_image = egui_extras::image::load_svg_bytes_with_size(
+ &data,
+ FitTo::Size(size as u32, size as u32),
+ )?;
+ round_image(&mut color_image);
+ Ok(color_image)
+ } else if content_type.starts_with("image/") {
+ #[cfg(feature = "profiling")]
+ puffin::profile_scope!("load_from_memory");
+ let mut dyn_image = image::load_from_memory(&data)?;
+ Ok(process_pfp_bitmap(&mut dyn_image))
+ } else {
+ Err(Error::InvalidProfilePic)
+ }
+}
diff --git a/src/render.rs b/src/render.rs
@@ -1,30 +1,423 @@
-struct ProfileRenderData {}
+use crate::{fonts, Error, Notecrumbs};
+use egui::{Color32, ColorImage, FontId, RichText, Visuals};
+use log::{debug, info, warn};
+use nostr_sdk::nips::nip19::Nip19;
+use nostr_sdk::prelude::*;
+use nostrdb::{Note, Transaction};
+use std::sync::Arc;
-use crate::Notecrumbs;
+impl ProfileRenderData {
+ pub fn default(pfp: egui::ImageData) -> Self {
+ ProfileRenderData {
+ name: "nostrich".to_string(),
+ display_name: None,
+ about: "A am a nosy nostrich".to_string(),
+ pfp: pfp,
+ }
+ }
+}
-struct NoteRenderData {
+#[derive(Debug, Clone)]
+pub struct NoteData {
content: String,
+}
+
+pub struct ProfileRenderData {
+ name: String,
+ display_name: Option<String>,
+ about: String,
+ pfp: egui::ImageData,
+}
+
+pub struct NoteRenderData {
+ note: NoteData,
profile: ProfileRenderData,
}
-enum RenderData {
+pub struct PartialNoteRenderData {
+ note: Option<NoteData>,
+ profile: Option<ProfileRenderData>,
+}
+
+pub enum PartialRenderData {
+ Note(PartialNoteRenderData),
+ Profile(Option<ProfileRenderData>),
+}
+
+pub enum RenderData {
Note(NoteRenderData),
+ Profile(ProfileRenderData),
+}
+
+#[derive(Debug)]
+pub enum EventSource {
+ Nip19(Nip19Event),
+ Id(EventId),
+}
+
+impl EventSource {
+ fn id(&self) -> EventId {
+ match self {
+ EventSource::Nip19(ev) => ev.event_id,
+ EventSource::Id(id) => *id,
+ }
+ }
+
+ fn author(&self) -> Option<XOnlyPublicKey> {
+ match self {
+ EventSource::Nip19(ev) => ev.author,
+ EventSource::Id(_) => None,
+ }
+ }
+}
+
+impl From<Nip19Event> for EventSource {
+ fn from(event: Nip19Event) -> EventSource {
+ EventSource::Nip19(event)
+ }
+}
+
+impl From<EventId> for EventSource {
+ fn from(event_id: EventId) -> EventSource {
+ EventSource::Id(event_id)
+ }
+}
+
+impl NoteData {
+ fn default() -> Self {
+ let content = "".to_string();
+ NoteData { content }
+ }
+}
+
+impl PartialRenderData {
+ pub async fn complete(self, app: &Notecrumbs, nip19: &Nip19) -> RenderData {
+ match self {
+ PartialRenderData::Note(partial) => {
+ RenderData::Note(partial.complete(app, nip19).await)
+ }
+
+ PartialRenderData::Profile(Some(profile)) => RenderData::Profile(profile),
+
+ PartialRenderData::Profile(None) => {
+ warn!("TODO: implement profile data completion");
+ RenderData::Profile(ProfileRenderData::default(app.default_pfp.clone()))
+ }
+ }
+ }
+}
+
+impl PartialNoteRenderData {
+ pub async fn complete(self, app: &Notecrumbs, nip19: &Nip19) -> NoteRenderData {
+ // we have everything, all done!
+ match (self.note, self.profile) {
+ (Some(note), Some(profile)) => {
+ return NoteRenderData { note, profile };
+ }
+
+ // Don't hold ourselves up on profile data for notes. We can spin
+ // off a background task to find the profile though.
+ (Some(note), None) => {
+ warn!("TODO: spin off profile query when missing note profile");
+ let profile = ProfileRenderData::default(app.default_pfp.clone());
+ return NoteRenderData { note, profile };
+ }
+
+ _ => (),
+ }
+
+ debug!("Finding {:?}", nip19);
+
+ match crate::find_note(app, &nip19).await {
+ Ok(note_res) => {
+ let note = match note_res.note {
+ Some(note) => {
+ debug!("saving {:?} to nostrdb", ¬e);
+ let _ = app
+ .ndb
+ .process_event(&json!(["EVENT", "s", note]).to_string());
+ sdk_note_to_note_data(¬e)
+ }
+ None => NoteData::default(),
+ };
+
+ let profile = match note_res.profile {
+ Some(profile) => {
+ debug!("saving profile to nostrdb: {:?}", &profile);
+ let _ = app
+ .ndb
+ .process_event(&json!(["EVENT", "s", profile]).to_string());
+ // TODO: wire profile to profile data, download pfp
+ ProfileRenderData::default(app.default_pfp.clone())
+ }
+ None => ProfileRenderData::default(app.default_pfp.clone()),
+ };
+
+ NoteRenderData { note, profile }
+ }
+ Err(_err) => {
+ let note = NoteData::default();
+ let profile = ProfileRenderData::default(app.default_pfp.clone());
+ NoteRenderData { note, profile }
+ }
+ }
+ }
+}
+
+fn get_profile_render_data(
+ txn: &Transaction,
+ app: &Notecrumbs,
+ pubkey: &XOnlyPublicKey,
+) -> Result<ProfileRenderData, Error> {
+ let profile = app.ndb.get_profile_by_pubkey(&txn, &pubkey.serialize())?;
+ info!("profile cache hit {:?}", pubkey);
+
+ let profile = profile.record.profile().ok_or(nostrdb::Error::NotFound)?;
+ let name = profile.name().unwrap_or("").to_string();
+ let about = profile.about().unwrap_or("").to_string();
+ let display_name = profile.display_name().as_ref().map(|a| a.to_string());
+ let pfp = app.default_pfp.clone();
+
+ Ok(ProfileRenderData {
+ name,
+ pfp,
+ about,
+ display_name,
+ })
}
-fn note_ui(app: &Notecrumbs, ctx: &egui::Context, content: &str) {
+fn ndb_note_to_data(note: &Note) -> NoteData {
+ let content = note.content().to_string();
+ NoteData { content }
+}
+
+fn sdk_note_to_note_data(note: &Event) -> NoteData {
+ let content = note.content.clone();
+ NoteData { content }
+}
+
+fn get_note_render_data(
+ app: &Notecrumbs,
+ source: &EventSource,
+) -> Result<PartialNoteRenderData, Error> {
+ debug!("got here a");
+ let txn = Transaction::new(&app.ndb)?;
+ let m_note = app
+ .ndb
+ .get_note_by_id(&txn, source.id().as_bytes().try_into()?)
+ .map_err(Error::Nostrdb);
+
+ debug!("note cached? {:?}", m_note);
+
+ // It's possible we have an author pk in an nevent, let's use it if we do.
+ // This gives us the opportunity to load the profile picture earlier if we
+ // have a cached profile
+ let mut profile: Option<ProfileRenderData> = None;
+
+ let m_note_pk = m_note
+ .as_ref()
+ .ok()
+ .and_then(|n| XOnlyPublicKey::from_slice(n.pubkey()).ok());
+
+ let m_pk = m_note_pk.or(source.author());
+
+ // get profile render data if we can
+ if let Some(pk) = m_pk {
+ match get_profile_render_data(&txn, app, &pk) {
+ Err(err) => warn!(
+ "No profile found for {} for note {}: {}",
+ &pk,
+ &source.id(),
+ err
+ ),
+ Ok(record) => {
+ debug!("profile record found for note");
+ profile = Some(record);
+ }
+ }
+ }
+
+ let note = m_note.map(|n| ndb_note_to_data(&n)).ok();
+ Ok(PartialNoteRenderData { profile, note })
+}
+
+pub fn get_render_data(app: &Notecrumbs, target: &Nip19) -> Result<PartialRenderData, Error> {
+ match target {
+ Nip19::Profile(profile) => {
+ let txn = Transaction::new(&app.ndb)?;
+ Ok(PartialRenderData::Profile(
+ get_profile_render_data(&txn, app, &profile.public_key).ok(),
+ ))
+ }
+
+ Nip19::Pubkey(pk) => {
+ let txn = Transaction::new(&app.ndb)?;
+ Ok(PartialRenderData::Profile(
+ get_profile_render_data(&txn, app, pk).ok(),
+ ))
+ }
+
+ Nip19::Event(event) => Ok(PartialRenderData::Note(get_note_render_data(
+ app,
+ &EventSource::Nip19(event.clone()),
+ )?)),
+
+ Nip19::EventId(evid) => Ok(PartialRenderData::Note(get_note_render_data(
+ app,
+ &EventSource::Id(*evid),
+ )?)),
+
+ Nip19::Secret(_nsec) => Err(Error::InvalidNip19),
+ }
+}
+
+#[inline]
+pub fn floor_char_boundary(s: &str, index: usize) -> usize {
+ if index >= s.len() {
+ s.len()
+ } else {
+ let lower_bound = index.saturating_sub(3);
+ let new_index = s.as_bytes()[lower_bound..=index]
+ .iter()
+ .rposition(|b| is_utf8_char_boundary(*b));
+
+ // SAFETY: we know that the character boundary will be within four bytes
+ unsafe { lower_bound + new_index.unwrap_unchecked() }
+ }
+}
+
+#[inline]
+fn is_utf8_char_boundary(c: u8) -> bool {
+ // This is bit magic equivalent to: b < 128 || b >= 192
+ (c as i8) >= -0x40
+}
+
+fn ui_abbreviate_name(ui: &mut egui::Ui, name: &str, len: usize) {
+ if name.len() > len {
+ let closest = floor_char_boundary(name, len);
+ heading(ui, &name[..closest]);
+ heading(ui, "...");
+ } else {
+ heading(ui, name);
+ }
+}
+
+fn render_username(app: &Notecrumbs, ui: &mut egui::Ui, profile: &ProfileRenderData) {
+ #[cfg(feature = "profiling")]
+ puffin::profile_function!();
+ let name = format!("@{}", profile.name);
+ ui.label(RichText::new(&name).size(30.0).color(Color32::DARK_GRAY));
+}
+
+fn heading(ui: &mut egui::Ui, text: impl Into<RichText>) {
+ ui.label(text.into().size(40.0));
+}
+
+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 wrapped_body(ui: &mut egui::Ui, text: &str) {
+ use egui::text::{LayoutJob, TextFormat};
+
+ let format = TextFormat {
+ font_id: FontId::proportional(40.0),
+ color: Color32::WHITE,
+ extra_letter_spacing: -1.0,
+ line_height: Some(40.0),
+ ..Default::default()
+ };
+
+ let mut job = LayoutJob::single_section(text.to_owned(), format);
+
+ job.justify = false;
+ job.halign = egui::Align::LEFT;
+ job.wrap = egui::text::TextWrapping {
+ max_rows: 5,
+ break_anywhere: false,
+ overflow_character: Some('…'),
+ ..Default::default()
+ };
+
+ ui.label(job);
+}
+
+fn centered_layout() -> egui::Layout {
+ use egui::{Align, Direction, Layout};
+
+ Layout {
+ main_dir: Direction::TopDown,
+ main_wrap: true,
+ main_align: Align::Center,
+ main_justify: true,
+ cross_align: Align::Center,
+ cross_justify: true,
+ }
+}
+
+fn note_ui(app: &Notecrumbs, ctx: &egui::Context, note: &NoteRenderData) {
+ use egui::{FontId, Label, RichText, Rounding};
+
+ let pfp = ctx.load_texture("pfp", note.profile.pfp.clone(), Default::default());
+ setup_visuals(&app.font_data, ctx);
+
+ let outer_margin = 40.0;
+ let inner_margin = 100.0;
+ let total_margin = outer_margin + inner_margin;
+
+ egui::CentralPanel::default()
+ .frame(egui::Frame::default().fill(Color32::from_rgb(0x43, 0x20, 0x62)))
+ .show(&ctx, |ui| {
+ egui::Frame::none()
+ .fill(Color32::from_rgb(0x0F, 0x0F, 0x0F))
+ .rounding(Rounding::same(20.0))
+ .outer_margin(outer_margin)
+ .inner_margin(inner_margin)
+ .show(ui, |ui| {
+ let desired_height = 630.0 - total_margin * 2.0;
+ let desired_width = 1200.0 - total_margin * 2.0;
+ let desired_size = egui::vec2(desired_width, desired_height);
+ ui.set_min_height(desired_height); // Set minimum height for the container
+ ui.set_min_width(desired_width); // Set minimum width for the container
+ //
+ ui.centered_and_justified(|ui| {
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ //ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
+
+ //ui.vertical(|ui| {
+ wrapped_body(ui, ¬e.note.content);
+ ui.horizontal(|ui| {
+ ui.image(&pfp);
+ render_username(app, ui, ¬e.profile);
+ });
+ //});
+ });
+ })
+ })
+ });
+}
+
+fn profile_ui(app: &Notecrumbs, ctx: &egui::Context, profile: &ProfileRenderData) {
use egui::{FontId, RichText};
+ let pfp = ctx.load_texture("pfp", profile.pfp.clone(), Default::default());
+ setup_visuals(&app.font_data, ctx);
+
egui::CentralPanel::default().show(&ctx, |ui| {
- ui.horizontal(|ui| {
- ui.label(RichText::new("✏").font(FontId::proportional(120.0)));
- ui.vertical(|ui| {
- ui.label(RichText::new(content).font(FontId::proportional(40.0)));
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ ui.image(&pfp);
+ render_username(app, ui, &profile);
});
- })
+ //body(ui, &profile.about);
+ });
});
}
-pub fn render_note(app: &Notecrumbs, content: &str) -> Vec<u8> {
+pub fn render_note(app: &Notecrumbs, render_data: &RenderData) -> Vec<u8> {
use egui_skia::{rasterize, RasterizeOptions};
use skia_safe::EncodedImageFormat;
@@ -33,7 +426,19 @@ pub fn render_note(app: &Notecrumbs, content: &str) -> Vec<u8> {
frames_before_screenshot: 1,
};
- let mut surface = rasterize((1200, 630), |ctx| note_ui(app, ctx, content), Some(options));
+ let mut surface = match render_data {
+ RenderData::Note(note_render_data) => rasterize(
+ (1200, 630),
+ |ctx| note_ui(app, ctx, note_render_data),
+ Some(options),
+ ),
+
+ RenderData::Profile(profile_render_data) => rasterize(
+ (1200, 630),
+ |ctx| profile_ui(app, ctx, profile_render_data),
+ Some(options),
+ ),
+ };
surface
.image_snapshot()