timeline.rs (31584B)
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 { velocity, offset })) 209 } else { 210 None 211 } 212 }); 213 214 DragResponse::output(action).scroll_raw(scroll_id) 215 } 216 217 fn goto_top_button(center: Pos2) -> impl egui::Widget { 218 move |ui: &mut egui::Ui| -> egui::Response { 219 let radius = 12.0; 220 let max_size = vec2( 221 ICON_EXPANSION_MULTIPLE * 2.0 * radius, 222 ICON_EXPANSION_MULTIPLE * 2.0 * radius, 223 ); 224 let helper = AnimationHelper::new_from_rect(ui, "goto_top", { 225 let painter = ui.painter(); 226 #[allow(deprecated)] 227 let center = painter.round_pos_to_pixel_center(center); 228 egui::Rect::from_center_size(center, max_size) 229 }); 230 231 let painter = ui.painter(); 232 painter.circle_filled( 233 center, 234 helper.scale_1d_pos(radius), 235 notedeck_ui::colors::PINK, 236 ); 237 238 let create_pt = |angle: f32| { 239 let side = radius / 2.0; 240 let x = side * angle.cos(); 241 let mut y = side * angle.sin(); 242 243 let height = (side * (3.0_f32).sqrt()) / 2.0; 244 y += height / 2.0; 245 Pos2 { x, y } 246 }; 247 248 #[allow(deprecated)] 249 let left_pt = 250 painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI))); 251 #[allow(deprecated)] 252 let center_pt = 253 painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI / 2.0))); 254 #[allow(deprecated)] 255 let right_pt = 256 painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(0.0))); 257 258 let line_width = helper.scale_1d_pos(4.0); 259 let line_color = ui.visuals().text_color(); 260 painter.line_segment([left_pt, center_pt], Stroke::new(line_width, line_color)); 261 painter.line_segment([center_pt, right_pt], Stroke::new(line_width, line_color)); 262 263 let end_radius = (line_width - 1.0) / 2.0; 264 painter.circle_filled(left_pt, end_radius, line_color); 265 painter.circle_filled(center_pt, end_radius, line_color); 266 painter.circle_filled(right_pt, end_radius, line_color); 267 268 helper.take_animation_response() 269 } 270 } 271 272 pub fn tabs_ui( 273 ui: &mut egui::Ui, 274 i18n: &mut Localization, 275 selected: usize, 276 views: &[TimelineTab], 277 ) -> egui::InnerResponse<usize> { 278 ui.spacing_mut().item_spacing.y = 0.0; 279 280 let tab_res = egui_tabs::Tabs::new(views.len() as i32) 281 .selected(selected as i32) 282 .hover_bg(TabColor::none()) 283 .selected_fg(TabColor::none()) 284 .selected_bg(TabColor::none()) 285 .hover_bg(TabColor::none()) 286 //.hover_bg(TabColor::custom(egui::Color32::RED)) 287 .height(32.0) 288 .layout(Layout::centered_and_justified(Direction::TopDown)) 289 .show(ui, |ui, state| { 290 ui.spacing_mut().item_spacing.y = 0.0; 291 292 let ind = state.index(); 293 294 let txt = views[ind as usize].filter.name(i18n); 295 296 let res = ui.add(egui::Label::new(txt.clone()).selectable(false)); 297 298 // underline 299 if state.is_selected() { 300 let rect = res.rect; 301 let underline = 302 shrink_range_to_width(rect.x_range(), get_label_width(ui, &txt) * 1.15); 303 #[allow(deprecated)] 304 let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5; 305 return (underline, underline_y); 306 } 307 308 (egui::Rangef::new(0.0, 0.0), 0.0) 309 }); 310 311 //ui.add_space(0.5); 312 notedeck_ui::hline(ui); 313 314 let sel = tab_res.selected().unwrap_or_default(); 315 316 let res_inner = &tab_res.inner()[sel as usize]; 317 318 let (underline, underline_y) = res_inner.inner; 319 let underline_width = underline.span(); 320 321 let tab_anim_id = ui.id().with("tab_anim"); 322 let tab_anim_size = tab_anim_id.with("size"); 323 324 let stroke = egui::Stroke { 325 color: ui.visuals().hyperlink_color, 326 width: 2.0, 327 }; 328 329 let speed = 0.1f32; 330 331 // animate underline position 332 let x = ui 333 .ctx() 334 .animate_value_with_time(tab_anim_id, underline.min, speed); 335 336 // animate underline width 337 let w = ui 338 .ctx() 339 .animate_value_with_time(tab_anim_size, underline_width, speed); 340 341 let underline = egui::Rangef::new(x, x + w); 342 343 ui.painter().hline(underline, underline_y, stroke); 344 345 egui::InnerResponse::new(sel as usize, res_inner.response.clone()) 346 } 347 348 fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 { 349 let font_id = egui::FontId::default(); 350 let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE)); 351 galley.rect.width() 352 } 353 354 fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { 355 let midpoint = (range.min + range.max) / 2.0; 356 let half_width = width / 2.0; 357 358 let min = midpoint - half_width; 359 let max = midpoint + half_width; 360 361 egui::Rangef::new(min, max) 362 } 363 364 pub struct TimelineTabView<'a, 'd> { 365 tab: &'a TimelineTab, 366 note_options: NoteOptions, 367 txn: &'a Transaction, 368 note_context: &'a mut NoteContext<'d>, 369 } 370 371 impl<'a, 'd> TimelineTabView<'a, 'd> { 372 #[allow(clippy::too_many_arguments)] 373 pub fn new( 374 tab: &'a TimelineTab, 375 note_options: NoteOptions, 376 txn: &'a Transaction, 377 note_context: &'a mut NoteContext<'d>, 378 ) -> Self { 379 Self { 380 tab, 381 note_options, 382 txn, 383 note_context, 384 } 385 } 386 387 pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { 388 let mut action: Option<NoteAction> = None; 389 let len = self.tab.units.len(); 390 391 let mute = self.note_context.accounts.mute(); 392 393 self.tab 394 .list 395 .borrow_mut() 396 .ui_custom_layout(ui, len, |ui, index| { 397 // tracing::info!("rendering index: {index}"); 398 ui.spacing_mut().item_spacing.y = 0.0; 399 ui.spacing_mut().item_spacing.x = 4.0; 400 401 let Some(entry) = self.tab.units.get(index) else { 402 return 0; 403 }; 404 405 match self.render_entry(ui, entry, &mute) { 406 RenderEntryResponse::Unsuccessful => return 0, 407 408 RenderEntryResponse::Success(note_action) => { 409 if let Some(cur_action) = note_action { 410 action = Some(cur_action); 411 } 412 } 413 } 414 415 1 416 }); 417 418 action 419 } 420 421 fn render_entry( 422 &mut self, 423 ui: &mut egui::Ui, 424 entry: &NoteUnit, 425 mute: &std::sync::Arc<Muted>, 426 ) -> RenderEntryResponse { 427 let underlying_note = { 428 let underlying_note_key = match entry { 429 NoteUnit::Single(note_ref) => note_ref.key, 430 NoteUnit::Composite(composite_unit) => match composite_unit { 431 CompositeUnit::Reaction(reaction_unit) => reaction_unit.note_reacted_to.key, 432 CompositeUnit::Repost(repost_unit) => repost_unit.note_reposted.key, 433 }, 434 }; 435 436 let Ok(note) = self 437 .note_context 438 .ndb 439 .get_note_by_key(self.txn, underlying_note_key) 440 else { 441 warn!("failed to query note {:?}", underlying_note_key); 442 return RenderEntryResponse::Unsuccessful; 443 }; 444 445 note 446 }; 447 448 let muted = root_note_id_from_selected_id( 449 self.note_context.ndb, 450 self.note_context.note_cache, 451 self.txn, 452 underlying_note.id(), 453 ) 454 .is_ok_and(|root_id| mute.is_muted(&underlying_note, root_id.bytes())); 455 456 if muted { 457 return RenderEntryResponse::Success(None); 458 } 459 460 match entry { 461 NoteUnit::Single(_) => { 462 render_note(ui, self.note_context, self.note_options, &underlying_note) 463 } 464 NoteUnit::Composite(composite) => match composite { 465 CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster( 466 ui, 467 self.note_context, 468 self.note_options, 469 mute, 470 self.txn, 471 &underlying_note, 472 reaction_unit, 473 ), 474 CompositeUnit::Repost(repost_unit) => render_repost_cluster( 475 ui, 476 self.note_context, 477 self.note_options, 478 mute, 479 self.txn, 480 &underlying_note, 481 repost_unit, 482 ), 483 }, 484 } 485 } 486 } 487 488 enum ReferencedNoteType { 489 Tagged, 490 Yours, 491 } 492 493 impl CompositeType { 494 fn image(&self, darkmode: bool) -> egui::Image<'static> { 495 match self { 496 CompositeType::Reaction => like_image_filled(), 497 CompositeType::Repost => { 498 repost_image(darkmode).tint(Color32::from_rgb(0x68, 0xC3, 0x51)) 499 } 500 } 501 } 502 503 fn description( 504 &self, 505 loc: &mut Localization, 506 first_name: &str, 507 total_count: usize, 508 referenced_type: ReferencedNoteType, 509 notification: bool, 510 rumor: bool, 511 ) -> String { 512 let count = total_count - 1; 513 514 match self { 515 CompositeType::Reaction => { 516 reaction_description(loc, first_name, count, referenced_type, rumor) 517 } 518 CompositeType::Repost => repost_description( 519 loc, 520 first_name, 521 count, 522 if notification { 523 DescriptionType::Notification(referenced_type) 524 } else { 525 DescriptionType::Other 526 }, 527 ), 528 } 529 } 530 } 531 532 fn reaction_description( 533 loc: &mut Localization, 534 first_name: &str, 535 count: usize, 536 referenced_type: ReferencedNoteType, 537 rumor: bool, 538 ) -> String { 539 let privately = if rumor { "privately " } else { "" }; 540 match referenced_type { 541 ReferencedNoteType::Tagged => { 542 if count == 0 { 543 tr!( 544 loc, 545 "{name} {privately}reacted to a note you were tagged in", 546 "reaction from user to a note you were tagged in", 547 name = first_name, 548 privately = privately 549 ) 550 } else { 551 tr_plural!( 552 loc, 553 "{name} and {count} other reacted to a note you were tagged in", 554 "{name} and {count} others reacted to a note you were tagged in", 555 "amount of reactions a note you were tagged in received", 556 count, 557 name = first_name 558 ) 559 } 560 } 561 ReferencedNoteType::Yours => { 562 if count == 0 { 563 tr!( 564 loc, 565 "{name} {privately}reacted to your note", 566 "reaction from user to your note", 567 name = first_name, 568 privately = privately 569 ) 570 } else { 571 tr_plural!( 572 loc, 573 "{name} and {count} other reacted to your note", 574 "{name} and {count} others reacted to your note", 575 "describing the amount of reactions your note received", 576 count, 577 name = first_name 578 ) 579 } 580 } 581 } 582 } 583 584 enum DescriptionType { 585 Notification(ReferencedNoteType), 586 Other, 587 } 588 589 fn repost_description( 590 loc: &mut Localization, 591 first_name: &str, 592 count: usize, 593 description_type: DescriptionType, 594 ) -> String { 595 match description_type { 596 DescriptionType::Notification(referenced_type) => match referenced_type { 597 ReferencedNoteType::Tagged => { 598 if count == 0 { 599 tr!( 600 loc, 601 "{name} reposted a note you were tagged in", 602 "repost from user", 603 name = first_name 604 ) 605 } else { 606 tr_plural!( 607 loc, 608 "{name} and {count} other reposted a note you were tagged in", 609 "{name} and {count} others reposted a note you were tagged in", 610 "describing the amount of reposts a note you were tagged in received", 611 count, 612 name = first_name 613 ) 614 } 615 } 616 ReferencedNoteType::Yours => { 617 if count == 0 { 618 tr!( 619 loc, 620 "{name} reposted your note", 621 "repost from user", 622 name = first_name 623 ) 624 } else { 625 tr_plural!( 626 loc, 627 "{name} and {count} other reposted your note", 628 "{name} and {count} others reposted your note", 629 "describing the amount of reposts your note received", 630 count, 631 name = first_name 632 ) 633 } 634 } 635 }, 636 DescriptionType::Other => { 637 if count == 0 { 638 tr!( 639 loc, 640 "{name} reposted", 641 "repost from user", 642 name = first_name 643 ) 644 } else { 645 tr_plural!( 646 loc, 647 "{name} and {count} other reposted", 648 "{name} and {count} others reposted", 649 "describing the amount of reposts a note has", 650 count, 651 name = first_name 652 ) 653 } 654 } 655 } 656 } 657 658 #[profiling::function] 659 fn render_note( 660 ui: &mut egui::Ui, 661 note_context: &mut NoteContext, 662 note_options: NoteOptions, 663 note: &Note, 664 ) -> RenderEntryResponse { 665 let mut action = None; 666 notedeck_ui::padding(8.0, ui, |ui| { 667 let resp = NoteView::new(note_context, note, note_options).show(ui); 668 669 if let Some(note_action) = resp.action { 670 action = Some(note_action); 671 } 672 }); 673 674 notedeck_ui::hline(ui); 675 676 RenderEntryResponse::Success(action) 677 } 678 679 #[allow(clippy::too_many_arguments)] 680 #[profiling::function] 681 fn render_reaction_cluster( 682 ui: &mut egui::Ui, 683 note_context: &mut NoteContext, 684 note_options: NoteOptions, 685 mute: &std::sync::Arc<Muted>, 686 txn: &Transaction, 687 underlying_note: &Note, 688 reaction: &ReactionUnit, 689 ) -> RenderEntryResponse { 690 let profiles_to_show: Vec<ProfileEntry> = { 691 profiling::scope!("vec profile entries"); 692 reaction 693 .reactions 694 .values() 695 .filter(|r| !mute.is_pk_muted(r.sender.bytes())) 696 .map(|r| (&r.sender, r.sender_profilekey)) 697 .map(|(p, key)| { 698 let record = if let Some(key) = key { 699 profiling::scope!("ndb by key"); 700 note_context.ndb.get_profile_by_key(txn, key).ok() 701 } else { 702 profiling::scope!("ndb by pubkey"); 703 note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok() 704 }; 705 ProfileEntry { record, pk: p } 706 }) 707 .collect() 708 }; 709 710 render_composite_entry( 711 ui, 712 note_context, 713 note_options | NoteOptions::Notification, 714 underlying_note, 715 profiles_to_show, 716 CompositeType::Reaction, 717 ) 718 } 719 720 #[allow(clippy::too_many_arguments)] 721 #[profiling::function] 722 fn render_composite_entry( 723 ui: &mut egui::Ui, 724 note_context: &mut NoteContext, 725 mut note_options: NoteOptions, 726 underlying_note: &nostrdb::Note<'_>, 727 profiles_to_show: Vec<ProfileEntry>, 728 composite_type: CompositeType, 729 ) -> RenderEntryResponse { 730 let first_name = get_display_name(profiles_to_show.iter().find_map(|opt| opt.record.as_ref())) 731 .name() 732 .to_string(); 733 let num_profiles = profiles_to_show.len(); 734 735 let mut action = None; 736 737 let referenced_type = if note_context 738 .accounts 739 .get_selected_account() 740 .key 741 .pubkey 742 .bytes() 743 != underlying_note.pubkey() 744 { 745 ReferencedNoteType::Tagged 746 } else { 747 ReferencedNoteType::Yours 748 }; 749 750 if !note_options.contains(NoteOptions::TrustMedia) { 751 let acc = note_context.accounts.get_selected_account(); 752 for entry in &profiles_to_show { 753 if matches!(acc.is_following(entry.pk), notedeck::IsFollowing::Yes) { 754 note_options = note_options.union(NoteOptions::TrustMedia); 755 break; 756 } 757 } 758 } 759 760 egui::Frame::new() 761 .inner_margin(Margin::symmetric(8, 4)) 762 .show(ui, |ui| { 763 let show_label_newline = ui 764 .horizontal_wrapped(|ui| { 765 profiling::scope!("header"); 766 let pfps_resp = ui 767 .allocate_ui_with_layout( 768 vec2(ui.available_width(), 32.0), 769 Layout::left_to_right(egui::Align::Center), 770 |ui| { 771 render_profiles( 772 ui, 773 profiles_to_show, 774 &composite_type, 775 note_context.img_cache, 776 note_context.jobs, 777 note_options.contains(NoteOptions::Notification), 778 ) 779 }, 780 ) 781 .inner; 782 783 if let Some(cur_action) = pfps_resp.action { 784 action = Some(cur_action); 785 } 786 787 let description = composite_type.description( 788 note_context.i18n, 789 &first_name, 790 num_profiles, 791 referenced_type, 792 note_options.contains(NoteOptions::Notification), 793 underlying_note.is_rumor(), 794 ); 795 let galley = ui.painter().layout_no_wrap( 796 description.clone(), 797 NotedeckTextStyle::Small.get_font_id(ui.ctx()), 798 ui.visuals().text_color(), 799 ); 800 801 ui.add_space(4.0); 802 803 let galley_pos = { 804 let mut galley_pos = ui.next_widget_position(); 805 galley_pos.y = pfps_resp.resp.rect.right_center().y; 806 galley_pos.y -= galley.rect.height() / 2.0; 807 galley_pos 808 }; 809 810 let fits_no_wrap = { 811 let mut rightmost_pos = galley_pos; 812 rightmost_pos.x += galley.rect.width(); 813 814 ui.available_rect_before_wrap().contains(rightmost_pos) 815 }; 816 817 if fits_no_wrap { 818 ui.painter() 819 .galley(galley_pos, galley, ui.visuals().text_color()); 820 None 821 } else { 822 Some(description) 823 } 824 }) 825 .inner; 826 827 if let Some(desc) = show_label_newline { 828 profiling::scope!("description"); 829 ui.add_space(4.0); 830 ui.horizontal(|ui| { 831 ui.add_space(48.0); 832 ui.horizontal_wrapped(|ui| { 833 ui.add(egui::Label::new( 834 RichText::new(desc) 835 .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)), 836 )); 837 }); 838 }); 839 } 840 841 ui.add_space(16.0); 842 843 let resp = ui 844 .horizontal(|ui| { 845 if note_options.contains(NoteOptions::Notification) { 846 note_options = note_options 847 .difference(NoteOptions::ActionBar | NoteOptions::OptionsButton) 848 .union(NoteOptions::NotificationPreview); 849 850 ui.add_space(48.0); 851 }; 852 NoteView::new(note_context, underlying_note, note_options).show(ui) 853 }) 854 .inner; 855 856 if let Some(note_action) = resp.action { 857 action.get_or_insert(note_action); 858 } 859 }); 860 861 notedeck_ui::hline(ui); 862 RenderEntryResponse::Success(action) 863 } 864 865 #[profiling::function] 866 fn render_profiles( 867 ui: &mut egui::Ui, 868 profiles_to_show: Vec<ProfileEntry>, 869 composite_type: &CompositeType, 870 img_cache: &mut notedeck::Images, 871 jobs: ¬edeck::MediaJobSender, 872 notification: bool, 873 ) -> PfpsResponse { 874 let mut action = None; 875 if notification { 876 ui.add_space(8.0); 877 } 878 879 ui.vertical(|ui| { 880 ui.add_space(9.0); 881 ui.add_sized( 882 vec2(20.0, 20.0), 883 composite_type.image(ui.visuals().dark_mode), 884 ); 885 }); 886 887 if notification { 888 ui.add_space(16.0); 889 } else { 890 ui.add_space(2.0); 891 } 892 893 let resp = ui.horizontal(|ui| { 894 profiling::scope!("scroll area"); 895 ScrollArea::horizontal() 896 .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) 897 .show(ui, |ui| { 898 profiling::scope!("scroll area closure"); 899 let clip_rect = ui.clip_rect(); 900 let mut last_resp = None; 901 902 let mut rendered = false; 903 for entry in profiles_to_show { 904 let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); 905 let should_render = rect.intersects(clip_rect); 906 907 if !should_render { 908 if rendered { 909 break; 910 } else { 911 continue; 912 } 913 } 914 915 profiling::scope!("actual rendering individual pfp"); 916 917 let mut widget = 918 ProfilePic::from_profile_or_default(img_cache, jobs, entry.record.as_ref()) 919 .size(24.0) 920 .sense(Sense::click()); 921 let mut resp = ui.put(rect, &mut widget); 922 rendered = true; 923 924 if let Some(record) = entry.record.as_ref() { 925 resp = resp.on_hover_ui_at_pointer(|ui| { 926 ui.set_max_width(300.0); 927 ui.add(ProfilePreview::new(record, img_cache, jobs)); 928 }); 929 } 930 931 if resp.clicked() { 932 action = Some(NoteAction::Profile(*entry.pk)); 933 } 934 935 last_resp = Some(resp); 936 } 937 938 last_resp 939 }) 940 .inner 941 }); 942 943 let resp = if let Some(r) = resp.inner { 944 r 945 } else { 946 resp.response 947 }; 948 949 PfpsResponse { action, resp } 950 } 951 952 struct PfpsResponse { 953 action: Option<NoteAction>, 954 resp: egui::Response, 955 } 956 957 #[allow(clippy::too_many_arguments)] 958 #[profiling::function] 959 fn render_repost_cluster( 960 ui: &mut egui::Ui, 961 note_context: &mut NoteContext, 962 note_options: NoteOptions, 963 mute: &std::sync::Arc<Muted>, 964 txn: &Transaction, 965 underlying_note: &Note, 966 repost: &RepostUnit, 967 ) -> RenderEntryResponse { 968 let profiles_to_show: Vec<ProfileEntry> = repost 969 .reposts 970 .values() 971 .filter(|r| !mute.is_pk_muted(r.bytes())) 972 .map(|p| ProfileEntry { 973 record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(), 974 pk: p, 975 }) 976 .collect(); 977 978 render_composite_entry( 979 ui, 980 note_context, 981 note_options, 982 underlying_note, 983 profiles_to_show, 984 CompositeType::Repost, 985 ) 986 } 987 988 enum RenderEntryResponse { 989 Unsuccessful, 990 Success(Option<NoteAction>), 991 } 992 993 struct ProfileEntry<'a> { 994 record: Option<ProfileRecord<'a>>, 995 pk: &'a Pubkey, 996 }