notedeck

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

commit 4b542c0a74b5cc449f66206335daf91e5297640c
parent e52ba5937fd7fda7562848da57c0a6e3356b1f65
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 19 Jan 2025 12:42:41 -0800

switch to TimelineCache

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/enostr/src/lib.rs | 2+-
Mcrates/notedeck/src/lib.rs | 2+-
Mcrates/notedeck/src/note.rs | 24++++++++++++++++++------
Mcrates/notedeck_columns/src/actionbar.rs | 183++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/notedeck_columns/src/app.rs | 32+++++++++-----------------------
Mcrates/notedeck_columns/src/lib.rs | 1-
Mcrates/notedeck_columns/src/multi_subscriber.rs | 35++++-------------------------------
Mcrates/notedeck_columns/src/nav.rs | 65+++++++++++++++++++++++++++++++++--------------------------------
Dcrates/notedeck_columns/src/notes_holder.rs | 215-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/profile.rs | 78++++++++++--------------------------------------------------------------------
Mcrates/notedeck_columns/src/storage/decks.rs | 4++++
Mcrates/notedeck_columns/src/thread.rs | 89+++++++++++--------------------------------------------------------------------
Acrates/notedeck_columns/src/timeline/cache.rs | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/timeline/kind.rs | 14+++++++++++++-
Mcrates/notedeck_columns/src/timeline/mod.rs | 185++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/notedeck_columns/src/timeline/route.rs | 20++++++++++----------
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 58++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/notedeck_columns/src/ui/thread.rs | 76++++++++++++++++++++++++++++++++++------------------------------------------
Mcrates/notedeck_columns/src/ui/timeline.rs | 12++++++++----
19 files changed, 709 insertions(+), 662 deletions(-)

