mod.rs (21961B)
1 pub mod contents; 2 pub mod context; 3 pub mod options; 4 pub mod post; 5 pub mod quote_repost; 6 pub mod reply; 7 pub mod reply_description; 8 9 pub use contents::NoteContents; 10 pub use context::{NoteContextButton, NoteContextSelection}; 11 pub use options::NoteOptions; 12 pub use post::{PostAction, PostResponse, PostType, PostView}; 13 pub use quote_repost::QuoteRepostView; 14 pub use reply::PostReplyView; 15 pub use reply_description::reply_desc; 16 17 use crate::{ 18 actionbar::NoteAction, 19 profile::get_display_name, 20 ui::{self, View}, 21 }; 22 23 use egui::emath::{pos2, Vec2}; 24 use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; 25 use enostr::{NoteId, Pubkey}; 26 use nostrdb::{Ndb, Note, NoteKey, Transaction}; 27 use notedeck::{CachedNote, ImageCache, NoteCache, NotedeckTextStyle}; 28 29 use super::profile::preview::one_line_display_name_widget; 30 31 pub struct NoteView<'a> { 32 ndb: &'a Ndb, 33 note_cache: &'a mut NoteCache, 34 img_cache: &'a mut ImageCache, 35 parent: Option<NoteKey>, 36 note: &'a nostrdb::Note<'a>, 37 flags: NoteOptions, 38 } 39 40 pub struct NoteResponse { 41 pub response: egui::Response, 42 pub context_selection: Option<NoteContextSelection>, 43 pub action: Option<NoteAction>, 44 } 45 46 impl NoteResponse { 47 pub fn new(response: egui::Response) -> Self { 48 Self { 49 response, 50 context_selection: None, 51 action: None, 52 } 53 } 54 55 pub fn with_action(mut self, action: Option<NoteAction>) -> Self { 56 self.action = action; 57 self 58 } 59 60 pub fn select_option(mut self, context_selection: Option<NoteContextSelection>) -> Self { 61 self.context_selection = context_selection; 62 self 63 } 64 } 65 66 impl View for NoteView<'_> { 67 fn ui(&mut self, ui: &mut egui::Ui) { 68 self.show(ui); 69 } 70 } 71 72 impl<'a> NoteView<'a> { 73 pub fn new( 74 ndb: &'a Ndb, 75 note_cache: &'a mut NoteCache, 76 img_cache: &'a mut ImageCache, 77 note: &'a nostrdb::Note<'a>, 78 ) -> Self { 79 let flags = NoteOptions::actionbar | NoteOptions::note_previews; 80 let parent: Option<NoteKey> = None; 81 Self { 82 ndb, 83 note_cache, 84 img_cache, 85 parent, 86 note, 87 flags, 88 } 89 } 90 91 pub fn note_options(mut self, options: NoteOptions) -> Self { 92 *self.options_mut() = options; 93 self 94 } 95 96 pub fn textmode(mut self, enable: bool) -> Self { 97 self.options_mut().set_textmode(enable); 98 self 99 } 100 101 pub fn actionbar(mut self, enable: bool) -> Self { 102 self.options_mut().set_actionbar(enable); 103 self 104 } 105 106 pub fn small_pfp(mut self, enable: bool) -> Self { 107 self.options_mut().set_small_pfp(enable); 108 self 109 } 110 111 pub fn medium_pfp(mut self, enable: bool) -> Self { 112 self.options_mut().set_medium_pfp(enable); 113 self 114 } 115 116 pub fn note_previews(mut self, enable: bool) -> Self { 117 self.options_mut().set_note_previews(enable); 118 self 119 } 120 121 pub fn selectable_text(mut self, enable: bool) -> Self { 122 self.options_mut().set_selectable_text(enable); 123 self 124 } 125 126 pub fn wide(mut self, enable: bool) -> Self { 127 self.options_mut().set_wide(enable); 128 self 129 } 130 131 pub fn options_button(mut self, enable: bool) -> Self { 132 self.options_mut().set_options_button(enable); 133 self 134 } 135 136 pub fn options(&self) -> NoteOptions { 137 self.flags 138 } 139 140 pub fn options_mut(&mut self) -> &mut NoteOptions { 141 &mut self.flags 142 } 143 144 pub fn parent(mut self, parent: NoteKey) -> Self { 145 self.parent = Some(parent); 146 self 147 } 148 149 fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { 150 let note_key = self.note.key().expect("todo: implement non-db notes"); 151 let txn = self.note.txn().expect("todo: implement non-db notes"); 152 153 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 154 let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); 155 156 //ui.horizontal(|ui| { 157 ui.spacing_mut().item_spacing.x = 2.0; 158 159 let cached_note = self 160 .note_cache 161 .cached_note_or_insert_mut(note_key, self.note); 162 163 let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); 164 ui.allocate_rect(rect, Sense::hover()); 165 ui.put(rect, |ui: &mut egui::Ui| { 166 render_reltime(ui, cached_note, false).response 167 }); 168 let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); 169 ui.allocate_rect(rect, Sense::hover()); 170 ui.put(rect, |ui: &mut egui::Ui| { 171 ui.add( 172 ui::Username::new(profile.as_ref().ok(), self.note.pubkey()) 173 .abbreviated(6) 174 .pk_colored(true), 175 ) 176 }); 177 178 ui.add(&mut NoteContents::new( 179 self.ndb, 180 self.img_cache, 181 self.note_cache, 182 txn, 183 self.note, 184 note_key, 185 self.flags, 186 )); 187 //}); 188 }) 189 .response 190 } 191 192 pub fn expand_size() -> f32 { 193 5.0 194 } 195 196 fn pfp( 197 &mut self, 198 note_key: NoteKey, 199 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 200 ui: &mut egui::Ui, 201 ) -> egui::Response { 202 if !self.options().has_wide() { 203 ui.spacing_mut().item_spacing.x = 16.0; 204 } else { 205 ui.spacing_mut().item_spacing.x = 4.0; 206 } 207 208 let pfp_size = self.options().pfp_size(); 209 210 let sense = Sense::click(); 211 match profile 212 .as_ref() 213 .ok() 214 .and_then(|p| p.record().profile()?.picture()) 215 { 216 // these have different lifetimes and types, 217 // so the calls must be separate 218 Some(pic) => { 219 let anim_speed = 0.05; 220 let profile_key = profile.as_ref().unwrap().record().note_key(); 221 let note_key = note_key.as_u64(); 222 223 let (rect, size, resp) = ui::anim::hover_expand( 224 ui, 225 egui::Id::new((profile_key, note_key)), 226 pfp_size, 227 ui::NoteView::expand_size(), 228 anim_speed, 229 ); 230 231 ui.put(rect, ui::ProfilePic::new(self.img_cache, pic).size(size)) 232 .on_hover_ui_at_pointer(|ui| { 233 ui.set_max_width(300.0); 234 ui.add(ui::ProfilePreview::new( 235 profile.as_ref().unwrap(), 236 self.img_cache, 237 )); 238 }); 239 240 if resp.hovered() || resp.clicked() { 241 ui::show_pointer(ui); 242 } 243 244 resp 245 } 246 None => ui 247 .add( 248 ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()) 249 .size(pfp_size), 250 ) 251 .interact(sense), 252 } 253 } 254 255 pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { 256 if self.options().has_textmode() { 257 NoteResponse::new(self.textmode_ui(ui)) 258 } else { 259 let txn = self.note.txn().expect("txn"); 260 if let Some(note_to_repost) = get_reposted_note(self.ndb, txn, self.note) { 261 let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); 262 263 let style = NotedeckTextStyle::Small; 264 ui.horizontal(|ui| { 265 ui.vertical(|ui| { 266 ui.add_space(2.0); 267 ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); 268 }); 269 ui.add_space(6.0); 270 let resp = ui.add(one_line_display_name_widget( 271 ui.visuals(), 272 get_display_name(profile.as_ref().ok()), 273 style, 274 )); 275 if let Ok(rec) = &profile { 276 resp.on_hover_ui_at_pointer(|ui| { 277 ui.set_max_width(300.0); 278 ui.add(ui::ProfilePreview::new(rec, self.img_cache)); 279 }); 280 } 281 let color = ui.style().visuals.noninteractive().fg_stroke.color; 282 ui.add_space(4.0); 283 ui.label( 284 RichText::new("Reposted") 285 .color(color) 286 .text_style(style.text_style()), 287 ); 288 }); 289 NoteView::new(self.ndb, self.note_cache, self.img_cache, ¬e_to_repost).show(ui) 290 } else { 291 self.show_standard(ui) 292 } 293 } 294 } 295 296 fn note_header( 297 ui: &mut egui::Ui, 298 note_cache: &mut NoteCache, 299 note: &Note, 300 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 301 options: NoteOptions, 302 container_right: Pos2, 303 ) -> NoteResponse { 304 #[cfg(feature = "profiling")] 305 puffin::profile_function!(); 306 307 let note_key = note.key().unwrap(); 308 309 let inner_response = ui.horizontal(|ui| { 310 ui.spacing_mut().item_spacing.x = 2.0; 311 ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); 312 313 let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); 314 render_reltime(ui, cached_note, true); 315 316 if options.has_options_button() { 317 let context_pos = { 318 let size = NoteContextButton::max_width(); 319 let min = Pos2::new(container_right.x - size, container_right.y); 320 Rect::from_min_size(min, egui::vec2(size, size)) 321 }; 322 323 let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); 324 NoteContextButton::menu(ui, resp.clone()) 325 } else { 326 None 327 } 328 }); 329 330 NoteResponse::new(inner_response.response).select_option(inner_response.inner) 331 } 332 333 fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { 334 #[cfg(feature = "profiling")] 335 puffin::profile_function!(); 336 let note_key = self.note.key().expect("todo: support non-db notes"); 337 let txn = self.note.txn().expect("todo: support non-db notes"); 338 339 let mut note_action: Option<NoteAction> = None; 340 let mut selected_option: Option<NoteContextSelection> = None; 341 342 let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); 343 let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); 344 let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id); 345 let container_right = { 346 let r = ui.available_rect_before_wrap(); 347 let x = r.max.x; 348 let y = r.min.y; 349 Pos2::new(x, y) 350 }; 351 352 // wide design 353 let response = if self.options().has_wide() { 354 ui.vertical(|ui| { 355 ui.horizontal(|ui| { 356 if self.pfp(note_key, &profile, ui).clicked() { 357 note_action = 358 Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey()))); 359 }; 360 361 let size = ui.available_size(); 362 ui.vertical(|ui| { 363 ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| { 364 ui.horizontal_centered(|ui| { 365 selected_option = NoteView::note_header( 366 ui, 367 self.note_cache, 368 self.note, 369 &profile, 370 self.options(), 371 container_right, 372 ) 373 .context_selection; 374 }) 375 .response 376 }); 377 378 let note_reply = self 379 .note_cache 380 .cached_note_or_insert_mut(note_key, self.note) 381 .reply 382 .borrow(self.note.tags()); 383 384 if note_reply.reply().is_some() { 385 let action = ui 386 .horizontal(|ui| { 387 reply_desc( 388 ui, 389 txn, 390 ¬e_reply, 391 self.ndb, 392 self.img_cache, 393 self.note_cache, 394 ) 395 }) 396 .inner; 397 398 if action.is_some() { 399 note_action = action; 400 } 401 } 402 }); 403 }); 404 405 let mut contents = NoteContents::new( 406 self.ndb, 407 self.img_cache, 408 self.note_cache, 409 txn, 410 self.note, 411 note_key, 412 self.options(), 413 ); 414 415 ui.add(&mut contents); 416 417 if let Some(action) = contents.action() { 418 note_action = Some(*action); 419 } 420 421 if self.options().has_actionbar() { 422 if let Some(action) = render_note_actionbar(ui, self.note.id(), note_key).inner 423 { 424 note_action = Some(action); 425 } 426 } 427 }) 428 .response 429 } else { 430 // main design 431 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 432 if self.pfp(note_key, &profile, ui).clicked() { 433 note_action = Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey()))); 434 }; 435 436 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { 437 selected_option = NoteView::note_header( 438 ui, 439 self.note_cache, 440 self.note, 441 &profile, 442 self.options(), 443 container_right, 444 ) 445 .context_selection; 446 ui.horizontal(|ui| { 447 ui.spacing_mut().item_spacing.x = 2.0; 448 449 let note_reply = self 450 .note_cache 451 .cached_note_or_insert_mut(note_key, self.note) 452 .reply 453 .borrow(self.note.tags()); 454 455 if note_reply.reply().is_some() { 456 let action = reply_desc( 457 ui, 458 txn, 459 ¬e_reply, 460 self.ndb, 461 self.img_cache, 462 self.note_cache, 463 ); 464 465 if action.is_some() { 466 note_action = action; 467 } 468 } 469 }); 470 471 let mut contents = NoteContents::new( 472 self.ndb, 473 self.img_cache, 474 self.note_cache, 475 txn, 476 self.note, 477 note_key, 478 self.options(), 479 ); 480 ui.add(&mut contents); 481 482 if let Some(action) = contents.action() { 483 note_action = Some(*action); 484 } 485 486 if self.options().has_actionbar() { 487 if let Some(action) = 488 render_note_actionbar(ui, self.note.id(), note_key).inner 489 { 490 note_action = Some(action); 491 } 492 } 493 }); 494 }) 495 .response 496 }; 497 498 let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) { 499 Some(NoteAction::OpenThread(NoteId::new(*self.note.id()))) 500 } else { 501 note_action 502 }; 503 504 NoteResponse::new(response) 505 .with_action(note_action) 506 .select_option(selected_option) 507 } 508 } 509 510 fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> { 511 let new_note_id: &[u8; 32] = if note.kind() == 6 { 512 let mut res = None; 513 for tag in note.tags().iter() { 514 if tag.count() == 0 { 515 continue; 516 } 517 518 if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { 519 if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { 520 res = Some(note_id); 521 break; 522 } 523 } 524 } 525 res? 526 } else { 527 return None; 528 }; 529 530 let note = ndb.get_note_by_id(txn, new_note_id).ok(); 531 note.filter(|note| note.kind() == 1) 532 } 533 534 fn note_hitbox_id( 535 note_key: NoteKey, 536 note_options: NoteOptions, 537 parent: Option<NoteKey>, 538 ) -> egui::Id { 539 Id::new(("note_size", note_key, note_options, parent)) 540 } 541 542 fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> { 543 ui.ctx() 544 .data_mut(|d| d.get_persisted(hitbox_id)) 545 .map(|note_size: Vec2| { 546 // The hitbox should extend the entire width of the 547 // container. The hitbox height was cached last layout. 548 let container_rect = ui.max_rect(); 549 let rect = Rect { 550 min: pos2(container_rect.min.x, container_rect.min.y), 551 max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), 552 }; 553 554 let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); 555 556 response 557 .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); 558 559 response 560 }) 561 } 562 563 fn note_hitbox_clicked( 564 ui: &mut egui::Ui, 565 hitbox_id: egui::Id, 566 note_rect: &Rect, 567 maybe_hitbox: Option<Response>, 568 ) -> bool { 569 // Stash the dimensions of the note content so we can render the 570 // hitbox in the next frame 571 ui.ctx().data_mut(|d| { 572 d.insert_persisted(hitbox_id, note_rect.size()); 573 }); 574 575 // If there was an hitbox and it was clicked open the thread 576 match maybe_hitbox { 577 Some(hitbox) => hitbox.clicked(), 578 _ => false, 579 } 580 } 581 582 fn render_note_actionbar( 583 ui: &mut egui::Ui, 584 note_id: &[u8; 32], 585 note_key: NoteKey, 586 ) -> egui::InnerResponse<Option<NoteAction>> { 587 #[cfg(feature = "profiling")] 588 puffin::profile_function!(); 589 590 ui.horizontal(|ui| { 591 let reply_resp = reply_button(ui, note_key); 592 let quote_resp = quote_repost_button(ui, note_key); 593 594 if reply_resp.clicked() { 595 Some(NoteAction::Reply(NoteId::new(*note_id))) 596 } else if quote_resp.clicked() { 597 Some(NoteAction::Quote(NoteId::new(*note_id))) 598 } else { 599 None 600 } 601 }) 602 } 603 604 fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) { 605 let color = ui.style().visuals.noninteractive().fg_stroke.color; 606 ui.add(Label::new(RichText::new(s).size(10.0).color(color))); 607 } 608 609 fn render_reltime( 610 ui: &mut egui::Ui, 611 note_cache: &mut CachedNote, 612 before: bool, 613 ) -> egui::InnerResponse<()> { 614 #[cfg(feature = "profiling")] 615 puffin::profile_function!(); 616 617 ui.horizontal(|ui| { 618 if before { 619 secondary_label(ui, "⋅"); 620 } 621 622 secondary_label(ui, note_cache.reltime_str_mut()); 623 624 if !before { 625 secondary_label(ui, "⋅"); 626 } 627 }) 628 } 629 630 fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { 631 let img_data = if ui.style().visuals.dark_mode { 632 egui::include_image!("../../../../../assets/icons/reply.png") 633 } else { 634 egui::include_image!("../../../../../assets/icons/reply-dark.png") 635 }; 636 637 let (rect, size, resp) = 638 ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); 639 640 // align rect to note contents 641 let expand_size = 5.0; // from hover_expand_small 642 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 643 644 let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size)); 645 646 resp.union(put_resp) 647 } 648 649 fn repost_icon(dark_mode: bool) -> egui::Image<'static> { 650 let img_data = if dark_mode { 651 egui::include_image!("../../../../../assets/icons/repost_icon_4x.png") 652 } else { 653 egui::include_image!("../../../../../assets/icons/repost_light_4x.png") 654 }; 655 egui::Image::new(img_data) 656 } 657 658 fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { 659 let size = 14.0; 660 let expand_size = 5.0; 661 let anim_speed = 0.05; 662 let id = ui.id().with(("repost_anim", note_key)); 663 664 let (rect, size, resp) = ui::anim::hover_expand(ui, id, size, expand_size, anim_speed); 665 666 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); 667 668 let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)); 669 670 resp.union(put_resp) 671 }