notedeck

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

commit a8693a2bd3116a776d0ac0e540fcd2d6294f8f38
parent 8663851e7e49223f667b9de6cafc91b3a1c0e2b8
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 17 May 2024 21:32:37 -0500

timeline: refactor tabs into TimelineView

TimelineView is a filtered view of a timeline. We will use this for
future tab rendering. We also introduce a new "selection" concept for
selecting notes on different timeline views. This is in preparation for
vim keybindings.

Diffstat:
Msrc/app.rs | 57++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/notecache.rs | 12+++++++++---
Msrc/timeline.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/ui/note/mod.rs | 4++--
4 files changed, 156 insertions(+), 30 deletions(-)

diff --git a/src/app.rs b/src/app.rs @@ -3,7 +3,7 @@ use crate::app_style::user_requested_visuals_change; use crate::error::Error; use crate::frame_history::FrameHistory; use crate::imgcache::ImageCache; -use crate::notecache::NoteCache; +use crate::notecache::{CachedNote, NoteCache}; use crate::timeline; use crate::timeline::{NoteRef, Timeline}; use crate::ui::is_mobile; @@ -15,7 +15,7 @@ use egui_extras::{Size, StripBuilder}; use enostr::{ClientMessage, Filter, Pubkey, RelayEvent, RelayMessage}; use nostrdb::{BlockType, Config, Mention, Ndb, Note, NoteKey, Transaction}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::hash::Hash; use std::path::Path; use std::time::Duration; @@ -33,11 +33,13 @@ pub enum DamusState { pub struct Damus { state: DamusState, //compose: String, - note_cache: HashMap<NoteKey, NoteCache>, + note_cache: NoteCache, pool: RelayPool, + pub textmode: bool, pub timelines: Vec<Timeline>, + pub selected_timeline: i32, pub img_cache: ImageCache, pub ndb: Ndb, @@ -94,7 +96,7 @@ fn send_initial_filters(damus: &mut Damus, relay_url: &str) { for timeline in &damus.timelines { let mut filter = timeline.filter.clone(); for f in &mut filter { - since_optimize_filter(f, &timeline.notes); + since_optimize_filter(f, timeline.notes()); } relay.subscribe(format!("initial{}", c), filter); c += 1; @@ -298,13 +300,14 @@ fn poll_notes_for_timeline<'a>( .collect(); let timeline = &mut damus.timelines[timeline]; - let prev_items = timeline.notes.len(); - timeline.notes = timeline::merge_sorted_vecs(&timeline.notes, &new_refs); - let new_items = timeline.notes.len() - prev_items; + let prev_items = timeline.notes().len(); + timeline.current_view_mut().notes = timeline::merge_sorted_vecs(&timeline.notes(), &new_refs); + let new_items = timeline.notes().len() - prev_items; // TODO: technically items could have been added inbetween if new_items > 0 { timeline + .current_view() .list .clone() .lock() @@ -339,7 +342,7 @@ fn setup_initial_nostrdb_subs(damus: &mut Damus) -> Result<()> { filters, timeline.filter[0].limit.unwrap_or(200) as i32, )?; - timeline.notes = res + timeline.notes_view_mut().notes = res .iter() .map(|qr| NoteRef { key: qr.note_key, @@ -384,7 +387,7 @@ fn get_unknown_ids<'a>(txn: &'a Transaction, damus: &Damus) -> Result<Vec<Unknow let mut ids: HashSet<UnknownId> = HashSet::new(); for timeline in &damus.timelines { - for noteref in &timeline.notes { + for noteref in timeline.notes() { let note = damus.ndb.get_note_by_key(txn, noteref.key)?; let _ = get_unknown_note_ids(&damus.ndb, txn, &note, note.key().unwrap(), &mut ids); } @@ -506,7 +509,8 @@ impl Damus { state: DamusState::Initializing, pool: RelayPool::new(), img_cache: ImageCache::new(imgcache_dir), - note_cache: HashMap::new(), + note_cache: NoteCache::default(), + selected_timeline: 0, timelines, textmode: false, ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), @@ -515,10 +519,37 @@ impl Damus { } } - pub fn get_note_cache_mut(&mut self, note_key: NoteKey, note: &Note<'_>) -> &mut NoteCache { + pub fn get_note_cache_mut(&mut self, note_key: NoteKey, note: &Note<'_>) -> &mut CachedNote { self.note_cache + .cache .entry(note_key) - .or_insert_with(|| NoteCache::new(note)) + .or_insert_with(|| CachedNote::new(note)) + } + + pub fn selected_timeline(&mut self) -> &mut Timeline { + &mut self.timelines[self.selected_timeline as usize] + } + + pub fn select_down(&mut self) { + self.selected_timeline().current_view_mut().select_down(); + } + + pub fn select_up(&mut self) { + self.selected_timeline().current_view_mut().select_up(); + } + + pub fn select_left(&mut self) { + if self.selected_timeline - 1 < 0 { + return; + } + self.selected_timeline -= 1; + } + + pub fn select_right(&mut self) { + if self.selected_timeline + 1 >= self.timelines.len() as i32 { + return; + } + self.selected_timeline += 1; } } @@ -598,7 +629,7 @@ fn render_panel(ctx: &egui::Context, app: &mut Damus, timeline_ind: usize) { ui.weak(format!( "{} notes", - &app.timelines[timeline_ind].notes.len() + &app.timelines[timeline_ind].notes().len() )); } }); diff --git a/src/notecache.rs b/src/notecache.rs @@ -1,15 +1,21 @@ use crate::time::time_ago_since; use crate::timecache::TimeCached; -use nostrdb::{Note, NoteReply, NoteReplyBuf}; +use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf}; +use std::collections::HashMap; use std::time::Duration; +#[derive(Default)] pub struct NoteCache { + pub cache: HashMap<NoteKey, CachedNote>, +} + +pub struct CachedNote { reltime: TimeCached<String>, pub reply: NoteReplyBuf, pub bar_open: bool, } -impl NoteCache { +impl CachedNote { pub fn new(note: &Note<'_>) -> Self { let created_at = note.created_at(); let reltime = TimeCached::new( @@ -18,7 +24,7 @@ impl NoteCache { ); let reply = NoteReply::new(note.tags()).to_owned(); let bar_open = false; - NoteCache { + CachedNote { reltime, reply, bar_open, diff --git a/src/timeline.rs b/src/timeline.rs @@ -1,10 +1,12 @@ +use crate::notecache::CachedNote; use crate::{ui, Damus}; + use egui::containers::scroll_area::ScrollBarVisibility; use egui::{Direction, Layout}; use egui_tabs::TabColor; use egui_virtual_list::VirtualList; use enostr::Filter; -use nostrdb::{NoteKey, Subscription, Transaction}; +use nostrdb::{Note, NoteKey, Subscription, Transaction}; use std::cmp::Ordering; use std::sync::{Arc, Mutex}; @@ -32,30 +34,116 @@ impl PartialOrd for NoteRef { } } +pub enum ViewFilter { + Notes, + NotesAndReplies, +} + +impl ViewFilter { + pub fn name(&self) -> &'static str { + match self { + ViewFilter::Notes => "Notes", + ViewFilter::NotesAndReplies => "Notes & Replies", + } + } + + fn index(&self) -> usize { + match self { + ViewFilter::Notes => 0, + ViewFilter::NotesAndReplies => 1, + } + } + + fn filter(&self, cache: &CachedNote, note: &Note) -> bool { + match self { + ViewFilter::Notes => !cache.reply.borrow(note.tags()).is_reply(), + ViewFilter::NotesAndReplies => true, + } + } +} + +/// 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 +/// be captured by a Filter itself. +pub struct TimelineView { + pub notes: Vec<NoteRef>, + pub selection: i32, + pub filter: ViewFilter, + pub list: Arc<Mutex<VirtualList>>, +} + +impl TimelineView { + pub fn new(filter: ViewFilter) -> Self { + let selection = 0i32; + let list = Arc::new(Mutex::new(VirtualList::new())); + let notes: Vec<NoteRef> = Vec::with_capacity(1000); + + TimelineView { + notes, + selection, + filter, + list, + } + } + + pub fn select_down(&mut self) { + if self.selection + 1 > self.notes.len() as i32 { + return; + } + + self.selection += 1; + } + + pub fn select_up(&mut self) { + if self.selection - 1 < 0 { + return; + } + + self.selection -= 1; + } +} + pub struct Timeline { pub filter: Vec<Filter>, - pub notes: Vec<NoteRef>, + pub views: Vec<TimelineView>, + pub selected_view: i32, /// Our nostrdb subscription pub subscription: Option<Subscription>, - - /// State for our virtual list egui widget - pub list: Arc<Mutex<VirtualList>>, } impl Timeline { pub fn new(filter: Vec<Filter>) -> Self { - let notes: Vec<NoteRef> = Vec::with_capacity(1000); let subscription: Option<Subscription> = None; - let list = Arc::new(Mutex::new(VirtualList::new())); + let notes = TimelineView::new(ViewFilter::Notes); + let replies = TimelineView::new(ViewFilter::NotesAndReplies); + let views = vec![notes, replies]; + let selected_view = 0; Timeline { filter, - notes, + views, subscription, - list, + selected_view, } } + + pub fn current_view(&self) -> &TimelineView { + &self.views[self.selected_view as usize] + } + + pub fn current_view_mut(&mut self) -> &mut TimelineView { + &mut self.views[self.selected_view as usize] + } + + pub fn notes(&self) -> &[NoteRef] { + &self.views[ViewFilter::NotesAndReplies.index()].notes + } + + pub fn notes_view_mut(&mut self) -> &mut TimelineView { + &mut self.views[ViewFilter::NotesAndReplies.index()] + } } fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 { @@ -156,15 +244,16 @@ pub fn timeline_view(ui: &mut egui::Ui, app: &mut Damus, timeline: usize) { .animated(false) .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible) .show(ui, |ui| { - let len = app.timelines[timeline].notes.len(); - let list = app.timelines[timeline].list.clone(); + let view = app.timelines[timeline].current_view(); + let len = view.notes.len(); + let list = view.list.clone(); list.lock() .unwrap() .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].notes[start_index].key; + let note_key = app.timelines[timeline].current_view().notes[start_index].key; let txn = if let Ok(txn) = Transaction::new(&app.ndb) { txn diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs @@ -4,7 +4,7 @@ pub mod options; pub use contents::NoteContents; pub use options::NoteOptions; -use crate::{colors, ui, ui::is_mobile, Damus}; +use crate::{colors, notecache::CachedNote, ui, ui::is_mobile, Damus}; use egui::{Label, RichText, Sense}; use nostrdb::{NoteKey, Transaction}; use std::hash::{Hash, Hasher}; @@ -308,7 +308,7 @@ fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) { fn render_reltime( ui: &mut egui::Ui, - note_cache: &mut crate::notecache::NoteCache, + note_cache: &mut CachedNote, before: bool, ) -> egui::InnerResponse<()> { #[cfg(feature = "profiling")]