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", ¬e); 147 let _ = app 148 .ndb 149 .process_event(&json!(["EVENT", "s", note]).to_string()); 150 sdk_note_to_note_data(¬e) 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, ¬e_id)?; 487 let blocks = 488 app.ndb.get_blocks_by_key(&txn, note.key().unwrap())?; 489 490 wrapped_body_blocks(ui, &app.ndb, ¬e, &blocks, &txn); 491 492 Ok(()) 493 })(); 494 495 if let Err(_) = ok { 496 wrapped_body_text(ui, ¬e.note.content); 497 } 498 }); 499 500 ui.horizontal(|ui| { 501 ui.image(&pfp); 502 render_username(ui, ¬e.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 }