notecrumbs

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

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:
Msrc/error.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/fonts.rs | 4+---
Msrc/main.rs | 138+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/nip19.rs | 10----------
Msrc/pfp.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/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", &note); + let _ = app + .ndb + .process_event(&json!(["EVENT", "s", note]).to_string()); + sdk_note_to_note_data(&note) + } + 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, &note.note.content); + ui.horizontal(|ui| { + ui.image(&pfp); + render_username(app, ui, &note.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()