use egui::containers::scroll_area::ScrollBarVisibility; use egui::{vec2, Direction, Layout, Margin, Pos2, ScrollArea, Sense, Stroke}; use egui_tabs::TabColor; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; use notedeck::name::get_display_name; use notedeck::ui::is_narrow; use notedeck::{tr_plural, JobsCache, Muted, NoteRef}; use notedeck_ui::app_images::{like_image, repost_image}; use notedeck_ui::ProfilePic; use std::f32::consts::PI; use tracing::{error, warn}; use crate::timeline::{ CompositeType, CompositeUnit, NoteUnit, ReactionUnit, RepostUnit, TimelineCache, TimelineKind, TimelineTab, ViewFilter, }; use notedeck::{ note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo, }; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, NoteOptions, NoteView, }; pub struct TimelineView<'a, 'd> { timeline_id: &'a TimelineKind, timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, note_context: &'a mut NoteContext<'d>, jobs: &'a mut JobsCache, col: usize, scroll_to_top: bool, } impl<'a, 'd> TimelineView<'a, 'd> { #[allow(clippy::too_many_arguments)] pub fn new( timeline_id: &'a TimelineKind, timeline_cache: &'a mut TimelineCache, note_context: &'a mut NoteContext<'d>, note_options: NoteOptions, jobs: &'a mut JobsCache, col: usize, ) -> Self { let scroll_to_top = false; TimelineView { timeline_id, timeline_cache, note_options, note_context, jobs, col, scroll_to_top, } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { timeline_ui( ui, self.timeline_id, self.timeline_cache, self.note_options, self.note_context, self.jobs, self.col, self.scroll_to_top, ) } pub fn scroll_to_top(mut self, enable: bool) -> Self { self.scroll_to_top = enable; self } pub fn scroll_id( timeline_cache: &TimelineCache, timeline_id: &TimelineKind, col: usize, ) -> Option { let timeline = timeline_cache.get(timeline_id)?; Some(egui::Id::new(("tlscroll", timeline.view_id(col)))) } } #[allow(clippy::too_many_arguments)] fn timeline_ui( ui: &mut egui::Ui, timeline_id: &TimelineKind, timeline_cache: &mut TimelineCache, note_options: NoteOptions, note_context: &mut NoteContext, jobs: &mut JobsCache, col: usize, scroll_to_top: bool, ) -> Option { //padding(4.0, ui, |ui| ui.heading("Notifications")); /* let font_id = egui::TextStyle::Body.resolve(ui.style()); let row_height = ui.fonts(|f| f.row_height(&font_id)) + ui.spacing().item_spacing.y; */ let scroll_id = TimelineView::scroll_id(timeline_cache, timeline_id, col)?; { let timeline = if let Some(timeline) = timeline_cache.get_mut(timeline_id) { timeline } else { error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... return None; }; timeline.selected_view = tabs_ui( ui, note_context.i18n, timeline.selected_view, &timeline.views, ); // need this for some reason?? ui.add_space(3.0); }; let show_top_button_id = ui.id().with((scroll_id, "at_top")); let show_top_button = ui .ctx() .data(|d| d.get_temp::(show_top_button_id)) .unwrap_or(false); let goto_top_resp = if show_top_button { let top_button_pos_x = if is_narrow(ui.ctx()) { 28.0 } else { 48.0 }; let top_button_pos = ui.available_rect_before_wrap().right_top() - vec2(top_button_pos_x, -24.0); egui::Area::new(ui.id().with("foreground_area")) .order(egui::Order::Middle) .fixed_pos(top_button_pos) .show(ui.ctx(), |ui| Some(ui.add(goto_top_button(top_button_pos)))) .inner .map(|r| r.on_hover_cursor(egui::CursorIcon::PointingHand)) } else { None }; let mut scroll_area = egui::ScrollArea::vertical() .id_salt(scroll_id) .animated(false) .auto_shrink([false, false]) .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible); let offset_id = scroll_id.with("timeline_scroll_offset"); if let Some(offset) = ui.data(|i| i.get_temp::(offset_id)) { scroll_area = scroll_area.vertical_scroll_offset(offset); } if goto_top_resp.is_some_and(|r| r.clicked()) { scroll_area = scroll_area.vertical_scroll_offset(0.0); } // chrome can ask to scroll to top as well via an app option if scroll_to_top { scroll_area = scroll_area.vertical_scroll_offset(0.0); } let scroll_output = scroll_area.show(ui, |ui| { let timeline = if let Some(timeline) = timeline_cache.get(timeline_id) { timeline } else { error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... // // NOTE (jb55): it can easily happen if you add a timeline column without calling // add_new_timeline_column, since that sets up the initial subs, etc return None; }; let txn = Transaction::new(note_context.ndb).expect("failed to create txn"); TimelineTabView::new( timeline.current_view(), note_options, &txn, note_context, jobs, ) .show(ui) }); ui.data_mut(|d| d.insert_temp(offset_id, scroll_output.state.offset.y)); let at_top_after_scroll = scroll_output.state.offset.y == 0.0; let cur_show_top_button = ui.ctx().data(|d| d.get_temp::(show_top_button_id)); if at_top_after_scroll { if cur_show_top_button != Some(false) { ui.ctx() .data_mut(|d| d.insert_temp(show_top_button_id, false)); } } else if cur_show_top_button == Some(false) { ui.ctx() .data_mut(|d| d.insert_temp(show_top_button_id, true)); } scroll_output.inner.or_else(|| { // if we're scrolling, return that as a response. We need this // for auto-closing the side menu let velocity = scroll_output.state.velocity(); let offset = scroll_output.state.offset; if velocity.length_sq() > 0.0 { Some(NoteAction::Scroll(ScrollInfo { velocity, offset })) } else { None } }) } fn goto_top_button(center: Pos2) -> impl egui::Widget { move |ui: &mut egui::Ui| -> egui::Response { let radius = 12.0; let max_size = vec2( ICON_EXPANSION_MULTIPLE * 2.0 * radius, ICON_EXPANSION_MULTIPLE * 2.0 * radius, ); let helper = AnimationHelper::new_from_rect(ui, "goto_top", { let painter = ui.painter(); #[allow(deprecated)] let center = painter.round_pos_to_pixel_center(center); egui::Rect::from_center_size(center, max_size) }); let painter = ui.painter(); painter.circle_filled( center, helper.scale_1d_pos(radius), notedeck_ui::colors::PINK, ); let create_pt = |angle: f32| { let side = radius / 2.0; let x = side * angle.cos(); let mut y = side * angle.sin(); let height = (side * (3.0_f32).sqrt()) / 2.0; y += height / 2.0; Pos2 { x, y } }; #[allow(deprecated)] let left_pt = painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI))); #[allow(deprecated)] let center_pt = painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI / 2.0))); #[allow(deprecated)] let right_pt = painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(0.0))); let line_width = helper.scale_1d_pos(4.0); let line_color = ui.visuals().text_color(); painter.line_segment([left_pt, center_pt], Stroke::new(line_width, line_color)); painter.line_segment([center_pt, right_pt], Stroke::new(line_width, line_color)); let end_radius = (line_width - 1.0) / 2.0; painter.circle_filled(left_pt, end_radius, line_color); painter.circle_filled(center_pt, end_radius, line_color); painter.circle_filled(right_pt, end_radius, line_color); helper.take_animation_response() } } pub fn tabs_ui( ui: &mut egui::Ui, i18n: &mut Localization, selected: usize, views: &[TimelineTab], ) -> usize { ui.spacing_mut().item_spacing.y = 0.0; let tab_res = egui_tabs::Tabs::new(views.len() as i32) .selected(selected as i32) .hover_bg(TabColor::none()) .selected_fg(TabColor::none()) .selected_bg(TabColor::none()) .hover_bg(TabColor::none()) //.hover_bg(TabColor::custom(egui::Color32::RED)) .height(32.0) .layout(Layout::centered_and_justified(Direction::TopDown)) .show(ui, |ui, state| { ui.spacing_mut().item_spacing.y = 0.0; let ind = state.index(); let txt = match views[ind as usize].filter { ViewFilter::Notes => tr!(i18n, "Notes", "Label for notes-only filter"), ViewFilter::NotesAndReplies => { tr!( i18n, "Notes & Replies", "Label for notes and replies filter" ) } }; let res = ui.add(egui::Label::new(txt.clone()).selectable(false)); // underline if state.is_selected() { let rect = res.rect; let underline = shrink_range_to_width(rect.x_range(), get_label_width(ui, &txt) * 1.15); #[allow(deprecated)] let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5; return (underline, underline_y); } (egui::Rangef::new(0.0, 0.0), 0.0) }); //ui.add_space(0.5); notedeck_ui::hline(ui); let sel = tab_res.selected().unwrap_or_default(); let (underline, underline_y) = tab_res.inner()[sel as usize].inner; let underline_width = underline.span(); let tab_anim_id = ui.id().with("tab_anim"); let tab_anim_size = tab_anim_id.with("size"); let stroke = egui::Stroke { color: ui.visuals().hyperlink_color, width: 2.0, }; let speed = 0.1f32; // animate underline position let x = ui .ctx() .animate_value_with_time(tab_anim_id, underline.min, speed); // animate underline width let w = ui .ctx() .animate_value_with_time(tab_anim_size, underline_width, speed); let underline = egui::Rangef::new(x, x + w); ui.painter().hline(underline, underline_y, stroke); sel as usize } fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 { let font_id = egui::FontId::default(); let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE)); galley.rect.width() } fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { let midpoint = (range.min + range.max) / 2.0; let half_width = width / 2.0; let min = midpoint - half_width; let max = midpoint + half_width; egui::Rangef::new(min, max) } pub struct TimelineTabView<'a, 'd> { tab: &'a TimelineTab, note_options: NoteOptions, txn: &'a Transaction, note_context: &'a mut NoteContext<'d>, jobs: &'a mut JobsCache, } impl<'a, 'd> TimelineTabView<'a, 'd> { #[allow(clippy::too_many_arguments)] pub fn new( tab: &'a TimelineTab, note_options: NoteOptions, txn: &'a Transaction, note_context: &'a mut NoteContext<'d>, jobs: &'a mut JobsCache, ) -> Self { Self { tab, note_options, txn, note_context, jobs, } } pub fn show(&mut self, ui: &mut egui::Ui) -> Option { let mut action: Option = None; let len = self.tab.units.len(); let mute = self.note_context.accounts.mute(); self.tab .list .borrow_mut() .ui_custom_layout(ui, len, |ui, index| { // tracing::info!("rendering index: {index}"); ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().item_spacing.x = 4.0; let Some(entry) = self.tab.units.get(index) else { return 0; }; match self.render_entry(ui, entry, &mute) { RenderEntryResponse::Unsuccessful => return 0, RenderEntryResponse::Success(note_action) => { if let Some(cur_action) = note_action { action = Some(cur_action); } } } 1 }); action } fn render_entry( &mut self, ui: &mut egui::Ui, entry: &NoteUnit, mute: &std::sync::Arc, ) -> RenderEntryResponse { match entry { NoteUnit::Single(note_ref) => render_note( ui, self.note_context, self.note_options, self.jobs, mute, self.txn, note_ref, ), NoteUnit::Composite(composite) => match composite { CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster( ui, self.note_context, self.note_options, self.jobs, mute, self.txn, reaction_unit, ), }, } } } enum ReferencedNoteType { Tagged, Yours, } impl CompositeType { fn image(&self, darkmode: bool) -> egui::Image<'static> { match self { CompositeType::Reaction => like_image(), CompositeType::Repost => repost_image(darkmode), } } fn description( &self, loc: &mut Localization, first_name: &str, total_count: usize, referenced_type: ReferencedNoteType, ) -> String { let count = total_count - 1; match self { CompositeType::Reaction => { reaction_description(loc, first_name, count, referenced_type) } CompositeType::Repost => repost_description(loc, first_name, count, referenced_type), } } } fn reaction_description( loc: &mut Localization, first_name: &str, count: usize, referenced_type: ReferencedNoteType, ) -> String { match referenced_type { ReferencedNoteType::Tagged => { if count == 0 { tr!( loc, "{name} reacted to a note you were tagged in", "reaction from user to a note you were tagged in", name = first_name ) } else { tr_plural!( loc, "{name} and {count} other reacted to a note you were tagged in", "{name} and {count} others reacted to a note you were tagged in", "amount of reactions a note you were tagged in received", count, name = first_name ) } } ReferencedNoteType::Yours => { if count == 0 { tr!( loc, "{name} reacted to your note", "reaction from user to your note", name = first_name ) } else { tr_plural!( loc, "{name} and {count} other reacted to your note", "{name} and {count} others reacted to your note", "describing the amount of reactions your note received", count, name = first_name ) } } } } fn repost_description( loc: &mut Localization, first_name: &str, count: usize, referenced_type: ReferencedNoteType, ) -> String { match referenced_type { ReferencedNoteType::Tagged => { if count == 0 { tr!( loc, "{name} reposted a note you were tagged in", "repost from user", name = first_name ) } else { tr_plural!( loc, "{name} and {count} other reposted a note you were tagged in", "{name} and {ocunt} others reposted a note you were tagged in", "describing the amount of reposts a note you were tagged in received", count, name = first_name ) } } ReferencedNoteType::Yours => { if count == 0 { tr!( loc, "{name} reposted your note", "repost from user", name = first_name ) } else { tr_plural!( loc, "{name} and {count} other reposted your note", "{name} and {count} others reposted your note", "describing the amount of reposts your note received", count, name = first_name ) } } } } fn render_note( ui: &mut egui::Ui, note_context: &mut NoteContext, note_options: NoteOptions, jobs: &mut JobsCache, mute: &std::sync::Arc, txn: &Transaction, note_ref: &NoteRef, ) -> RenderEntryResponse { let note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, note_ref.key) { note } else { warn!("failed to query note {:?}", note_ref.key); return RenderEntryResponse::Unsuccessful; }; let muted = if let Ok(root_id) = root_note_id_from_selected_id(note_context.ndb, note_context.note_cache, txn, note.id()) { mute.is_muted(¬e, root_id.bytes()) } else { false }; if muted { return RenderEntryResponse::Success(None); } let mut action = None; notedeck_ui::padding(8.0, ui, |ui| { let resp = NoteView::new(note_context, ¬e, note_options, jobs).show(ui); if let Some(note_action) = resp.action { action = Some(note_action); } }); notedeck_ui::hline(ui); RenderEntryResponse::Success(action) } fn render_reaction_cluster( ui: &mut egui::Ui, note_context: &mut NoteContext, note_options: NoteOptions, jobs: &mut JobsCache, mute: &std::sync::Arc, txn: &Transaction, reaction: &ReactionUnit, ) -> RenderEntryResponse { let reacted_to_key = reaction.note_reacted_to.key; let reacted_to_note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, reacted_to_key) { note } else { warn!("failed to query note {:?}", reacted_to_key); return RenderEntryResponse::Unsuccessful; }; let profiles_to_show: Vec = reaction .reactions .values() .filter(|r| !mute.is_pk_muted(r.sender.bytes())) .map(|r| &r.sender) .map(|p| ProfileEntry { record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(), pk: p, }) .collect(); render_composite_entry( ui, note_context, note_options, jobs, reacted_to_note, profiles_to_show, CompositeType::Reaction, ) } fn render_composite_entry( ui: &mut egui::Ui, note_context: &mut NoteContext, note_options: NoteOptions, jobs: &mut JobsCache, underlying_note: nostrdb::Note<'_>, profiles_to_show: Vec, composite_type: CompositeType, ) -> RenderEntryResponse { let first_name = get_display_name(profiles_to_show.iter().find_map(|opt| opt.record.as_ref())) .name() .to_string(); let num_profiles = profiles_to_show.len(); let mut action = None; egui::Frame::new() .inner_margin(Margin::symmetric(8, 4)) .show(ui, |ui| { ui.allocate_ui_with_layout( vec2(ui.available_width(), 32.0), Layout::left_to_right(egui::Align::Center), |ui| { ui.vertical(|ui| { ui.add_space(4.0); ui.add_sized( vec2(28.0, 28.0), composite_type.image(ui.visuals().dark_mode), ); }); ui.add_space(16.0); ui.horizontal(|ui| { ScrollArea::horizontal() .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) .show(ui, |ui| { for entry in profiles_to_show { let resp = ui.add( &mut ProfilePic::from_profile_or_default( note_context.img_cache, entry.record.as_ref(), ) .size(24.0) .sense(Sense::click()), ); if resp.clicked() { action = Some(NoteAction::Profile(*entry.pk)) } } }); }); }, ); let referenced_type = if note_context .accounts .get_selected_account() .key .pubkey .bytes() != underlying_note.pubkey() { ReferencedNoteType::Tagged } else { ReferencedNoteType::Yours }; ui.add_space(2.0); ui.horizontal(|ui| { ui.add_space(52.0); ui.horizontal_wrapped(|ui| { ui.label(composite_type.description( note_context.i18n, &first_name, num_profiles, referenced_type, )) }); }); ui.add_space(16.0); ui.horizontal(|ui| { ui.add_space(48.0); let options = note_options .difference(NoteOptions::ActionBar | NoteOptions::OptionsButton) .union(NoteOptions::NotificationPreview); let resp = NoteView::new(note_context, &underlying_note, options, jobs).show(ui); if let Some(note_action) = resp.action { action = Some(note_action); } }); }); notedeck_ui::hline(ui); RenderEntryResponse::Success(action) } fn render_repost_cluster( ui: &mut egui::Ui, note_context: &mut NoteContext, note_options: NoteOptions, jobs: &mut JobsCache, mute: &std::sync::Arc, txn: &Transaction, repost: &RepostUnit, ) -> RenderEntryResponse { let reposted_key = repost.note_reposted.key; let reposted_note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, reposted_key) { note } else { warn!("failed to query note {:?}", reposted_key); return RenderEntryResponse::Unsuccessful; }; let profiles_to_show: Vec = repost .reposts .values() .filter(|r| !mute.is_pk_muted(r.bytes())) .map(|p| ProfileEntry { record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(), pk: p, }) .collect(); render_composite_entry( ui, note_context, note_options, jobs, reposted_note, profiles_to_show, CompositeType::Repost, ) } enum RenderEntryResponse { Unsuccessful, Success(Option), } struct ProfileEntry<'a> { record: Option>, pk: &'a Pubkey, }