notedeck

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

commit 78504a6673147a472cd83c6dcd8c9555fa114342
parent ae204cbd5ca2e1d65323f0f15aa098235ed06662
Author: kernelkind <kernelkind@gmail.com>
Date:   Mon, 25 Aug 2025 10:09:58 -0400

use `TimelineUnits` instead of `Vec<NoteRef>`

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck_columns/src/timeline/cache.rs | 48++++++++++++++++++++++++++++++++++--------------
Mcrates/notedeck_columns/src/timeline/mod.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mcrates/notedeck_columns/src/ui/search/mod.rs | 9++++++---
Mcrates/notedeck_columns/src/ui/timeline.rs | 51++++++++++++---------------------------------------
4 files changed, 175 insertions(+), 123 deletions(-)

diff --git a/crates/notedeck_columns/src/timeline/cache.rs b/crates/notedeck_columns/src/timeline/cache.rs @@ -1,7 +1,7 @@ use crate::{ actionbar::TimelineOpenResult, error::Error, - timeline::{Timeline, TimelineKind}, + timeline::{Timeline, TimelineKind, UnknownPksOwned}, }; use notedeck::{filter, FilterState, NoteCache, NoteRef}; @@ -90,17 +90,19 @@ impl TimelineCache { ndb: &Ndb, notes: &[NoteRef], note_cache: &mut NoteCache, - ) { + ) -> Option<UnknownPksOwned> { let mut timeline = if let Some(timeline) = id.clone().into_timeline(txn, ndb) { timeline } else { error!("Error creating timeline from {:?}", &id); - return; + return None; }; // insert initial notes into timeline - timeline.insert_new(txn, ndb, note_cache, notes); + let res = timeline.insert_new(txn, ndb, note_cache, notes); self.timelines.insert(id, timeline); + + res } pub fn insert(&mut self, id: TimelineKind, timeline: Timeline) { @@ -119,13 +121,16 @@ impl TimelineCache { note_cache: &mut NoteCache, txn: &Transaction, id: &TimelineKind, - ) -> Vitality<'a, Timeline> { + ) -> GetNotesResponse<'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.timelines.contains_key(id) { - return Vitality::Stale(self.get_expected_mut(id)); + return GetNotesResponse { + vitality: Vitality::Stale(self.get_expected_mut(id)), + unknown_pks: None, + }; } let notes = if let FilterState::Ready(filters) = id.filters(txn, ndb) { @@ -149,9 +154,12 @@ impl TimelineCache { info!("found NotesHolder with {} notes", notes.len()); } - self.insert_new(id.to_owned(), txn, ndb, &notes, note_cache); + let unknown_pks = self.insert_new(id.to_owned(), txn, ndb, &notes, note_cache); - Vitality::Fresh(self.get_expected_mut(id)) + GetNotesResponse { + vitality: Vitality::Fresh(self.get_expected_mut(id)), + unknown_pks, + } } /// Open a timeline, this is another way of saying insert a timeline @@ -166,11 +174,12 @@ impl TimelineCache { pool: &mut RelayPool, id: &TimelineKind, ) -> Option<TimelineOpenResult> { - let (open_result, timeline) = match self.notes(ndb, note_cache, txn, id) { + let notes_resp = self.notes(ndb, note_cache, txn, id); + let (mut open_result, timeline) = match notes_resp.vitality { Vitality::Stale(timeline) => { // The timeline cache is stale, let's update it let notes = find_new_notes( - timeline.all_or_any_notes(), + timeline.all_or_any_entries().latest(), timeline.subscription.get_filter()?.local(), txn, ndb, @@ -207,6 +216,13 @@ impl TimelineCache { timeline.subscription.increment(); + if let Some(unknowns) = notes_resp.unknown_pks { + match &mut open_result { + Some(o) => o.insert_pks(unknowns.pks), + None => open_result = Some(TimelineOpenResult::new_pks(unknowns.pks)), + } + } + open_result } @@ -231,18 +247,22 @@ impl TimelineCache { } } +pub struct GetNotesResponse<'a> { + vitality: Vitality<'a, Timeline>, + unknown_pks: Option<UnknownPksOwned>, +} + /// Look for new thread notes since our last fetch fn find_new_notes( - notes: &[NoteRef], + latest: Option<&NoteRef>, filters: &[Filter], txn: &Transaction, ndb: &Ndb, ) -> Vec<NoteRef> { - if notes.is_empty() { + let Some(last_note) = latest else { return vec![]; - } + }; - let last_note = notes[0]; let filters = filter::make_filters_since(filters, last_note.created_at + 1); if let Ok(results) = ndb.query(txn, &filters, 1000) { diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -2,7 +2,7 @@ use crate::{ error::Error, multi_subscriber::TimelineSub, subscriptions::{self, SubKind, Subscriptions}, - timeline::kind::ListKind, + timeline::{kind::ListKind, note_units::InsertManyResponse, timeline_units::NotePayload}, Result, }; @@ -19,6 +19,7 @@ use enostr::{PoolRelay, Pubkey, RelayPool}; use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction}; use std::{ cell::RefCell, + collections::HashSet, time::{Duration, UNIX_EPOCH}, }; use std::{rc::Rc, time::SystemTime}; @@ -36,6 +37,7 @@ mod unit; pub use cache::TimelineCache; pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind}; pub use note_units::{InsertionResponse, NoteUnits}; +pub use timeline_units::{TimelineUnits, UnknownPks}; pub use unit::{CompositeUnit, NoteUnit, ReactionUnit}; #[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] @@ -82,7 +84,7 @@ impl ViewFilter { /// be captured by a Filter itself. #[derive(Default, Debug)] pub struct TimelineTab { - pub notes: Vec<NoteRef>, + pub units: TimelineUnits, pub selection: i32, pub filter: ViewFilter, pub list: Rc<RefCell<VirtualList>>, @@ -115,10 +117,9 @@ impl TimelineTab { list.hide_on_resize(None); list.over_scan(50.0); let list = Rc::new(RefCell::new(list)); - let notes: Vec<NoteRef> = Vec::with_capacity(cap); TimelineTab { - notes, + units: TimelineUnits::with_capacity(cap), selection, filter, list, @@ -126,45 +127,54 @@ impl TimelineTab { } } - fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) { - if new_refs.is_empty() { - return; + fn insert<'a>( + &mut self, + payloads: Vec<&'a NotePayload>, + ndb: &Ndb, + txn: &Transaction, + reversed: bool, + ) -> Option<UnknownPks<'a>> { + if payloads.is_empty() { + return None; } - 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 => { - debug!( - "spliced when inserting {} new notes, resetting virtual list", - new_refs.len() - ); - list.reset(); - } - 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 { - debug!("inserting {} new notes at start", new_refs.len()); - list.items_inserted_at_start(new_items); - } + + let num_refs = payloads.len(); + + let resp = self.units.merge_new_notes(payloads, ndb, txn); + + let InsertManyResponse::Some { + entries_merged, + merge_kind, + } = resp.insertion_response + else { + return resp.tl_response; + }; + + let mut list = self.list.borrow_mut(); + + match merge_kind { + // TODO: update egui_virtual_list to support spliced inserts + MergeKind::Spliced => { + debug!("spliced when inserting {num_refs} new notes, resetting virtual list",); + list.reset(); + } + 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 { + debug!("inserting {num_refs} new notes at start"); + list.items_inserted_at_start(entries_merged); } } - } + }; + + resp.tl_response } pub fn select_down(&mut self) { debug!("select_down {}", self.selection + 1); - if self.selection + 1 > self.notes.len() as i32 { + if self.selection + 1 > self.units.len() as i32 { return; } @@ -181,6 +191,14 @@ impl TimelineTab { } } +impl<'a> UnknownPks<'a> { + pub fn process(&self, unknown_ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) { + for pk in &self.unknown_pks { + unknown_ids.add_pubkey_if_missing(ndb, txn, pk); + } + } +} + /// A column in a deck. Holds navigation state, loaded notes, column kind, etc. #[derive(Debug)] pub struct Timeline { @@ -272,15 +290,20 @@ impl Timeline { /// Get the note refs for NotesAndReplies. If we only have Notes, then /// just return that instead - pub fn all_or_any_notes(&self) -> &[NoteRef] { - self.notes(ViewFilter::NotesAndReplies).unwrap_or_else(|| { - self.notes(ViewFilter::Notes) - .expect("should have at least notes") - }) + pub fn all_or_any_entries(&self) -> &TimelineUnits { + self.entries(ViewFilter::NotesAndReplies) + .unwrap_or_else(|| { + self.entries(ViewFilter::Notes) + .expect("should have at least notes") + }) } - pub fn notes(&self, view: ViewFilter) -> Option<&[NoteRef]> { - self.view(view).map(|v| &*v.notes) + pub fn entries(&self, view: ViewFilter) -> Option<&TimelineUnits> { + self.view(view).map(|v| &v.units) + } + + pub fn latest_note(&self, view: ViewFilter) -> Option<&NoteRef> { + self.view(view).and_then(|v| v.units.latest()) } pub fn view(&self, view: ViewFilter) -> Option<&TimelineTab> { @@ -299,7 +322,7 @@ impl Timeline { ndb: &Ndb, note_cache: &mut NoteCache, notes: &[NoteRef], - ) { + ) -> Option<UnknownPksOwned> { let filters = { let views = &self.views; let filters: Vec<fn(&CachedNote, &Note) -> bool> = @@ -307,6 +330,7 @@ impl Timeline { filters }; + let mut unknown_pks = HashSet::new(); 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) { @@ -314,11 +338,32 @@ impl Timeline { note_cache.cached_note_or_insert_mut(note_ref.key, &note), &note, ) { - self.views[view].notes.push(*note_ref) + if let Some(resp) = self.views[view] + .units + .merge_new_notes( + vec![&NotePayload { + note, + key: note_ref.key, + }], + ndb, + txn, + ) + .tl_response + { + let pks: HashSet<Pubkey> = resp + .unknown_pks + .into_iter() + .map(|r| Pubkey::new(*r)) + .collect(); + + unknown_pks.extend(pks); + } } } } } + + Some(UnknownPksOwned { pks: unknown_pks }) } /// The main function used for inserting notes into timelines. Handles @@ -333,7 +378,7 @@ impl Timeline { note_cache: &mut NoteCache, reversed: bool, ) -> Result<()> { - let mut new_refs: Vec<(Note, NoteRef)> = Vec::with_capacity(new_note_ids.len()); + let mut payloads: Vec<NotePayload> = 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) { @@ -350,35 +395,32 @@ impl Timeline { // 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: *key, - created_at, - }, - )); + payloads.push(NotePayload { note, key: *key }); } for view in &mut self.views { match view.filter { ViewFilter::NotesAndReplies => { - let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect(); - - view.insert(&refs, reversed); + let res: Vec<&NotePayload<'_>> = payloads.iter().collect(); + if let Some(res) = view.insert(res, ndb, txn, reversed) { + res.process(unknown_ids, ndb, txn); + } } 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); + let mut filtered_payloads = Vec::with_capacity(payloads.len()); + for payload in &payloads { + let cached_note = + note_cache.cached_note_or_insert(payload.key, &payload.note); - if ViewFilter::filter_notes(cached_note, note) { - filtered_refs.push(*nr); + if ViewFilter::filter_notes(cached_note, &payload.note) { + filtered_payloads.push(payload); } } - view.insert(&filtered_refs, reversed); + if let Some(res) = view.insert(filtered_payloads, ndb, txn, reversed) { + res.process(unknown_ids, ndb, txn); + } } } } @@ -415,6 +457,18 @@ impl Timeline { } } +pub struct UnknownPksOwned { + pub pks: HashSet<Pubkey>, +} + +impl UnknownPksOwned { + pub fn process(&self, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds) { + self.pks + .iter() + .for_each(|p| unknown_ids.add_pubkey_if_missing(ndb, txn, p)); + } +} + pub enum MergeKind { FrontInsert, Spliced, @@ -544,7 +598,7 @@ pub fn send_initial_timeline_filter( filter = filter.limit_mut(lim); } - let notes = timeline.all_or_any_notes(); + let entries = timeline.all_or_any_entries(); // Should we since optimize? Not always. For example // if we only have a few notes locally. One way to @@ -552,8 +606,8 @@ pub fn send_initial_timeline_filter( // and seeing what its limit is. If we have less // notes than the limit, we might want to backfill // older notes - if can_since_optimize && filter::should_since_optimize(lim, notes.len()) { - filter = filter::since_optimize_filter(filter, Some(&notes[0])); + if can_since_optimize && filter::should_since_optimize(lim, entries.len()) { + filter = filter::since_optimize_filter(filter, entries.latest()); } else { warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", &timeline.kind); } @@ -635,7 +689,9 @@ fn setup_initial_timeline( .map(NoteRef::from_query_result) .collect(); - timeline.insert_new(txn, ndb, note_cache, &notes); + if let Some(pks) = timeline.insert_new(txn, ndb, note_cache, &notes) { + pks.process(ndb, txn, unknown_ids); + } Ok(()) } diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -2,7 +2,10 @@ use egui::{vec2, Align, Color32, CornerRadius, RichText, Stroke, TextEdit}; use enostr::{NoteId, Pubkey}; use state::TypingType; -use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView}; +use crate::{ + timeline::{TimelineTab, TimelineUnits}, + ui::timeline::TimelineTabView, +}; use egui_winit::clipboard::Clipboard; use nostrdb::{Filter, Ndb, Transaction}; use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef}; @@ -125,7 +128,7 @@ impl<'a, 'd> SearchView<'a, 'd> { "Got {count} result for '{query}'", // one "Got {count} results for '{query}'", // other "Search results count", // comment - self.query.notes.notes.len(), // count + self.query.notes.units.len(), // count query = &self.query.string )); note_action = self.show_search_results(ui); @@ -190,7 +193,7 @@ fn execute_search( return; }; - tab.notes = note_refs; + tab.units = TimelineUnits::from_refs_single(note_refs); tab.list.borrow_mut().reset(); ctx.request_repaint(); } diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -415,57 +415,30 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { let mut action: Option<NoteAction> = None; - let len = self.tab.notes.len(); + let len = self.tab.units.len(); - let is_muted = self.note_context.accounts.mutefun(); + let mute = self.note_context.accounts.mute(); self.tab .list .borrow_mut() - .ui_custom_layout(ui, len, |ui, start_index| { + .ui_custom_layout(ui, len, |ui, index| { + // tracing::info!("rendering index: {index}"); ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().item_spacing.x = 4.0; - let ind = if self.reversed { - len - start_index - 1 - } else { - start_index - }; - - let note_key = self.tab.notes[ind].key; - - let note = - if let Ok(note) = self.note_context.ndb.get_note_by_key(self.txn, note_key) { - note - } else { - warn!("failed to query note {:?}", note_key); - return 0; - }; - - // should we mute the thread? we might not have it! - let muted = if let Ok(root_id) = root_note_id_from_selected_id( - self.note_context.ndb, - self.note_context.note_cache, - self.txn, - note.id(), - ) { - is_muted(&note, root_id.bytes()) - } else { - false + let Some(entry) = self.tab.units.get(index) else { + return 0; }; - if !muted { - notedeck_ui::padding(8.0, ui, |ui| { - let resp = - NoteView::new(self.note_context, &note, self.note_options, self.jobs) - .show(ui); + match self.render_entry(ui, entry, &mute) { + RenderEntryResponse::Unsuccessful => return 0, - if let Some(note_action) = resp.action { - action = Some(note_action) + RenderEntryResponse::Success(note_action) => { + if let Some(cur_action) = note_action { + action = Some(cur_action); } - }); - - notedeck_ui::hline(ui); + } } 1