From 5be6b1ca681d5ee922ac48182c3ebb3485b8b0e8 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 28 Jul 2024 16:13:08 -0500 Subject: [PATCH] ui: move timeline view to its own file Also add some thread methods for fetching new notes Signed-off-by: William Casarin --- src/app.rs | 30 +----- src/filter.rs | 27 ++++++ src/thread.rs | 57 ++++++++++-- src/timeline.rs | 222 +++---------------------------------------- src/ui/mod.rs | 2 + src/ui/timeline.rs | 228 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 325 insertions(+), 241 deletions(-) create mode 100644 src/ui/timeline.rs diff --git a/src/app.rs b/src/app.rs index 8e4c5fa..5de6df2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,6 @@ use crate::notecache::{CachedNote, NoteCache}; use crate::relay_pool_manager::RelayPoolManager; use crate::route::Route; use crate::thread::{DecrementResult, Threads}; -use crate::timeline; use crate::timeline::{Timeline, TimelineSource, ViewFilter}; use crate::ui::note::PostAction; use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup}; @@ -94,27 +93,6 @@ fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) { /// notes locally. One way to determine this is by looking at the current filter /// and seeing what its limit is. If we have less notes than the limit, /// we might want to backfill older notes -fn should_since_optimize(limit: Option, num_notes: usize) -> bool { - let limit = limit.unwrap_or(enostr::Filter::default_limit()) as usize; - - // rough heuristic for bailing since optimization if we don't have enough notes - limit <= num_notes -} - -fn since_optimize_filter(filter: &mut enostr::Filter, notes: &[NoteRef]) { - // Get the latest entry in the events - if notes.is_empty() { - return; - } - - // get the latest note - let latest = notes[0]; - let since = latest.created_at - 60; - - // update the filters - filter.since = Some(since); -} - fn send_initial_filters(damus: &mut Damus, relay_url: &str) { info!("Sending initial filters to {}", relay_url); let mut c: u32 = 1; @@ -133,8 +111,8 @@ fn send_initial_filters(damus: &mut Damus, relay_url: &str) { } let notes = timeline.notes(ViewFilter::NotesAndReplies); - if should_since_optimize(f.limit, notes.len()) { - since_optimize_filter(f, notes); + if crate::filter::should_since_optimize(f.limit, notes.len()) { + crate::filter::since_optimize_filter(f, notes); } else { warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", f); } @@ -650,6 +628,7 @@ fn parse_args(args: &[String]) -> Args { res } +/* fn determine_key_storage_type() -> KeyStorageType { #[cfg(target_os = "macos")] { @@ -666,6 +645,7 @@ fn determine_key_storage_type() -> KeyStorageType { KeyStorageType::None } } +*/ impl Damus { /// Called once before the first frame. @@ -955,7 +935,7 @@ fn render_nav(routes: Vec, timeline_ind: usize, app: &mut Damus, ui: &mut .show(ui, |ui, nav| match nav.top() { Route::Timeline(_n) => { let app = &mut app_ctx.borrow_mut(); - timeline::timeline_view(ui, app, timeline_ind); + ui::TimelineView::new(app, timeline_ind).ui(ui); None } diff --git a/src/filter.rs b/src/filter.rs index b7b2db9..8679f50 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -1,3 +1,30 @@ +use crate::note::NoteRef; + +pub fn should_since_optimize(limit: Option, num_notes: usize) -> bool { + let limit = limit.unwrap_or(enostr::Filter::default_limit()) as usize; + + // rough heuristic for bailing since optimization if we don't have enough notes + limit <= num_notes +} + +pub fn since_optimize_filter_with(filter: &mut enostr::Filter, notes: &[NoteRef], since_gap: u64) { + // Get the latest entry in the events + if notes.is_empty() { + return; + } + + // get the latest note + let latest = notes[0]; + let since = latest.created_at - since_gap; + + // update the filters + filter.since = Some(since); +} + +pub fn since_optimize_filter(filter: &mut enostr::Filter, notes: &[NoteRef]) { + since_optimize_filter_with(filter, notes, 60); +} + pub fn convert_enostr_filter(filter: &enostr::Filter) -> nostrdb::Filter { let mut nfilter = nostrdb::Filter::new(); diff --git a/src/thread.rs b/src/thread.rs index af7e86e..067e6cd 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -1,5 +1,5 @@ use crate::note::NoteRef; -use crate::timeline::{TimelineView, ViewFilter}; +use crate::timeline::{TimelineTab, ViewFilter}; use crate::Error; use nostrdb::{Filter, Ndb, Subscription, Transaction}; use std::collections::HashMap; @@ -7,7 +7,7 @@ use tracing::debug; #[derive(Default)] pub struct Thread { - pub view: TimelineView, + pub view: TimelineTab, sub: Option, pub subscribers: i32, } @@ -24,7 +24,7 @@ impl Thread { if cap == 0 { cap = 25; } - let mut view = TimelineView::new_with_capacity(ViewFilter::NotesAndReplies, cap); + let mut view = TimelineTab::new_with_capacity(ViewFilter::NotesAndReplies, cap); view.notes = notes; let sub: Option = None; let subscribers: i32 = 0; @@ -36,6 +36,34 @@ impl Thread { } } + /// Look for new thread notes since our last fetch + pub fn new_notes( + notes: &[NoteRef], + root_id: &[u8; 32], + txn: &Transaction, + ndb: &Ndb, + ) -> Vec { + if notes.is_empty() { + return vec![]; + } + + let last_note = notes[0]; + let filters = Thread::filters_since(root_id, last_note.created_at - 60); + + if let Ok(results) = ndb.query(txn, filters, 1000) { + results + .into_iter() + .map(NoteRef::from_query_result) + .collect() + } else { + debug!( + "got no results from thread update for {}", + hex::encode(root_id) + ); + vec![] + } + } + pub fn decrement_sub(&mut self) -> Result { debug!("decrementing sub {:?}", self.subscription().map(|s| s.id)); self.subscribers -= 1; @@ -60,9 +88,22 @@ impl Thread { pub fn subscription_mut(&mut self) -> &mut Option { &mut self.sub } -} -impl Thread { + pub fn filters_since(root: &[u8; 32], since: u64) -> Vec { + vec![ + nostrdb::Filter::new() + .since(since) + .kinds(vec![1]) + .event(root) + .build(), + nostrdb::Filter::new() + .kinds(vec![1]) + .ids(vec![*root]) + .since(since) + .build(), + ] + } + pub fn filters(root: &[u8; 32]) -> Vec { vec![ nostrdb::Filter::new().kinds(vec![1]).event(root).build(), @@ -106,7 +147,7 @@ impl Threads { let root = if let Ok(root) = ndb.get_note_by_id(txn, root_id) { root } else { - debug!("couldnt find root note for id {}", hex::encode(root_id)); + debug!("couldnt find root note root_id:{}", hex::encode(root_id)); self.root_id_to_thread .insert(root_id.to_owned(), Thread::new(vec![])); return self.root_id_to_thread.get_mut(root_id).unwrap(); @@ -115,9 +156,7 @@ impl Threads { // we don't have the thread, query for it! let filters = Thread::filters(root_id); - // TODO: what should be the max results ? - let notes = if let Ok(mut results) = ndb.query(txn, filters, 10000) { - results.reverse(); + let notes = if let Ok(results) = ndb.query(txn, filters, 1000) { results .into_iter() .map(NoteRef::from_query_result) diff --git a/src/timeline.rs b/src/timeline.rs index 6df07b6..10c023e 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -1,16 +1,11 @@ use crate::app::{get_unknown_note_ids, UnknownId}; -use crate::draft::DraftSource; use crate::error::Error; use crate::note::NoteRef; use crate::notecache::CachedNote; -use crate::ui::note::PostAction; -use crate::{ui, Damus, Result}; +use crate::{Damus, Result}; use crate::route::Route; -use egui::containers::scroll_area::ScrollBarVisibility; -use egui::{Direction, Layout}; -use egui_tabs::TabColor; use egui_virtual_list::VirtualList; use enostr::Filter; use nostrdb::{Note, Subscription, Transaction}; @@ -18,7 +13,7 @@ use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; -use tracing::{debug, info, warn}; +use tracing::debug; #[derive(Debug, Copy, Clone)] pub enum TimelineSource<'a> { @@ -36,7 +31,7 @@ impl<'a> TimelineSource<'a> { app: &'b mut Damus, txn: &Transaction, filter: ViewFilter, - ) -> &'b mut TimelineView { + ) -> &'b mut TimelineTab { match self { TimelineSource::Column { ind, .. } => app.timelines[ind].view_mut(filter), TimelineSource::Thread(root_id) => { @@ -187,19 +182,19 @@ impl ViewFilter { /// A timeline view is a filtered view of notes in a timeline. Two standard views /// are "Notes" and "Notes & Replies". A timeline is associated with a Filter, -/// but a TimelineView is a further filtered view of this Filter that can't +/// but a TimelineTab is a further filtered view of this Filter that can't /// be captured by a Filter itself. #[derive(Default)] -pub struct TimelineView { +pub struct TimelineTab { pub notes: Vec, pub selection: i32, pub filter: ViewFilter, pub list: Rc>, } -impl TimelineView { +impl TimelineTab { pub fn new(filter: ViewFilter) -> Self { - TimelineView::new_with_capacity(filter, 1000) + TimelineTab::new_with_capacity(filter, 1000) } pub fn new_with_capacity(filter: ViewFilter, cap: usize) -> Self { @@ -209,7 +204,7 @@ impl TimelineView { let list = Rc::new(RefCell::new(list)); let notes: Vec = Vec::with_capacity(cap); - TimelineView { + TimelineTab { notes, selection, filter, @@ -257,7 +252,7 @@ impl TimelineView { pub struct Timeline { pub filter: Vec, - pub views: Vec, + pub views: Vec, pub selected_view: i32, pub routes: Vec, pub navigating: bool, @@ -270,8 +265,8 @@ pub struct Timeline { impl Timeline { pub fn new(filter: Vec) -> Self { let subscription: Option = None; - let notes = TimelineView::new(ViewFilter::Notes); - let replies = TimelineView::new(ViewFilter::NotesAndReplies); + let notes = TimelineTab::new(ViewFilter::Notes); + let replies = TimelineTab::new(ViewFilter::NotesAndReplies); let views = vec![notes, replies]; let selected_view = 0; let routes = vec![Route::Timeline("Timeline".to_string())]; @@ -289,11 +284,11 @@ impl Timeline { } } - pub fn current_view(&self) -> &TimelineView { + pub fn current_view(&self) -> &TimelineTab { &self.views[self.selected_view as usize] } - pub fn current_view_mut(&mut self) -> &mut TimelineView { + pub fn current_view_mut(&mut self) -> &mut TimelineTab { &mut self.views[self.selected_view as usize] } @@ -301,202 +296,15 @@ impl Timeline { &self.views[view.index()].notes } - pub fn view(&self, view: ViewFilter) -> &TimelineView { + pub fn view(&self, view: ViewFilter) -> &TimelineTab { &self.views[view.index()] } - pub fn view_mut(&mut self, view: ViewFilter) -> &mut TimelineView { + pub fn view_mut(&mut self, view: ViewFilter) -> &mut TimelineTab { &mut self.views[view.index()] } } -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) -} - -fn tabs_ui(ui: &mut egui::Ui) -> i32 { - ui.spacing_mut().item_spacing.y = 0.0; - - let tab_res = egui_tabs::Tabs::new(2) - .selected(1) - .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 = if ind == 0 { "Notes" } else { "Notes & Replies" }; - - let res = ui.add(egui::Label::new(txt).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); - 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); - 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 -} - -pub fn timeline_view(ui: &mut egui::Ui, app: &mut Damus, timeline: usize) { - //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; - */ - - if timeline == 0 { - // show a postbox in the first timeline - - if let Some(account) = app.account_manager.get_selected_account_index() { - if app - .account_manager - .get_selected_account() - .map_or(false, |a| a.secret_key.is_some()) - { - if let Ok(txn) = Transaction::new(&app.ndb) { - let response = - ui::PostView::new(app, DraftSource::Compose, account).ui(&txn, ui); - - if let Some(action) = response.action { - match action { - PostAction::Post(np) => { - let seckey = app - .account_manager - .get_account(account) - .unwrap() - .secret_key - .as_ref() - .unwrap() - .to_secret_bytes(); - - let note = np.to_note(&seckey); - let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap()); - info!("sending {}", raw_msg); - app.pool.send(&enostr::ClientMessage::raw(raw_msg)); - app.drafts.clear(DraftSource::Compose); - } - } - } - } - } - } - } - - app.timelines[timeline].selected_view = tabs_ui(ui); - - // need this for some reason?? - ui.add_space(3.0); - - let scroll_id = egui::Id::new(("tlscroll", app.timelines[timeline].selected_view, timeline)); - egui::ScrollArea::vertical() - .id_source(scroll_id) - .animated(false) - .auto_shrink([false, false]) - .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible) - .show(ui, |ui| { - let view = app.timelines[timeline].current_view(); - let len = view.notes.len(); - view.list - .clone() - .borrow_mut() - .ui_custom_layout(ui, len, |ui, start_index| { - ui.spacing_mut().item_spacing.y = 0.0; - ui.spacing_mut().item_spacing.x = 4.0; - - let note_key = app.timelines[timeline].current_view().notes[start_index].key; - - let txn = if let Ok(txn) = Transaction::new(&app.ndb) { - txn - } else { - warn!("failed to create transaction for {:?}", note_key); - return 0; - }; - - let note = if let Ok(note) = app.ndb.get_note_by_key(&txn, note_key) { - note - } else { - warn!("failed to query note {:?}", note_key); - return 0; - }; - - ui::padding(8.0, ui, |ui| { - let textmode = app.textmode; - let resp = ui::NoteView::new(app, ¬e) - .note_previews(!textmode) - .show(ui); - - if let Some(action) = resp.action { - action.execute(app, timeline, note.id(), &txn); - } else if resp.response.clicked() { - debug!("clicked note"); - } - }); - - ui::hline(ui); - //ui.add(egui::Separator::default().spacing(0.0)); - - 1 - }); - }); -} - #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MergeKind { FrontInsert, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0331a11..ef19442 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,6 +11,7 @@ pub mod profile; pub mod relay; pub mod side_panel; pub mod thread; +pub mod timeline; pub mod username; pub use account_management::AccountManagementView; @@ -24,6 +25,7 @@ pub use profile::{profile_preview_controller, ProfilePic, ProfilePreview}; pub use relay::RelayView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use thread::ThreadView; +pub use timeline::TimelineView; pub use username::Username; use egui::Margin; diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs new file mode 100644 index 0000000..1f0355a --- /dev/null +++ b/src/ui/timeline.rs @@ -0,0 +1,228 @@ +use crate::{draft::DraftSource, ui, ui::note::PostAction, Damus}; +use egui::containers::scroll_area::ScrollBarVisibility; +use egui::{Direction, Layout}; +use egui_tabs::TabColor; +use nostrdb::Transaction; +use tracing::{debug, info, warn}; + +pub struct TimelineView<'a> { + app: &'a mut Damus, + reverse: bool, + timeline: usize, +} + +impl<'a> TimelineView<'a> { + pub fn new(app: &'a mut Damus, timeline: usize) -> TimelineView<'a> { + let reverse = false; + TimelineView { + app, + timeline, + reverse, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + timeline_ui(ui, self.app, self.timeline, self.reverse); + } + + pub fn reversed(mut self) -> Self { + self.reverse = true; + self + } +} + +fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bool) { + //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; + */ + + if timeline == 0 { + postbox_view(app, ui); + } + + app.timelines[timeline].selected_view = tabs_ui(ui); + + // need this for some reason?? + ui.add_space(3.0); + + let scroll_id = egui::Id::new(("tlscroll", app.timelines[timeline].selected_view, timeline)); + egui::ScrollArea::vertical() + .id_source(scroll_id) + .animated(false) + .auto_shrink([false, false]) + .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible) + .show(ui, |ui| { + let view = app.timelines[timeline].current_view(); + let len = view.notes.len(); + view.list + .clone() + .borrow_mut() + .ui_custom_layout(ui, len, |ui, start_index| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().item_spacing.x = 4.0; + + let ind = if reversed { + len - start_index - 1 + } else { + start_index + }; + + let note_key = app.timelines[timeline].current_view().notes[ind].key; + + let txn = if let Ok(txn) = Transaction::new(&app.ndb) { + txn + } else { + warn!("failed to create transaction for {:?}", note_key); + return 0; + }; + + let note = if let Ok(note) = app.ndb.get_note_by_key(&txn, note_key) { + note + } else { + warn!("failed to query note {:?}", note_key); + return 0; + }; + + ui::padding(8.0, ui, |ui| { + let textmode = app.textmode; + let resp = ui::NoteView::new(app, ¬e) + .note_previews(!textmode) + .show(ui); + + if let Some(action) = resp.action { + action.execute(app, timeline, note.id(), &txn); + } else if resp.response.clicked() { + debug!("clicked note"); + } + }); + + ui::hline(ui); + //ui.add(egui::Separator::default().spacing(0.0)); + + 1 + }); + }); +} + +fn postbox_view(app: &mut Damus, ui: &mut egui::Ui) { + // show a postbox in the first timeline + + if let Some(account) = app.account_manager.get_selected_account_index() { + if app + .account_manager + .get_selected_account() + .map_or(false, |a| a.secret_key.is_some()) + { + if let Ok(txn) = Transaction::new(&app.ndb) { + let response = ui::PostView::new(app, DraftSource::Compose, account).ui(&txn, ui); + + if let Some(action) = response.action { + match action { + PostAction::Post(np) => { + let seckey = app + .account_manager + .get_account(account) + .unwrap() + .secret_key + .as_ref() + .unwrap() + .to_secret_bytes(); + + let note = np.to_note(&seckey); + let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap()); + info!("sending {}", raw_msg); + app.pool.send(&enostr::ClientMessage::raw(raw_msg)); + app.drafts.clear(DraftSource::Compose); + } + } + } + } + } + } +} + +fn tabs_ui(ui: &mut egui::Ui) -> i32 { + ui.spacing_mut().item_spacing.y = 0.0; + + let tab_res = egui_tabs::Tabs::new(2) + .selected(1) + .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 = if ind == 0 { "Notes" } else { "Notes & Replies" }; + + let res = ui.add(egui::Label::new(txt).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); + 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); + 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 +} + +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) +}