mirror of
https://github.com/damus-io/notecrumbs.git
synced 2024-09-29 18:10:43 +00:00
refactor data completion, add initial design from karnage
This commit is contained in:
parent
7a41982f14
commit
6cc648652a
53
src/error.rs
53
src/error.rs
@ -7,20 +7,65 @@ use tokio::sync::broadcast::error::RecvError;
|
|||||||
pub enum Error {
|
pub enum Error {
|
||||||
Nip19(nip19::Error),
|
Nip19(nip19::Error),
|
||||||
Http(hyper::http::Error),
|
Http(hyper::http::Error),
|
||||||
|
Hyper(hyper::Error),
|
||||||
Nostrdb(nostrdb::Error),
|
Nostrdb(nostrdb::Error),
|
||||||
NostrClient(nostr_sdk::client::Error),
|
NostrClient(nostr_sdk::client::Error),
|
||||||
Recv(RecvError),
|
Recv(RecvError),
|
||||||
|
Io(std::io::Error),
|
||||||
|
Generic(String),
|
||||||
|
Image(image::error::ImageError),
|
||||||
|
Secp(nostr_sdk::secp256k1::Error),
|
||||||
|
InvalidUri,
|
||||||
NotFound,
|
NotFound,
|
||||||
|
/// Profile picture is too big
|
||||||
|
TooBig,
|
||||||
InvalidNip19,
|
InvalidNip19,
|
||||||
|
InvalidProfilePic,
|
||||||
SliceErr,
|
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 {
|
impl From<RecvError> for Error {
|
||||||
fn from(err: RecvError) -> Self {
|
fn from(err: RecvError) -> Self {
|
||||||
Error::Recv(err)
|
Error::Recv(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<hyper::Error> for Error {
|
||||||
|
fn from(err: hyper::Error) -> Self {
|
||||||
|
Error::Hyper(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<TryFromSliceError> for Error {
|
impl From<TryFromSliceError> for Error {
|
||||||
fn from(_: TryFromSliceError) -> Self {
|
fn from(_: TryFromSliceError) -> Self {
|
||||||
Error::SliceErr
|
Error::SliceErr
|
||||||
@ -63,6 +108,14 @@ impl fmt::Display for Error {
|
|||||||
Error::Recv(e) => write!(f, "Recieve error: {}", e),
|
Error::Recv(e) => write!(f, "Recieve error: {}", e),
|
||||||
Error::InvalidNip19 => write!(f, "Invalid nip19 object"),
|
Error::InvalidNip19 => write!(f, "Invalid nip19 object"),
|
||||||
Error::SliceErr => write!(f, "Array slice error"),
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
// TODO: figure out the custom font situation
|
// TODO: figure out the custom font situation
|
||||||
|
|
||||||
/*
|
pub fn setup_fonts(font_data: &egui::FontData, ctx: &egui::Context) {
|
||||||
fn setup_fonts(font_data: &egui::FontData, ctx: &egui::Context) {
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
|
||||||
// Install my own font (maybe supporting non-latin characters).
|
// 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:
|
// Tell egui to use these fonts:
|
||||||
ctx.set_fonts(fonts);
|
ctx.set_fonts(fonts);
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
138
src/main.rs
138
src/main.rs
@ -12,6 +12,7 @@ use std::sync::Arc;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
use crate::render::NoteRenderData;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use nostrdb::{Config, Ndb, Transaction};
|
use nostrdb::{Config, Ndb, Transaction};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@ -19,28 +20,32 @@ use std::time::Duration;
|
|||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
|
mod fonts;
|
||||||
|
mod gradient;
|
||||||
mod nip19;
|
mod nip19;
|
||||||
mod pfp;
|
mod pfp;
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
pub enum Target {
|
|
||||||
Profile(XOnlyPublicKey),
|
|
||||||
Event(EventId),
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>;
|
type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Notecrumbs {
|
pub struct Notecrumbs {
|
||||||
ndb: Ndb,
|
ndb: Ndb,
|
||||||
keys: Keys,
|
keys: Keys,
|
||||||
|
font_data: egui::FontData,
|
||||||
img_cache: Arc<ImageCache>,
|
img_cache: Arc<ImageCache>,
|
||||||
|
default_pfp: egui::ImageData,
|
||||||
|
|
||||||
/// How long do we wait for remote note requests
|
/// How long do we wait for remote note requests
|
||||||
timeout: Duration,
|
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 opts = Options::new().shutdown_on_drop(true);
|
||||||
let client = Client::with_opts(&app.keys, opts);
|
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))
|
.req_events_of(filters.clone(), Some(app.timeout))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let mut note: Option<Event> = None;
|
||||||
|
let mut profile: Option<Event> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match client.notifications().recv().await? {
|
match client.notifications().recv().await? {
|
||||||
RelayPoolNotification::Event(_url, ev) => {
|
RelayPoolNotification::Event(_url, ev) => {
|
||||||
info!("got ev: {:?}", ev);
|
debug!("got event 1 {:?}", ev);
|
||||||
return Ok(ev);
|
note = Some(ev);
|
||||||
|
return Ok(FindNoteResult { note, profile });
|
||||||
}
|
}
|
||||||
RelayPoolNotification::RelayStatus { .. } => continue,
|
RelayPoolNotification::RelayStatus { .. } => continue,
|
||||||
RelayPoolNotification::Message(_url, msg) => match msg {
|
RelayPoolNotification::Message(_url, msg) => match msg {
|
||||||
RelayMessage::Event { event, .. } => return Ok(*event),
|
RelayMessage::Event { event, .. } => {
|
||||||
RelayMessage::EndOfStoredEvents(_) => return Err(Error::NotFound),
|
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,
|
_ => continue,
|
||||||
},
|
},
|
||||||
RelayPoolNotification::Stop | RelayPoolNotification::Shutdown => {
|
RelayPoolNotification::Stop | RelayPoolNotification::Shutdown => {
|
||||||
@ -91,66 +108,22 @@ async fn serve(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let target = match nip19::to_target(&nip19) {
|
// render_data is always returned, it just might be empty
|
||||||
Some(target) => target,
|
let partial_render_data = match render::get_render_data(&app, &nip19) {
|
||||||
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))),
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::BAD_REQUEST)
|
||||||
.body(Full::new(Bytes::from(format!("{}\n", err))))?);
|
.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()
|
Ok(Response::builder()
|
||||||
.header(header::CONTENT_TYPE, "image/png")
|
.header(header::CONTENT_TYPE, "image/png")
|
||||||
@ -164,6 +137,33 @@ fn get_env_timeout() -> Duration {
|
|||||||
Duration::from_millis(timeout_ms)
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
@ -181,11 +181,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let timeout = get_env_timeout();
|
let timeout = get_env_timeout();
|
||||||
let img_cache = Arc::new(LruCache::new(std::num::NonZeroUsize::new(64).unwrap()));
|
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 {
|
let app = Notecrumbs {
|
||||||
ndb,
|
ndb,
|
||||||
keys,
|
keys,
|
||||||
timeout,
|
timeout,
|
||||||
img_cache,
|
img_cache,
|
||||||
|
font_data,
|
||||||
|
default_pfp,
|
||||||
};
|
};
|
||||||
|
|
||||||
// We start a loop to continuously accept incoming connections
|
// We start a loop to continuously accept incoming connections
|
||||||
|
10
src/nip19.rs
10
src/nip19.rs
@ -3,16 +3,6 @@ use crate::Target;
|
|||||||
use nostr_sdk::nips::nip19::Nip19;
|
use nostr_sdk::nips::nip19::Nip19;
|
||||||
use nostr_sdk::prelude::*;
|
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> {
|
pub fn to_filters(nip19: &Nip19) -> Result<Vec<Filter>, Error> {
|
||||||
match nip19 {
|
match nip19 {
|
||||||
Nip19::Event(ev) => {
|
Nip19::Event(ev) => {
|
||||||
|
97
src/pfp.rs
97
src/pfp.rs
@ -1,6 +1,11 @@
|
|||||||
|
use crate::Error;
|
||||||
|
use bytes::Bytes;
|
||||||
use egui::{Color32, ColorImage};
|
use egui::{Color32, ColorImage};
|
||||||
|
use hyper::body::Incoming;
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
|
|
||||||
|
pub const PFP_SIZE: u32 = 64;
|
||||||
|
|
||||||
// Thank to gossip for this one!
|
// Thank to gossip for this one!
|
||||||
pub fn round_image(image: &mut ColorImage) {
|
pub fn round_image(image: &mut ColorImage) {
|
||||||
#[cfg(feature = "profiling")]
|
#[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")]
|
#[cfg(features = "profiling")]
|
||||||
puffin::profile_function!();
|
puffin::profile_function!();
|
||||||
|
|
||||||
|
let size = PFP_SIZE;
|
||||||
|
|
||||||
// Crop square
|
// Crop square
|
||||||
let smaller = image.width().min(image.height());
|
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);
|
round_image(&mut color_image);
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
431
src/render.rs
431
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,
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProfileRenderData {
|
||||||
|
name: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
about: String,
|
||||||
|
pfp: egui::ImageData,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NoteRenderData {
|
||||||
|
note: NoteData,
|
||||||
profile: ProfileRenderData,
|
profile: ProfileRenderData,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RenderData {
|
pub struct PartialNoteRenderData {
|
||||||
Note(NoteRenderData),
|
note: Option<NoteData>,
|
||||||
|
profile: Option<ProfileRenderData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn note_ui(app: &Notecrumbs, ctx: &egui::Context, content: &str) {
|
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 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};
|
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| {
|
egui::CentralPanel::default().show(&ctx, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(RichText::new("✏").font(FontId::proportional(120.0)));
|
ui.horizontal(|ui| {
|
||||||
ui.vertical(|ui| {
|
ui.image(&pfp);
|
||||||
ui.label(RichText::new(content).font(FontId::proportional(40.0)));
|
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 egui_skia::{rasterize, RasterizeOptions};
|
||||||
use skia_safe::EncodedImageFormat;
|
use skia_safe::EncodedImageFormat;
|
||||||
|
|
||||||
@ -33,7 +426,19 @@ pub fn render_note(app: &Notecrumbs, content: &str) -> Vec<u8> {
|
|||||||
frames_before_screenshot: 1,
|
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
|
surface
|
||||||
.image_snapshot()
|
.image_snapshot()
|
||||||
|
Loading…
Reference in New Issue
Block a user