From 1024affdbdc10c337090edd34177171a09dc255a Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 16 Jul 2024 12:48:29 -0700 Subject: [PATCH 01/14] actionbar: add thread button for testing Signed-off-by: William Casarin --- src/ui/note/mod.rs | 72 +++++++++++++++++++++++++++++---------------- src/ui/note/post.rs | 1 + 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs index dfd2ed90..1117ec97 100644 --- a/src/ui/note/mod.rs +++ b/src/ui/note/mod.rs @@ -373,33 +373,13 @@ fn render_note_actionbar( note_key: NoteKey, ) -> egui::InnerResponse> { ui.horizontal(|ui| { - let img_data = if ui.style().visuals.dark_mode { - egui::include_image!("../../../assets/icons/reply.png") - } else { - egui::include_image!("../../../assets/icons/reply-dark.png") - }; - - ui.spacing_mut().button_padding = egui::vec2(0.0, 0.0); - - let button_size = 10.0; - let expand_size = 5.0; - let anim_speed = 0.05; - - let (rect, size, resp) = ui::anim::hover_expand( - ui, - ui.id().with(("reply_anim", note_key)), - button_size, - expand_size, - anim_speed, - ); - - // align rect to note contents - let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); + let reply_resp = reply_button(ui, note_key); + let thread_resp = thread_button(ui, note_key); - ui.put(rect, egui::Image::new(img_data).max_width(size)); - - if resp.clicked() { + if reply_resp.clicked() { Some(BarAction::Reply) + } else if thread_resp.clicked() { + Some(BarAction::OpenThread) } else { None } @@ -432,3 +412,45 @@ fn render_reltime( } }) } + +fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { + let img_data = if ui.style().visuals.dark_mode { + egui::include_image!("../../../assets/icons/reply.png") + } else { + egui::include_image!("../../../assets/icons/reply-dark.png") + }; + + let (rect, size, resp) = + ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); + + // align rect to note contents + let expand_size = 5.0; // from hover_expand_small + let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); + + let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size)); + + resp.union(put_resp) +} + +fn thread_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { + let id = ui.id().with(("thread_anim", note_key)); + let size = 8.0; + let expand_size = 5.0; + let anim_speed = 0.05; + + let (rect, size, resp) = ui::anim::hover_expand(ui, id, size, expand_size, anim_speed); + + let color = if ui.style().visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + + ui.painter_at(rect).circle_stroke( + rect.center(), + (size - 1.0) / 2.0, + egui::Stroke::new(1.0, color), + ); + + resp +} diff --git a/src/ui/note/post.rs b/src/ui/note/post.rs index 0d53d971..fda11d8a 100644 --- a/src/ui/note/post.rs +++ b/src/ui/note/post.rs @@ -76,6 +76,7 @@ impl<'app, 'd> PostView<'app, 'd> { } let buffer = &mut self.draft_source.draft(&mut self.app.drafts).buffer; + let response = ui.add_sized( ui.available_size(), TextEdit::multiline(buffer) From 33e5b6886bb89937672644dd7fe67e99c9b50aab Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 16 Jul 2024 12:48:57 -0700 Subject: [PATCH 02/14] threads: add initial thread support This is a really dumb and broken version of threads, but it will be our foundation for future changes. All it currently does is load whatever notes we have locally for a thread in chronological order. It currently does not open any subscriptions. It is not clear what is replying to what, but hey, its a start. Signed-off-by: William Casarin --- src/app.rs | 24 ++++++++++---- src/lib.rs | 1 + src/thread.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 2 ++ src/ui/thread.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 src/thread.rs create mode 100644 src/ui/thread.rs diff --git a/src/app.rs b/src/app.rs index c7eb45a0..a8ae8653 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,6 +10,7 @@ use crate::note::NoteRef; use crate::notecache::{CachedNote, NoteCache}; use crate::relay_pool_manager::RelayPoolManager; use crate::route::Route; +use crate::thread::Threads; use crate::timeline; use crate::timeline::{MergeKind, Timeline, ViewFilter}; use crate::ui::note::PostAction; @@ -53,10 +54,11 @@ pub struct Damus { pub timelines: Vec, pub selected_timeline: i32, - pub drafts: Drafts, - pub img_cache: ImageCache, pub ndb: Ndb, + pub drafts: Drafts, + pub threads: Threads, + pub img_cache: ImageCache, pub account_manager: AccountManager, frame_history: crate::frame_history::FrameHistory, @@ -820,6 +822,7 @@ impl Damus { Self { pool, is_mobile, + threads: Threads::default(), drafts: Drafts::default(), state: DamusState::Initializing, img_cache: ImageCache::new(imgcache_dir), @@ -849,6 +852,7 @@ impl Damus { config.set_ingester_threads(2); Self { is_mobile, + threads: Threads::default(), drafts: Drafts::default(), state: DamusState::Initializing, pool: RelayPool::new(), @@ -1013,11 +1017,6 @@ fn render_nav(routes: Vec, timeline_ind: usize, app: &mut Damus, ui: &mut None } - Route::Thread(_key) => { - ui.label("thread view"); - None - } - Route::Relays => { let pool = &mut app_ctx.borrow_mut().pool; let manager = RelayPoolManager::new(pool); @@ -1025,6 +1024,17 @@ fn render_nav(routes: Vec, timeline_ind: usize, app: &mut Damus, ui: &mut None } + Route::Thread(id) => { + let app = &mut app_ctx.borrow_mut(); + if let Ok(txn) = Transaction::new(&app.ndb) { + if let Ok(note) = app.ndb.get_note_by_id(&txn, id.bytes()) { + ui::ThreadView::new(app, timeline_ind, ¬e).ui(ui); + } + } + + None + } + Route::Reply(id) => { let mut app = app_ctx.borrow_mut(); diff --git a/src/lib.rs b/src/lib.rs index 1abd35c1..2b2a3265 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod relay_pool_manager; mod result; mod route; mod test_data; +mod thread; mod time; mod timecache; mod timeline; diff --git a/src/thread.rs b/src/thread.rs new file mode 100644 index 00000000..1666e2ff --- /dev/null +++ b/src/thread.rs @@ -0,0 +1,84 @@ +use crate::note::NoteRef; +use crate::timeline::{TimelineView, ViewFilter}; +use nostrdb::{Ndb, Transaction}; +use std::collections::HashMap; +use tracing::debug; + +#[derive(Default)] +pub struct Thread { + pub view: TimelineView, +} + +impl Thread { + pub fn new(notes: Vec) -> Self { + let mut cap = ((notes.len() as f32) * 1.5) as usize; + if cap == 0 { + cap = 25; + } + let mut view = TimelineView::new_with_capacity(ViewFilter::NotesAndReplies, cap); + view.notes = notes; + + Thread { view } + } +} + +#[derive(Default)] +pub struct Threads { + threads: HashMap<[u8; 32], Thread>, +} + +impl Threads { + pub fn thread_mut(&mut self, ndb: &Ndb, txn: &Transaction, root_id: &[u8; 32]) -> &mut Thread { + // we can't use the naive hashmap entry API here because lookups + // require a copy, wait until we have a raw entry api. We could + // also use hashbrown? + + if self.threads.contains_key(root_id) { + return self.threads.get_mut(root_id).unwrap(); + } + + // looks like we don't have this thread yet, populate it + // TODO: should we do this in the caller? + 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)); + self.threads.insert(root_id.to_owned(), Thread::new(vec![])); + return self.threads.get_mut(root_id).unwrap(); + }; + + // we don't have the thread, query for it! + let filter = vec![ + nostrdb::Filter::new() + .kinds(vec![1]) + .event(root.id()) + .build(), + nostrdb::Filter::new() + .kinds(vec![1]) + .ids(vec![*root.id()]) + .build(), + ]; + + // TODO: what should be the max results ? + let notes = if let Ok(mut results) = ndb.query(txn, filter, 10000) { + results.reverse(); + results + .into_iter() + .map(NoteRef::from_query_result) + .collect() + } else { + debug!( + "got no results from thread lookup for {}", + hex::encode(root.id()) + ); + vec![] + }; + + debug!("found thread with {} notes", notes.len()); + self.threads.insert(root_id.to_owned(), Thread::new(notes)); + self.threads.get_mut(root_id).unwrap() + } + + //fn thread_by_id(&self, ndb: &Ndb, id: &[u8; 32]) -> &mut Thread { + //} +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9c185bfc..0331a11e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -10,6 +10,7 @@ pub mod preview; pub mod profile; pub mod relay; pub mod side_panel; +pub mod thread; pub mod username; pub use account_management::AccountManagementView; @@ -22,6 +23,7 @@ pub use preview::{Preview, PreviewApp, PreviewConfig}; pub use profile::{profile_preview_controller, ProfilePic, ProfilePreview}; pub use relay::RelayView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; +pub use thread::ThreadView; pub use username::Username; use egui::Margin; diff --git a/src/ui/thread.rs b/src/ui/thread.rs new file mode 100644 index 00000000..d065e7a1 --- /dev/null +++ b/src/ui/thread.rs @@ -0,0 +1,85 @@ +use crate::{ui, Damus}; +use nostrdb::{Note, NoteReply}; +use tracing::warn; + +pub struct ThreadView<'a> { + app: &'a mut Damus, + timeline: usize, + selected_note: &'a Note<'a>, +} + +impl<'a> ThreadView<'a> { + pub fn new(app: &'a mut Damus, timeline: usize, selected_note: &'a Note<'a>) -> Self { + ThreadView { + app, + timeline, + selected_note, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + let txn = self.selected_note.txn().unwrap(); + let key = self.selected_note.key().unwrap(); + let scroll_id = egui::Id::new(( + "threadscroll", + self.app.timelines[self.timeline].selected_view, + self.timeline, + key, + )); + ui.label( + egui::RichText::new("Threads ALPHA! It's not done. Things will be broken.") + .color(egui::Color32::RED), + ); + egui::ScrollArea::vertical() + .id_source(scroll_id) + .animated(false) + .auto_shrink([false, false]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible) + .show(ui, |ui| { + let root_id = NoteReply::new(self.selected_note.tags()) + .root() + .map_or_else(|| self.selected_note.id(), |nr| nr.id); + + let (len, list) = { + let thread = self.app.threads.thread_mut(&self.app.ndb, txn, root_id); + let len = thread.view.notes.len(); + (len, &mut thread.view.list) + }; + + 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 = { + let thread = self.app.threads.thread_mut(&self.app.ndb, txn, root_id); + thread.view.notes[start_index].key + }; + + let note = if let Ok(note) = self.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 = self.app.textmode; + let resp = ui::NoteView::new(self.app, ¬e) + .note_previews(!textmode) + .show(ui); + + if let Some(action) = resp.action { + action.execute(self.app, self.timeline, note.id()); + } + }); + + ui::hline(ui); + //ui.add(egui::Separator::default().spacing(0.0)); + + 1 + }); + }); + } +} From a28db5d330bcef57e910b5d1e108d003deb3daf8 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 23 Jul 2024 14:10:00 -0700 Subject: [PATCH 03/14] local thread subscriptions This adds local nostrdb thread subscriptions. When navigating to a thread, we first check to see if we have any active nostrdb subscriptions for that thread. If not, we create a new subscription. If we do, we re-use that subscription. This works by storing thread state in the Threads struct in the Damus application state. When we pop a route, we check to see if its a thread route. If it is, then we try to unsubscribe, but only if that is the last remaining subscriber for that thread, as there could be more than one. Signed-off-by: William Casarin --- Cargo.lock | 2 +- Cargo.toml | 3 +- src/actionbar.rs | 55 +++++++++++++-- src/app.rs | 173 ++++++++++++++++------------------------------- src/error.rs | 39 ++++++++++- src/note.rs | 32 ++++++++- src/thread.rs | 99 +++++++++++++++++++++------ src/timeline.rs | 152 ++++++++++++++++++++++++++++++++++++++++- src/ui/thread.rs | 69 +++++++++++++++---- 9 files changed, 459 insertions(+), 165 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c75951c0..a31a146c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.3.4" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=8ef4b9c26145572ad7543d955778499e84723099#8ef4b9c26145572ad7543d955778499e84723099" +source = "git+https://github.com/damus-io/nostrdb-rs?branch=threads#27e7c19c8941fe996490a82512fd2660e5da1900" dependencies = [ "bindgen", "cc", diff --git a/Cargo.toml b/Cargo.toml index aa66a0bb..f51873b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,8 @@ serde_json = "1.0.89" env_logger = "0.10.0" puffin_egui = { version = "0.27.0", optional = true } puffin = { version = "0.19.0", optional = true } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "8ef4b9c26145572ad7543d955778499e84723099" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", branch = "threads" } +#nostrdb = { path = "/Users/jb55/dev/github/damus-io/nostrdb-rs" } #nostrdb = "0.3.4" hex = "0.4.3" base32 = "0.4.0" diff --git a/src/actionbar.rs b/src/actionbar.rs index 8fc20209..0863fdbd 100644 --- a/src/actionbar.rs +++ b/src/actionbar.rs @@ -1,5 +1,7 @@ -use crate::{route::Route, Damus}; +use crate::{route::Route, thread::Thread, Damus}; use enostr::NoteId; +use nostrdb::Transaction; +use tracing::{info, warn}; #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum BarAction { @@ -8,7 +10,13 @@ pub enum BarAction { } impl BarAction { - pub fn execute(self, app: &mut Damus, timeline: usize, replying_to: &[u8; 32]) { + pub fn execute( + self, + app: &mut Damus, + timeline: usize, + replying_to: &[u8; 32], + txn: &Transaction, + ) { match self { BarAction::Reply => { let timeline = &mut app.timelines[timeline]; @@ -19,11 +27,44 @@ impl BarAction { } BarAction::OpenThread => { - let timeline = &mut app.timelines[timeline]; - timeline - .routes - .push(Route::Thread(NoteId::new(replying_to.to_owned()))); - timeline.navigating = true; + { + let timeline = &mut app.timelines[timeline]; + timeline + .routes + .push(Route::Thread(NoteId::new(replying_to.to_owned()))); + timeline.navigating = true; + } + + let root_id = crate::note::root_note_id_from_selected_id(app, txn, replying_to); + let thread = app.threads.thread_mut(&app.ndb, txn, root_id); + + // only start a subscription on nav and if we don't have + // an active subscription for this thread. + if thread.subscription().is_none() { + *thread.subscription_mut() = app.ndb.subscribe(Thread::filters(root_id)).ok(); + + match thread.subscription() { + Some(_sub) => { + thread.subscribers += 1; + info!( + "Locally subscribing to thread. {} total active subscriptions, {} on this thread", + app.ndb.subscription_count(), + thread.subscribers, + ); + } + None => warn!( + "Error subscribing locally to selected note '{}''s thread", + hex::encode(replying_to) + ), + } + } else { + thread.subscribers += 1; + info!( + "Re-using existing thread subscription. {} total active subscriptions, {} on this thread", + app.ndb.subscription_count(), + thread.subscribers, + ) + } } } } diff --git a/src/app.rs b/src/app.rs index a8ae8653..8e4c5fab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,6 @@ use crate::account_manager::AccountManager; use crate::app_creation::setup_cc; use crate::app_style::user_requested_visuals_change; use crate::draft::Drafts; -use crate::error::Error; use crate::frame_history::FrameHistory; use crate::imgcache::ImageCache; use crate::key_storage::KeyStorageType; @@ -10,9 +9,9 @@ use crate::note::NoteRef; use crate::notecache::{CachedNote, NoteCache}; use crate::relay_pool_manager::RelayPoolManager; use crate::route::Route; -use crate::thread::Threads; +use crate::thread::{DecrementResult, Threads}; use crate::timeline; -use crate::timeline::{MergeKind, Timeline, ViewFilter}; +use crate::timeline::{Timeline, TimelineSource, ViewFilter}; use crate::ui::note::PostAction; use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup}; use crate::ui::{DesktopSidePanel, RelayView, View}; @@ -231,7 +230,8 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { let txn = Transaction::new(&damus.ndb)?; let mut unknown_ids: HashSet = HashSet::new(); for timeline in 0..damus.timelines.len() { - if let Err(err) = poll_notes_for_timeline(damus, &txn, timeline, &mut unknown_ids) { + let src = TimelineSource::column(timeline); + if let Err(err) = src.poll_notes_into_view(damus, &txn, &mut unknown_ids) { error!("{}", err); } } @@ -250,7 +250,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { } #[derive(Hash, Clone, Copy, PartialEq, Eq)] -enum UnknownId<'a> { +pub enum UnknownId<'a> { Pubkey(&'a [u8; 32]), Id(&'a [u8; 32]), } @@ -271,7 +271,7 @@ impl<'a> UnknownId<'a> { } } -fn get_unknown_note_ids<'a>( +pub fn get_unknown_note_ids<'a>( ndb: &Ndb, _cached_note: &CachedNote, txn: &'a Transaction, @@ -354,103 +354,6 @@ fn get_unknown_note_ids<'a>( Ok(()) } -fn poll_notes_for_timeline<'a>( - damus: &mut Damus, - txn: &'a Transaction, - timeline_ind: usize, - ids: &mut HashSet>, -) -> Result<()> { - let sub = if let Some(sub) = &damus.timelines[timeline_ind].subscription { - sub - } else { - return Err(Error::NoActiveSubscription); - }; - - let new_note_ids = damus.ndb.poll_for_notes(sub, 100); - if new_note_ids.is_empty() { - return Ok(()); - } else { - debug!("{} new notes! {:?}", new_note_ids.len(), new_note_ids); - } - - let new_refs: Vec<(Note, NoteRef)> = new_note_ids - .iter() - .map(|key| { - let note = damus.ndb.get_note_by_key(txn, *key).expect("no note??"); - let cached_note = damus - .note_cache_mut() - .cached_note_or_insert(*key, ¬e) - .clone(); - let _ = get_unknown_note_ids(&damus.ndb, &cached_note, txn, ¬e, *key, ids); - - let created_at = note.created_at(); - ( - note, - NoteRef { - key: *key, - created_at, - }, - ) - }) - .collect(); - - // ViewFilter::NotesAndReplies - { - let refs: Vec = new_refs.iter().map(|(_note, nr)| *nr).collect(); - - insert_notes_into_timeline(damus, timeline_ind, ViewFilter::NotesAndReplies, &refs) - } - - // - // handle the filtered case (ViewFilter::Notes, no replies) - // - // TODO(jb55): this is mostly just copied from above, let's just use a loop - // I initially tried this but ran into borrow checker issues - { - let mut filtered_refs = Vec::with_capacity(new_refs.len()); - for (note, nr) in &new_refs { - let cached_note = damus.note_cache_mut().cached_note_or_insert(nr.key, note); - - if ViewFilter::filter_notes(cached_note, note) { - filtered_refs.push(*nr); - } - } - - insert_notes_into_timeline(damus, timeline_ind, ViewFilter::Notes, &filtered_refs); - } - - Ok(()) -} - -fn insert_notes_into_timeline( - app: &mut Damus, - timeline_ind: usize, - filter: ViewFilter, - new_refs: &[NoteRef], -) { - let timeline = &mut app.timelines[timeline_ind]; - let num_prev_items = timeline.notes(filter).len(); - let (notes, merge_kind) = timeline::merge_sorted_vecs(timeline.notes(filter), new_refs); - debug!( - "got merge kind {:?} for {:?} on timeline {}", - merge_kind, filter, timeline_ind - ); - - timeline.view_mut(filter).notes = notes; - let new_items = timeline.notes(filter).len() - num_prev_items; - - // TODO: technically items could have been added inbetween - if new_items > 0 { - let mut list = app.timelines[timeline_ind].view(filter).list.borrow_mut(); - - match merge_kind { - // TODO: update egui_virtual_list to support spliced inserts - MergeKind::Spliced => list.reset(), - MergeKind::FrontInsert => list.items_inserted_at_start(new_items), - } - } -} - #[cfg(feature = "profiling")] fn setup_profiling() { puffin::set_scopes_on(true); // tell puffin to collect data @@ -787,7 +690,7 @@ impl Damus { // TODO: should pull this from settings None, // TODO: use correct KeyStorage mechanism for current OS arch - determine_key_storage_type(), + KeyStorageType::None, ); for key in parsed_args.keys { @@ -996,6 +899,50 @@ fn render_panel(ctx: &egui::Context, app: &mut Damus, timeline_ind: usize) { }); } +/// Local thread unsubscribe +fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { + let unsubscribe = { + let txn = Transaction::new(&app.ndb).expect("txn"); + let root_id = crate::note::root_note_id_from_selected_id(app, &txn, id); + + debug!("thread unsubbing from root_id {}", hex::encode(root_id)); + + app.threads + .thread_mut(&app.ndb, &txn, root_id) + .decrement_sub() + }; + + match unsubscribe { + Ok(DecrementResult::LastSubscriber(sub_id)) => { + if let Err(e) = app.ndb.unsubscribe(sub_id) { + error!("failed to unsubscribe from thread: {e}, subid:{sub_id}, {} active subscriptions", app.ndb.subscription_count()); + } else { + info!( + "Unsubscribed from thread subid:{}. {} active subscriptions", + sub_id, + app.ndb.subscription_count() + ); + } + } + + Ok(DecrementResult::ActiveSubscribers) => { + info!( + "Keeping thread subscription. {} active subscriptions.", + app.ndb.subscription_count() + ); + // do nothing + } + + Err(e) => { + // something is wrong! + error!( + "Thread unsubscribe error: {e}. {} active subsciptions.", + app.ndb.subscription_count() + ); + } + } +} + fn render_nav(routes: Vec, timeline_ind: usize, app: &mut Damus, ui: &mut egui::Ui) { let navigating = app.timelines[timeline_ind].navigating; let returning = app.timelines[timeline_ind].returning; @@ -1026,12 +973,7 @@ fn render_nav(routes: Vec, timeline_ind: usize, app: &mut Damus, ui: &mut Route::Thread(id) => { let app = &mut app_ctx.borrow_mut(); - if let Ok(txn) = Transaction::new(&app.ndb) { - if let Ok(note) = app.ndb.get_note_by_id(&txn, id.bytes()) { - ui::ThreadView::new(app, timeline_ind, ¬e).ui(ui); - } - } - + ui::ThreadView::new(app, timeline_ind, id.bytes()).ui(ui); None } @@ -1063,18 +1005,21 @@ fn render_nav(routes: Vec, timeline_ind: usize, app: &mut Damus, ui: &mut } }); + let mut app = app_ctx.borrow_mut(); if let Some(reply_response) = nav_response.inner { if let Some(PostAction::Post(_np)) = reply_response.inner.action { - app_ctx.borrow_mut().timelines[timeline_ind].returning = true; + app.timelines[timeline_ind].returning = true; } } if let Some(NavAction::Returned) = nav_response.action { - let mut app = app_ctx.borrow_mut(); - app.timelines[timeline_ind].routes.pop(); + let popped = app.timelines[timeline_ind].routes.pop(); + if let Some(Route::Thread(id)) = popped { + thread_unsubscribe(&mut app, id.bytes()); + } app.timelines[timeline_ind].returning = false; } else if let Some(NavAction::Navigated) = nav_response.action { - app_ctx.borrow_mut().timelines[timeline_ind].navigating = false; + app.timelines[timeline_ind].navigating = false; } } diff --git a/src/error.rs b/src/error.rs index 37f18ebb..116d1363 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,8 +1,41 @@ use std::{fmt, io}; +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +pub enum SubscriptionError { + //#[error("No active subscriptions")] + NoActive, + + /// When a timeline has an unexpected number + /// of active subscriptions. Should only happen if there + /// is a bug in notedeck + //#[error("Unexpected subscription count")] + UnexpectedSubscriptionCount(i32), +} + +impl Error { + pub fn unexpected_sub_count(c: i32) -> Self { + Error::SubscriptionError(SubscriptionError::UnexpectedSubscriptionCount(c)) + } + + pub fn no_active_sub() -> Self { + Error::SubscriptionError(SubscriptionError::NoActive) + } +} + +impl fmt::Display for SubscriptionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoActive => write!(f, "No active subscriptions"), + Self::UnexpectedSubscriptionCount(c) => { + write!(f, "Unexpected subscription count: {}", c) + } + } + } +} + #[derive(Debug)] pub enum Error { - NoActiveSubscription, + SubscriptionError(SubscriptionError), LoadFailed, Io(io::Error), Nostr(enostr::Error), @@ -14,8 +47,8 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::NoActiveSubscription => { - write!(f, "subscription not active in timeline") + Self::SubscriptionError(sub_err) => { + write!(f, "{sub_err}") } Self::LoadFailed => { write!(f, "load failed") diff --git a/src/note.rs b/src/note.rs index 46863302..496b320a 100644 --- a/src/note.rs +++ b/src/note.rs @@ -1,4 +1,5 @@ -use nostrdb::{NoteKey, QueryResult}; +use crate::Damus; +use nostrdb::{NoteKey, QueryResult, Transaction}; use std::cmp::Ordering; #[derive(Debug, Eq, PartialEq, Copy, Clone)] @@ -35,3 +36,32 @@ impl PartialOrd for NoteRef { Some(self.cmp(other)) } } + +pub fn root_note_id_from_selected_id<'a>( + app: &mut Damus, + txn: &'a Transaction, + selected_note_id: &'a [u8; 32], +) -> &'a [u8; 32] { + let selected_note_key = if let Ok(key) = app + .ndb + .get_notekey_by_id(txn, selected_note_id) + .map(NoteKey::new) + { + key + } else { + return selected_note_id; + }; + + let note = if let Ok(note) = app.ndb.get_note_by_key(txn, selected_note_key) { + note + } else { + return selected_note_id; + }; + + app.note_cache_mut() + .cached_note_or_insert(selected_note_key, ¬e) + .reply + .borrow(note.tags()) + .root() + .map_or_else(|| selected_note_id, |nr| nr.id) +} diff --git a/src/thread.rs b/src/thread.rs index 1666e2ff..af7e86ee 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -1,12 +1,21 @@ use crate::note::NoteRef; use crate::timeline::{TimelineView, ViewFilter}; -use nostrdb::{Ndb, Transaction}; +use crate::Error; +use nostrdb::{Filter, Ndb, Subscription, Transaction}; use std::collections::HashMap; use tracing::debug; #[derive(Default)] pub struct Thread { pub view: TimelineView, + sub: Option, + pub subscribers: i32, +} + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +pub enum DecrementResult { + LastSubscriber(u64), + ActiveSubscribers, } impl Thread { @@ -17,24 +26,79 @@ impl Thread { } let mut view = TimelineView::new_with_capacity(ViewFilter::NotesAndReplies, cap); view.notes = notes; + let sub: Option = None; + let subscribers: i32 = 0; + + Thread { + view, + sub, + subscribers, + } + } + + pub fn decrement_sub(&mut self) -> Result { + debug!("decrementing sub {:?}", self.subscription().map(|s| s.id)); + self.subscribers -= 1; + if self.subscribers == 0 { + // unsub from thread + if let Some(sub) = self.subscription() { + Ok(DecrementResult::LastSubscriber(sub.id)) + } else { + Err(Error::no_active_sub()) + } + } else if self.subscribers < 0 { + Err(Error::unexpected_sub_count(self.subscribers)) + } else { + Ok(DecrementResult::ActiveSubscribers) + } + } + + pub fn subscription(&self) -> Option<&Subscription> { + self.sub.as_ref() + } + + pub fn subscription_mut(&mut self) -> &mut Option { + &mut self.sub + } +} - Thread { view } +impl Thread { + pub fn filters(root: &[u8; 32]) -> Vec { + vec![ + nostrdb::Filter::new().kinds(vec![1]).event(root).build(), + nostrdb::Filter::new() + .kinds(vec![1]) + .ids(vec![*root]) + .build(), + ] } } #[derive(Default)] pub struct Threads { - threads: HashMap<[u8; 32], Thread>, + /// root id to thread + pub root_id_to_thread: HashMap<[u8; 32], Thread>, } impl Threads { - pub fn thread_mut(&mut self, ndb: &Ndb, txn: &Transaction, root_id: &[u8; 32]) -> &mut Thread { + pub fn thread_expected_mut(&mut self, root_id: &[u8; 32]) -> &mut Thread { + self.root_id_to_thread + .get_mut(root_id) + .expect("thread_expected_mut used but there was no thread") + } + + pub fn thread_mut<'a>( + &mut self, + ndb: &Ndb, + txn: &Transaction, + root_id: &[u8; 32], + ) -> &mut Thread { // we can't use the naive hashmap entry API here because lookups // require a copy, wait until we have a raw entry api. We could // also use hashbrown? - if self.threads.contains_key(root_id) { - return self.threads.get_mut(root_id).unwrap(); + if self.root_id_to_thread.contains_key(root_id) { + return self.root_id_to_thread.get_mut(root_id).unwrap(); } // looks like we don't have this thread yet, populate it @@ -43,24 +107,16 @@ impl Threads { root } else { debug!("couldnt find root note for id {}", hex::encode(root_id)); - self.threads.insert(root_id.to_owned(), Thread::new(vec![])); - return self.threads.get_mut(root_id).unwrap(); + self.root_id_to_thread + .insert(root_id.to_owned(), Thread::new(vec![])); + return self.root_id_to_thread.get_mut(root_id).unwrap(); }; // we don't have the thread, query for it! - let filter = vec![ - nostrdb::Filter::new() - .kinds(vec![1]) - .event(root.id()) - .build(), - nostrdb::Filter::new() - .kinds(vec![1]) - .ids(vec![*root.id()]) - .build(), - ]; + let filters = Thread::filters(root_id); // TODO: what should be the max results ? - let notes = if let Ok(mut results) = ndb.query(txn, filter, 10000) { + let notes = if let Ok(mut results) = ndb.query(txn, filters, 10000) { results.reverse(); results .into_iter() @@ -75,8 +131,9 @@ impl Threads { }; debug!("found thread with {} notes", notes.len()); - self.threads.insert(root_id.to_owned(), Thread::new(notes)); - self.threads.get_mut(root_id).unwrap() + self.root_id_to_thread + .insert(root_id.to_owned(), Thread::new(notes)); + self.root_id_to_thread.get_mut(root_id).unwrap() } //fn thread_by_id(&self, ndb: &Ndb, id: &[u8; 32]) -> &mut Thread { diff --git a/src/timeline.rs b/src/timeline.rs index dbdf888d..6df07b6c 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -1,8 +1,10 @@ +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}; +use crate::{ui, Damus, Result}; use crate::route::Route; use egui::containers::scroll_area::ScrollBarVisibility; @@ -13,10 +15,137 @@ use egui_virtual_list::VirtualList; use enostr::Filter; use nostrdb::{Note, Subscription, Transaction}; use std::cell::RefCell; +use std::collections::HashSet; use std::rc::Rc; use tracing::{debug, info, warn}; +#[derive(Debug, Copy, Clone)] +pub enum TimelineSource<'a> { + Column { ind: usize }, + Thread(&'a [u8; 32]), +} + +impl<'a> TimelineSource<'a> { + pub fn column(ind: usize) -> Self { + TimelineSource::Column { ind } + } + + pub fn view<'b>( + self, + app: &'b mut Damus, + txn: &Transaction, + filter: ViewFilter, + ) -> &'b mut TimelineView { + match self { + TimelineSource::Column { ind, .. } => app.timelines[ind].view_mut(filter), + TimelineSource::Thread(root_id) => { + // TODO: replace all this with the raw entry api eventually + + let thread = if app.threads.root_id_to_thread.contains_key(root_id) { + app.threads.thread_expected_mut(root_id) + } else { + app.threads.thread_mut(&app.ndb, txn, root_id) + }; + + &mut thread.view + } + } + } + + pub fn sub<'b>(self, app: &'b mut Damus, txn: &Transaction) -> Option<&'b Subscription> { + match self { + TimelineSource::Column { ind, .. } => app.timelines[ind].subscription.as_ref(), + TimelineSource::Thread(root_id) => { + // TODO: replace all this with the raw entry api eventually + + let thread = if app.threads.root_id_to_thread.contains_key(root_id) { + app.threads.thread_expected_mut(root_id) + } else { + app.threads.thread_mut(&app.ndb, txn, root_id) + }; + + thread.subscription() + } + } + } + + pub fn poll_notes_into_view( + &self, + app: &mut Damus, + txn: &'a Transaction, + ids: &mut HashSet>, + ) -> Result<()> { + let sub_id = if let Some(sub_id) = self.sub(app, txn).map(|s| s.id) { + sub_id + } else { + return Err(Error::no_active_sub()); + }; + + // + // TODO(BUG!): poll for these before the txn, otherwise we can hit + // a race condition where we hit the "no note??" expect below. This may + // require some refactoring due to the missing ids logic + // + let new_note_ids = app.ndb.poll_for_notes(sub_id, 100); + if new_note_ids.is_empty() { + return Ok(()); + } else { + debug!("{} new notes! {:?}", new_note_ids.len(), new_note_ids); + } + + let new_refs: Vec<(Note, NoteRef)> = new_note_ids + .iter() + .map(|key| { + let note = app.ndb.get_note_by_key(txn, *key).expect("no note??"); + let cached_note = app + .note_cache_mut() + .cached_note_or_insert(*key, ¬e) + .clone(); + let _ = get_unknown_note_ids(&app.ndb, &cached_note, txn, ¬e, *key, ids); + + let created_at = note.created_at(); + ( + note, + NoteRef { + key: *key, + created_at, + }, + ) + }) + .collect(); + + // ViewFilter::NotesAndReplies + { + let refs: Vec = new_refs.iter().map(|(_note, nr)| *nr).collect(); + + self.view(app, txn, ViewFilter::NotesAndReplies) + .insert(&refs); + } + + // + // handle the filtered case (ViewFilter::Notes, no replies) + // + // TODO(jb55): this is mostly just copied from above, let's just use a loop + // I initially tried this but ran into borrow checker issues + { + let mut filtered_refs = Vec::with_capacity(new_refs.len()); + for (note, nr) in &new_refs { + let cached_note = app.note_cache_mut().cached_note_or_insert(nr.key, note); + + if ViewFilter::filter_notes(cached_note, note) { + filtered_refs.push(*nr); + } + } + + self.view(app, txn, ViewFilter::Notes) + .insert(&filtered_refs); + } + + Ok(()) + } +} + #[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] pub enum ViewFilter { Notes, @@ -88,6 +217,25 @@ impl TimelineView { } } + pub fn insert(&mut self, new_refs: &[NoteRef]) { + let num_prev_items = self.notes.len(); + let (notes, merge_kind) = crate::timeline::merge_sorted_vecs(&self.notes, new_refs); + + self.notes = notes; + let new_items = self.notes.len() - num_prev_items; + + // TODO: technically items could have been added inbetween + if new_items > 0 { + let mut list = self.list.borrow_mut(); + + match merge_kind { + // TODO: update egui_virtual_list to support spliced inserts + MergeKind::Spliced => list.reset(), + MergeKind::FrontInsert => list.items_inserted_at_start(new_items), + } + } + } + pub fn select_down(&mut self) { debug!("select_down {}", self.selection + 1); if self.selection + 1 > self.notes.len() as i32 { @@ -335,7 +483,7 @@ pub fn timeline_view(ui: &mut egui::Ui, app: &mut Damus, timeline: usize) { .show(ui); if let Some(action) = resp.action { - action.execute(app, timeline, note.id()); + action.execute(app, timeline, note.id(), &txn); } else if resp.response.clicked() { debug!("clicked note"); } diff --git a/src/ui/thread.rs b/src/ui/thread.rs index d065e7a1..8161f715 100644 --- a/src/ui/thread.rs +++ b/src/ui/thread.rs @@ -1,47 +1,86 @@ -use crate::{ui, Damus}; -use nostrdb::{Note, NoteReply}; +use crate::{timeline::TimelineSource, ui, Damus}; +use nostrdb::{NoteKey, Transaction}; +use std::collections::HashSet; use tracing::warn; pub struct ThreadView<'a> { app: &'a mut Damus, timeline: usize, - selected_note: &'a Note<'a>, + selected_note_id: &'a [u8; 32], } impl<'a> ThreadView<'a> { - pub fn new(app: &'a mut Damus, timeline: usize, selected_note: &'a Note<'a>) -> Self { + pub fn new(app: &'a mut Damus, timeline: usize, selected_note_id: &'a [u8; 32]) -> Self { ThreadView { app, timeline, - selected_note, + selected_note_id, } } pub fn ui(&mut self, ui: &mut egui::Ui) { - let txn = self.selected_note.txn().unwrap(); - let key = self.selected_note.key().unwrap(); + let txn = Transaction::new(&self.app.ndb).expect("txn"); + + let selected_note_key = if let Ok(key) = self + .app + .ndb + .get_notekey_by_id(&txn, self.selected_note_id) + .map(NoteKey::new) + { + key + } else { + // TODO: render 404 ? + return; + }; + let scroll_id = egui::Id::new(( "threadscroll", self.app.timelines[self.timeline].selected_view, self.timeline, - key, + selected_note_key, )); + ui.label( egui::RichText::new("Threads ALPHA! It's not done. Things will be broken.") .color(egui::Color32::RED), ); + egui::ScrollArea::vertical() .id_source(scroll_id) .animated(false) .auto_shrink([false, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible) .show(ui, |ui| { - let root_id = NoteReply::new(self.selected_note.tags()) - .root() - .map_or_else(|| self.selected_note.id(), |nr| nr.id); + let note = if let Ok(note) = self.app.ndb.get_note_by_key(&txn, selected_note_key) { + note + } else { + return; + }; + + let root_id = { + let cached_note = self + .app + .note_cache_mut() + .cached_note_or_insert(selected_note_key, ¬e); + + cached_note + .reply + .borrow(note.tags()) + .root() + .map_or_else(|| self.selected_note_id, |nr| nr.id) + }; + + // poll for new notes and insert them into our existing notes + { + let mut ids = HashSet::new(); + let _ = TimelineSource::Thread(root_id) + .poll_notes_into_view(self.app, &txn, &mut ids); + // TODO: do something with unknown ids + } let (len, list) = { - let thread = self.app.threads.thread_mut(&self.app.ndb, txn, root_id); + let thread = self.app.threads.thread_mut(&self.app.ndb, &txn, root_id); + let len = thread.view.notes.len(); (len, &mut thread.view.list) }; @@ -53,11 +92,11 @@ impl<'a> ThreadView<'a> { ui.spacing_mut().item_spacing.x = 4.0; let note_key = { - let thread = self.app.threads.thread_mut(&self.app.ndb, txn, root_id); + let thread = self.app.threads.thread_mut(&self.app.ndb, &txn, root_id); thread.view.notes[start_index].key }; - let note = if let Ok(note) = self.app.ndb.get_note_by_key(txn, note_key) { + let note = if let Ok(note) = self.app.ndb.get_note_by_key(&txn, note_key) { note } else { warn!("failed to query note {:?}", note_key); @@ -71,7 +110,7 @@ impl<'a> ThreadView<'a> { .show(ui); if let Some(action) = resp.action { - action.execute(self.app, self.timeline, note.id()); + action.execute(self.app, self.timeline, note.id(), &txn); } }); From 5be6b1ca681d5ee922ac48182c3ebb3485b8b0e8 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 28 Jul 2024 16:13:08 -0500 Subject: [PATCH 04/14] 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 8e4c5fab..5de6df28 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 b7b2db91..8679f50c 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 af7e86ee..067e6cd9 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 6df07b6c..10c023e2 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 0331a11e..ef19442f 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 00000000..1f0355a9 --- /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) +} From 593df9145bf61d2adc5d716b797e1eb9a26e9cc1 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 29 Jul 2024 10:48:16 -0500 Subject: [PATCH 05/14] threads: check for new notes locally when thread is re-opened We have a NoteRef cache for threads in memory, which is just a list of NoteKeys and timestamps. When reopening a thread, query the local DB to see if there are any new notes that we might have missed because we weren't actively subscribed to them. Signed-off-by: William Casarin --- src/actionbar.rs | 124 ++++++++++++++++++++++++++++++--------------- src/app.rs | 11 ++-- src/thread.rs | 58 ++++++++++++++------- src/timeline.rs | 7 ++- src/ui/thread.rs | 12 ++++- src/ui/timeline.rs | 18 ++++++- 6 files changed, 160 insertions(+), 70 deletions(-) diff --git a/src/actionbar.rs b/src/actionbar.rs index 0863fdbd..576c50fb 100644 --- a/src/actionbar.rs +++ b/src/actionbar.rs @@ -1,4 +1,9 @@ -use crate::{route::Route, thread::Thread, Damus}; +use crate::{ + note::NoteRef, + route::Route, + thread::{Thread, ThreadResult}, + Damus, +}; use enostr::NoteId; use nostrdb::Transaction; use tracing::{info, warn}; @@ -9,6 +14,79 @@ pub enum BarAction { OpenThread, } +pub enum BarResult { + NewThreadNotes(Vec), +} + +/// open_thread is called when a note is selected and we need to navigate +/// to a thread It is responsible for managing the subscription and +/// making sure the thread is up to date. In a sense, it's a model for +/// the thread view. We don't have a concept of model/view/controller etc +/// in egui, but this is the closest thing to that. +fn open_thread( + app: &mut Damus, + txn: &Transaction, + timeline: usize, + selected_note: &[u8; 32], +) -> Option { + { + let timeline = &mut app.timelines[timeline]; + timeline + .routes + .push(Route::Thread(NoteId::new(selected_note.to_owned()))); + timeline.navigating = true; + } + + let root_id = crate::note::root_note_id_from_selected_id(app, txn, selected_note); + let thread_res = app.threads.thread_mut(&app.ndb, txn, root_id); + + // The thread is stale, let's update it + let (thread, result) = match thread_res { + ThreadResult::Stale(thread) => { + let notes = Thread::new_notes(&thread.view.notes, root_id, txn, &app.ndb); + // + // we can't insert and update the VirtualList now, because we + // are already borrowing it mutably. Let's pass it as a + // result instead + // + // thread.view.insert(¬es); + (thread, Some(BarResult::NewThreadNotes(notes))) + } + + ThreadResult::Fresh(thread) => (thread, None), + }; + + // only start a subscription on nav and if we don't have + // an active subscription for this thread. + if thread.subscription().is_none() { + *thread.subscription_mut() = app.ndb.subscribe(Thread::filters(root_id)).ok(); + + match thread.subscription() { + Some(_sub) => { + thread.subscribers += 1; + info!( + "Locally subscribing to thread. {} total active subscriptions, {} on this thread", + app.ndb.subscription_count(), + thread.subscribers, + ); + } + None => warn!( + "Error subscribing locally to selected note '{}''s thread", + hex::encode(selected_note) + ), + } + } else { + thread.subscribers += 1; + info!( + "Re-using existing thread subscription. {} total active subscriptions, {} on this thread", + app.ndb.subscription_count(), + thread.subscribers, + ) + } + + result +} + impl BarAction { pub fn execute( self, @@ -16,7 +94,7 @@ impl BarAction { timeline: usize, replying_to: &[u8; 32], txn: &Transaction, - ) { + ) -> Option { match self { BarAction::Reply => { let timeline = &mut app.timelines[timeline]; @@ -24,48 +102,10 @@ impl BarAction { .routes .push(Route::Reply(NoteId::new(replying_to.to_owned()))); timeline.navigating = true; + None } - BarAction::OpenThread => { - { - let timeline = &mut app.timelines[timeline]; - timeline - .routes - .push(Route::Thread(NoteId::new(replying_to.to_owned()))); - timeline.navigating = true; - } - - let root_id = crate::note::root_note_id_from_selected_id(app, txn, replying_to); - let thread = app.threads.thread_mut(&app.ndb, txn, root_id); - - // only start a subscription on nav and if we don't have - // an active subscription for this thread. - if thread.subscription().is_none() { - *thread.subscription_mut() = app.ndb.subscribe(Thread::filters(root_id)).ok(); - - match thread.subscription() { - Some(_sub) => { - thread.subscribers += 1; - info!( - "Locally subscribing to thread. {} total active subscriptions, {} on this thread", - app.ndb.subscription_count(), - thread.subscribers, - ); - } - None => warn!( - "Error subscribing locally to selected note '{}''s thread", - hex::encode(replying_to) - ), - } - } else { - thread.subscribers += 1; - info!( - "Re-using existing thread subscription. {} total active subscriptions, {} on this thread", - app.ndb.subscription_count(), - thread.subscribers, - ) - } - } + BarAction::OpenThread => open_thread(app, txn, timeline, replying_to), } } } diff --git a/src/app.rs b/src/app.rs index 5de6df28..3f270b2f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -887,9 +887,14 @@ fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { debug!("thread unsubbing from root_id {}", hex::encode(root_id)); - app.threads - .thread_mut(&app.ndb, &txn, root_id) - .decrement_sub() + let thread = app.threads.thread_mut(&app.ndb, &txn, root_id).get_ptr(); + let unsub = thread.decrement_sub(); + + if let Ok(DecrementResult::LastSubscriber(_subid)) = unsub { + *thread.subscription_mut() = None; + } + + unsub }; match unsubscribe { diff --git a/src/thread.rs b/src/thread.rs index 067e6cd9..17c9371b 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -2,6 +2,7 @@ use crate::note::NoteRef; use crate::timeline::{TimelineTab, ViewFilter}; use crate::Error; use nostrdb::{Filter, Ndb, Subscription, Transaction}; +use std::cmp::Ordering; use std::collections::HashMap; use tracing::debug; @@ -51,15 +52,13 @@ impl Thread { let filters = Thread::filters_since(root_id, last_note.created_at - 60); if let Ok(results) = ndb.query(txn, filters, 1000) { + debug!("got {} results from thread update", results.len()); results .into_iter() .map(NoteRef::from_query_result) .collect() } else { - debug!( - "got no results from thread update for {}", - hex::encode(root_id) - ); + debug!("got no results from thread update",); vec![] } } @@ -67,17 +66,17 @@ impl Thread { pub fn decrement_sub(&mut self) -> Result { debug!("decrementing sub {:?}", self.subscription().map(|s| s.id)); self.subscribers -= 1; - if self.subscribers == 0 { - // unsub from thread - if let Some(sub) = self.subscription() { - Ok(DecrementResult::LastSubscriber(sub.id)) - } else { - Err(Error::no_active_sub()) + + match self.subscribers.cmp(&0) { + Ordering::Equal => { + if let Some(sub) = self.subscription() { + Ok(DecrementResult::LastSubscriber(sub.id)) + } else { + Err(Error::no_active_sub()) + } } - } else if self.subscribers < 0 { - Err(Error::unexpected_sub_count(self.subscribers)) - } else { - Ok(DecrementResult::ActiveSubscribers) + Ordering::Less => Err(Error::unexpected_sub_count(self.subscribers)), + Ordering::Greater => Ok(DecrementResult::ActiveSubscribers), } } @@ -121,6 +120,27 @@ pub struct Threads { pub root_id_to_thread: HashMap<[u8; 32], Thread>, } +pub enum ThreadResult<'a> { + Fresh(&'a mut Thread), + Stale(&'a mut Thread), +} + +impl<'a> ThreadResult<'a> { + pub fn get_ptr(self) -> &'a mut Thread { + match self { + Self::Fresh(ptr) => ptr, + Self::Stale(ptr) => ptr, + } + } + + pub fn is_stale(&self) -> bool { + match self { + Self::Fresh(_ptr) => false, + Self::Stale(_ptr) => true, + } + } +} + impl Threads { pub fn thread_expected_mut(&mut self, root_id: &[u8; 32]) -> &mut Thread { self.root_id_to_thread @@ -129,17 +149,17 @@ impl Threads { } pub fn thread_mut<'a>( - &mut self, + &'a mut self, ndb: &Ndb, txn: &Transaction, root_id: &[u8; 32], - ) -> &mut Thread { + ) -> ThreadResult<'a> { // we can't use the naive hashmap entry API here because lookups // require a copy, wait until we have a raw entry api. We could // also use hashbrown? if self.root_id_to_thread.contains_key(root_id) { - return self.root_id_to_thread.get_mut(root_id).unwrap(); + return ThreadResult::Stale(self.root_id_to_thread.get_mut(root_id).unwrap()); } // looks like we don't have this thread yet, populate it @@ -150,7 +170,7 @@ impl Threads { 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(); + return ThreadResult::Fresh(self.root_id_to_thread.get_mut(root_id).unwrap()); }; // we don't have the thread, query for it! @@ -172,7 +192,7 @@ impl Threads { debug!("found thread with {} notes", notes.len()); self.root_id_to_thread .insert(root_id.to_owned(), Thread::new(notes)); - self.root_id_to_thread.get_mut(root_id).unwrap() + ThreadResult::Fresh(self.root_id_to_thread.get_mut(root_id).unwrap()) } //fn thread_by_id(&self, ndb: &Ndb, id: &[u8; 32]) -> &mut Thread { diff --git a/src/timeline.rs b/src/timeline.rs index 10c023e2..d3a4ca42 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -40,7 +40,7 @@ impl<'a> TimelineSource<'a> { let thread = if app.threads.root_id_to_thread.contains_key(root_id) { app.threads.thread_expected_mut(root_id) } else { - app.threads.thread_mut(&app.ndb, txn, root_id) + app.threads.thread_mut(&app.ndb, txn, root_id).get_ptr() }; &mut thread.view @@ -57,7 +57,7 @@ impl<'a> TimelineSource<'a> { let thread = if app.threads.root_id_to_thread.contains_key(root_id) { app.threads.thread_expected_mut(root_id) } else { - app.threads.thread_mut(&app.ndb, txn, root_id) + app.threads.thread_mut(&app.ndb, txn, root_id).get_ptr() }; thread.subscription() @@ -213,6 +213,9 @@ impl TimelineTab { } pub fn insert(&mut self, new_refs: &[NoteRef]) { + if new_refs.is_empty() { + return; + } let num_prev_items = self.notes.len(); let (notes, merge_kind) = crate::timeline::merge_sorted_vecs(&self.notes, new_refs); diff --git a/src/ui/thread.rs b/src/ui/thread.rs index 8161f715..f1414e21 100644 --- a/src/ui/thread.rs +++ b/src/ui/thread.rs @@ -79,7 +79,11 @@ impl<'a> ThreadView<'a> { } let (len, list) = { - let thread = self.app.threads.thread_mut(&self.app.ndb, &txn, root_id); + let thread = self + .app + .threads + .thread_mut(&self.app.ndb, &txn, root_id) + .get_ptr(); let len = thread.view.notes.len(); (len, &mut thread.view.list) @@ -92,7 +96,11 @@ impl<'a> ThreadView<'a> { ui.spacing_mut().item_spacing.x = 4.0; let note_key = { - let thread = self.app.threads.thread_mut(&self.app.ndb, &txn, root_id); + let thread = self + .app + .threads + .thread_mut(&self.app.ndb, &txn, root_id) + .get_ptr(); thread.view.notes[start_index].key }; diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index 1f0355a9..a6af4d85 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -1,4 +1,4 @@ -use crate::{draft::DraftSource, ui, ui::note::PostAction, Damus}; +use crate::{actionbar::BarResult, draft::DraftSource, ui, ui::note::PostAction, Damus}; use egui::containers::scroll_area::ScrollBarVisibility; use egui::{Direction, Layout}; use egui_tabs::TabColor; @@ -56,6 +56,7 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo .show(ui, |ui| { let view = app.timelines[timeline].current_view(); let len = view.notes.len(); + let mut bar_result: Option = None; view.list .clone() .borrow_mut() @@ -92,7 +93,10 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo .show(ui); if let Some(action) = resp.action { - action.execute(app, timeline, note.id(), &txn); + let br = action.execute(app, timeline, note.id(), &txn); + if br.is_some() { + bar_result = br; + } } else if resp.response.clicked() { debug!("clicked note"); } @@ -103,6 +107,16 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo 1 }); + + if let Some(br) = bar_result { + match br { + // update the thread for next render if we have new notes + BarResult::NewThreadNotes(notes) => { + let view = app.timelines[timeline].current_view_mut(); + view.insert(¬es); + } + } + } }); } From dd9f41b04a5f9170a9f3935f708e857097b6b128 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 29 Jul 2024 11:24:55 -0500 Subject: [PATCH 06/14] threads: ensure we always handle bar results We were not handling it in ThreadView, now we do. Signed-off-by: William Casarin --- src/actionbar.rs | 36 ++++++++++++++++++++++++++++++++++-- src/app.rs | 15 ++++++++++++--- src/ui/thread.rs | 14 ++++++++++---- src/ui/timeline.rs | 25 +++++++++++++++---------- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/actionbar.rs b/src/actionbar.rs index 576c50fb..254bb518 100644 --- a/src/actionbar.rs +++ b/src/actionbar.rs @@ -14,8 +14,13 @@ pub enum BarAction { OpenThread, } +pub struct NewThreadNotes { + pub root_id: NoteId, + pub notes: Vec, +} + pub enum BarResult { - NewThreadNotes(Vec), + NewThreadNotes(NewThreadNotes), } /// open_thread is called when a note is selected and we need to navigate @@ -44,13 +49,22 @@ fn open_thread( let (thread, result) = match thread_res { ThreadResult::Stale(thread) => { let notes = Thread::new_notes(&thread.view.notes, root_id, txn, &app.ndb); + let br = if notes.is_empty() { + None + } else { + Some(BarResult::new_thread_notes( + notes, + NoteId::new(root_id.to_owned()), + )) + }; + // // we can't insert and update the VirtualList now, because we // are already borrowing it mutably. Let's pass it as a // result instead // // thread.view.insert(¬es); - (thread, Some(BarResult::NewThreadNotes(notes))) + (thread, br) } ThreadResult::Fresh(thread) => (thread, None), @@ -109,3 +123,21 @@ impl BarAction { } } } + +impl BarResult { + pub fn new_thread_notes(notes: Vec, root_id: NoteId) -> Self { + BarResult::NewThreadNotes(NewThreadNotes::new(notes, root_id)) + } +} + +impl NewThreadNotes { + pub fn new(notes: Vec, root_id: NoteId) -> Self { + NewThreadNotes { notes, root_id } + } + + /// Simple helper for processing a NewThreadNotes result. It simply + /// inserts/merges the notes into the thread cache + pub fn process(&self, thread: &mut Thread) { + thread.view.insert(&self.notes); + } +} diff --git a/src/app.rs b/src/app.rs index 3f270b2f..fff2506e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use crate::account_manager::AccountManager; +use crate::actionbar::BarResult; use crate::app_creation::setup_cc; use crate::app_style::user_requested_visuals_change; use crate::draft::Drafts; @@ -885,8 +886,6 @@ fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { let txn = Transaction::new(&app.ndb).expect("txn"); let root_id = crate::note::root_note_id_from_selected_id(app, &txn, id); - debug!("thread unsubbing from root_id {}", hex::encode(root_id)); - let thread = app.threads.thread_mut(&app.ndb, &txn, root_id).get_ptr(); let unsub = thread.decrement_sub(); @@ -958,7 +957,17 @@ fn render_nav(routes: Vec, timeline_ind: usize, app: &mut Damus, ui: &mut Route::Thread(id) => { let app = &mut app_ctx.borrow_mut(); - ui::ThreadView::new(app, timeline_ind, id.bytes()).ui(ui); + let result = ui::ThreadView::new(app, timeline_ind, id.bytes()).ui(ui); + + if let Some(bar_result) = result { + match bar_result { + BarResult::NewThreadNotes(new_notes) => { + let thread = app.threads.thread_expected_mut(new_notes.root_id.bytes()); + new_notes.process(thread); + } + } + } + None } diff --git a/src/ui/thread.rs b/src/ui/thread.rs index f1414e21..7ed4b895 100644 --- a/src/ui/thread.rs +++ b/src/ui/thread.rs @@ -1,4 +1,4 @@ -use crate::{timeline::TimelineSource, ui, Damus}; +use crate::{actionbar::BarResult, timeline::TimelineSource, ui, Damus}; use nostrdb::{NoteKey, Transaction}; use std::collections::HashSet; use tracing::warn; @@ -18,8 +18,9 @@ impl<'a> ThreadView<'a> { } } - pub fn ui(&mut self, ui: &mut egui::Ui) { + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { let txn = Transaction::new(&self.app.ndb).expect("txn"); + let mut result: Option = None; let selected_note_key = if let Ok(key) = self .app @@ -30,7 +31,7 @@ impl<'a> ThreadView<'a> { key } else { // TODO: render 404 ? - return; + return None; }; let scroll_id = egui::Id::new(( @@ -118,7 +119,10 @@ impl<'a> ThreadView<'a> { .show(ui); if let Some(action) = resp.action { - action.execute(self.app, self.timeline, note.id(), &txn); + let br = action.execute(self.app, self.timeline, note.id(), &txn); + if br.is_some() { + result = br; + } } }); @@ -128,5 +132,7 @@ impl<'a> ThreadView<'a> { 1 }); }); + + result } } diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index a6af4d85..8ea6af29 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -57,6 +57,13 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo let view = app.timelines[timeline].current_view(); let len = view.notes.len(); let mut bar_result: Option = None; + let txn = if let Ok(txn) = Transaction::new(&app.ndb) { + txn + } else { + warn!("failed to create transaction"); + return 0; + }; + view.list .clone() .borrow_mut() @@ -72,13 +79,6 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo 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 { @@ -111,12 +111,17 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo if let Some(br) = bar_result { match br { // update the thread for next render if we have new notes - BarResult::NewThreadNotes(notes) => { - let view = app.timelines[timeline].current_view_mut(); - view.insert(¬es); + BarResult::NewThreadNotes(new_notes) => { + let thread = app + .threads + .thread_mut(&app.ndb, &txn, new_notes.root_id.bytes()) + .get_ptr(); + new_notes.process(thread); } } } + + 1 }); } From c3fc4e09e80e87b14f7401d6b8570d51f7399bab Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 31 Jul 2024 13:25:04 -0700 Subject: [PATCH 07/14] thread: fix ordering and duplication bugs Signed-off-by: William Casarin --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/filter.rs | 18 ++++++++--------- src/thread.rs | 50 ++++++++++++++++-------------------------------- src/ui/thread.rs | 3 ++- 5 files changed, 30 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a31a146c..f7d3f91a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.3.4" -source = "git+https://github.com/damus-io/nostrdb-rs?branch=threads#27e7c19c8941fe996490a82512fd2660e5da1900" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=86ff69438221932a1b6d26a349b9c65c80d51989#86ff69438221932a1b6d26a349b9c65c80d51989" dependencies = [ "bindgen", "cc", diff --git a/Cargo.toml b/Cargo.toml index f51873b7..1eda2b95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ serde_json = "1.0.89" env_logger = "0.10.0" puffin_egui = { version = "0.27.0", optional = true } puffin = { version = "0.19.0", optional = true } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", branch = "threads" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "86ff69438221932a1b6d26a349b9c65c80d51989" } #nostrdb = { path = "/Users/jb55/dev/github/damus-io/nostrdb-rs" } #nostrdb = "0.3.4" hex = "0.4.3" diff --git a/src/filter.rs b/src/filter.rs index 8679f50c..b9be13fd 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -29,43 +29,43 @@ pub fn convert_enostr_filter(filter: &enostr::Filter) -> nostrdb::Filter { let mut nfilter = nostrdb::Filter::new(); if let Some(ref ids) = filter.ids { - nfilter.ids(ids.iter().map(|a| *a.bytes()).collect()); + nfilter = nfilter.ids(ids.iter().map(|a| *a.bytes()).collect()); } if let Some(ref authors) = filter.authors { let authors: Vec<[u8; 32]> = authors.iter().map(|a| *a.bytes()).collect(); - nfilter.authors(authors); + nfilter = nfilter.authors(authors); } if let Some(ref kinds) = filter.kinds { - nfilter.kinds(kinds.clone()); + nfilter = nfilter.kinds(kinds.clone()); } // #e if let Some(ref events) = filter.events { - nfilter.events(events.iter().map(|a| *a.bytes()).collect()); + nfilter = nfilter.events(events.iter().map(|a| *a.bytes()).collect()); } // #p if let Some(ref pubkeys) = filter.pubkeys { - nfilter.pubkeys(pubkeys.iter().map(|a| *a.bytes()).collect()); + nfilter = nfilter.pubkeys(pubkeys.iter().map(|a| *a.bytes()).collect()); } // #t if let Some(ref hashtags) = filter.hashtags { - nfilter.tags(hashtags.clone(), 't'); + nfilter = nfilter.tags(hashtags.clone(), 't'); } if let Some(since) = filter.since { - nfilter.since(since); + nfilter = nfilter.since(since); } if let Some(until) = filter.until { - nfilter.until(until); + nfilter = nfilter.until(until); } if let Some(limit) = filter.limit { - nfilter.limit(limit.into()); + nfilter = nfilter.limit(limit.into()); } nfilter.build() diff --git a/src/thread.rs b/src/thread.rs index 17c9371b..a43180f2 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -1,7 +1,7 @@ use crate::note::NoteRef; use crate::timeline::{TimelineTab, ViewFilter}; use crate::Error; -use nostrdb::{Filter, Ndb, Subscription, Transaction}; +use nostrdb::{Filter, FilterBuilder, Ndb, Subscription, Transaction}; use std::cmp::Ordering; use std::collections::HashMap; use tracing::debug; @@ -49,7 +49,7 @@ impl Thread { } let last_note = notes[0]; - let filters = Thread::filters_since(root_id, last_note.created_at - 60); + let filters = Thread::filters_since(root_id, last_note.created_at + 1); if let Ok(results) = ndb.query(txn, filters, 1000) { debug!("got {} results from thread update", results.len()); @@ -64,7 +64,6 @@ impl Thread { } pub fn decrement_sub(&mut self) -> Result { - debug!("decrementing sub {:?}", self.subscription().map(|s| s.id)); self.subscribers -= 1; match self.subscribers.cmp(&0) { @@ -88,29 +87,25 @@ impl Thread { &mut self.sub } - pub fn filters_since(root: &[u8; 32], since: u64) -> Vec { + fn filters_raw(root: &[u8; 32]) -> 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(), + nostrdb::Filter::new().kinds(vec![1]).event(root), + nostrdb::Filter::new().ids(vec![*root]).limit(1), ] } + pub fn filters_since(root: &[u8; 32], since: u64) -> Vec { + Self::filters_raw(root) + .into_iter() + .map(|fb| fb.since(since).build()) + .collect() + } + pub fn filters(root: &[u8; 32]) -> Vec { - vec![ - nostrdb::Filter::new().kinds(vec![1]).event(root).build(), - nostrdb::Filter::new() - .kinds(vec![1]) - .ids(vec![*root]) - .build(), - ] + Self::filters_raw(root) + .into_iter() + .map(|mut fb| fb.build()) + .collect() } } @@ -162,17 +157,6 @@ impl Threads { return ThreadResult::Stale(self.root_id_to_thread.get_mut(root_id).unwrap()); } - // looks like we don't have this thread yet, populate it - // TODO: should we do this in the caller? - let root = if let Ok(root) = ndb.get_note_by_id(txn, root_id) { - root - } else { - 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 ThreadResult::Fresh(self.root_id_to_thread.get_mut(root_id).unwrap()); - }; - // we don't have the thread, query for it! let filters = Thread::filters(root_id); @@ -184,7 +168,7 @@ impl Threads { } else { debug!( "got no results from thread lookup for {}", - hex::encode(root.id()) + hex::encode(root_id) ); vec![] }; diff --git a/src/ui/thread.rs b/src/ui/thread.rs index 7ed4b895..7ea0d568 100644 --- a/src/ui/thread.rs +++ b/src/ui/thread.rs @@ -96,13 +96,14 @@ impl<'a> ThreadView<'a> { ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().item_spacing.x = 4.0; + let ind = len - 1 - start_index; let note_key = { let thread = self .app .threads .thread_mut(&self.app.ndb, &txn, root_id) .get_ptr(); - thread.view.notes[start_index].key + thread.view.notes[ind].key }; let note = if let Ok(note) = self.app.ndb.get_note_by_key(&txn, note_key) { From 4b644bee32561444c339d0cf2fa30aa76d5573d8 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 31 Jul 2024 13:26:15 -0700 Subject: [PATCH 08/14] restore unknown id fetching of root notes still technically depends on the notebuf branch Signed-off-by: William Casarin --- src/app.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index fff2506e..ad230cff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -252,7 +252,7 @@ impl<'a> UnknownId<'a> { pub fn get_unknown_note_ids<'a>( ndb: &Ndb, - _cached_note: &CachedNote, + cached_note: &CachedNote, txn: &'a Transaction, note: &Note<'a>, note_key: NoteKey, @@ -265,7 +265,6 @@ pub fn get_unknown_note_ids<'a>( } // pull notes that notes are replying to - /* TODO: FIX tags lifetime if cached_note.reply.root.is_some() { let note_reply = cached_note.reply.borrow(note.tags()); if let Some(root) = note_reply.root() { @@ -282,7 +281,6 @@ pub fn get_unknown_note_ids<'a>( } } } - */ let blocks = ndb.get_blocks_by_key(txn, note_key)?; for block in blocks.iter(note) { From 38626520c19d5d1089636874dce1568de193163a Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 31 Jul 2024 13:27:41 -0700 Subject: [PATCH 09/14] thread: warn when we return 0 notes This is a bug Signed-off-by: William Casarin --- src/thread.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/thread.rs b/src/thread.rs index a43180f2..5a065e4b 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -4,7 +4,7 @@ use crate::Error; use nostrdb::{Filter, FilterBuilder, Ndb, Subscription, Transaction}; use std::cmp::Ordering; use std::collections::HashMap; -use tracing::debug; +use tracing::{debug, warn}; #[derive(Default)] pub struct Thread { @@ -173,7 +173,12 @@ impl Threads { vec![] }; - debug!("found thread with {} notes", notes.len()); + if notes.is_empty() { + warn!("thread query returned 0 notes? ") + } else { + debug!("found thread with {} notes", notes.len()); + } + self.root_id_to_thread .insert(root_id.to_owned(), Thread::new(notes)); ThreadResult::Fresh(self.root_id_to_thread.get_mut(root_id).unwrap()) From abd529e91b1ba98c5059793d45d99b5ea71db676 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 3 Aug 2024 11:32:56 -0700 Subject: [PATCH 10/14] arg: add dbpath argument This is great for testing without using an existing DB Signed-off-by: William Casarin --- src/app.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index ad230cff..5bc6b366 100644 --- a/src/app.rs +++ b/src/app.rs @@ -529,6 +529,7 @@ struct Args { is_mobile: Option, keys: Vec, light: bool, + dbpath: Option, } fn parse_args(args: &[String]) -> Args { @@ -538,6 +539,7 @@ fn parse_args(args: &[String]) -> Args { is_mobile: None, keys: vec![], light: false, + dbpath: None, }; let mut i = 0; @@ -584,6 +586,15 @@ fn parse_args(args: &[String]) -> Args { } else { error!("failed to parse filter '{}'", filter); } + } else if arg == "--dbpath" { + i += 1; + let path = if let Some(next_arg) = args.get(i) { + next_arg + } else { + error!("dbpath argument missing?"); + continue; + }; + res.dbpath = Some(path.clone()); } else if arg == "-r" || arg == "--relay" { i += 1; let relay = if let Some(next_arg) = args.get(i) { @@ -659,6 +670,12 @@ impl Damus { setup_cc(cc, is_mobile, parsed_args.light); + let dbpath = parsed_args + .dbpath + .unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string()); + + let _ = std::fs::create_dir_all(dbpath.clone()); + let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir()); let _ = std::fs::create_dir_all(imgcache_dir.clone()); @@ -712,7 +729,7 @@ impl Damus { selected_timeline: 0, timelines: parsed_args.timelines, textmode: false, - ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), + ndb: Ndb::new(&dbpath, &config).expect("ndb"), account_manager, //compose: "".to_string(), frame_history: FrameHistory::default(), From ce5142656e160667e23046eaa4ae37584bcccd92 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 3 Aug 2024 11:33:40 -0700 Subject: [PATCH 11/14] bump ingester threads from 2 to 4 Signed-off-by: William Casarin --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 5bc6b366..6ce91b22 100644 --- a/src/app.rs +++ b/src/app.rs @@ -680,7 +680,7 @@ impl Damus { let _ = std::fs::create_dir_all(imgcache_dir.clone()); let mut config = Config::new(); - config.set_ingester_threads(2); + config.set_ingester_threads(4); let mut account_manager = AccountManager::new( // TODO: should pull this from settings From 0869cdde65b15dea39d8e0487091610fbf7715d4 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 3 Aug 2024 11:34:06 -0700 Subject: [PATCH 12/14] temp fix crash due to race condition we should fix the race condition though Link: https://github.com/damus-io/nostrdb/issues/35 Signed-off-by: William Casarin --- src/timeline.rs | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/timeline.rs b/src/timeline.rs index d3a4ca42..86e009b6 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -13,7 +13,7 @@ use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; -use tracing::debug; +use tracing::{debug, error}; #[derive(Debug, Copy, Clone)] pub enum TimelineSource<'a> { @@ -89,26 +89,25 @@ impl<'a> TimelineSource<'a> { debug!("{} new notes! {:?}", new_note_ids.len(), new_note_ids); } - let new_refs: Vec<(Note, NoteRef)> = new_note_ids - .iter() - .map(|key| { - let note = app.ndb.get_note_by_key(txn, *key).expect("no note??"); - let cached_note = app - .note_cache_mut() - .cached_note_or_insert(*key, ¬e) - .clone(); - let _ = get_unknown_note_ids(&app.ndb, &cached_note, txn, ¬e, *key, ids); - - let created_at = note.created_at(); - ( - note, - NoteRef { - key: *key, - created_at, - }, - ) - }) - .collect(); + let mut new_refs: Vec<(Note, NoteRef)> = Vec::with_capacity(new_note_ids.len()); + + for key in new_note_ids { + let note = if let Ok(note) = app.ndb.get_note_by_key(txn, key) { + note + } else { + error!("hit race condition in poll_notes_into_view: https://github.com/damus-io/nostrdb/issues/35 note {:?} was not added to timeline", key); + continue; + }; + + let cached_note = app + .note_cache_mut() + .cached_note_or_insert(key, ¬e) + .clone(); + let _ = get_unknown_note_ids(&app.ndb, &cached_note, txn, ¬e, key, ids); + + let created_at = note.created_at(); + new_refs.push((note, NoteRef { key, created_at })); + } // ViewFilter::NotesAndReplies { From f769ddad09d5d6b840db7719fabd23e396bdbee9 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 6 Aug 2024 14:38:21 -0700 Subject: [PATCH 13/14] fix threads Signed-off-by: William Casarin --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7d3f91a..3d71760a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.3.4" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=86ff69438221932a1b6d26a349b9c65c80d51989#86ff69438221932a1b6d26a349b9c65c80d51989" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=04e5917b44b0112ecfd0eb93e8a1e2c81fce1d75#04e5917b44b0112ecfd0eb93e8a1e2c81fce1d75" dependencies = [ "bindgen", "cc", diff --git a/Cargo.toml b/Cargo.toml index 1eda2b95..21c740de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ serde_json = "1.0.89" env_logger = "0.10.0" puffin_egui = { version = "0.27.0", optional = true } puffin = { version = "0.19.0", optional = true } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "86ff69438221932a1b6d26a349b9c65c80d51989" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "04e5917b44b0112ecfd0eb93e8a1e2c81fce1d75" } #nostrdb = { path = "/Users/jb55/dev/github/damus-io/nostrdb-rs" } #nostrdb = "0.3.4" hex = "0.4.3" From 579b47fc403272aab0832bfc9d2a293250adce10 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 12 Aug 2024 13:26:29 -0700 Subject: [PATCH 14/14] selectable text option Add a selectable text option to various note views. We don't want selection events to interfere with back drag, so this is the first step toward ensure back drag works. Vertical scrollviews also interfere with back drag, so we'll still need a way to compose gestures. It's not clear if this is currently possibly with egui (union of responses somehow maybe?) Signed-off-by: William Casarin --- src/actionbar.rs | 4 +- src/timeline.rs | 23 ++++++-- src/ui/mention.rs | 25 ++++++-- src/ui/note/contents.rs | 3 +- src/ui/note/mod.rs | 123 +++++++++++++++++++++++++++------------- src/ui/note/options.rs | 71 ++++++++++------------- src/ui/timeline.rs | 1 + 7 files changed, 160 insertions(+), 90 deletions(-) diff --git a/src/actionbar.rs b/src/actionbar.rs index 254bb518..f0a7e99c 100644 --- a/src/actionbar.rs +++ b/src/actionbar.rs @@ -138,6 +138,8 @@ impl NewThreadNotes { /// Simple helper for processing a NewThreadNotes result. It simply /// inserts/merges the notes into the thread cache pub fn process(&self, thread: &mut Thread) { - thread.view.insert(&self.notes); + // threads are chronological, ie reversed from reverse-chronological, the default. + let reversed = true; + thread.view.insert(&self.notes, reversed); } } diff --git a/src/timeline.rs b/src/timeline.rs index 86e009b6..417c9c23 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -109,12 +109,20 @@ impl<'a> TimelineSource<'a> { new_refs.push((note, NoteRef { key, created_at })); } + // We're assuming reverse-chronological here (timelines). This + // flag ensures we trigger the items_inserted_at_start + // optimization in VirtualList. We need this flag because we can + // insert notes into chronological order sometimes, and this + // optimization doesn't make sense in those situations. + let reversed = false; + // ViewFilter::NotesAndReplies { let refs: Vec = new_refs.iter().map(|(_note, nr)| *nr).collect(); + let reversed = false; self.view(app, txn, ViewFilter::NotesAndReplies) - .insert(&refs); + .insert(&refs, reversed); } // @@ -133,7 +141,7 @@ impl<'a> TimelineSource<'a> { } self.view(app, txn, ViewFilter::Notes) - .insert(&filtered_refs); + .insert(&filtered_refs, reversed); } Ok(()) @@ -211,7 +219,7 @@ impl TimelineTab { } } - pub fn insert(&mut self, new_refs: &[NoteRef]) { + pub fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) { if new_refs.is_empty() { return; } @@ -228,7 +236,14 @@ impl TimelineTab { match merge_kind { // TODO: update egui_virtual_list to support spliced inserts MergeKind::Spliced => list.reset(), - MergeKind::FrontInsert => list.items_inserted_at_start(new_items), + MergeKind::FrontInsert => { + // only run this logic if we're reverse-chronological + // reversed in this case means chronological, since the + // default is reverse-chronological. yeah it's confusing. + if !reversed { + list.items_inserted_at_start(new_items); + } + } } } } diff --git a/src/ui/mention.rs b/src/ui/mention.rs index 7fb15f39..f363390e 100644 --- a/src/ui/mention.rs +++ b/src/ui/mention.rs @@ -5,13 +5,26 @@ pub struct Mention<'a> { app: &'a mut Damus, txn: &'a Transaction, pk: &'a [u8; 32], + selectable: bool, size: f32, } impl<'a> Mention<'a> { pub fn new(app: &'a mut Damus, txn: &'a Transaction, pk: &'a [u8; 32]) -> Self { let size = 16.0; - Mention { app, txn, pk, size } + let selectable = true; + Mention { + app, + txn, + pk, + selectable, + size, + } + } + + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = selectable; + self } pub fn size(mut self, size: f32) -> Self { @@ -22,7 +35,7 @@ impl<'a> Mention<'a> { impl<'a> egui::Widget for Mention<'a> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - mention_ui(self.app, self.txn, self.pk, ui, self.size) + mention_ui(self.app, self.txn, self.pk, ui, self.size, self.selectable) } } @@ -32,6 +45,7 @@ fn mention_ui( pk: &[u8; 32], ui: &mut egui::Ui, size: f32, + selectable: bool ) -> egui::Response { #[cfg(feature = "profiling")] puffin::profile_function!(); @@ -46,9 +60,10 @@ fn mention_ui( "??".to_string() }; - let resp = ui.add(egui::Label::new( - egui::RichText::new(name).color(colors::PURPLE).size(size), - )); + let resp = ui.add( + egui::Label::new(egui::RichText::new(name).color(colors::PURPLE).size(size)) + .selectable(selectable), + ); if let Some(rec) = profile.as_ref() { resp.on_hover_ui_at_pointer(|ui| { diff --git a/src/ui/note/contents.rs b/src/ui/note/contents.rs index a352e049..f4ffee18 100644 --- a/src/ui/note/contents.rs +++ b/src/ui/note/contents.rs @@ -110,6 +110,7 @@ fn render_note_contents( #[cfg(feature = "profiling")] puffin::profile_function!(); + let selectable = options.has_selectable_text(); let images: Vec = vec![]; let mut inline_note: Option<(&[u8; 32], &str)> = None; @@ -173,7 +174,7 @@ fn render_note_contents( BlockType::Text => { #[cfg(feature = "profiling")] puffin::profile_scope!("text contents"); - ui.label(block.as_str()); + ui.add(egui::Label::new(block.as_str()).selectable(selectable)); } _ => { diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs index 1117ec97..25b322ea 100644 --- a/src/ui/note/mod.rs +++ b/src/ui/note/mod.rs @@ -33,11 +33,17 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: #[cfg(feature = "profiling")] puffin::profile_function!(); - ui.add(Label::new( - RichText::new("replying to") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + let size = 10.0; + let selectable = false; + + ui.add( + Label::new( + RichText::new("replying to") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); let reply = if let Some(reply) = note_reply.reply() { reply @@ -48,55 +54,91 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: let reply_note = if let Ok(reply_note) = app.ndb.get_note_by_id(txn, reply.id) { reply_note } else { - ui.add(Label::new( - RichText::new("a note") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + Label::new( + RichText::new("a note") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); return; }; if note_reply.is_reply_to_root() { // We're replying to the root, let's show this - ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("'s note") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("'s note") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } else if let Some(root) = note_reply.root() { // replying to another post in a thread, not the root if let Ok(root_note) = app.ndb.get_note_by_id(txn, root.id) { if root_note.pubkey() == reply_note.pubkey() { // simply "replying to bob's note" when replying to bob in his thread - ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("'s note") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("'s note") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } else { // replying to bob in alice's thread - ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("in").size(10.0).color(colors::GRAY_SECONDARY), - )); - ui.add(ui::Mention::new(app, txn, root_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("'s thread") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new(RichText::new("in").size(size).color(colors::GRAY_SECONDARY)) + .selectable(selectable), + ); + ui.add( + ui::Mention::new(app, txn, root_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("'s thread") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } } else { - ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("in someone's thread") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("in someone's thread") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } } } @@ -127,6 +169,11 @@ impl<'a> NoteView<'a> { self } + pub fn selectable_text(mut self, enable: bool) -> Self { + self.options_mut().set_selectable_text(enable); + self + } + pub fn wide(mut self, enable: bool) -> Self { self.options_mut().set_wide(enable); self diff --git a/src/ui/note/options.rs b/src/ui/note/options.rs index a25d44a2..8bc3ad0e 100644 --- a/src/ui/note/options.rs +++ b/src/ui/note/options.rs @@ -6,20 +6,45 @@ bitflags! { #[repr(transparent)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NoteOptions: u32 { - const actionbar = 0b00000001; - const note_previews = 0b00000010; - const small_pfp = 0b00000100; - const medium_pfp = 0b00001000; - const wide = 0b00010000; + const actionbar = 0b00000001; + const note_previews = 0b00000010; + const small_pfp = 0b00000100; + const medium_pfp = 0b00001000; + const wide = 0b00010000; + const selectable_text = 0b00100000; } } +macro_rules! create_setter { + ($fn_name:ident, $option:ident) => { + #[inline] + pub fn $fn_name(&mut self, enable: bool) { + if enable { + *self |= NoteOptions::$option; + } else { + *self &= !NoteOptions::$option; + } + } + }; +} + impl NoteOptions { + create_setter!(set_small_pfp, small_pfp); + create_setter!(set_medium_pfp, medium_pfp); + create_setter!(set_note_previews, note_previews); + create_setter!(set_selectable_text, selectable_text); + create_setter!(set_actionbar, actionbar); + #[inline] pub fn has_actionbar(self) -> bool { (self & NoteOptions::actionbar) == NoteOptions::actionbar } + #[inline] + pub fn has_selectable_text(self) -> bool { + (self & NoteOptions::selectable_text) == NoteOptions::selectable_text + } + #[inline] pub fn has_note_previews(self) -> bool { (self & NoteOptions::note_previews) == NoteOptions::note_previews @@ -58,40 +83,4 @@ impl NoteOptions { *self &= !NoteOptions::wide; } } - - #[inline] - pub fn set_small_pfp(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::small_pfp; - } else { - *self &= !NoteOptions::small_pfp; - } - } - - #[inline] - pub fn set_medium_pfp(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::medium_pfp; - } else { - *self &= !NoteOptions::medium_pfp; - } - } - - #[inline] - pub fn set_note_previews(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::note_previews; - } else { - *self &= !NoteOptions::note_previews; - } - } - - #[inline] - pub fn set_actionbar(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::actionbar; - } else { - *self &= !NoteOptions::actionbar; - } - } } diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index 8ea6af29..a3ec99cb 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -90,6 +90,7 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo let textmode = app.textmode; let resp = ui::NoteView::new(app, ¬e) .note_previews(!textmode) + .selectable_text(false) .show(ui); if let Some(action) = resp.action {