diff --git a/crates/enostr/src/lib.rs b/crates/enostr/src/lib.rs @@ -15,7 +15,7 @@ pub use keypair::{FilledKeypair, FullKeypair, Keypair, SerializableKeypair}; pub use nostr::SecretKey; pub use note::{Note, NoteId}; pub use profile::Profile; -pub use pubkey::Pubkey; +pub use pubkey::{Pubkey, PubkeyRef}; pub use relay::message::{RelayEvent, RelayMessage}; pub use relay::pool::{PoolEvent, PoolRelay, RelayPool}; pub use relay::subs_debug::{OwnedRelayEvent, RelayLogEvent, SubsDebug, TransferStats}; diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -29,7 +29,7 @@ pub use filter::{FilterState, FilterStates, UnifiedSubscription}; pub use fonts::NamedFontFamily; pub use imgcache::ImageCache; pub use muted::{MuteFun, Muted}; -pub use note::NoteRef; +pub use note::{NoteRef, RootIdError, RootNoteId, RootNoteIdBuf}; pub use notecache::{CachedNote, NoteCache}; pub use result::Result; pub use storage::{ diff --git a/crates/notedeck/src/note.rs b/crates/notedeck/src/note.rs @@ -117,22 +117,31 @@ impl PartialOrd for NoteRef { } } -pub fn root_note_id_from_selected_id<'a>( +#[derive(Debug, Copy, Clone)] +pub enum RootIdError { + NoteNotFound, + NoRootId, +} + +pub fn root_note_id_from_selected_id<'txn, 'a>( ndb: &Ndb, note_cache: &mut NoteCache, - txn: &'a Transaction, + txn: &'txn Transaction, selected_note_id: &'a [u8; 32], -) -> &'a [u8; 32] { +) -> Result<RootNoteId<'txn>, RootIdError> +where + 'a: 'txn, +{ let selected_note_key = if let Ok(key) = ndb.get_notekey_by_id(txn, selected_note_id) { key } else { - return selected_note_id; + return Err(RootIdError::NoteNotFound); }; let note = if let Ok(note) = ndb.get_note_by_key(txn, selected_note_key) { note } else { - return selected_note_id; + return Err(RootIdError::NoteNotFound); }; note_cache @@ -140,5 +149,8 @@ pub fn root_note_id_from_selected_id<'a>( .reply .borrow(note.tags()) .root() - .map_or_else(|| selected_note_id, |nr| nr.id) + .map_or_else( + || Ok(RootNoteId::new_unsafe(selected_note_id)), + |rnid| Ok(RootNoteId::new_unsafe(rnid.id)), + ) } diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -1,14 +1,13 @@ use crate::{ column::Columns, - notes_holder::{NotesHolder, NotesHolderStorage}, - profile::Profile, route::{Route, Router}, - thread::Thread, + timeline::{TimelineCache, TimelineCacheKey}, }; use enostr::{NoteId, Pubkey, RelayPool}; -use nostrdb::{Ndb, Transaction}; -use notedeck::{note::root_note_id_from_selected_id, NoteCache, NoteRef}; +use nostrdb::{Ndb, NoteKey, Transaction}; +use notedeck::{note::root_note_id_from_selected_id, NoteCache, RootIdError, UnknownIds}; +use tracing::error; #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum NoteAction { @@ -18,13 +17,13 @@ pub enum NoteAction { OpenProfile(Pubkey), } -pub struct NewNotes { - pub id: [u8; 32], - pub notes: Vec<NoteRef>, +pub struct NewNotes<'a> { + pub id: TimelineCacheKey<'a>, + pub notes: Vec<NoteKey>, } -pub enum NotesHolderResult { - NewNotes(NewNotes), +pub enum TimelineOpenResult<'a> { + NewNotes(NewNotes<'a>), } /// open_thread is called when a note is selected and we need to navigate @@ -33,109 +32,189 @@ pub enum NotesHolderResult { /// the thread view. We don't have a concept of model/view/controller etc /// in egui, but this is the closest thing to that. #[allow(clippy::too_many_arguments)] -fn open_thread( +fn open_thread<'txn>( ndb: &Ndb, - txn: &Transaction, + txn: &'txn Transaction, router: &mut Router<Route>, note_cache: &mut NoteCache, pool: &mut RelayPool, - threads: &mut NotesHolderStorage<Thread>, - selected_note: &[u8; 32], -) -> Option<NotesHolderResult> { + timeline_cache: &mut TimelineCache, + selected_note: &'txn [u8; 32], +) -> Option<TimelineOpenResult<'txn>> { router.route_to(Route::thread(NoteId::new(selected_note.to_owned()))); - let root_id = root_note_id_from_selected_id(ndb, note_cache, txn, selected_note); - Thread::open(ndb, note_cache, txn, pool, threads, root_id) + match root_note_id_from_selected_id(ndb, note_cache, txn, selected_note) { + Ok(root_id) => timeline_cache.open( + ndb, + note_cache, + txn, + pool, + TimelineCacheKey::thread(root_id), + ), + + Err(RootIdError::NoteNotFound) => { + error!( + "open_thread: note not found: {}", + hex::encode(selected_note) + ); + None + } + + Err(RootIdError::NoRootId) => { + error!( + "open_thread: note has no root id: {}", + hex::encode(selected_note) + ); + None + } + } } impl NoteAction { #[allow(clippy::too_many_arguments)] - pub fn execute( - self, + pub fn execute<'txn, 'a>( + &'a self, ndb: &Ndb, router: &mut Router<Route>, - threads: &mut NotesHolderStorage<Thread>, - profiles: &mut NotesHolderStorage<Profile>, + timeline_cache: &mut TimelineCache, note_cache: &mut NoteCache, pool: &mut RelayPool, - txn: &Transaction, - ) -> Option<NotesHolderResult> { + txn: &'txn Transaction, + ) -> Option<TimelineOpenResult<'txn>> + where + 'a: 'txn, + { match self { NoteAction::Reply(note_id) => { - router.route_to(Route::reply(note_id)); + router.route_to(Route::reply(*note_id)); None } - NoteAction::OpenThread(note_id) => { - open_thread(ndb, txn, router, note_cache, pool, threads, note_id.bytes()) - } + NoteAction::OpenThread(note_id) => open_thread( + ndb, + txn, + router, + note_cache, + pool, + timeline_cache, + note_id.bytes(), + ), NoteAction::OpenProfile(pubkey) => { - router.route_to(Route::profile(pubkey)); - Profile::open(ndb, note_cache, txn, pool, profiles, pubkey.bytes()) + router.route_to(Route::profile(*pubkey)); + timeline_cache.open( + ndb, + note_cache, + txn, + pool, + TimelineCacheKey::profile(pubkey.as_ref()), + ) } NoteAction::Quote(note_id) => { - router.route_to(Route::quote(note_id)); + router.route_to(Route::quote(*note_id)); None } } } - /// Execute the NoteAction and process the NotesHolderResult + /// Execute the NoteAction and process the TimelineOpenResult #[allow(clippy::too_many_arguments)] pub fn execute_and_process_result( self, ndb: &Ndb, columns: &mut Columns, col: usize, - threads: &mut NotesHolderStorage<Thread>, - profiles: &mut NotesHolderStorage<Profile>, + timeline_cache: &mut TimelineCache, note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, + unknown_ids: &mut UnknownIds, ) { let router = columns.column_mut(col).router_mut(); - if let Some(br) = self.execute(ndb, router, threads, profiles, note_cache, pool, txn) { - br.process(ndb, note_cache, txn, threads); + if let Some(br) = self.execute(ndb, router, timeline_cache, note_cache, pool, txn) { + br.process(ndb, note_cache, txn, timeline_cache, unknown_ids); } } } -impl NotesHolderResult { - pub fn new_notes(notes: Vec<NoteRef>, id: [u8; 32]) -> Self { - NotesHolderResult::NewNotes(NewNotes::new(notes, id)) +impl<'a> TimelineOpenResult<'a> { + pub fn new_notes(notes: Vec<NoteKey>, id: TimelineCacheKey<'a>) -> Self { + Self::NewNotes(NewNotes::new(notes, id)) } - pub fn process<N: NotesHolder>( + pub fn process( &self, ndb: &Ndb, note_cache: &mut NoteCache, txn: &Transaction, - storage: &mut NotesHolderStorage<N>, + storage: &mut TimelineCache, + unknown_ids: &mut UnknownIds, ) { match self { // update the thread for next render if we have new notes - NotesHolderResult::NewNotes(new_notes) => { - let holder = storage - .notes_holder_mutated(ndb, note_cache, txn, &new_notes.id) - .get_ptr(); - new_notes.process(holder); + TimelineOpenResult::NewNotes(new_notes) => { + new_notes.process(storage, ndb, txn, unknown_ids, note_cache); } } } } -impl NewNotes { - pub fn new(notes: Vec<NoteRef>, id: [u8; 32]) -> Self { +impl<'a> NewNotes<'a> { + pub fn new(notes: Vec<NoteKey>, id: TimelineCacheKey<'a>) -> Self { NewNotes { notes, id } } /// Simple helper for processing a NewThreadNotes result. It simply - /// inserts/merges the notes into the thread cache - pub fn process<N: NotesHolder>(&self, thread: &mut N) { - // threads are chronological, ie reversed from reverse-chronological, the default. - let reversed = true; - thread.get_view().insert(&self.notes, reversed); + /// inserts/merges the notes into the corresponding timeline cache + pub fn process( + &self, + timeline_cache: &mut TimelineCache, + ndb: &Ndb, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + note_cache: &mut NoteCache, + ) { + match self.id { + TimelineCacheKey::Profile(pubkey) => { + let profile = if let Some(profile) = timeline_cache.profiles.get_mut(pubkey.bytes()) + { + profile + } else { + return; + }; + + let reversed = false; + + if let Err(err) = profile.timeline.insert( + &self.notes, + ndb, + txn, + unknown_ids, + note_cache, + reversed, + ) { + error!("error inserting notes into profile timeline: {err}") + } + } + + TimelineCacheKey::Thread(root_id) => { + // threads are chronological, ie reversed from reverse-chronological, the default. + let reversed = true; + let thread = if let Some(thread) = timeline_cache.threads.get_mut(root_id.bytes()) { + thread + } else { + return; + }; + + if let Err(err) = + thread + .timeline + .insert(&self.notes, ndb, txn, unknown_ids, note_cache, reversed) + { + error!("error inserting notes into thread timeline: {err}") + } + } + } } } diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -3,14 +3,10 @@ use crate::{ column::Columns, decks::{Decks, DecksCache, FALLBACK_PUBKEY}, draft::Drafts, - nav, - notes_holder::NotesHolderStorage, - profile::Profile, - storage, + nav, storage, subscriptions::{SubKind, Subscriptions}, support::Support, - thread::Thread, - timeline::{self, Timeline}, + timeline::{self, TimelineCache}, ui::{self, DesktopSidePanel}, unknowns, view_state::ViewState, @@ -43,8 +39,7 @@ pub struct Damus { pub decks_cache: DecksCache, pub view_state: ViewState, pub drafts: Drafts, - pub threads: NotesHolderStorage<Thread>, - pub profiles: NotesHolderStorage<Profile>, + pub timeline_cache: TimelineCache, pub subscriptions: Subscriptions, pub support: Support, @@ -152,14 +147,15 @@ fn try_process_event( if is_ready { let txn = Transaction::new(app_ctx.ndb).expect("txn"); + // only thread timelines are reversed + let reversed = false; - if let Err(err) = Timeline::poll_notes_into_view( - timeline_ind, - current_columns.timelines_mut(), + if let Err(err) = current_columns.timelines_mut()[timeline_ind].poll_notes_into_view( app_ctx.ndb, &txn, app_ctx.unknown_ids, app_ctx.note_cache, + reversed, ) { error!("poll_notes_into_view: {err}"); } @@ -420,8 +416,7 @@ impl Damus { Self { subscriptions: Subscriptions::default(), since_optimize: parsed_args.since_optimize, - threads: NotesHolderStorage::default(), - profiles: NotesHolderStorage::default(), + timeline_cache: TimelineCache::default(), drafts: Drafts::default(), state: DamusState::Initializing, textmode: parsed_args.textmode, @@ -464,8 +459,7 @@ impl Damus { debug, subscriptions: Subscriptions::default(), since_optimize: true, - threads: NotesHolderStorage::default(), - profiles: NotesHolderStorage::default(), + timeline_cache: TimelineCache::default(), drafts: Drafts::default(), state: DamusState::Initializing, textmode: false, @@ -480,14 +474,6 @@ impl Damus { pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> { &mut self.subscriptions.subs } - - pub fn threads(&self) -> &NotesHolderStorage<Thread> { - &self.threads - } - - pub fn threads_mut(&mut self) -> &mut NotesHolderStorage<Thread> { - &mut self.threads - } } /* diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -20,7 +20,6 @@ mod key_parsing; pub mod login_manager; mod multi_subscriber; mod nav; -mod notes_holder; mod post; mod profile; mod profile_state; diff --git a/crates/notedeck_columns/src/multi_subscriber.rs b/crates/notedeck_columns/src/multi_subscriber.rs @@ -1,14 +1,13 @@ use enostr::{Filter, RelayPool}; -use nostrdb::{Ndb, Note, Transaction}; -use tracing::{debug, error, info}; +use nostrdb::Ndb; +use tracing::{error, info}; use uuid::Uuid; -use crate::Error; -use notedeck::{NoteRef, UnifiedSubscription}; +use notedeck::UnifiedSubscription; pub struct MultiSubscriber { filters: Vec<Filter>, - sub: Option<UnifiedSubscription>, + pub sub: Option<UnifiedSubscription>, subscribers: u32, } @@ -105,30 +104,4 @@ impl MultiSubscriber { ) } } - - pub fn poll_for_notes(&mut self, ndb: &Ndb, txn: &Transaction) -> Result<Vec<NoteRef>, Error> { - let sub = self.sub.as_ref().ok_or(notedeck::Error::no_active_sub())?; - let new_note_keys = ndb.poll_for_notes(sub.local, 500); - - if new_note_keys.is_empty() { - return Ok(vec![]); - } else { - debug!("{} new notes! {:?}", new_note_keys.len(), new_note_keys); - } - - let mut notes: Vec<Note<'_>> = Vec::with_capacity(new_note_keys.len()); - for key in new_note_keys { - let note = if let Ok(note) = ndb.get_note_by_key(txn, key) { - note - } else { - continue; - }; - - notes.push(note); - } - - let note_refs: Vec<NoteRef> = notes.iter().map(|n| NoteRef::from_note(n)).collect(); - - Ok(note_refs) - } } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -5,12 +5,10 @@ use crate::{ column::ColumnsAction, deck_state::DeckState, decks::{Deck, DecksAction, DecksCache}, - notes_holder::NotesHolder, - profile::{Profile, ProfileAction, SaveProfileChanges}, + profile::{ProfileAction, SaveProfileChanges}, profile_state::ProfileState, relay_pool_manager::RelayPoolManager, route::Route, - thread::Thread, timeline::{ route::{render_timeline_route, TimelineRoute}, Timeline, @@ -29,7 +27,7 @@ use crate::{ Damus, }; -use notedeck::{AccountsAction, AppContext}; +use notedeck::{AccountsAction, AppContext, RootIdError}; use egui_nav::{Nav, NavAction, NavResponse, NavUiType}; use nostrdb::{Ndb, Transaction}; @@ -162,11 +160,11 @@ impl RenderNavResponse { ctx.ndb, get_active_columns_mut(ctx.accounts, &mut app.decks_cache), col, - &mut app.threads, - &mut app.profiles, + &mut app.timeline_cache, ctx.note_cache, ctx.pool, &txn, + ctx.unknown_ids, ); } @@ -195,34 +193,38 @@ impl RenderNavResponse { .router_mut() .pop(); let txn = Transaction::new(ctx.ndb).expect("txn"); + if let Some(Route::Timeline(TimelineRoute::Thread(id))) = r { - let root_id = { - notedeck::note::root_note_id_from_selected_id( - ctx.ndb, - ctx.note_cache, - &txn, - id.bytes(), - ) - }; - Thread::unsubscribe_locally( - &txn, + match notedeck::note::root_note_id_from_selected_id( ctx.ndb, ctx.note_cache, - &mut app.threads, - ctx.pool, - root_id, - ); - } - - if let Some(Route::Timeline(TimelineRoute::Profile(pubkey))) = r { - Profile::unsubscribe_locally( &txn, - ctx.ndb, - ctx.note_cache, - &mut app.profiles, - ctx.pool, - pubkey.bytes(), - ); + id.bytes(), + ) { + Ok(root_id) => { + if let Some(thread) = + app.timeline_cache.threads.get_mut(root_id.bytes()) + { + if let Some(sub) = &mut thread.subscription { + sub.unsubscribe(ctx.ndb, ctx.pool); + } + } + } + + Err(RootIdError::NoteNotFound) => { + error!("thread returned: note not found for unsub??: {}", id.hex()) + } + + Err(RootIdError::NoRootId) => { + error!("thread returned: note not found for unsub??: {}", id.hex()) + } + } + } else if let Some(Route::Timeline(TimelineRoute::Profile(pubkey))) = r { + if let Some(profile) = app.timeline_cache.profiles.get_mut(pubkey.bytes()) { + if let Some(sub) = &mut profile.subscription { + sub.unsubscribe(ctx.ndb, ctx.pool); + } + } } switching_occured = true; @@ -263,8 +265,7 @@ fn render_nav_body( ctx.img_cache, ctx.unknown_ids, ctx.note_cache, - &mut app.threads, - &mut app.profiles, + &mut app.timeline_cache, ctx.accounts, *tlr, col, diff --git a/crates/notedeck_columns/src/notes_holder.rs b/crates/notedeck_columns/src/notes_holder.rs @@ -1,215 +0,0 @@ -use std::collections::HashMap; - -use enostr::{Filter, RelayPool}; -use nostrdb::{Ndb, Transaction}; -use notedeck::{NoteCache, NoteRef, NoteRefsUnkIdAction}; -use tracing::{debug, info, warn}; - -use crate::{ - actionbar::NotesHolderResult, multi_subscriber::MultiSubscriber, timeline::TimelineTab, Error, - Result, -}; - -pub struct NotesHolderStorage<M: NotesHolder> { - pub id_to_object: HashMap<[u8; 32], M>, -} - -impl<M: NotesHolder> Default for NotesHolderStorage<M> { - fn default() -> Self { - NotesHolderStorage { - id_to_object: HashMap::new(), - } - } -} - -pub enum Vitality<'a, M> { - Fresh(&'a mut M), - Stale(&'a mut M), -} - -impl<'a, M> Vitality<'a, M> { - pub fn get_ptr(self) -> &'a mut M { - 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<M: NotesHolder> NotesHolderStorage<M> { - pub fn notes_holder_expected_mut(&mut self, id: &[u8; 32]) -> &mut M { - self.id_to_object - .get_mut(id) - .expect("notes_holder_expected_mut used but there was no NotesHolder") - } - - pub fn notes_holder_mutated<'a>( - &'a mut self, - ndb: &Ndb, - note_cache: &mut NoteCache, - txn: &Transaction, - id: &[u8; 32], - ) -> Vitality<'a, M> { - // 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.id_to_object.contains_key(id) { - return Vitality::Stale(self.notes_holder_expected_mut(id)); - } - - // we don't have the note holder, query for it! - let filters = M::filters(id); - - let notes = if let Ok(results) = ndb.query(txn, &filters, 1000) { - results - .into_iter() - .map(NoteRef::from_query_result) - .collect() - } else { - debug!( - "got no results from NotesHolder lookup for {}", - hex::encode(id) - ); - vec![] - }; - - if notes.is_empty() { - warn!("NotesHolder query returned 0 notes? ") - } else { - info!("found NotesHolder with {} notes", notes.len()); - } - - self.id_to_object.insert( - id.to_owned(), - M::new_notes_holder(txn, ndb, note_cache, id, M::filters(id), notes), - ); - Vitality::Fresh(self.id_to_object.get_mut(id).unwrap()) - } -} - -pub trait NotesHolder { - fn get_multi_subscriber(&mut self) -> Option<&mut MultiSubscriber>; - fn set_multi_subscriber(&mut self, subscriber: MultiSubscriber); - fn get_view(&mut self) -> &mut TimelineTab; - fn filters(for_id: &[u8; 32]) -> Vec<Filter>; - fn filters_since(for_id: &[u8; 32], since: u64) -> Vec<Filter>; - fn new_notes_holder( - txn: &Transaction, - ndb: &Ndb, - note_cache: &mut NoteCache, - id: &[u8; 32], - filters: Vec<Filter>, - notes: Vec<NoteRef>, - ) -> Self; - - #[must_use = "process_action must be handled in the Ok(action) case"] - fn poll_notes_into_view( - &mut self, - txn: &Transaction, - ndb: &Ndb, - ) -> Result<NoteRefsUnkIdAction> { - if let Some(multi_subscriber) = self.get_multi_subscriber() { - let reversed = true; - let note_refs: Vec<NoteRef> = multi_subscriber.poll_for_notes(ndb, txn)?; - self.get_view().insert(&note_refs, reversed); - Ok(NoteRefsUnkIdAction::new(note_refs)) - } else { - Err(Error::Generic( - "NotesHolder unexpectedly has no MultiSubscriber".to_owned(), - )) - } - } - - /// Look for new thread notes since our last fetch - fn new_notes(notes: &[NoteRef], id: &[u8; 32], txn: &Transaction, ndb: &Ndb) -> Vec<NoteRef> { - if notes.is_empty() { - return vec![]; - } - - let last_note = notes[0]; - let filters = Self::filters_since(id, last_note.created_at + 1); - - if let Ok(results) = ndb.query(txn, &filters, 1000) { - debug!("got {} results from NotesHolder update", results.len()); - results - .into_iter() - .map(NoteRef::from_query_result) - .collect() - } else { - debug!("got no results from NotesHolder update",); - vec![] - } - } - - /// Local NotesHolder unsubscribe - fn unsubscribe_locally<M: NotesHolder>( - txn: &Transaction, - ndb: &mut Ndb, - note_cache: &mut NoteCache, - notes_holder_storage: &mut NotesHolderStorage<M>, - pool: &mut RelayPool, - id: &[u8; 32], - ) { - let notes_holder = notes_holder_storage - .notes_holder_mutated(ndb, note_cache, txn, id) - .get_ptr(); - - if let Some(multi_subscriber) = notes_holder.get_multi_subscriber() { - multi_subscriber.unsubscribe(ndb, pool); - } - } - - fn open<M: NotesHolder>( - ndb: &Ndb, - note_cache: &mut NoteCache, - txn: &Transaction, - pool: &mut RelayPool, - storage: &mut NotesHolderStorage<M>, - id: &[u8; 32], - ) -> Option<NotesHolderResult> { - let vitality = storage.notes_holder_mutated(ndb, note_cache, txn, id); - - let (holder, result) = match vitality { - Vitality::Stale(holder) => { - // The NotesHolder is stale, let's update it - let notes = M::new_notes(&holder.get_view().notes, id, txn, ndb); - let holder_result = if notes.is_empty() { - None - } else { - Some(NotesHolderResult::new_notes(notes, 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 - // - // holder.get_view().insert(&notes); <-- no - // - (holder, holder_result) - } - - Vitality::Fresh(thread) => (thread, None), - }; - - let multi_subscriber = if let Some(multi_subscriber) = holder.get_multi_subscriber() { - multi_subscriber - } else { - let filters = M::filters(id); - holder.set_multi_subscriber(MultiSubscriber::new(filters)); - holder.get_multi_subscriber().unwrap() - }; - - multi_subscriber.subscribe(ndb, pool); - - result - } -} diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -1,19 +1,16 @@ use std::collections::HashMap; -use enostr::{Filter, FullKeypair, Pubkey, RelayPool}; -use nostrdb::{ - FilterBuilder, Ndb, Note, NoteBuildOptions, NoteBuilder, ProfileRecord, Transaction, -}; +use enostr::{Filter, FullKeypair, Pubkey, PubkeyRef, RelayPool}; +use nostrdb::{FilterBuilder, Ndb, Note, NoteBuildOptions, NoteBuilder, ProfileRecord}; -use notedeck::{filter::default_limit, FilterState, NoteCache, NoteRef}; +use notedeck::{filter::default_limit, FilterState}; use tracing::info; use crate::{ multi_subscriber::MultiSubscriber, - notes_holder::NotesHolder, profile_state::ProfileState, route::{Route, Router}, - timeline::{copy_notes_into_timeline, PubkeySource, Timeline, TimelineKind, TimelineTab}, + timeline::{PubkeySource, Timeline, TimelineKind, TimelineTab}, }; pub struct NostrName<'a> { @@ -80,86 +77,31 @@ pub fn get_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> NostrName<'a> pub struct Profile { pub timeline: Timeline, - pub multi_subscriber: Option<MultiSubscriber>, + pub subscription: Option<MultiSubscriber>, } impl Profile { - pub fn new( - txn: &Transaction, - ndb: &Ndb, - note_cache: &mut NoteCache, - source: PubkeySource, - filters: Vec<Filter>, - notes: Vec<NoteRef>, - ) -> Self { - let mut timeline = Timeline::new( + pub fn new(source: PubkeySource, filters: Vec<Filter>) -> Self { + let timeline = Timeline::new( TimelineKind::profile(source), FilterState::ready(filters), TimelineTab::full_tabs(), ); - copy_notes_into_timeline(&mut timeline, txn, ndb, note_cache, notes); - Profile { timeline, - multi_subscriber: None, + subscription: None, } } - fn filters_raw(pk: &[u8; 32]) -> Vec<FilterBuilder> { + pub fn filters_raw(pk: PubkeyRef<'_>) -> Vec<FilterBuilder> { vec![Filter::new() - .authors([pk]) + .authors([pk.bytes()]) .kinds([1]) .limit(default_limit())] } } -impl NotesHolder for Profile { - fn get_multi_subscriber(&mut self) -> Option<&mut MultiSubscriber> { - self.multi_subscriber.as_mut() - } - - fn get_view(&mut self) -> &mut crate::timeline::TimelineTab { - self.timeline.current_view_mut() - } - - fn filters(for_id: &[u8; 32]) -> Vec<enostr::Filter> { - Profile::filters_raw(for_id) - .into_iter() - .map(|mut f| f.build()) - .collect() - } - - fn filters_since(for_id: &[u8; 32], since: u64) -> Vec<enostr::Filter> { - Profile::filters_raw(for_id) - .into_iter() - .map(|f| f.since(since).build()) - .collect() - } - - fn new_notes_holder( - txn: &Transaction, - ndb: &Ndb, - note_cache: &mut NoteCache, - id: &[u8; 32], - filters: Vec<Filter>, - notes: Vec<NoteRef>, - ) -> Self { - Profile::new( - txn, - ndb, - note_cache, - PubkeySource::Explicit(Pubkey::new(*id)), - filters, - notes, - ) - } - - fn set_multi_subscriber(&mut self, subscriber: MultiSubscriber) { - self.multi_subscriber = Some(subscriber); - } -} - pub struct SaveProfileChanges { pub kp: FullKeypair, pub state: ProfileState, diff --git a/crates/notedeck_columns/src/storage/decks.rs b/crates/notedeck_columns/src/storage/decks.rs @@ -473,6 +473,10 @@ fn serialize_route(route: &Route, columns: &Columns) -> Option<String> { TimelineKind::Universe => { selections.push(Selection::Keyword(Keyword::Universe)) } + TimelineKind::Thread(root_id) => { + selections.push(Selection::Keyword(Keyword::Thread)); + selections.push(Selection::Payload(hex::encode(root_id.bytes()))); + } TimelineKind::Generic => { selections.push(Selection::Keyword(Keyword::Generic)) } diff --git a/crates/notedeck_columns/src/thread.rs b/crates/notedeck_columns/src/thread.rs @@ -1,92 +1,27 @@ -use crate::{ - multi_subscriber::MultiSubscriber, - notes_holder::NotesHolder, - timeline::{TimelineTab, ViewFilter}, -}; +use crate::{multi_subscriber::MultiSubscriber, timeline::Timeline}; -use nostrdb::{Filter, FilterBuilder, Ndb, Transaction}; -use notedeck::{NoteCache, NoteRef}; +use nostrdb::FilterBuilder; +use notedeck::{RootNoteId, RootNoteIdBuf}; -#[derive(Default)] pub struct Thread { - view: TimelineTab, - pub multi_subscriber: Option<MultiSubscriber>, + pub timeline: Timeline, + pub subscription: Option<MultiSubscriber>, } impl Thread { - pub fn new(notes: Vec<NoteRef>) -> Self { - let mut cap = ((notes.len() as f32) * 1.5) as usize; - if cap == 0 { - cap = 25; - } - let mut view = TimelineTab::new_with_capacity(ViewFilter::NotesAndReplies, cap); - view.notes = notes; + pub fn new(root_id: RootNoteIdBuf) -> Self { + let timeline = Timeline::thread(root_id); Thread { - view, - multi_subscriber: None, + timeline, + subscription: None, } } - pub fn view(&self) -> &TimelineTab { - &self.view - } - - pub fn view_mut(&mut self) -> &mut TimelineTab { - &mut self.view - } - - fn filters_raw(root: &[u8; 32]) -> Vec<FilterBuilder> { + pub fn filters_raw(root_id: RootNoteId<'_>) -> Vec<FilterBuilder> { vec![ - nostrdb::Filter::new().kinds([1]).event(root), - nostrdb::Filter::new().ids([root]).limit(1), + nostrdb::Filter::new().kinds([1]).event(root_id.bytes()), + nostrdb::Filter::new().ids([root_id.bytes()]).limit(1), ] } - - pub fn filters_since(root: &[u8; 32], since: u64) -> Vec<Filter> { - Self::filters_raw(root) - .into_iter() - .map(|fb| fb.since(since).build()) - .collect() - } - - pub fn filters(root: &[u8; 32]) -> Vec<Filter> { - Self::filters_raw(root) - .into_iter() - .map(|mut fb| fb.build()) - .collect() - } -} - -impl NotesHolder for Thread { - fn get_multi_subscriber(&mut self) -> Option<&mut MultiSubscriber> { - self.multi_subscriber.as_mut() - } - - fn filters(for_id: &[u8; 32]) -> Vec<Filter> { - Thread::filters(for_id) - } - - fn new_notes_holder( - _: &Transaction, - _: &Ndb, - _: &mut NoteCache, - _: &[u8; 32], - _: Vec<Filter>, - notes: Vec<NoteRef>, - ) -> Self { - Thread::new(notes) - } - - fn get_view(&mut self) -> &mut TimelineTab { - &mut self.view - } - - fn filters_since(for_id: &[u8; 32], since: u64) -> Vec<Filter> { - Thread::filters_since(for_id, since) - } - - fn set_multi_subscriber(&mut self, subscriber: MultiSubscriber) { - self.multi_subscriber = Some(subscriber); - } } diff --git a/crates/notedeck_columns/src/timeline/cache.rs b/crates/notedeck_columns/src/timeline/cache.rs @@ -0,0 +1,276 @@ +use crate::{ + actionbar::TimelineOpenResult, + multi_subscriber::MultiSubscriber, + profile::Profile, + thread::Thread, + //subscriptions::SubRefs, + timeline::{PubkeySource, Timeline}, +}; + +use notedeck::{NoteCache, NoteRef, RootNoteId, RootNoteIdBuf}; + +use enostr::{Pubkey, PubkeyRef, RelayPool}; +use nostrdb::{Filter, FilterBuilder, Ndb, Transaction}; +use std::collections::HashMap; +use tracing::{debug, info, warn}; + +#[derive(Default)] +pub struct TimelineCache { + pub threads: HashMap<RootNoteIdBuf, Thread>, + pub profiles: HashMap<Pubkey, Profile>, +} + +pub enum Vitality<'a, M> { + Fresh(&'a mut M), + Stale(&'a mut M), +} + +impl<'a, M> Vitality<'a, M> { + pub fn get_ptr(self) -> &'a mut M { + 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, + } + } +} + +#[derive(Hash, Debug, Copy, Clone)] +pub enum TimelineCacheKey<'a> { + Profile(PubkeyRef<'a>), + Thread(RootNoteId<'a>), +} + +impl<'a> TimelineCacheKey<'a> { + pub fn profile(pubkey: PubkeyRef<'a>) -> Self { + Self::Profile(pubkey) + } + + pub fn thread(root_id: RootNoteId<'a>) -> Self { + Self::Thread(root_id) + } + + pub fn bytes(&self) -> &[u8; 32] { + match self { + Self::Profile(pk) => pk.bytes(), + Self::Thread(root_id) => root_id.bytes(), + } + } + + /// The filters used to update our timeline cache + pub fn filters_raw(&self) -> Vec<FilterBuilder> { + match self { + TimelineCacheKey::Thread(root_id) => Thread::filters_raw(*root_id), + + TimelineCacheKey::Profile(pubkey) => vec![Filter::new() + .authors([pubkey.bytes()]) + .kinds([1]) + .limit(notedeck::filter::default_limit())], + } + } + + pub fn filters_since(&self, since: u64) -> Vec<Filter> { + self.filters_raw() + .into_iter() + .map(|fb| fb.since(since).build()) + .collect() + } + + pub fn filters(&self) -> Vec<Filter> { + self.filters_raw() + .into_iter() + .map(|mut fb| fb.build()) + .collect() + } +} + +impl TimelineCache { + fn contains_key(&self, key: TimelineCacheKey<'_>) -> bool { + match key { + TimelineCacheKey::Profile(pubkey) => self.profiles.contains_key(pubkey.bytes()), + TimelineCacheKey::Thread(root_id) => self.threads.contains_key(root_id.bytes()), + } + } + + fn get_expected_mut(&mut self, key: TimelineCacheKey<'_>) -> &mut Timeline { + match key { + TimelineCacheKey::Profile(pubkey) => self + .profiles + .get_mut(pubkey.bytes()) + .map(|p| &mut p.timeline), + TimelineCacheKey::Thread(root_id) => self + .threads + .get_mut(root_id.bytes()) + .map(|t| &mut t.timeline), + } + .expect("expected notes in timline cache") + } + + /// Insert a new profile or thread into the cache, based on the TimelineCacheKey + #[allow(clippy::too_many_arguments)] + fn insert_new( + &mut self, + id: TimelineCacheKey<'_>, + txn: &Transaction, + ndb: &Ndb, + notes: &[NoteRef], + note_cache: &mut NoteCache, + filters: Vec<Filter>, + ) { + match id { + TimelineCacheKey::Profile(pubkey) => { + let mut profile = Profile::new(PubkeySource::Explicit(pubkey.to_owned()), filters); + // insert initial notes into timeline + profile.timeline.insert_new(txn, ndb, note_cache, notes); + self.profiles.insert(pubkey.to_owned(), profile); + } + + TimelineCacheKey::Thread(root_id) => { + let mut thread = Thread::new(root_id.to_owned()); + thread.timeline.insert_new(txn, ndb, note_cache, notes); + self.threads.insert(root_id.to_owned(), thread); + } + } + } + + /// Get and/or update the notes associated with this timeline + pub fn notes<'a>( + &'a mut self, + ndb: &Ndb, + note_cache: &mut NoteCache, + txn: &Transaction, + id: TimelineCacheKey<'a>, + ) -> Vitality<'a, Timeline> { + // 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.contains_key(id) { + return Vitality::Stale(self.get_expected_mut(id)); + } + + let filters = id.filters(); + let notes = if let Ok(results) = ndb.query(txn, &filters, 1000) { + results + .into_iter() + .map(NoteRef::from_query_result) + .collect() + } else { + debug!("got no results from TimelineCache lookup for {:?}", id); + vec![] + }; + + if notes.is_empty() { + warn!("NotesHolder query returned 0 notes? ") + } else { + info!("found NotesHolder with {} notes", notes.len()); + } + + self.insert_new(id, txn, ndb, &notes, note_cache, filters); + + Vitality::Fresh(self.get_expected_mut(id)) + } + + pub fn subscription( + &mut self, + id: TimelineCacheKey<'_>, + ) -> Option<&mut Option<MultiSubscriber>> { + match id { + TimelineCacheKey::Profile(pubkey) => self + .profiles + .get_mut(pubkey.bytes()) + .map(|p| &mut p.subscription), + TimelineCacheKey::Thread(root_id) => self + .threads + .get_mut(root_id.bytes()) + .map(|t| &mut t.subscription), + } + } + + pub fn open<'a>( + &mut self, + ndb: &Ndb, + note_cache: &mut NoteCache, + txn: &Transaction, + pool: &mut RelayPool, + id: TimelineCacheKey<'a>, + ) -> Option<TimelineOpenResult<'a>> { + let result = match self.notes(ndb, note_cache, txn, id) { + Vitality::Stale(timeline) => { + // The timeline cache is stale, let's update it + let notes = find_new_notes(timeline.all_or_any_notes(), id, txn, ndb); + let cached_timeline_result = if notes.is_empty() { + None + } else { + let new_notes = notes.iter().map(|n| n.key).collect(); + Some(TimelineOpenResult::new_notes(new_notes, id)) + }; + + // we can't insert and update the VirtualList now, because we + // are already borrowing it mutably. Let's pass it as a + // result instead + // + // holder.get_view().insert(&notes); <-- no + cached_timeline_result + } + + Vitality::Fresh(_timeline) => None, + }; + + let sub_id = if let Some(sub) = self.subscription(id) { + if let Some(multi_subscriber) = sub { + multi_subscriber.subscribe(ndb, pool); + multi_subscriber.sub.as_ref().map(|s| s.local) + } else { + let mut multi_sub = MultiSubscriber::new(id.filters()); + multi_sub.subscribe(ndb, pool); + let sub_id = multi_sub.sub.as_ref().map(|s| s.local); + *sub = Some(multi_sub); + sub_id + } + } else { + None + }; + + let timeline = self.get_expected_mut(id); + if let Some(sub_id) = sub_id { + timeline.subscription = Some(sub_id); + } + + // TODO: We have subscription ids tracked in different places. Fix this + + result + } +} + +/// Look for new thread notes since our last fetch +fn find_new_notes( + notes: &[NoteRef], + id: TimelineCacheKey<'_>, + txn: &Transaction, + ndb: &Ndb, +) -> Vec<NoteRef> { + if notes.is_empty() { + return vec![]; + } + + let last_note = notes[0]; + let filters = id.filters_since(last_note.created_at + 1); + + if let Ok(results) = ndb.query(txn, &filters, 1000) { + debug!("got {} results from NotesHolder update", results.len()); + results + .into_iter() + .map(NoteRef::from_query_result) + .collect() + } else { + debug!("got no results from NotesHolder update",); + vec![] + } +} diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs @@ -2,7 +2,7 @@ use crate::error::Error; use crate::timeline::{Timeline, TimelineTab}; use enostr::{Filter, Pubkey}; use nostrdb::{Ndb, Transaction}; -use notedeck::{filter::default_limit, FilterError, FilterState}; +use notedeck::{filter::default_limit, FilterError, FilterState, RootNoteIdBuf}; use serde::{Deserialize, Serialize}; use std::{borrow::Cow, fmt::Display}; use tracing::{error, warn}; @@ -58,6 +58,9 @@ pub enum TimelineKind { Profile(PubkeySource), + /// This could be any note id, doesn't need to be the root id + Thread(RootNoteIdBuf), + Universe, /// Generic filter @@ -75,6 +78,7 @@ impl Display for TimelineKind { TimelineKind::Profile(_) => f.write_str("Profile"), TimelineKind::Universe => f.write_str("Universe"), TimelineKind::Hashtag(_) => f.write_str("Hashtag"), + TimelineKind::Thread(_) => f.write_str("Thread"), } } } @@ -88,6 +92,7 @@ impl TimelineKind { TimelineKind::Universe => None, TimelineKind::Generic => None, TimelineKind::Hashtag(_ht) => None, + TimelineKind::Thread(_ht) => None, } } @@ -103,6 +108,10 @@ impl TimelineKind { TimelineKind::Profile(pk) } + pub fn thread(root_id: RootNoteIdBuf) -> Self { + TimelineKind::Thread(root_id) + } + pub fn is_notifications(&self) -> bool { matches!(self, TimelineKind::Notifications(_)) } @@ -122,6 +131,8 @@ impl TimelineKind { TimelineTab::no_replies(), )), + TimelineKind::Thread(root_id) => Some(Timeline::thread(root_id)), + TimelineKind::Generic => { warn!("you can't convert a TimelineKind::Generic to a Timeline"); None @@ -213,6 +224,7 @@ impl TimelineKind { }, TimelineKind::Notifications(_pubkey_source) => ColumnTitle::simple("Notifications"), TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self), + TimelineKind::Thread(_root_id) => ColumnTitle::simple("Thread"), TimelineKind::Universe => ColumnTitle::simple("Universe"), TimelineKind::Generic => ColumnTitle::simple("Custom"), TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.to_string()), diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -3,11 +3,13 @@ use crate::{ decks::DecksCache, error::Error, subscriptions::{self, SubKind, Subscriptions}, + thread::Thread, Result, }; use notedeck::{ - filter, CachedNote, FilterError, FilterState, FilterStates, NoteCache, NoteRef, UnknownIds, + filter, CachedNote, FilterError, FilterState, FilterStates, NoteCache, NoteRef, RootNoteIdBuf, + UnknownIds, }; use std::fmt; @@ -15,16 +17,18 @@ use std::sync::atomic::{AtomicU32, Ordering}; use egui_virtual_list::VirtualList; use enostr::{PoolRelay, Pubkey, RelayPool}; -use nostrdb::{Filter, Ndb, Note, Subscription, Transaction}; +use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction}; use std::cell::RefCell; use std::hash::Hash; use std::rc::Rc; use tracing::{debug, error, info, warn}; +pub mod cache; pub mod kind; pub mod route; +pub use cache::{TimelineCache, TimelineCacheKey}; pub use kind::{ColumnTitle, PubkeySource, TimelineKind}; pub use route::TimelineRoute; @@ -123,7 +127,7 @@ impl TimelineTab { } } - pub fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) { + fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) { if new_refs.is_empty() { return; } @@ -189,7 +193,6 @@ pub struct Timeline { pub views: Vec<TimelineTab>, pub selected_view: usize, - /// Our nostrdb subscription pub subscription: Option<Subscription>, } @@ -210,6 +213,18 @@ impl Timeline { )) } + pub fn thread(note_id: RootNoteIdBuf) -> Self { + let filter = Thread::filters_raw(note_id.borrow()) + .iter_mut() + .map(|fb| fb.build()) + .collect(); + Timeline::new( + TimelineKind::Thread(note_id), + FilterState::ready(filter), + TimelineTab::only_notes_and_replies(), + ) + } + pub fn hashtag(hashtag: String) -> Self { let filter = Filter::new() .kinds([1]) @@ -280,78 +295,119 @@ impl Timeline { self.views.iter_mut().find(|tab| tab.filter == view) } - pub fn poll_notes_into_view( - timeline_idx: usize, - mut timelines: Vec<&mut Timeline>, + /// Initial insert of notes into a timeline. Subsequent inserts should + /// just use the insert function + pub fn insert_new( + &mut self, + txn: &Transaction, + ndb: &Ndb, + note_cache: &mut NoteCache, + notes: &[NoteRef], + ) { + let filters = { + let views = &self.views; + let filters: Vec<fn(&CachedNote, &Note) -> bool> = + views.iter().map(|v| v.filter.filter()).collect(); + filters + }; + + for note_ref in notes { + for (view, filter) in filters.iter().enumerate() { + if let Ok(note) = ndb.get_note_by_key(txn, note_ref.key) { + if filter( + note_cache.cached_note_or_insert_mut(note_ref.key, &note), + &note, + ) { + self.views[view].notes.push(*note_ref) + } + } + } + } + } + + /// The main function used for inserting notes into timelines. Handles + /// inserting into multiple views if we have them. All timeline note + /// insertions should use this function. + pub fn insert( + &mut self, + new_note_ids: &[NoteKey], ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, + reversed: bool, ) -> Result<()> { - let timeline = timelines - .get_mut(timeline_idx) - .ok_or(Error::TimelineNotFound)?; - let sub = timeline - .subscription - .ok_or(Error::App(notedeck::Error::no_active_sub()))?; - - let new_note_ids = ndb.poll_for_notes(sub, 500); - if new_note_ids.is_empty() { - return Ok(()); - } else { - debug!("{} new notes! {:?}", new_note_ids.len(), new_note_ids); - } - 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) = ndb.get_note_by_key(txn, key) { + let note = if let Ok(note) = 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; }; + // Ensure that unknown ids are captured when inserting notes + // into the timeline UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &note); let created_at = note.created_at(); - new_refs.push((note, NoteRef { key, created_at })); + new_refs.push(( + note, + NoteRef { + key: *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; + for view in &mut self.views { + match view.filter { + ViewFilter::NotesAndReplies => { + let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect(); - // ViewFilter::NotesAndReplies - if let Some(view) = timeline.view_mut(ViewFilter::NotesAndReplies) { - let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect(); + view.insert(&refs, reversed); + } - view.insert(&refs, reversed); - } + ViewFilter::Notes => { + let mut filtered_refs = Vec::with_capacity(new_refs.len()); + for (note, nr) in &new_refs { + let cached_note = note_cache.cached_note_or_insert(nr.key, note); - // - // 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 - if let Some(view) = timeline.view_mut(ViewFilter::Notes) { - let mut filtered_refs = Vec::with_capacity(new_refs.len()); - for (note, nr) in &new_refs { - let cached_note = note_cache.cached_note_or_insert(nr.key, note); - - if ViewFilter::filter_notes(cached_note, note) { - filtered_refs.push(*nr); + if ViewFilter::filter_notes(cached_note, note) { + filtered_refs.push(*nr); + } + } + + view.insert(&filtered_refs, reversed); } } - - view.insert(&filtered_refs, reversed); } Ok(()) } + + pub fn poll_notes_into_view( + &mut self, + ndb: &Ndb, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + note_cache: &mut NoteCache, + reversed: bool, + ) -> Result<()> { + let sub = self + .subscription + .ok_or(Error::App(notedeck::Error::no_active_sub()))?; + + let new_note_ids = ndb.poll_for_notes(sub, 500); + if new_note_ids.is_empty() { + return Ok(()); + } else { + debug!("{} new notes! {:?}", new_note_ids.len(), new_note_ids); + } + + self.insert(&new_note_ids, ndb, txn, unknown_ids, note_cache, reversed) + } } pub enum MergeKind { @@ -550,45 +606,18 @@ fn setup_initial_timeline( timeline.subscription, timeline.filter ); let lim = filters[0].limit().unwrap_or(filter::default_limit()) as i32; - let notes = ndb + + let notes: Vec<NoteRef> = ndb .query(&txn, filters, lim)? .into_iter() .map(NoteRef::from_query_result) .collect(); - copy_notes_into_timeline(timeline, &txn, ndb, note_cache, notes); + timeline.insert_new(&txn, ndb, note_cache, &notes); Ok(()) } -pub fn copy_notes_into_timeline( - timeline: &mut Timeline, - txn: &Transaction, - ndb: &Ndb, - note_cache: &mut NoteCache, - notes: Vec<NoteRef>, -) { - let filters = { - let views = &timeline.views; - let filters: Vec<fn(&CachedNote, &Note) -> bool> = - views.iter().map(|v| v.filter.filter()).collect(); - filters - }; - - for note_ref in notes { - for (view, filter) in filters.iter().enumerate() { - if let Ok(note) = ndb.get_note_by_key(txn, note_ref.key) { - if filter( - note_cache.cached_note_or_insert_mut(note_ref.key, &note), - &note, - ) { - timeline.views[view].notes.push(note_ref) - } - } - } - } -} - pub fn setup_initial_nostrdb_subs( ndb: &Ndb, note_cache: &mut NoteCache, diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -2,10 +2,8 @@ use crate::{ column::Columns, draft::Drafts, nav::RenderNavAction, - notes_holder::NotesHolderStorage, - profile::{Profile, ProfileAction}, - thread::Thread, - timeline::{TimelineId, TimelineKind}, + profile::ProfileAction, + timeline::{TimelineCache, TimelineId, TimelineKind}, ui::{ self, note::{NoteOptions, QuoteRepostView}, @@ -34,8 +32,7 @@ pub fn render_timeline_route( img_cache: &mut ImageCache, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, - threads: &mut NotesHolderStorage<Thread>, - profiles: &mut NotesHolderStorage<Profile>, + timeline_cache: &mut TimelineCache, accounts: &mut Accounts, route: TimelineRoute, col: usize, @@ -71,7 +68,7 @@ pub fn render_timeline_route( } TimelineRoute::Thread(id) => ui::ThreadView::new( - threads, + timeline_cache, ndb, note_cache, unknown_ids, @@ -121,9 +118,10 @@ pub fn render_timeline_route( &pubkey, accounts, ndb, - profiles, + timeline_cache, img_cache, note_cache, + unknown_ids, col, ui, &accounts.mutefun(), @@ -160,9 +158,10 @@ pub fn render_profile_route( pubkey: &Pubkey, accounts: &Accounts, ndb: &Ndb, - profiles: &mut NotesHolderStorage<Profile>, + timeline_cache: &mut TimelineCache, img_cache: &mut ImageCache, note_cache: &mut NoteCache, + unknown_ids: &mut UnknownIds, col: usize, ui: &mut egui::Ui, is_muted: &MuteFun, @@ -171,10 +170,11 @@ pub fn render_profile_route( pubkey, accounts, col, - profiles, + timeline_cache, ndb, note_cache, img_cache, + unknown_ids, is_muted, NoteOptions::default(), ) diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -2,33 +2,39 @@ pub mod edit; pub mod picture; pub mod preview; -use crate::profile::get_display_name; -use crate::ui::note::NoteOptions; -use crate::{colors, images}; -use crate::{notes_holder::NotesHolder, NostrName}; pub use edit::EditProfileView; use egui::load::TexturePoll; use egui::{vec2, Color32, Label, Layout, Rect, RichText, Rounding, ScrollArea, Sense, Stroke}; -use enostr::Pubkey; +use enostr::{Pubkey, PubkeyRef}; use nostrdb::{Ndb, ProfileRecord, Transaction}; pub use picture::ProfilePic; pub use preview::ProfilePreview; use tracing::error; -use crate::{actionbar::NoteAction, notes_holder::NotesHolderStorage, profile::Profile}; +use crate::{ + actionbar::NoteAction, + colors, images, + profile::get_display_name, + timeline::{TimelineCache, TimelineCacheKey}, + ui::{ + note::NoteOptions, + timeline::{tabs_ui, TimelineTabView}, + }, + NostrName, +}; -use super::timeline::{tabs_ui, TimelineTabView}; -use notedeck::{Accounts, ImageCache, MuteFun, NoteCache, NotedeckTextStyle}; +use notedeck::{Accounts, ImageCache, MuteFun, NoteCache, NotedeckTextStyle, UnknownIds}; pub struct ProfileView<'a> { pubkey: &'a Pubkey, accounts: &'a Accounts, col_id: usize, - profiles: &'a mut NotesHolderStorage<Profile>, + timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, + unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, } @@ -43,10 +49,11 @@ impl<'a> ProfileView<'a> { pubkey: &'a Pubkey, accounts: &'a Accounts, col_id: usize, - profiles: &'a mut NotesHolderStorage<Profile>, + timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, + unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, note_options: NoteOptions, ) -> Self { @@ -54,10 +61,11 @@ impl<'a> ProfileView<'a> { pubkey, accounts, col_id, - profiles, + timeline_cache, ndb, note_cache, img_cache, + unknown_ids, note_options, is_muted, } @@ -76,23 +84,33 @@ impl<'a> ProfileView<'a> { action = Some(ProfileViewAction::EditProfile); } } - let profile = self - .profiles - .notes_holder_mutated(self.ndb, self.note_cache, &txn, self.pubkey.bytes()) + let profile_timeline = self + .timeline_cache + .notes( + self.ndb, + self.note_cache, + &txn, + TimelineCacheKey::Profile(PubkeyRef::new(self.pubkey.bytes())), + ) .get_ptr(); - profile.timeline.selected_view = - tabs_ui(ui, profile.timeline.selected_view, &profile.timeline.views); + profile_timeline.selected_view = + tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views); + let reversed = false; // poll for new notes and insert them into our existing notes - if let Err(e) = profile.poll_notes_into_view(&txn, self.ndb) { + if let Err(e) = profile_timeline.poll_notes_into_view( + self.ndb, + &txn, + self.unknown_ids, + self.note_cache, + reversed, + ) { error!("Profile::poll_notes_into_view: {e}"); } - let reversed = false; - if let Some(note_action) = TimelineTabView::new( - profile.timeline.current_view(), + profile_timeline.current_view(), reversed, self.note_options, &txn, diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -1,18 +1,17 @@ use crate::{ actionbar::NoteAction, - notes_holder::{NotesHolder, NotesHolderStorage}, - thread::Thread, + timeline::{TimelineCache, TimelineCacheKey}, ui::note::NoteOptions, }; use nostrdb::{Ndb, Transaction}; -use notedeck::{ImageCache, MuteFun, NoteCache, UnknownIds}; +use notedeck::{ImageCache, MuteFun, NoteCache, RootNoteId, UnknownIds}; use tracing::error; use super::timeline::TimelineTabView; pub struct ThreadView<'a> { - threads: &'a mut NotesHolderStorage<Thread>, + timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, unknown_ids: &'a mut UnknownIds, @@ -26,7 +25,7 @@ pub struct ThreadView<'a> { impl<'a> ThreadView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( - threads: &'a mut NotesHolderStorage<Thread>, + timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, unknown_ids: &'a mut UnknownIds, @@ -37,7 +36,7 @@ impl<'a> ThreadView<'a> { ) -> Self { let id_source = egui::Id::new("threadscroll_threadview"); ThreadView { - threads, + timeline_cache, ndb, note_cache, unknown_ids, @@ -57,14 +56,6 @@ impl<'a> ThreadView<'a> { pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { let txn = Transaction::new(self.ndb).expect("txn"); - let selected_note_key = - if let Ok(key) = self.ndb.get_notekey_by_id(&txn, self.selected_note_id) { - key - } else { - // TODO: render 404 ? - return None; - }; - ui.label( egui::RichText::new("Threads ALPHA! It's not done. Things will be broken.") .color(egui::Color32::RED), @@ -76,38 +67,39 @@ impl<'a> ThreadView<'a> { .auto_shrink([false, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible) .show(ui, |ui| { - let note = if let Ok(note) = self.ndb.get_note_by_key(&txn, selected_note_key) { - note - } else { - return None; - }; - - let root_id = { - let cached_note = self - .note_cache - .cached_note_or_insert(selected_note_key, &note); - - cached_note - .reply - .borrow(note.tags()) - .root() - .map_or_else(|| self.selected_note_id, |nr| nr.id) - }; - - let thread = self - .threads - .notes_holder_mutated(self.ndb, self.note_cache, &txn, root_id) + let root_id = + match RootNoteId::new(self.ndb, self.note_cache, &txn, self.selected_note_id) { + Ok(root_id) => root_id, + + Err(err) => { + ui.label(format!("Error loading thread: {:?}", err)); + return None; + } + }; + + let thread_timeline = self + .timeline_cache + .notes( + self.ndb, + self.note_cache, + &txn, + TimelineCacheKey::Thread(root_id), + ) .get_ptr(); // TODO(jb55): skip poll if ThreadResult is fresh? + let reversed = true; // poll for new notes and insert them into our existing notes - match thread.poll_notes_into_view(&txn, self.ndb) { - Ok(action) => { - action.process_action(&txn, self.ndb, self.unknown_ids, self.note_cache) - } - Err(err) => error!("{err}"), - }; + if let Err(err) = thread_timeline.poll_notes_into_view( + self.ndb, + &txn, + self.unknown_ids, + self.note_cache, + reversed, + ) { + error!("error polling notes into thread timeline: {err}"); + } // This is threadview. We are not the universe view... let is_universe = false; @@ -115,7 +107,7 @@ impl<'a> ThreadView<'a> { note_options.set_textmode(self.textmode); TimelineTabView::new( - thread.view(), + thread_timeline.current_view(), true, note_options, &txn, diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -286,10 +286,14 @@ impl<'a> TimelineTabView<'a> { return 0; }; - let muted = is_muted( - &note, - root_note_id_from_selected_id(self.ndb, self.note_cache, self.txn, note.id()), - ); + // should we mute the thread? we might not have it! + let muted = if let Ok(root_id) = + root_note_id_from_selected_id(self.ndb, self.note_cache, self.txn, note.id()) + { + is_muted(&note, root_id.bytes()) + } else { + false + }; if !muted { ui::padding(8.0, ui, |ui| {