timeline.rs (31757B)
1 use egui::containers::scroll_area::ScrollBarVisibility; 2 use egui::{vec2, Color32, Direction, Layout, Margin, Pos2, RichText, ScrollArea, Sense, Stroke}; 3 use egui_tabs::TabColor; 4 use enostr::Pubkey; 5 use nostrdb::{Note, ProfileRecord, Transaction}; 6 use notedeck::fonts::get_font_size; 7 use notedeck::name::get_display_name; 8 use notedeck::ui::is_narrow; 9 use notedeck::{tr_plural, Muted, NotedeckTextStyle}; 10 use notedeck_ui::app_images::{like_image_filled, repost_image}; 11 use notedeck_ui::{ProfilePic, ProfilePreview}; 12 use std::f32::consts::PI; 13 use tracing::{error, warn}; 14 15 use crate::timeline::{ 16 CompositeType, CompositeUnit, NoteUnit, ReactionUnit, RepostUnit, TimelineCache, TimelineKind, 17 TimelineTab, 18 }; 19 use notedeck::DragResponse; 20 use notedeck::{ 21 note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo, 22 }; 23 use notedeck_ui::{ 24 anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, 25 NoteOptions, NoteView, 26 }; 27 28 pub struct TimelineView<'a, 'd> { 29 timeline_id: &'a TimelineKind, 30 timeline_cache: &'a mut TimelineCache, 31 note_options: NoteOptions, 32 note_context: &'a mut NoteContext<'d>, 33 col: usize, 34 scroll_to_top: bool, 35 } 36 37 impl<'a, 'd> TimelineView<'a, 'd> { 38 #[allow(clippy::too_many_arguments)] 39 pub fn new( 40 timeline_id: &'a TimelineKind, 41 timeline_cache: &'a mut TimelineCache, 42 note_context: &'a mut NoteContext<'d>, 43 note_options: NoteOptions, 44 col: usize, 45 ) -> Self { 46 let scroll_to_top = false; 47 TimelineView { 48 timeline_id, 49 timeline_cache, 50 note_options, 51 note_context, 52 col, 53 scroll_to_top, 54 } 55 } 56 57 pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> { 58 timeline_ui( 59 ui, 60 self.timeline_id, 61 self.timeline_cache, 62 self.note_options, 63 self.note_context, 64 self.col, 65 self.scroll_to_top, 66 ) 67 } 68 69 pub fn scroll_to_top(mut self, enable: bool) -> Self { 70 self.scroll_to_top = enable; 71 self 72 } 73 74 pub fn scroll_id( 75 timeline_cache: &TimelineCache, 76 timeline_id: &TimelineKind, 77 col: usize, 78 ) -> Option<egui::Id> { 79 let timeline = timeline_cache.get(timeline_id)?; 80 Some(egui::Id::new(("tlscroll", timeline.view_id(col)))) 81 } 82 } 83 84 #[allow(clippy::too_many_arguments)] 85 #[profiling::function] 86 fn timeline_ui( 87 ui: &mut egui::Ui, 88 timeline_id: &TimelineKind, 89 timeline_cache: &mut TimelineCache, 90 mut note_options: NoteOptions, 91 note_context: &mut NoteContext, 92 col: usize, 93 scroll_to_top: bool, 94 ) -> DragResponse<NoteAction> { 95 //padding(4.0, ui, |ui| ui.heading("Notifications")); 96 /* 97 let font_id = egui::TextStyle::Body.resolve(ui.style()); 98 let row_height = ui.fonts(|f| f.row_height(&font_id)) + ui.spacing().item_spacing.y; 99 100 */ 101 102 let Some(scroll_id) = TimelineView::scroll_id(timeline_cache, timeline_id, col) else { 103 return DragResponse::none(); 104 }; 105 106 { 107 let timeline = if let Some(timeline) = timeline_cache.get_mut(timeline_id) { 108 timeline 109 } else { 110 error!("tried to render timeline in column, but timeline was missing"); 111 // TODO (jb55): render error when timeline is missing? 112 // this shouldn't happen... 113 return DragResponse::none(); 114 }; 115 116 timeline.selected_view = tabs_ui( 117 ui, 118 note_context.i18n, 119 timeline.selected_view, 120 &timeline.views, 121 ) 122 .inner; 123 124 // need this for some reason?? 125 ui.add_space(3.0); 126 }; 127 128 let show_top_button_id = ui.id().with((scroll_id, "at_top")); 129 130 let show_top_button = ui 131 .ctx() 132 .data(|d| d.get_temp::<bool>(show_top_button_id)) 133 .unwrap_or(false); 134 135 let goto_top_resp = if show_top_button { 136 let top_button_pos_x = if is_narrow(ui.ctx()) { 28.0 } else { 48.0 }; 137 let top_button_pos = 138 ui.available_rect_before_wrap().right_top() - vec2(top_button_pos_x, -24.0); 139 egui::Area::new(ui.id().with("foreground_area")) 140 .order(egui::Order::Middle) 141 .fixed_pos(top_button_pos) 142 .show(ui.ctx(), |ui| Some(ui.add(goto_top_button(top_button_pos)))) 143 .inner 144 .map(|r| r.on_hover_cursor(egui::CursorIcon::PointingHand)) 145 } else { 146 None 147 }; 148 149 let mut scroll_area = egui::ScrollArea::vertical() 150 .id_salt(scroll_id) 151 .animated(false) 152 .auto_shrink([false, false]) 153 .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible); 154 155 if goto_top_resp.is_some_and(|r| r.clicked()) { 156 scroll_area = scroll_area.vertical_scroll_offset(0.0); 157 } 158 159 // chrome can ask to scroll to top as well via an app option 160 if scroll_to_top { 161 scroll_area = scroll_area.vertical_scroll_offset(0.0); 162 } 163 164 let scroll_output = scroll_area.show(ui, |ui| { 165 let timeline = if let Some(timeline) = timeline_cache.get(timeline_id) { 166 timeline 167 } else { 168 error!("tried to render timeline in column, but timeline was missing"); 169 // TODO (jb55): render error when timeline is missing? 170 // this shouldn't happen... 171 // 172 // NOTE (jb55): it can easily happen if you add a timeline column without calling 173 // add_new_timeline_column, since that sets up the initial subs, etc 174 return None; 175 }; 176 177 let txn = Transaction::new(note_context.ndb).expect("failed to create txn"); 178 179 if matches!(timeline_id, TimelineKind::Notifications(_)) { 180 note_options.set(NoteOptions::Notification, true) 181 } 182 183 TimelineTabView::new(timeline.current_view(), note_options, &txn, note_context).show(ui) 184 }); 185 186 let at_top_after_scroll = scroll_output.state.offset.y == 0.0; 187 let cur_show_top_button = ui.ctx().data(|d| d.get_temp::<bool>(show_top_button_id)); 188 189 if at_top_after_scroll { 190 if cur_show_top_button != Some(false) { 191 ui.ctx() 192 .data_mut(|d| d.insert_temp(show_top_button_id, false)); 193 } 194 } else if cur_show_top_button == Some(false) { 195 ui.ctx() 196 .data_mut(|d| d.insert_temp(show_top_button_id, true)); 197 } 198 199 let scroll_id = scroll_output.id; 200 201 let action = scroll_output.inner.or_else(|| { 202 // if we're scrolling, return that as a response. We need this 203 // for auto-closing the side menu 204 205 let velocity = scroll_output.state.velocity(); 206 let offset = scroll_output.state.offset; 207 if velocity.length_sq() > 0.0 { 208 Some(NoteAction::Scroll(ScrollInfo { 209 velocity, 210 offset, 211 full_content_size: scroll_output.content_size, 212 viewable_content_rect: scroll_output.inner_rect, 213 })) 214 } else { 215 None 216 } 217 }); 218 219 DragResponse::output(action).scroll_raw(scroll_id) 220 } 221 222 fn goto_top_button(center: Pos2) -> impl egui::Widget { 223 move |ui: &mut egui::Ui| -> egui::Response { 224 let radius = 12.0; 225 let max_size = vec2( 226 ICON_EXPANSION_MULTIPLE * 2.0 * radius, 227 ICON_EXPANSION_MULTIPLE * 2.0 * radius, 228 ); 229 let helper = AnimationHelper::new_from_rect(ui, "goto_top", { 230 let painter = ui.painter(); 231 #[allow(deprecated)] 232 let center = painter.round_pos_to_pixel_center(center); 233 egui::Rect::from_center_size(center, max_size) 234 }); 235 236 let painter = ui.painter(); 237 painter.circle_filled( 238 center, 239 helper.scale_1d_pos(radius), 240 notedeck_ui::colors::PINK, 241 ); 242 243 let create_pt = |angle: f32| { 244 let side = radius / 2.0; 245 let x = side * angle.cos(); 246 let mut y = side * angle.sin(); 247 248 let height = (side * (3.0_f32).sqrt()) / 2.0; 249 y += height / 2.0; 250 Pos2 { x, y } 251 }; 252 253 #[allow(deprecated)] 254 let left_pt = 255 painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI))); 256 #[allow(deprecated)] 257 let center_pt = 258 painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI / 2.0))); 259 #[allow(deprecated)] 260 let right_pt = 261 painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(0.0))); 262 263 let line_width = helper.scale_1d_pos(4.0); 264 let line_color = ui.visuals().text_color(); 265 painter.line_segment([left_pt, center_pt], Stroke::new(line_width, line_color)); 266 painter.line_segment([center_pt, right_pt], Stroke::new(line_width, line_color)); 267 268 let end_radius = (line_width - 1.0) / 2.0; 269 painter.circle_filled(left_pt, end_radius, line_color); 270 painter.circle_filled(center_pt, end_radius, line_color); 271 painter.circle_filled(right_pt, end_radius, line_color); 272 273 helper.take_animation_response() 274 } 275 } 276 277 pub fn tabs_ui( 278 ui: &mut egui::Ui, 279 i18n: &mut Localization, 280 selected: usize, 281 views: &[TimelineTab], 282 ) -> egui::InnerResponse<usize> { 283 ui.spacing_mut().item_spacing.y = 0.0; 284 285 let tab_res = egui_tabs::Tabs::new(views.len() as i32) 286 .selected(selected as i32) 287 .hover_bg(TabColor::none()) 288 .selected_fg(TabColor::none()) 289 .selected_bg(TabColor::none()) 290 .hover_bg(TabColor::none()) 291 //.hover_bg(TabColor::custom(egui::Color32::RED)) 292 .height(32.0) 293 .layout(Layout::centered_and_justified(Direction::TopDown)) 294 .show(ui, |ui, state| { 295 ui.spacing_mut().item_spacing.y = 0.0; 296 297 let ind = state.index(); 298 299 let txt = views[ind as usize].filter.name(i18n); 300 301 let res = ui.add(egui::Label::new(txt.clone()).selectable(false)); 302 303 // underline 304 if state.is_selected() { 305 let rect = res.rect; 306 let underline = 307 shrink_range_to_width(rect.x_range(), get_label_width(ui, &txt) * 1.15); 308 #[allow(deprecated)] 309 let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5; 310 return (underline, underline_y); 311 } 312 313 (egui::Rangef::new(0.0, 0.0), 0.0) 314 }); 315 316 //ui.add_space(0.5); 317 notedeck_ui::hline(ui); 318 319 let sel = tab_res.selected().unwrap_or_default(); 320 321 let res_inner = &tab_res.inner()[sel as usize]; 322 323 let (underline, underline_y) = res_inner.inner; 324 let underline_width = underline.span(); 325 326 let tab_anim_id = ui.id().with("tab_anim"); 327 let tab_anim_size = tab_anim_id.with("size"); 328 329 let stroke = egui::Stroke { 330 color: ui.visuals().hyperlink_color, 331 width: 2.0, 332 }; 333 334 let speed = 0.1f32; 335 336 // animate underline position 337 let x = ui 338 .ctx() 339 .animate_value_with_time(tab_anim_id, underline.min, speed); 340 341 // animate underline width 342 let w = ui 343 .ctx() 344 .animate_value_with_time(tab_anim_size, underline_width, speed); 345 346 let underline = egui::Rangef::new(x, x + w); 347 348 ui.painter().hline(underline, underline_y, stroke); 349 350 egui::InnerResponse::new(sel as usize, res_inner.response.clone()) 351 } 352 353 fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 { 354 let font_id = egui::FontId::default(); 355 let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE)); 356 galley.rect.width() 357 } 358 359 fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { 360 let midpoint = (range.min + range.max) / 2.0; 361 let half_width = width / 2.0; 362 363 let min = midpoint - half_width; 364 let max = midpoint + half_width; 365 366 egui::Rangef::new(min, max) 367 } 368 369 pub struct TimelineTabView<'a, 'd> { 370 tab: &'a TimelineTab, 371 note_options: NoteOptions, 372 txn: &'a Transaction, 373 note_context: &'a mut NoteContext<'d>, 374 } 375 376 impl<'a, 'd> TimelineTabView<'a, 'd> { 377 #[allow(clippy::too_many_arguments)] 378 pub fn new( 379 tab: &'a TimelineTab, 380 note_options: NoteOptions, 381 txn: &'a Transaction, 382 note_context: &'a mut NoteContext<'d>, 383 ) -> Self { 384 Self { 385 tab, 386 note_options, 387 txn, 388 note_context, 389 } 390 } 391 392 pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { 393 let mut action: Option<NoteAction> = None; 394 let len = self.tab.units.len(); 395 396 let mute = self.note_context.accounts.mute(); 397 398 self.tab 399 .list 400 .borrow_mut() 401 .ui_custom_layout(ui, len, |ui, index| { 402 // tracing::info!("rendering index: {index}"); 403 ui.spacing_mut().item_spacing.y = 0.0; 404 ui.spacing_mut().item_spacing.x = 4.0; 405 406 let Some(entry) = self.tab.units.get(index) else { 407 return 0; 408 }; 409 410 match self.render_entry(ui, entry, &mute) { 411 RenderEntryResponse::Unsuccessful => return 0, 412 413 RenderEntryResponse::Success(note_action) => { 414 if let Some(cur_action) = note_action { 415 action = Some(cur_action); 416 } 417 } 418 } 419 420 1 421 }); 422 423 action 424 } 425 426 fn render_entry( 427 &mut self, 428 ui: &mut egui::Ui, 429 entry: &NoteUnit, 430 mute: &std::sync::Arc<Muted>, 431 ) -> RenderEntryResponse { 432 let underlying_note = { 433 let underlying_note_key = match entry { 434 NoteUnit::Single(note_ref) => note_ref.key, 435 NoteUnit::Composite(composite_unit) => match composite_unit { 436 CompositeUnit::Reaction(reaction_unit) => reaction_unit.note_reacted_to.key, 437 CompositeUnit::Repost(repost_unit) => repost_unit.note_reposted.key, 438 }, 439 }; 440 441 let Ok(note) = self 442 .note_context 443 .ndb 444 .get_note_by_key(self.txn, underlying_note_key) 445 else { 446 warn!("failed to query note {:?}", underlying_note_key); 447 return RenderEntryResponse::Unsuccessful; 448 }; 449 450 note 451 }; 452 453 let muted = root_note_id_from_selected_id( 454 self.note_context.ndb, 455 self.note_context.note_cache, 456 self.txn, 457 underlying_note.id(), 458 ) 459 .is_ok_and(|root_id| mute.is_muted(&underlying_note, root_id.bytes())); 460 461 if muted { 462 return RenderEntryResponse::Success(None); 463 } 464 465 match entry { 466 NoteUnit::Single(_) => { 467 render_note(ui, self.note_context, self.note_options, &underlying_note) 468 } 469 NoteUnit::Composite(composite) => match composite { 470 CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster( 471 ui, 472 self.note_context, 473 self.note_options, 474 mute, 475 self.txn, 476 &underlying_note, 477 reaction_unit, 478 ), 479 CompositeUnit::Repost(repost_unit) => render_repost_cluster( 480 ui, 481 self.note_context, 482 self.note_options, 483 mute, 484 self.txn, 485 &underlying_note, 486 repost_unit, 487 ), 488 }, 489 } 490 } 491 } 492 493 enum ReferencedNoteType { 494 Tagged, 495 Yours, 496 } 497 498 impl CompositeType { 499 fn image(&self, darkmode: bool) -> egui::Image<'static> { 500 match self { 501 CompositeType::Reaction => like_image_filled(), 502 CompositeType::Repost => { 503 repost_image(darkmode).tint(Color32::from_rgb(0x68, 0xC3, 0x51)) 504 } 505 } 506 } 507 508 fn description( 509 &self, 510 loc: &mut Localization, 511 first_name: &str, 512 total_count: usize, 513 referenced_type: ReferencedNoteType, 514 notification: bool, 515 rumor: bool, 516 ) -> String { 517 let count = total_count - 1; 518 519 match self { 520 CompositeType::Reaction => { 521 reaction_description(loc, first_name, count, referenced_type, rumor) 522 } 523 CompositeType::Repost => repost_description( 524 loc, 525 first_name, 526 count, 527 if notification { 528 DescriptionType::Notification(referenced_type) 529 } else { 530 DescriptionType::Other 531 }, 532 ), 533 } 534 } 535 } 536 537 fn reaction_description( 538 loc: &mut Localization, 539 first_name: &str, 540 count: usize, 541 referenced_type: ReferencedNoteType, 542 rumor: bool, 543 ) -> String { 544 let privately = if rumor { "privately " } else { "" }; 545 match referenced_type { 546 ReferencedNoteType::Tagged => { 547 if count == 0 { 548 tr!( 549 loc, 550 "{name} {privately}reacted to a note you were tagged in", 551 "reaction from user to a note you were tagged in", 552 name = first_name, 553 privately = privately 554 ) 555 } else { 556 tr_plural!( 557 loc, 558 "{name} and {count} other reacted to a note you were tagged in", 559 "{name} and {count} others reacted to a note you were tagged in", 560 "amount of reactions a note you were tagged in received", 561 count, 562 name = first_name 563 ) 564 } 565 } 566 ReferencedNoteType::Yours => { 567 if count == 0 { 568 tr!( 569 loc, 570 "{name} {privately}reacted to your note", 571 "reaction from user to your note", 572 name = first_name, 573 privately = privately 574 ) 575 } else { 576 tr_plural!( 577 loc, 578 "{name} and {count} other reacted to your note", 579 "{name} and {count} others reacted to your note", 580 "describing the amount of reactions your note received", 581 count, 582 name = first_name 583 ) 584 } 585 } 586 } 587 } 588 589 enum DescriptionType { 590 Notification(ReferencedNoteType), 591 Other, 592 } 593 594 fn repost_description( 595 loc: &mut Localization, 596 first_name: &str, 597 count: usize, 598 description_type: DescriptionType, 599 ) -> String { 600 match description_type { 601 DescriptionType::Notification(referenced_type) => match referenced_type { 602 ReferencedNoteType::Tagged => { 603 if count == 0 { 604 tr!( 605 loc, 606 "{name} reposted a note you were tagged in", 607 "repost from user", 608 name = first_name 609 ) 610 } else { 611 tr_plural!( 612 loc, 613 "{name} and {count} other reposted a note you were tagged in", 614 "{name} and {count} others reposted a note you were tagged in", 615 "describing the amount of reposts a note you were tagged in received", 616 count, 617 name = first_name 618 ) 619 } 620 } 621 ReferencedNoteType::Yours => { 622 if count == 0 { 623 tr!( 624 loc, 625 "{name} reposted your note", 626 "repost from user", 627 name = first_name 628 ) 629 } else { 630 tr_plural!( 631 loc, 632 "{name} and {count} other reposted your note", 633 "{name} and {count} others reposted your note", 634 "describing the amount of reposts your note received", 635 count, 636 name = first_name 637 ) 638 } 639 } 640 }, 641 DescriptionType::Other => { 642 if count == 0 { 643 tr!( 644 loc, 645 "{name} reposted", 646 "repost from user", 647 name = first_name 648 ) 649 } else { 650 tr_plural!( 651 loc, 652 "{name} and {count} other reposted", 653 "{name} and {count} others reposted", 654 "describing the amount of reposts a note has", 655 count, 656 name = first_name 657 ) 658 } 659 } 660 } 661 } 662 663 #[profiling::function] 664 fn render_note( 665 ui: &mut egui::Ui, 666 note_context: &mut NoteContext, 667 note_options: NoteOptions, 668 note: &Note, 669 ) -> RenderEntryResponse { 670 let mut action = None; 671 notedeck_ui::padding(8.0, ui, |ui| { 672 let resp = NoteView::new(note_context, note, note_options).show(ui); 673 674 if let Some(note_action) = resp.action { 675 action = Some(note_action); 676 } 677 }); 678 679 notedeck_ui::hline(ui); 680 681 RenderEntryResponse::Success(action) 682 } 683 684 #[allow(clippy::too_many_arguments)] 685 #[profiling::function] 686 fn render_reaction_cluster( 687 ui: &mut egui::Ui, 688 note_context: &mut NoteContext, 689 note_options: NoteOptions, 690 mute: &std::sync::Arc<Muted>, 691 txn: &Transaction, 692 underlying_note: &Note, 693 reaction: &ReactionUnit, 694 ) -> RenderEntryResponse { 695 let profiles_to_show: Vec<ProfileEntry> = { 696 profiling::scope!("vec profile entries"); 697 reaction 698 .reactions 699 .values() 700 .filter(|r| !mute.is_pk_muted(r.sender.bytes())) 701 .map(|r| (&r.sender, r.sender_profilekey)) 702 .map(|(p, key)| { 703 let record = if let Some(key) = key { 704 profiling::scope!("ndb by key"); 705 note_context.ndb.get_profile_by_key(txn, key).ok() 706 } else { 707 profiling::scope!("ndb by pubkey"); 708 note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok() 709 }; 710 ProfileEntry { record, pk: p } 711 }) 712 .collect() 713 }; 714 715 render_composite_entry( 716 ui, 717 note_context, 718 note_options | NoteOptions::Notification, 719 underlying_note, 720 profiles_to_show, 721 CompositeType::Reaction, 722 ) 723 } 724 725 #[allow(clippy::too_many_arguments)] 726 #[profiling::function] 727 fn render_composite_entry( 728 ui: &mut egui::Ui, 729 note_context: &mut NoteContext, 730 mut note_options: NoteOptions, 731 underlying_note: &nostrdb::Note<'_>, 732 profiles_to_show: Vec<ProfileEntry>, 733 composite_type: CompositeType, 734 ) -> RenderEntryResponse { 735 let first_name = get_display_name(profiles_to_show.iter().find_map(|opt| opt.record.as_ref())) 736 .name() 737 .to_string(); 738 let num_profiles = profiles_to_show.len(); 739 740 let mut action = None; 741 742 let referenced_type = if note_context 743 .accounts 744 .get_selected_account() 745 .key 746 .pubkey 747 .bytes() 748 != underlying_note.pubkey() 749 { 750 ReferencedNoteType::Tagged 751 } else { 752 ReferencedNoteType::Yours 753 }; 754 755 if !note_options.contains(NoteOptions::TrustMedia) { 756 let acc = note_context.accounts.get_selected_account(); 757 for entry in &profiles_to_show { 758 if matches!(acc.is_following(entry.pk), notedeck::IsFollowing::Yes) { 759 note_options = note_options.union(NoteOptions::TrustMedia); 760 break; 761 } 762 } 763 } 764 765 egui::Frame::new() 766 .inner_margin(Margin::symmetric(8, 4)) 767 .show(ui, |ui| { 768 let show_label_newline = ui 769 .horizontal_wrapped(|ui| { 770 profiling::scope!("header"); 771 let pfps_resp = ui 772 .allocate_ui_with_layout( 773 vec2(ui.available_width(), 32.0), 774 Layout::left_to_right(egui::Align::Center), 775 |ui| { 776 render_profiles( 777 ui, 778 profiles_to_show, 779 &composite_type, 780 note_context.img_cache, 781 note_context.jobs, 782 note_options.contains(NoteOptions::Notification), 783 ) 784 }, 785 ) 786 .inner; 787 788 if let Some(cur_action) = pfps_resp.action { 789 action = Some(cur_action); 790 } 791 792 let description = composite_type.description( 793 note_context.i18n, 794 &first_name, 795 num_profiles, 796 referenced_type, 797 note_options.contains(NoteOptions::Notification), 798 underlying_note.is_rumor(), 799 ); 800 let galley = ui.painter().layout_no_wrap( 801 description.clone(), 802 NotedeckTextStyle::Small.get_font_id(ui.ctx()), 803 ui.visuals().text_color(), 804 ); 805 806 ui.add_space(4.0); 807 808 let galley_pos = { 809 let mut galley_pos = ui.next_widget_position(); 810 galley_pos.y = pfps_resp.resp.rect.right_center().y; 811 galley_pos.y -= galley.rect.height() / 2.0; 812 galley_pos 813 }; 814 815 let fits_no_wrap = { 816 let mut rightmost_pos = galley_pos; 817 rightmost_pos.x += galley.rect.width(); 818 819 ui.available_rect_before_wrap().contains(rightmost_pos) 820 }; 821 822 if fits_no_wrap { 823 ui.painter() 824 .galley(galley_pos, galley, ui.visuals().text_color()); 825 None 826 } else { 827 Some(description) 828 } 829 }) 830 .inner; 831 832 if let Some(desc) = show_label_newline { 833 profiling::scope!("description"); 834 ui.add_space(4.0); 835 ui.horizontal(|ui| { 836 ui.add_space(48.0); 837 ui.horizontal_wrapped(|ui| { 838 ui.add(egui::Label::new( 839 RichText::new(desc) 840 .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)), 841 )); 842 }); 843 }); 844 } 845 846 ui.add_space(16.0); 847 848 let resp = ui 849 .horizontal(|ui| { 850 if note_options.contains(NoteOptions::Notification) { 851 note_options = note_options 852 .difference(NoteOptions::ActionBar | NoteOptions::OptionsButton) 853 .union(NoteOptions::NotificationPreview); 854 855 ui.add_space(48.0); 856 }; 857 NoteView::new(note_context, underlying_note, note_options).show(ui) 858 }) 859 .inner; 860 861 if let Some(note_action) = resp.action { 862 action.get_or_insert(note_action); 863 } 864 }); 865 866 notedeck_ui::hline(ui); 867 RenderEntryResponse::Success(action) 868 } 869 870 #[profiling::function] 871 fn render_profiles( 872 ui: &mut egui::Ui, 873 profiles_to_show: Vec<ProfileEntry>, 874 composite_type: &CompositeType, 875 img_cache: &mut notedeck::Images, 876 jobs: ¬edeck::MediaJobSender, 877 notification: bool, 878 ) -> PfpsResponse { 879 let mut action = None; 880 if notification { 881 ui.add_space(8.0); 882 } 883 884 ui.vertical(|ui| { 885 ui.add_space(9.0); 886 ui.add_sized( 887 vec2(20.0, 20.0), 888 composite_type.image(ui.visuals().dark_mode), 889 ); 890 }); 891 892 if notification { 893 ui.add_space(16.0); 894 } else { 895 ui.add_space(2.0); 896 } 897 898 let resp = ui.horizontal(|ui| { 899 profiling::scope!("scroll area"); 900 ScrollArea::horizontal() 901 .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) 902 .show(ui, |ui| { 903 profiling::scope!("scroll area closure"); 904 let clip_rect = ui.clip_rect(); 905 let mut last_resp = None; 906 907 let mut rendered = false; 908 for entry in profiles_to_show { 909 let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); 910 let should_render = rect.intersects(clip_rect); 911 912 if !should_render { 913 if rendered { 914 break; 915 } else { 916 continue; 917 } 918 } 919 920 profiling::scope!("actual rendering individual pfp"); 921 922 let mut widget = 923 ProfilePic::from_profile_or_default(img_cache, jobs, entry.record.as_ref()) 924 .size(24.0) 925 .sense(Sense::click()); 926 let mut resp = ui.put(rect, &mut widget); 927 rendered = true; 928 929 if let Some(record) = entry.record.as_ref() { 930 resp = resp.on_hover_ui_at_pointer(|ui| { 931 ui.set_max_width(300.0); 932 ui.add(ProfilePreview::new(record, img_cache, jobs)); 933 }); 934 } 935 936 if resp.clicked() { 937 action = Some(NoteAction::Profile(*entry.pk)); 938 } 939 940 last_resp = Some(resp); 941 } 942 943 last_resp 944 }) 945 .inner 946 }); 947 948 let resp = if let Some(r) = resp.inner { 949 r 950 } else { 951 resp.response 952 }; 953 954 PfpsResponse { action, resp } 955 } 956 957 struct PfpsResponse { 958 action: Option<NoteAction>, 959 resp: egui::Response, 960 } 961 962 #[allow(clippy::too_many_arguments)] 963 #[profiling::function] 964 fn render_repost_cluster( 965 ui: &mut egui::Ui, 966 note_context: &mut NoteContext, 967 note_options: NoteOptions, 968 mute: &std::sync::Arc<Muted>, 969 txn: &Transaction, 970 underlying_note: &Note, 971 repost: &RepostUnit, 972 ) -> RenderEntryResponse { 973 let profiles_to_show: Vec<ProfileEntry> = repost 974 .reposts 975 .values() 976 .filter(|r| !mute.is_pk_muted(r.bytes())) 977 .map(|p| ProfileEntry { 978 record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(), 979 pk: p, 980 }) 981 .collect(); 982 983 render_composite_entry( 984 ui, 985 note_context, 986 note_options, 987 underlying_note, 988 profiles_to_show, 989 CompositeType::Repost, 990 ) 991 } 992 993 enum RenderEntryResponse { 994 Unsuccessful, 995 Success(Option<NoteAction>), 996 } 997 998 struct ProfileEntry<'a> { 999 record: Option<ProfileRecord<'a>>, 1000 pk: &'a Pubkey, 1001 }