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:
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, ¬es, note_cache);
+ let unknown_pks = self.insert_new(id.to_owned(), txn, ndb, ¬es, 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, ¬e),
¬e,
) {
- 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, ¬e);
- 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(¬es[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, ¬es);
+ if let Some(pks) = timeline.insert_new(txn, ndb, note_cache, ¬es) {
+ 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(¬e, 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, ¬e, 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