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