notecrumbs

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

render.rs (18622B)


      1 use crate::{abbrev::abbrev_str, fonts, Error, Notecrumbs};
      2 use egui::epaint::Shadow;
      3 use egui::{
      4     pos2,
      5     text::{LayoutJob, TextFormat},
      6     Color32, FontFamily, FontId, Mesh, Rect, RichText, Rounding, Shape, TextureHandle, Vec2,
      7     Visuals,
      8 };
      9 use log::{debug, info, warn};
     10 use nostr_sdk::nips::nip19::Nip19;
     11 use nostr_sdk::prelude::{json, Event, EventId, Nip19Event, XOnlyPublicKey};
     12 use nostrdb::{Block, BlockType, Blocks, Mention, Ndb, Note, Transaction};
     13 
     14 const PURPLE: Color32 = Color32::from_rgb(0xcc, 0x43, 0xc5);
     15 
     16 //use egui::emath::Rot2;
     17 //use std::f32::consts::PI;
     18 
     19 impl ProfileRenderData {
     20     pub fn default(pfp: egui::ImageData) -> Self {
     21         ProfileRenderData {
     22             name: "nostrich".to_string(),
     23             display_name: None,
     24             about: "A am a nosy nostrich".to_string(),
     25             pfp: pfp,
     26         }
     27     }
     28 }
     29 
     30 #[derive(Debug, Clone)]
     31 pub struct NoteData {
     32     pub id: Option<[u8; 32]>,
     33     pub content: String,
     34 }
     35 
     36 pub struct ProfileRenderData {
     37     pub name: String,
     38     pub display_name: Option<String>,
     39     pub about: String,
     40     pub pfp: egui::ImageData,
     41 }
     42 
     43 pub struct NoteRenderData {
     44     pub note: NoteData,
     45     pub profile: ProfileRenderData,
     46 }
     47 
     48 pub struct PartialNoteRenderData {
     49     pub note: Option<NoteData>,
     50     pub profile: Option<ProfileRenderData>,
     51 }
     52 
     53 pub enum PartialRenderData {
     54     Note(PartialNoteRenderData),
     55     Profile(Option<ProfileRenderData>),
     56 }
     57 
     58 pub enum RenderData {
     59     Note(NoteRenderData),
     60     Profile(ProfileRenderData),
     61 }
     62 
     63 #[derive(Debug)]
     64 pub enum EventSource {
     65     Nip19(Nip19Event),
     66     Id(EventId),
     67 }
     68 
     69 impl EventSource {
     70     fn id(&self) -> EventId {
     71         match self {
     72             EventSource::Nip19(ev) => ev.event_id,
     73             EventSource::Id(id) => *id,
     74         }
     75     }
     76 
     77     fn author(&self) -> Option<XOnlyPublicKey> {
     78         match self {
     79             EventSource::Nip19(ev) => ev.author,
     80             EventSource::Id(_) => None,
     81         }
     82     }
     83 }
     84 
     85 impl From<Nip19Event> for EventSource {
     86     fn from(event: Nip19Event) -> EventSource {
     87         EventSource::Nip19(event)
     88     }
     89 }
     90 
     91 impl From<EventId> for EventSource {
     92     fn from(event_id: EventId) -> EventSource {
     93         EventSource::Id(event_id)
     94     }
     95 }
     96 
     97 impl NoteData {
     98     fn default() -> Self {
     99         let content = "".to_string();
    100         NoteData { content, id: None }
    101     }
    102 }
    103 
    104 impl PartialRenderData {
    105     pub async fn complete(self, app: &Notecrumbs, nip19: &Nip19) -> RenderData {
    106         match self {
    107             PartialRenderData::Note(partial) => {
    108                 RenderData::Note(partial.complete(app, nip19).await)
    109             }
    110 
    111             PartialRenderData::Profile(Some(profile)) => RenderData::Profile(profile),
    112 
    113             PartialRenderData::Profile(None) => {
    114                 warn!("TODO: implement profile data completion");
    115                 RenderData::Profile(ProfileRenderData::default(app.default_pfp.clone()))
    116             }
    117         }
    118     }
    119 }
    120 
    121 impl PartialNoteRenderData {
    122     pub async fn complete(self, app: &Notecrumbs, nip19: &Nip19) -> NoteRenderData {
    123         // we have everything, all done!
    124         match (self.note, self.profile) {
    125             (Some(note), Some(profile)) => {
    126                 return NoteRenderData { note, profile };
    127             }
    128 
    129             // Don't hold ourselves up on profile data for notes. We can spin
    130             // off a background task to find the profile though.
    131             (Some(note), None) => {
    132                 warn!("TODO: spin off profile query when missing note profile");
    133                 let profile = ProfileRenderData::default(app.default_pfp.clone());
    134                 return NoteRenderData { note, profile };
    135             }
    136 
    137             _ => (),
    138         }
    139 
    140         debug!("Finding {:?}", nip19);
    141 
    142         match crate::find_note(app, &nip19).await {
    143             Ok(note_res) => {
    144                 let note = match note_res.note {
    145                     Some(note) => {
    146                         debug!("saving {:?} to nostrdb", &note);
    147                         let _ = app
    148                             .ndb
    149                             .process_event(&json!(["EVENT", "s", note]).to_string());
    150                         sdk_note_to_note_data(&note)
    151                     }
    152                     None => NoteData::default(),
    153                 };
    154 
    155                 let profile = match note_res.profile {
    156                     Some(profile) => {
    157                         debug!("saving profile to nostrdb: {:?}", &profile);
    158                         let _ = app
    159                             .ndb
    160                             .process_event(&json!(["EVENT", "s", profile]).to_string());
    161                         // TODO: wire profile to profile data, download pfp
    162                         ProfileRenderData::default(app.default_pfp.clone())
    163                     }
    164                     None => ProfileRenderData::default(app.default_pfp.clone()),
    165                 };
    166 
    167                 NoteRenderData { note, profile }
    168             }
    169             Err(_err) => {
    170                 let note = NoteData::default();
    171                 let profile = ProfileRenderData::default(app.default_pfp.clone());
    172                 NoteRenderData { note, profile }
    173             }
    174         }
    175     }
    176 }
    177 
    178 fn get_profile_render_data(
    179     txn: &Transaction,
    180     app: &Notecrumbs,
    181     pubkey: &XOnlyPublicKey,
    182 ) -> Result<ProfileRenderData, Error> {
    183     let profile = app.ndb.get_profile_by_pubkey(&txn, &pubkey.serialize())?;
    184     info!("profile cache hit {:?}", pubkey);
    185 
    186     let profile = profile.record.profile().ok_or(nostrdb::Error::NotFound)?;
    187     let name = profile.name().unwrap_or("").to_string();
    188     let about = profile.about().unwrap_or("").to_string();
    189     let display_name = profile.display_name().as_ref().map(|a| a.to_string());
    190     let pfp = app.default_pfp.clone();
    191 
    192     Ok(ProfileRenderData {
    193         name,
    194         pfp,
    195         about,
    196         display_name,
    197     })
    198 }
    199 
    200 fn ndb_note_to_data(note: &Note) -> NoteData {
    201     let content = note.content().to_string();
    202     let id = Some(*note.id());
    203     NoteData { content, id }
    204 }
    205 
    206 fn sdk_note_to_note_data(note: &Event) -> NoteData {
    207     let content = note.content.clone();
    208     NoteData {
    209         content,
    210         id: Some(note.id.to_bytes()),
    211     }
    212 }
    213 
    214 fn get_note_render_data(
    215     app: &Notecrumbs,
    216     source: &EventSource,
    217 ) -> Result<PartialNoteRenderData, Error> {
    218     debug!("got here a");
    219     let txn = Transaction::new(&app.ndb)?;
    220     let m_note = app
    221         .ndb
    222         .get_note_by_id(&txn, source.id().as_bytes().try_into()?)
    223         .map_err(Error::Nostrdb);
    224 
    225     debug!("note cached? {:?}", m_note);
    226 
    227     // It's possible we have an author pk in an nevent, let's use it if we do.
    228     // This gives us the opportunity to load the profile picture earlier if we
    229     // have a cached profile
    230     let mut profile: Option<ProfileRenderData> = None;
    231 
    232     let m_note_pk = m_note
    233         .as_ref()
    234         .ok()
    235         .and_then(|n| XOnlyPublicKey::from_slice(n.pubkey()).ok());
    236 
    237     let m_pk = m_note_pk.or(source.author());
    238 
    239     // get profile render data if we can
    240     if let Some(pk) = m_pk {
    241         match get_profile_render_data(&txn, app, &pk) {
    242             Err(err) => warn!(
    243                 "No profile found for {} for note {}: {}",
    244                 &pk,
    245                 &source.id(),
    246                 err
    247             ),
    248             Ok(record) => {
    249                 debug!("profile record found for note");
    250                 profile = Some(record);
    251             }
    252         }
    253     }
    254 
    255     let note = m_note.map(|n| ndb_note_to_data(&n)).ok();
    256     Ok(PartialNoteRenderData { profile, note })
    257 }
    258 
    259 pub fn get_render_data(app: &Notecrumbs, target: &Nip19) -> Result<PartialRenderData, Error> {
    260     match target {
    261         Nip19::Profile(profile) => {
    262             let txn = Transaction::new(&app.ndb)?;
    263             Ok(PartialRenderData::Profile(
    264                 get_profile_render_data(&txn, app, &profile.public_key).ok(),
    265             ))
    266         }
    267 
    268         Nip19::Pubkey(pk) => {
    269             let txn = Transaction::new(&app.ndb)?;
    270             Ok(PartialRenderData::Profile(
    271                 get_profile_render_data(&txn, app, pk).ok(),
    272             ))
    273         }
    274 
    275         Nip19::Event(event) => Ok(PartialRenderData::Note(get_note_render_data(
    276             app,
    277             &EventSource::Nip19(event.clone()),
    278         )?)),
    279 
    280         Nip19::EventId(evid) => Ok(PartialRenderData::Note(get_note_render_data(
    281             app,
    282             &EventSource::Id(*evid),
    283         )?)),
    284 
    285         Nip19::Secret(_nsec) => Err(Error::InvalidNip19),
    286         Nip19::Coordinate(_coord) => Err(Error::InvalidNip19),
    287     }
    288 }
    289 
    290 fn render_username(ui: &mut egui::Ui, profile: &ProfileRenderData) {
    291     #[cfg(feature = "profiling")]
    292     puffin::profile_function!();
    293     let name = format!("@{}", profile.name);
    294     ui.label(RichText::new(&name).size(40.0).color(Color32::LIGHT_GRAY));
    295 }
    296 
    297 fn setup_visuals(font_data: &egui::FontData, ctx: &egui::Context) {
    298     let mut visuals = Visuals::dark();
    299     visuals.override_text_color = Some(Color32::WHITE);
    300     ctx.set_visuals(visuals);
    301     fonts::setup_fonts(font_data, ctx);
    302 }
    303 
    304 fn push_job_text(job: &mut LayoutJob, s: &str, color: Color32) {
    305     job.append(
    306         s,
    307         0.0,
    308         TextFormat {
    309             font_id: FontId::new(50.0, FontFamily::Proportional),
    310             color,
    311             ..Default::default()
    312         },
    313     )
    314 }
    315 
    316 fn push_job_user_mention(
    317     job: &mut LayoutJob,
    318     ndb: &Ndb,
    319     block: &Block,
    320     txn: &Transaction,
    321     pk: &[u8; 32],
    322 ) {
    323     let record = ndb.get_profile_by_pubkey(&txn, pk);
    324     if let Ok(record) = record {
    325         let profile = record.record.profile().unwrap();
    326         push_job_text(
    327             job,
    328             &format!("@{}", &abbrev_str(profile.name().unwrap_or("nostrich"))),
    329             PURPLE,
    330         );
    331     } else {
    332         push_job_text(job, &format!("@{}", &abbrev_str(block.as_str())), PURPLE);
    333     }
    334 }
    335 
    336 fn wrapped_body_blocks(
    337     ui: &mut egui::Ui,
    338     ndb: &Ndb,
    339     note: &Note,
    340     blocks: &Blocks,
    341     txn: &Transaction,
    342 ) {
    343     let mut job = LayoutJob::default();
    344     job.justify = false;
    345     job.halign = egui::Align::LEFT;
    346     job.wrap = egui::text::TextWrapping {
    347         max_rows: 5,
    348         break_anywhere: false,
    349         overflow_character: Some('…'),
    350         ..Default::default()
    351     };
    352 
    353     for block in blocks.iter(note) {
    354         match block.blocktype() {
    355             BlockType::Url => push_job_text(&mut job, block.as_str(), PURPLE),
    356 
    357             BlockType::Hashtag => {
    358                 push_job_text(&mut job, "#", PURPLE);
    359                 push_job_text(&mut job, block.as_str(), PURPLE);
    360             }
    361 
    362             BlockType::MentionBech32 => {
    363                 let pk = match block.as_mention().unwrap() {
    364                     Mention::Event(_ev) => push_job_text(
    365                         &mut job,
    366                         &format!("@{}", &abbrev_str(block.as_str())),
    367                         PURPLE,
    368                     ),
    369                     Mention::Note(_ev) => {
    370                         push_job_text(
    371                             &mut job,
    372                             &format!("@{}", &abbrev_str(block.as_str())),
    373                             PURPLE,
    374                         );
    375                     }
    376                     Mention::Profile(nprofile) => {
    377                         push_job_user_mention(&mut job, ndb, &block, &txn, nprofile.pubkey())
    378                     }
    379                     Mention::Pubkey(npub) => {
    380                         push_job_user_mention(&mut job, ndb, &block, &txn, npub.pubkey())
    381                     }
    382                     Mention::Secret(sec) => push_job_text(&mut job, "--redacted--", PURPLE),
    383                     Mention::Relay(relay) => {
    384                         push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE)
    385                     }
    386                     Mention::Addr(addr) => {
    387                         push_job_text(&mut job, &abbrev_str(block.as_str()), PURPLE)
    388                     }
    389                 };
    390             }
    391 
    392             _ => push_job_text(&mut job, block.as_str(), Color32::WHITE),
    393         };
    394     }
    395 
    396     ui.label(job);
    397 }
    398 
    399 fn wrapped_body_text(ui: &mut egui::Ui, text: &str) {
    400     let format = TextFormat {
    401         font_id: FontId::proportional(52.0),
    402         color: Color32::WHITE,
    403         extra_letter_spacing: 0.0,
    404         line_height: Some(50.0),
    405         ..Default::default()
    406     };
    407 
    408     let job = LayoutJob::single_section(text.to_owned(), format);
    409     ui.label(job);
    410 }
    411 
    412 fn right_aligned() -> egui::Layout {
    413     use egui::{Align, Direction, Layout};
    414 
    415     Layout {
    416         main_dir: Direction::RightToLeft,
    417         main_wrap: false,
    418         main_align: Align::Center,
    419         main_justify: false,
    420         cross_align: Align::Center,
    421         cross_justify: false,
    422     }
    423 }
    424 
    425 fn note_frame_align() -> egui::Layout {
    426     use egui::{Align, Direction, Layout};
    427 
    428     Layout {
    429         main_dir: Direction::TopDown,
    430         main_wrap: false,
    431         main_align: Align::Center,
    432         main_justify: false,
    433         cross_align: Align::Center,
    434         cross_justify: false,
    435     }
    436 }
    437 
    438 fn note_ui(app: &Notecrumbs, ctx: &egui::Context, note: &NoteRenderData) {
    439     setup_visuals(&app.font_data, ctx);
    440 
    441     let outer_margin = 60.0;
    442     let inner_margin = 40.0;
    443     let canvas_width = 1200.0;
    444     let canvas_height = 600.0;
    445     //let canvas_size = Vec2::new(canvas_width, canvas_height);
    446 
    447     let total_margin = outer_margin + inner_margin;
    448     let pfp = ctx.load_texture("pfp", note.profile.pfp.clone(), Default::default());
    449     let bg = ctx.load_texture("background", app.background.clone(), Default::default());
    450 
    451     egui::CentralPanel::default()
    452         .frame(
    453             egui::Frame::default()
    454                 //.fill(Color32::from_rgb(0x43, 0x20, 0x62)
    455                 .fill(Color32::from_rgb(0x00, 0x00, 0x00)),
    456         )
    457         .show(&ctx, |ui| {
    458             background_texture(ui, &bg);
    459             egui::Frame::none()
    460                 .fill(Color32::from_rgb(0x0F, 0x0F, 0x0F))
    461                 .shadow(Shadow {
    462                     extrusion: 50.0,
    463                     color: Color32::from_black_alpha(60),
    464                 })
    465                 .rounding(Rounding::same(20.0))
    466                 .outer_margin(outer_margin)
    467                 .inner_margin(inner_margin)
    468                 .show(ui, |ui| {
    469                     let desired_height = canvas_height - total_margin * 2.0;
    470                     let desired_width = canvas_width - total_margin * 2.0;
    471                     let desired_size = Vec2::new(desired_width, desired_height);
    472                     ui.set_max_size(desired_size);
    473 
    474                     ui.with_layout(note_frame_align(), |ui| {
    475                         //egui::ScrollArea::vertical().show(ui, |ui| {
    476                         ui.spacing_mut().item_spacing = Vec2::new(10.0, 50.0);
    477 
    478                         ui.vertical(|ui| {
    479                             let desired = Vec2::new(desired_width, desired_height / 1.5);
    480                             ui.set_max_size(desired);
    481                             ui.set_min_size(desired);
    482 
    483                             let ok = (|| -> Result<(), nostrdb::Error> {
    484                                 let txn = Transaction::new(&app.ndb)?;
    485                                 let note_id = note.note.id.ok_or(nostrdb::Error::NotFound)?;
    486                                 let note = app.ndb.get_note_by_id(&txn, &note_id)?;
    487                                 let blocks =
    488                                     app.ndb.get_blocks_by_key(&txn, note.key().unwrap())?;
    489 
    490                                 wrapped_body_blocks(ui, &app.ndb, &note, &blocks, &txn);
    491 
    492                                 Ok(())
    493                             })();
    494 
    495                             if let Err(_) = ok {
    496                                 wrapped_body_text(ui, &note.note.content);
    497                             }
    498                         });
    499 
    500                         ui.horizontal(|ui| {
    501                             ui.image(&pfp);
    502                             render_username(ui, &note.profile);
    503                             ui.with_layout(right_aligned(), discuss_on_damus);
    504                         });
    505                     });
    506                 });
    507         });
    508 }
    509 
    510 fn background_texture(ui: &mut egui::Ui, texture: &TextureHandle) {
    511     // Get the size of the panel
    512     let size = ui.available_size();
    513 
    514     // Create a rectangle for the texture
    515     let rect = Rect::from_min_size(ui.min_rect().min, size);
    516 
    517     // Get the current layer ID
    518     let layer_id = ui.layer_id();
    519 
    520     let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
    521     //let uv_skewed = Rect::from_min_max(uv.min, pos2(uv.max.x, uv.max.y * 0.5));
    522 
    523     // Get the painter and draw the texture
    524     let painter = ui.ctx().layer_painter(layer_id);
    525     //let tint = Color32::WHITE;
    526 
    527     let mut mesh = Mesh::with_texture(texture.into());
    528 
    529     // Define vertices for a rectangle
    530     mesh.add_rect_with_uv(rect, uv, Color32::WHITE);
    531 
    532     //let origin = pos2(600.0, 300.0);
    533     //let angle = Rot2::from_angle(45.0);
    534     //mesh.rotate(angle, origin);
    535 
    536     // Draw the mesh
    537     painter.add(Shape::mesh(mesh));
    538 
    539     //painter.image(texture.into(), rect, uv_skewed, tint);
    540 }
    541 
    542 fn discuss_on_damus(ui: &mut egui::Ui) {
    543     let button = egui::Button::new(
    544         RichText::new("Discuss on Damus ➡")
    545             .size(30.0)
    546             .color(Color32::BLACK),
    547     )
    548     .rounding(50.0)
    549     .min_size(Vec2::new(330.0, 75.0))
    550     .fill(Color32::WHITE);
    551 
    552     ui.add(button);
    553 }
    554 
    555 fn profile_ui(app: &Notecrumbs, ctx: &egui::Context, profile: &ProfileRenderData) {
    556     let pfp = ctx.load_texture("pfp", profile.pfp.clone(), Default::default());
    557     setup_visuals(&app.font_data, ctx);
    558 
    559     egui::CentralPanel::default().show(&ctx, |ui| {
    560         ui.vertical(|ui| {
    561             ui.horizontal(|ui| {
    562                 ui.image(&pfp);
    563                 render_username(ui, &profile);
    564             });
    565             //body(ui, &profile.about);
    566         });
    567     });
    568 }
    569 
    570 pub fn render_note(app: &Notecrumbs, render_data: &RenderData) -> Vec<u8> {
    571     use egui_skia::{rasterize, RasterizeOptions};
    572     use skia_safe::EncodedImageFormat;
    573 
    574     let options = RasterizeOptions {
    575         pixels_per_point: 1.0,
    576         frames_before_screenshot: 1,
    577     };
    578 
    579     let mut surface = match render_data {
    580         RenderData::Note(note_render_data) => rasterize(
    581             (1200, 600),
    582             |ctx| note_ui(app, ctx, note_render_data),
    583             Some(options),
    584         ),
    585 
    586         RenderData::Profile(profile_render_data) => rasterize(
    587             (1200, 600),
    588             |ctx| profile_ui(app, ctx, profile_render_data),
    589             Some(options),
    590         ),
    591     };
    592 
    593     surface
    594         .image_snapshot()
    595         .encode_to_data(EncodedImageFormat::PNG)
    596         .expect("expected image")
    597         .as_bytes()
    598         .into()
    599 }