notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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: &notedeck::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 }