notedeck

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

commit b80369344972ebfeae7529e9528230cc8ad3e19a
parent 1a490eb0690f16e2e8d5923ef5504631fea78ba6
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 16 Oct 2024 08:41:46 -0700

Merge Profile View

Noticing a small delay when clicking on macos with large nostrdb, still
need to investigate

kernelkind (14):
      init profile routing
      add profile preview and implement scrolling
      profile unsub
      click on thread pfp
      consolidate timelineTab ui to TimelineTabView
      generify Threads
      profile struct
      integrate profile view caching
      proper timelineTabs
      revert timeline no_scroll stuff
      fix unnecessary copy every frame
      reword comments and logs thread -> NotesHolder
      rename TimelineResponse -> ColumnNoteResponse
      NoteActionResponse for note preview pfp clicking

Diffstat:
Msrc/actionbar.rs | 104++++++++++++++++++++++++++++++-------------------------------------------------
Msrc/app.rs | 58++++++++++++++++++++++++++++++++++++++--------------------
Msrc/column.rs | 2+-
Msrc/lib.rs | 1+
Msrc/nav.rs | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Asrc/notes_holder.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/profile.rs | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/route.rs | 9+++++++--
Msrc/thread.rs | 152++++++++++++-------------------------------------------------------------------
Msrc/timeline/route.rs | 58+++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/ui/note/contents.rs | 12++++++------
Msrc/ui/note/mod.rs | 67++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/ui/profile/mod.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/thread.rs | 83++++++++++++++++++++++++++++---------------------------------------------------
Msrc/ui/timeline.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
15 files changed, 779 insertions(+), 400 deletions(-)

diff --git a/src/actionbar.rs b/src/actionbar.rs @@ -1,11 +1,11 @@ use crate::{ - multi_subscriber::MultiSubscriber, note::NoteRef, notecache::NoteCache, + notes_holder::{NotesHolder, NotesHolderStorage}, route::{Route, Router}, - thread::{Thread, ThreadResult, Threads}, + thread::Thread, }; -use enostr::{NoteId, RelayPool}; +use enostr::{NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; #[derive(Debug, Eq, PartialEq, Copy, Clone)] @@ -15,13 +15,19 @@ pub enum BarAction { OpenThread(NoteId), } -pub struct NewThreadNotes { - pub root_id: NoteId, +#[derive(Default)] +pub struct NoteActionResponse { + pub bar_action: Option<BarAction>, + pub open_profile: Option<Pubkey>, +} + +pub struct NewNotes { + pub id: [u8; 32], pub notes: Vec<NoteRef>, } -pub enum BarResult { - NewThreadNotes(NewThreadNotes), +pub enum NotesHolderResult { + NewNotes(NewNotes), } /// open_thread is called when a note is selected and we need to navigate @@ -35,51 +41,13 @@ fn open_thread( router: &mut Router<Route>, note_cache: &mut NoteCache, pool: &mut RelayPool, - threads: &mut Threads, + threads: &mut NotesHolderStorage<Thread>, selected_note: &[u8; 32], -) -> Option<BarResult> { +) -> Option<NotesHolderResult> { router.route_to(Route::thread(NoteId::new(selected_note.to_owned()))); let root_id = crate::note::root_note_id_from_selected_id(ndb, note_cache, txn, selected_note); - let thread_res = threads.thread_mut(ndb, txn, root_id); - - let (thread, result) = match thread_res { - ThreadResult::Stale(thread) => { - // The thread is stale, let's update it - let notes = Thread::new_notes(&thread.view().notes, root_id, txn, ndb); - let bar_result = if notes.is_empty() { - None - } else { - Some(BarResult::new_thread_notes( - notes, - NoteId::new(root_id.to_owned()), - )) - }; - - // - // we can't insert and update the VirtualList now, because we - // are already borrowing it mutably. Let's pass it as a - // result instead - // - // thread.view.insert(&notes); <-- no - // - (thread, bar_result) - } - - ThreadResult::Fresh(thread) => (thread, None), - }; - - let multi_subscriber = if let Some(multi_subscriber) = &mut thread.multi_subscriber { - multi_subscriber - } else { - let filters = Thread::filters(root_id); - thread.multi_subscriber = Some(MultiSubscriber::new(filters)); - thread.multi_subscriber.as_mut().unwrap() - }; - - multi_subscriber.subscribe(ndb, pool); - - result + Thread::open(ndb, note_cache, txn, pool, threads, root_id) } impl BarAction { @@ -88,11 +56,11 @@ impl BarAction { self, ndb: &Ndb, router: &mut Router<Route>, - threads: &mut Threads, + threads: &mut NotesHolderStorage<Thread>, note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, - ) -> Option<BarResult> { + ) -> Option<NotesHolderResult> { match self { BarAction::Reply(note_id) => { router.route_to(Route::reply(note_id)); @@ -117,45 +85,51 @@ impl BarAction { self, ndb: &Ndb, router: &mut Router<Route>, - threads: &mut Threads, + threads: &mut NotesHolderStorage<Thread>, note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, ) { if let Some(br) = self.execute(ndb, router, threads, note_cache, pool, txn) { - br.process(ndb, txn, threads); + br.process(ndb, note_cache, txn, threads); } } } -impl BarResult { - pub fn new_thread_notes(notes: Vec<NoteRef>, root_id: NoteId) -> Self { - BarResult::NewThreadNotes(NewThreadNotes::new(notes, root_id)) +impl NotesHolderResult { + pub fn new_notes(notes: Vec<NoteRef>, id: [u8; 32]) -> Self { + NotesHolderResult::NewNotes(NewNotes::new(notes, id)) } - pub fn process(&self, ndb: &Ndb, txn: &Transaction, threads: &mut Threads) { + pub fn process<N: NotesHolder>( + &self, + ndb: &Ndb, + note_cache: &mut NoteCache, + txn: &Transaction, + storage: &mut NotesHolderStorage<N>, + ) { match self { // update the thread for next render if we have new notes - BarResult::NewThreadNotes(new_notes) => { - let thread = threads - .thread_mut(ndb, txn, new_notes.root_id.bytes()) + NotesHolderResult::NewNotes(new_notes) => { + let holder = storage + .notes_holder_mutated(ndb, note_cache, txn, &new_notes.id) .get_ptr(); - new_notes.process(thread); + new_notes.process(holder); } } } } -impl NewThreadNotes { - pub fn new(notes: Vec<NoteRef>, root_id: NoteId) -> Self { - NewThreadNotes { notes, root_id } +impl NewNotes { + pub fn new(notes: Vec<NoteRef>, id: [u8; 32]) -> Self { + NewNotes { notes, id } } /// Simple helper for processing a NewThreadNotes result. It simply /// inserts/merges the notes into the thread cache - pub fn process(&self, thread: &mut Thread) { + pub fn process<N: NotesHolder>(&self, thread: &mut N) { // threads are chronological, ie reversed from reverse-chronological, the default. let reversed = true; - thread.view_mut().insert(&self.notes, reversed); + thread.get_view().insert(&self.notes, reversed); } } diff --git a/src/app.rs b/src/app.rs @@ -13,8 +13,10 @@ use crate::{ nav, note::NoteRef, notecache::{CachedNote, NoteCache}, + notes_holder::NotesHolderStorage, + profile::Profile, subscriptions::{SubKind, Subscriptions}, - thread::Threads, + thread::Thread, timeline::{Timeline, TimelineId, TimelineKind, ViewFilter}, ui::{self, DesktopSidePanel}, unknowns::UnknownIds, @@ -53,7 +55,8 @@ pub struct Damus { pub view_state: ViewState, pub unknown_ids: UnknownIds, pub drafts: Drafts, - pub threads: Threads, + pub threads: NotesHolderStorage<Thread>, + pub profiles: NotesHolderStorage<Profile>, pub img_cache: ImageCache, pub accounts: AccountManager, pub subscriptions: Subscriptions, @@ -361,8 +364,24 @@ fn setup_initial_timeline( timeline.subscription, timeline.filter ); let lim = filters[0].limit().unwrap_or(crate::filter::default_limit()) as i32; - let results = ndb.query(&txn, filters, lim)?; + let notes = ndb + .query(&txn, filters, lim)? + .into_iter() + .map(NoteRef::from_query_result) + .collect(); + copy_notes_into_timeline(timeline, &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> = @@ -370,21 +389,18 @@ fn setup_initial_timeline( filters }; - for result in results { + for note_ref in notes { for (view, filter) in filters.iter().enumerate() { - if filter( - note_cache.cached_note_or_insert_mut(result.note_key, &result.note), - &result.note, - ) { - timeline.views[view].notes.push(NoteRef { - key: result.note_key, - created_at: result.note.created_at(), - }) + 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) + } } } } - - Ok(()) } fn setup_initial_nostrdb_subs( @@ -693,7 +709,7 @@ impl Damus { let mut columns: Columns = Columns::new(); for col in parsed_args.columns { if let Some(timeline) = col.into_timeline(&ndb, account) { - columns.add_timeline(timeline); + columns.add_new_timeline_column(timeline); } } @@ -709,7 +725,8 @@ impl Damus { unknown_ids: UnknownIds::default(), subscriptions: Subscriptions::default(), since_optimize: parsed_args.since_optimize, - threads: Threads::default(), + threads: NotesHolderStorage::default(), + profiles: NotesHolderStorage::default(), drafts: Drafts::default(), state: DamusState::Initializing, img_cache: ImageCache::new(imgcache_dir.into()), @@ -777,7 +794,7 @@ impl Damus { let timeline = Timeline::new(TimelineKind::Universe, FilterState::ready(vec![filter])); - columns.add_timeline(timeline); + columns.add_new_timeline_column(timeline); let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir()); let _ = std::fs::create_dir_all(imgcache_dir.clone()); @@ -790,7 +807,8 @@ impl Damus { unknown_ids: UnknownIds::default(), subscriptions: Subscriptions::default(), since_optimize: true, - threads: Threads::default(), + threads: NotesHolderStorage::default(), + profiles: NotesHolderStorage::default(), drafts: Drafts::default(), state: DamusState::Initializing, pool: RelayPool::new(), @@ -817,11 +835,11 @@ impl Damus { &mut self.unknown_ids } - pub fn threads(&self) -> &Threads { + pub fn threads(&self) -> &NotesHolderStorage<Thread> { &self.threads } - pub fn threads_mut(&mut self) -> &mut Threads { + pub fn threads_mut(&mut self) -> &mut NotesHolderStorage<Thread> { &mut self.threads } diff --git a/src/column.rs b/src/column.rs @@ -45,7 +45,7 @@ impl Columns { Columns::default() } - pub fn add_timeline(&mut self, timeline: Timeline) { + pub fn add_new_timeline_column(&mut self, timeline: Timeline) { let id = Self::get_new_id(); let routes = vec![Route::timeline(timeline.id)]; self.timelines.insert(id, timeline); diff --git a/src/lib.rs b/src/lib.rs @@ -40,6 +40,7 @@ pub mod ui; mod unknowns; mod user_account; mod view_state; +mod notes_holder; #[cfg(test)] #[macro_use] diff --git a/src/nav.rs b/src/nav.rs @@ -2,10 +2,15 @@ use crate::{ account_manager::render_accounts_route, app_style::{get_font_size, NotedeckTextStyle}, fonts::NamedFontFamily, + notes_holder::NotesHolder, + profile::Profile, relay_pool_manager::RelayPoolManager, route::Route, - thread::thread_unsubscribe, - timeline::route::{render_timeline_route, AfterRouteExecution, TimelineRoute}, + thread::Thread, + timeline::{ + route::{render_profile_route, render_timeline_route, AfterRouteExecution, TimelineRoute}, + Timeline, + }, ui::{ self, add_column::{AddColumnResponse, AddColumnView}, @@ -18,6 +23,7 @@ use crate::{ use egui::{pos2, Color32, InnerResponse, Stroke}; use egui_nav::{Nav, NavAction, TitleBarResponse}; +use nostrdb::{Ndb, Transaction}; use tracing::{error, info}; pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { @@ -109,6 +115,19 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { } None } + + Route::Profile(pubkey) => render_profile_route( + pubkey, + &app.ndb, + &mut app.columns, + &mut app.profiles, + &mut app.pool, + &mut app.img_cache, + &mut app.note_cache, + &mut app.threads, + col, + ui, + ), } }); @@ -124,18 +143,57 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { } } } + + AfterRouteExecution::OpenProfile(pubkey) => { + app.columns + .column_mut(col) + .router_mut() + .route_to(Route::Profile(pubkey)); + let txn = Transaction::new(&app.ndb).expect("txn"); + if let Some(res) = Profile::open( + &app.ndb, + &mut app.note_cache, + &txn, + &mut app.pool, + &mut app.profiles, + pubkey.bytes(), + ) { + res.process(&app.ndb, &mut app.note_cache, &txn, &mut app.profiles); + } + } } } if let Some(NavAction::Returned) = nav_response.action { let r = app.columns_mut().column_mut(col).router_mut().pop(); + let txn = Transaction::new(&app.ndb).expect("txn"); if let Some(Route::Timeline(TimelineRoute::Thread(id))) = r { - thread_unsubscribe( + let root_id = { + crate::note::root_note_id_from_selected_id( + &app.ndb, + &mut app.note_cache, + &txn, + id.bytes(), + ) + }; + Thread::unsubscribe_locally( + &txn, &app.ndb, + &mut app.note_cache, &mut app.threads, &mut app.pool, + root_id, + ); + } + + if let Some(Route::Profile(pubkey)) = r { + Profile::unsubscribe_locally( + &txn, + &app.ndb, &mut app.note_cache, - id.bytes(), + &mut app.profiles, + &mut app.pool, + pubkey.bytes(), ); } } else if let Some(NavAction::Navigated) = nav_response.action { @@ -152,23 +210,27 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { app.columns_mut().request_deletion_at_index(col); let tl = app.columns().find_timeline_for_column_index(col); if let Some(timeline) = tl { - if let Some(sub_id) = timeline.subscription { - if let Err(e) = app.ndb.unsubscribe(sub_id) { - error!("unsubscribe error: {}", e); - } else { - info!( - "successfully unsubscribed from timeline {} with sub id {}", - timeline.id, - sub_id.id() - ); - } - } + unsubscribe_timeline(app.ndb(), timeline); } } } } } +fn unsubscribe_timeline(ndb: &Ndb, timeline: &Timeline) { + if let Some(sub_id) = timeline.subscription { + if let Err(e) = ndb.unsubscribe(sub_id) { + error!("unsubscribe error: {}", e); + } else { + info!( + "successfully unsubscribed from timeline {} with sub id {}", + timeline.id, + sub_id.id() + ); + } + } +} + fn title_bar( ui: &mut egui::Ui, allocated_response: egui::Response, diff --git a/src/notes_holder.rs b/src/notes_holder.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; + +use enostr::{Filter, RelayPool}; +use nostrdb::{Ndb, Transaction}; +use tracing::{debug, info, warn}; + +use crate::{ + actionbar::NotesHolderResult, multi_subscriber::MultiSubscriber, note::NoteRef, + notecache::NoteCache, 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 = "UnknownIds::update_from_note_refs should be used on this result"] + fn poll_notes_into_view(&mut self, txn: &Transaction, ndb: &Ndb) -> Result<()> { + 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); + } else { + return Err(Error::Generic( + "NotesHolder unexpectedly has no MultiSubscriber".to_owned(), + )); + } + + Ok(()) + } + + /// 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: &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/src/profile.rs b/src/profile.rs @@ -1,4 +1,9 @@ -use nostrdb::ProfileRecord; +use enostr::{Filter, Pubkey}; +use nostrdb::{FilterBuilder, Ndb, ProfileRecord, Transaction}; + +use crate::{ + app::copy_notes_into_timeline, filter::{self, FilterState}, multi_subscriber::MultiSubscriber, note::NoteRef, notecache::NoteCache, notes_holder::NotesHolder, timeline::{PubkeySource, Timeline, TimelineKind} +}; pub enum DisplayName<'a> { One(&'a str), @@ -37,3 +42,82 @@ pub fn get_profile_name<'a>(record: &'a ProfileRecord) -> Option<DisplayName<'a> }), } } + +pub struct Profile { + pub timeline: Timeline, + pub multi_subscriber: 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(TimelineKind::profile(source), FilterState::ready(filters)); + + copy_notes_into_timeline(&mut timeline, txn, ndb, note_cache, notes); + + Profile { + timeline, + multi_subscriber: None, + } + } + + fn filters_raw(pk: &[u8; 32]) -> Vec<FilterBuilder> { + vec![Filter::new() + .authors([pk]) + .kinds([1]) + .limit(filter::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); + } +} diff --git a/src/route.rs b/src/route.rs @@ -1,4 +1,4 @@ -use enostr::NoteId; +use enostr::{NoteId, Pubkey}; use nostrdb::Ndb; use std::fmt::{self}; @@ -6,7 +6,7 @@ use crate::{ account_manager::AccountsRoute, column::Columns, timeline::{TimelineId, TimelineRoute}, - ui::profile::preview::get_note_users_displayname_string, + ui::profile::preview::{get_note_users_displayname_string, get_profile_displayname_string}, }; /// App routing. These describe different places you can go inside Notedeck. @@ -17,6 +17,7 @@ pub enum Route { Relays, ComposeNote, AddColumn, + Profile(Pubkey), } #[derive(Clone)] @@ -96,6 +97,9 @@ impl Route { }, Route::ComposeNote => "Compose Note".to_owned(), Route::AddColumn => "Add Column".to_owned(), + Route::Profile(pubkey) => { + format!("{}'s Profile", get_profile_displayname_string(ndb, pubkey)) + } }; TitledRoute { @@ -203,6 +207,7 @@ impl fmt::Display for Route { Route::ComposeNote => write!(f, "Compose Note"), Route::AddColumn => write!(f, "Add Column"), + Route::Profile(_) => write!(f, "Profile"), } } } diff --git a/src/thread.rs b/src/thread.rs @@ -2,13 +2,10 @@ use crate::{ multi_subscriber::MultiSubscriber, note::NoteRef, notecache::NoteCache, + notes_holder::NotesHolder, timeline::{TimelineTab, ViewFilter}, - Error, Result, }; -use enostr::RelayPool; use nostrdb::{Filter, FilterBuilder, Ndb, Transaction}; -use std::collections::HashMap; -use tracing::{debug, warn}; #[derive(Default)] pub struct Thread { @@ -39,47 +36,6 @@ impl Thread { &mut self.view } - #[must_use = "UnknownIds::update_from_note_refs should be used on this result"] - pub fn poll_notes_into_view(&mut self, txn: &Transaction, ndb: &Ndb) -> Result<()> { - if let Some(multi_subscriber) = &mut self.multi_subscriber { - let reversed = true; - let note_refs: Vec<NoteRef> = multi_subscriber.poll_for_notes(ndb, txn)?; - self.view.insert(&note_refs, reversed); - } else { - return Err(Error::Generic( - "Thread unexpectedly has no MultiSubscriber".to_owned(), - )); - } - - Ok(()) - } - - /// Look for new thread notes since our last fetch - pub fn new_notes( - notes: &[NoteRef], - root_id: &[u8; 32], - txn: &Transaction, - ndb: &Ndb, - ) -> Vec<NoteRef> { - if notes.is_empty() { - return vec![]; - } - - let last_note = notes[0]; - let filters = Thread::filters_since(root_id, last_note.created_at + 1); - - if let Ok(results) = ndb.query(txn, &filters, 1000) { - debug!("got {} results from thread update", results.len()); - results - .into_iter() - .map(NoteRef::from_query_result) - .collect() - } else { - debug!("got no results from thread update",); - vec![] - } - } - fn filters_raw(root: &[u8; 32]) -> Vec<FilterBuilder> { vec![ nostrdb::Filter::new().kinds([1]).event(root), @@ -102,99 +58,35 @@ impl Thread { } } -#[derive(Default)] -pub struct Threads { - /// root id to thread - pub root_id_to_thread: HashMap<[u8; 32], Thread>, -} - -pub enum ThreadResult<'a> { - Fresh(&'a mut Thread), - Stale(&'a mut Thread), -} - -impl<'a> ThreadResult<'a> { - pub fn get_ptr(self) -> &'a mut Thread { - match self { - Self::Fresh(ptr) => ptr, - Self::Stale(ptr) => ptr, - } +impl NotesHolder for Thread { + fn get_multi_subscriber(&mut self) -> Option<&mut MultiSubscriber> { + self.multi_subscriber.as_mut() } - pub fn is_stale(&self) -> bool { - match self { - Self::Fresh(_ptr) => false, - Self::Stale(_ptr) => true, - } + fn filters(for_id: &[u8; 32]) -> Vec<Filter> { + Thread::filters(for_id) } -} -impl Threads { - pub fn thread_expected_mut(&mut self, root_id: &[u8; 32]) -> &mut Thread { - self.root_id_to_thread - .get_mut(root_id) - .expect("thread_expected_mut used but there was no thread") + fn new_notes_holder( + _: &Transaction, + _: &Ndb, + _: &mut NoteCache, + _: &[u8; 32], + _: Vec<Filter>, + notes: Vec<NoteRef>, + ) -> Self { + Thread::new(notes) } - pub fn thread_mut<'a>( - &'a mut self, - ndb: &Ndb, - txn: &Transaction, - root_id: &[u8; 32], - ) -> ThreadResult<'a> { - // we can't use the naive hashmap entry API here because lookups - // require a copy, wait until we have a raw entry api. We could - // also use hashbrown? - - if self.root_id_to_thread.contains_key(root_id) { - return ThreadResult::Stale(self.thread_expected_mut(root_id)); - } - - // we don't have the thread, query for it! - let filters = Thread::filters(root_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 thread lookup for {}", - hex::encode(root_id) - ); - vec![] - }; - - if notes.is_empty() { - warn!("thread query returned 0 notes? ") - } else { - debug!("found thread with {} notes", notes.len()); - } - - self.root_id_to_thread - .insert(root_id.to_owned(), Thread::new(notes)); - ThreadResult::Fresh(self.root_id_to_thread.get_mut(root_id).unwrap()) + fn get_view(&mut self) -> &mut TimelineTab { + &mut self.view } - //fn thread_by_id(&self, ndb: &Ndb, id: &[u8; 32]) -> &mut Thread { - //} -} - -/// Local thread unsubscribe -pub fn thread_unsubscribe( - ndb: &Ndb, - threads: &mut Threads, - pool: &mut RelayPool, - note_cache: &mut NoteCache, - id: &[u8; 32], -) { - let txn = Transaction::new(ndb).expect("txn"); - let root_id = crate::note::root_note_id_from_selected_id(ndb, note_cache, &txn, id); - - let thread = threads.thread_mut(ndb, &txn, root_id).get_ptr(); + fn filters_since(for_id: &[u8; 32], since: u64) -> Vec<Filter> { + Thread::filters_since(for_id, since) + } - if let Some(multi_subscriber) = &mut thread.multi_subscriber { - multi_subscriber.unsubscribe(ndb, pool); + fn set_multi_subscriber(&mut self, subscriber: MultiSubscriber) { + self.multi_subscriber = Some(subscriber); } } diff --git a/src/timeline/route.rs b/src/timeline/route.rs @@ -4,7 +4,9 @@ use crate::{ draft::Drafts, imgcache::ImageCache, notecache::NoteCache, - thread::Threads, + notes_holder::NotesHolderStorage, + profile::Profile, + thread::Thread, timeline::TimelineId, ui::{ self, @@ -12,10 +14,11 @@ use crate::{ post::{PostAction, PostResponse}, QuoteRepostView, }, + profile::ProfileView, }, }; -use enostr::{NoteId, RelayPool}; +use enostr::{NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; #[derive(Debug, Eq, PartialEq, Clone, Copy)] @@ -28,6 +31,7 @@ pub enum TimelineRoute { pub enum AfterRouteExecution { Post(PostResponse), + OpenProfile(Pubkey), } impl AfterRouteExecution { @@ -44,7 +48,7 @@ pub fn render_timeline_route( drafts: &mut Drafts, img_cache: &mut ImageCache, note_cache: &mut NoteCache, - threads: &mut Threads, + threads: &mut NotesHolderStorage<Thread>, accounts: &mut AccountManager, route: TimelineRoute, col: usize, @@ -53,10 +57,10 @@ pub fn render_timeline_route( ) -> Option<AfterRouteExecution> { match route { TimelineRoute::Timeline(timeline_id) => { - if let Some(bar_action) = + let timeline_response = ui::TimelineView::new(timeline_id, columns, ndb, note_cache, img_cache, textmode) - .ui(ui) - { + .ui(ui); + if let Some(bar_action) = timeline_response.bar_action { let txn = Transaction::new(ndb).expect("txn"); let mut cur_column = columns.columns_mut(); let router = cur_column[col].router_mut(); @@ -64,22 +68,26 @@ pub fn render_timeline_route( bar_action.execute_and_process_result(ndb, router, threads, note_cache, pool, &txn); } - None + timeline_response + .open_profile + .map(AfterRouteExecution::OpenProfile) } TimelineRoute::Thread(id) => { - if let Some(bar_action) = + let timeline_response = ui::ThreadView::new(threads, ndb, note_cache, img_cache, id.bytes(), textmode) .id_source(egui::Id::new(("threadscroll", col))) - .ui(ui) - { + .ui(ui); + if let Some(bar_action) = timeline_response.bar_action { let txn = Transaction::new(ndb).expect("txn"); let mut cur_column = columns.columns_mut(); let router = cur_column[col].router_mut(); bar_action.execute_and_process_result(ndb, router, threads, note_cache, pool, &txn); } - None + timeline_response + .open_profile + .map(AfterRouteExecution::OpenProfile) } TimelineRoute::Reply(id) => { @@ -146,3 +154,31 @@ pub fn render_timeline_route( } } } + +#[allow(clippy::too_many_arguments)] +pub fn render_profile_route( + pubkey: &Pubkey, + ndb: &Ndb, + columns: &mut Columns, + profiles: &mut NotesHolderStorage<Profile>, + pool: &mut RelayPool, + img_cache: &mut ImageCache, + note_cache: &mut NoteCache, + threads: &mut NotesHolderStorage<Thread>, + col: usize, + ui: &mut egui::Ui, +) -> Option<AfterRouteExecution> { + let timeline_response = + ProfileView::new(pubkey, col, profiles, ndb, note_cache, img_cache).ui(ui); + if let Some(bar_action) = timeline_response.bar_action { + let txn = nostrdb::Transaction::new(ndb).expect("txn"); + let mut cur_column = columns.columns_mut(); + let router = cur_column[col].router_mut(); + + bar_action.execute_and_process_result(ndb, router, threads, note_cache, pool, &txn); + } + + timeline_response + .open_profile + .map(AfterRouteExecution::OpenProfile) +} diff --git a/src/ui/note/contents.rs b/src/ui/note/contents.rs @@ -1,4 +1,4 @@ -use crate::actionbar::BarAction; +use crate::actionbar::NoteActionResponse; use crate::images::ImageType; use crate::imgcache::ImageCache; use crate::notecache::NoteCache; @@ -17,7 +17,7 @@ pub struct NoteContents<'a> { note: &'a Note<'a>, note_key: NoteKey, options: NoteOptions, - action: Option<BarAction>, + action: NoteActionResponse, } impl<'a> NoteContents<'a> { @@ -38,12 +38,12 @@ impl<'a> NoteContents<'a> { note, note_key, options, - action: None, + action: NoteActionResponse::default(), } } - pub fn action(&self) -> Option<BarAction> { - self.action + pub fn action(&self) -> &NoteActionResponse { + &self.action } } @@ -211,7 +211,7 @@ fn render_note_contents( let note_action = if let Some((id, block_str)) = inline_note { render_note_preview(ui, ndb, note_cache, img_cache, txn, id, block_str).action } else { - None + NoteActionResponse::default() }; if !images.is_empty() && !options.has_textmode() { diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs @@ -13,7 +13,7 @@ pub use quote_repost::QuoteRepostView; pub use reply::PostReplyView; use crate::{ - actionbar::BarAction, + actionbar::{BarAction, NoteActionResponse}, app_style::NotedeckTextStyle, colors, imgcache::ImageCache, @@ -22,7 +22,7 @@ use crate::{ }; use egui::emath::{pos2, Vec2}; use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; -use enostr::NoteId; +use enostr::{NoteId, Pubkey}; use nostrdb::{Ndb, Note, NoteKey, NoteReply, Transaction}; use super::profile::preview::{get_display_name, one_line_display_name_widget}; @@ -37,28 +37,27 @@ pub struct NoteView<'a> { pub struct NoteResponse { pub response: egui::Response, - pub action: Option<BarAction>, pub context_selection: Option<NoteContextSelection>, + pub action: NoteActionResponse, } impl NoteResponse { pub fn new(response: egui::Response) -> Self { Self { response, - action: None, context_selection: None, + action: NoteActionResponse::default(), } } - pub fn with_action(self, action: Option<BarAction>) -> Self { - Self { action, ..self } + pub fn with_action(mut self, action: NoteActionResponse) -> Self { + self.action = action; + self } - pub fn select_option(self, context_selection: Option<NoteContextSelection>) -> Self { - Self { - context_selection, - ..self - } + pub fn select_option(mut self, context_selection: Option<NoteContextSelection>) -> Self { + self.context_selection = context_selection; + self } } @@ -305,7 +304,7 @@ impl<'a> NoteView<'a> { note_key: NoteKey, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, ui: &mut egui::Ui, - ) { + ) -> egui::Response { if !self.options().has_wide() { ui.spacing_mut().item_spacing.x = 16.0; } else { @@ -314,6 +313,7 @@ impl<'a> NoteView<'a> { let pfp_size = self.options().pfp_size(); + let sense = Sense::click(); match profile .as_ref() .ok() @@ -326,7 +326,7 @@ impl<'a> NoteView<'a> { let profile_key = profile.as_ref().unwrap().record().note_key(); let note_key = note_key.as_u64(); - let (rect, size, _resp) = ui::anim::hover_expand( + let (rect, size, resp) = ui::anim::hover_expand( ui, egui::Id::new((profile_key, note_key)), pfp_size, @@ -342,13 +342,14 @@ impl<'a> NoteView<'a> { self.img_cache, )); }); + resp } - None => { - ui.add( + None => ui + .add( ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()) .size(pfp_size), - ); - } + ) + .interact(sense), } } @@ -430,8 +431,11 @@ impl<'a> NoteView<'a> { puffin::profile_function!(); let note_key = self.note.key().expect("todo: support non-db notes"); let txn = self.note.txn().expect("todo: support non-db notes"); - let mut note_action: Option<BarAction> = None; + + let mut open_profile: Option<Pubkey> = None; + let mut bar_action: Option<BarAction> = None; let mut selected_option: Option<NoteContextSelection> = None; + let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); let maybe_hitbox = maybe_note_hitbox(ui, note_key); let container_right = { @@ -444,7 +448,9 @@ impl<'a> NoteView<'a> { // wide design let response = if self.options().has_wide() { ui.horizontal(|ui| { - self.pfp(note_key, &profile, ui); + if self.pfp(note_key, &profile, ui).clicked() { + open_profile = Some(Pubkey::new(*self.note.pubkey())); + }; let size = ui.available_size(); ui.vertical(|ui| { @@ -487,18 +493,21 @@ impl<'a> NoteView<'a> { self.options(), ); let resp = ui.add(&mut contents); - note_action = note_action.or(contents.action()); + bar_action = bar_action.or(contents.action().bar_action); + open_profile = open_profile.or(contents.action().open_profile); if self.options().has_actionbar() { let ab = render_note_actionbar(ui, self.note.id(), note_key); - note_action = note_action.or(ab.inner); + bar_action = bar_action.or(ab.inner); } resp } else { // main design ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { - self.pfp(note_key, &profile, ui); + if self.pfp(note_key, &profile, ui).clicked() { + open_profile = Some(Pubkey::new(*self.note.pubkey())); + }; ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { selected_option = NoteView::note_header( @@ -534,28 +543,32 @@ impl<'a> NoteView<'a> { self.options(), ); ui.add(&mut contents); - note_action = note_action.or(contents.action()); + bar_action = bar_action.or(contents.action().bar_action); + open_profile = open_profile.or(contents.action().open_profile); if self.options().has_actionbar() { let ab = render_note_actionbar(ui, self.note.id(), note_key); - note_action = note_action.or(ab.inner); + bar_action = bar_action.or(ab.inner); } }); }) .response }; - note_action = check_note_hitbox( + bar_action = check_note_hitbox( ui, self.note.id(), note_key, &response, maybe_hitbox, - note_action, + bar_action, ); NoteResponse::new(response) - .with_action(note_action) + .with_action(NoteActionResponse { + bar_action, + open_profile, + }) .select_option(selected_option) } } diff --git a/src/ui/profile/mod.rs b/src/ui/profile/mod.rs @@ -1,5 +1,75 @@ pub mod picture; pub mod preview; +use egui::{ScrollArea, Widget}; +use enostr::Pubkey; +use nostrdb::{Ndb, Transaction}; pub use picture::ProfilePic; pub use preview::ProfilePreview; + +use crate::{ + actionbar::NoteActionResponse, imgcache::ImageCache, notecache::NoteCache, + notes_holder::NotesHolderStorage, profile::Profile, +}; + +use super::timeline::{tabs_ui, TimelineTabView}; + +pub struct ProfileView<'a> { + pubkey: &'a Pubkey, + col_id: usize, + profiles: &'a mut NotesHolderStorage<Profile>, + ndb: &'a Ndb, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, +} + +impl<'a> ProfileView<'a> { + pub fn new( + pubkey: &'a Pubkey, + col_id: usize, + profiles: &'a mut NotesHolderStorage<Profile>, + ndb: &'a Ndb, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + ) -> Self { + ProfileView { + pubkey, + col_id, + profiles, + ndb, + note_cache, + img_cache, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> NoteActionResponse { + let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey)); + + ScrollArea::vertical() + .id_source(scroll_id) + .show(ui, |ui| { + let txn = Transaction::new(self.ndb).expect("txn"); + if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, self.pubkey.bytes()) { + ProfilePreview::new(&profile, self.img_cache).ui(ui); + } + let profile = self + .profiles + .notes_holder_mutated(self.ndb, self.note_cache, &txn, self.pubkey.bytes()) + .get_ptr(); + + profile.timeline.selected_view = tabs_ui(ui); + + TimelineTabView::new( + profile.timeline.current_view(), + false, + false, + &txn, + self.ndb, + self.note_cache, + self.img_cache, + ) + .show(ui) + }) + .inner + } +} diff --git a/src/ui/thread.rs b/src/ui/thread.rs @@ -1,11 +1,17 @@ use crate::{ - actionbar::BarAction, imgcache::ImageCache, notecache::NoteCache, thread::Threads, ui, + actionbar::NoteActionResponse, + imgcache::ImageCache, + notecache::NoteCache, + notes_holder::{NotesHolder, NotesHolderStorage}, + thread::Thread, }; use nostrdb::{Ndb, NoteKey, Transaction}; -use tracing::{error, warn}; +use tracing::error; + +use super::timeline::TimelineTabView; pub struct ThreadView<'a> { - threads: &'a mut Threads, + threads: &'a mut NotesHolderStorage<Thread>, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, @@ -17,7 +23,7 @@ pub struct ThreadView<'a> { impl<'a> ThreadView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( - threads: &'a mut Threads, + threads: &'a mut NotesHolderStorage<Thread>, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, @@ -41,9 +47,8 @@ impl<'a> ThreadView<'a> { self } - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<BarAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> NoteActionResponse { let txn = Transaction::new(self.ndb).expect("txn"); - let mut action: Option<BarAction> = None; let selected_note_key = if let Ok(key) = self .ndb @@ -53,7 +58,7 @@ impl<'a> ThreadView<'a> { key } else { // TODO: render 404 ? - return None; + return NoteActionResponse::default(); }; ui.label( @@ -70,7 +75,7 @@ impl<'a> ThreadView<'a> { let note = if let Ok(note) = self.ndb.get_note_by_key(&txn, selected_note_key) { note } else { - return; + return NoteActionResponse::default(); }; let root_id = { @@ -85,7 +90,10 @@ impl<'a> ThreadView<'a> { .map_or_else(|| self.selected_note_id, |nr| nr.id) }; - let thread = self.threads.thread_mut(self.ndb, &txn, root_id).get_ptr(); + let thread = self + .threads + .notes_holder_mutated(self.ndb, self.note_cache, &txn, root_id) + .get_ptr(); // TODO(jb55): skip poll if ThreadResult is fresh? @@ -94,50 +102,17 @@ impl<'a> ThreadView<'a> { error!("Thread::poll_notes_into_view: {e}"); } - let len = thread.view().notes.len(); - - thread.view().list.clone().borrow_mut().ui_custom_layout( - ui, - len, - |ui, start_index| { - ui.spacing_mut().item_spacing.y = 0.0; - ui.spacing_mut().item_spacing.x = 4.0; - - let ind = len - 1 - start_index; - - let note_key = thread.view().notes[ind].key; - - let note = if let Ok(note) = self.ndb.get_note_by_key(&txn, note_key) { - note - } else { - warn!("failed to query note {:?}", note_key); - return 0; - }; - - ui::padding(8.0, ui, |ui| { - let note_response = - ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, &note) - .note_previews(!self.textmode) - .textmode(self.textmode) - .options_button(!self.textmode) - .show(ui); - if let Some(bar_action) = note_response.action { - action = Some(bar_action); - } - - if let Some(selection) = note_response.context_selection { - selection.process(ui, &note); - } - }); - - ui::hline(ui); - //ui.add(egui::Separator::default().spacing(0.0)); - - 1 - }, - ); - }); - - action + TimelineTabView::new( + thread.view(), + true, + self.textmode, + &txn, + self.ndb, + self.note_cache, + self.img_cache, + ) + .show(ui) + }) + .inner } } diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs @@ -1,12 +1,13 @@ +use crate::actionbar::{BarAction, NoteActionResponse}; +use crate::timeline::TimelineTab; use crate::{ - actionbar::BarAction, column::Columns, imgcache::ImageCache, notecache::NoteCache, - timeline::TimelineId, ui, + column::Columns, imgcache::ImageCache, notecache::NoteCache, timeline::TimelineId, ui, }; use egui::containers::scroll_area::ScrollBarVisibility; use egui::{Direction, Layout}; use egui_tabs::TabColor; use nostrdb::{Ndb, Transaction}; -use tracing::{debug, error, warn}; +use tracing::{error, warn}; pub struct TimelineView<'a> { timeline_id: TimelineId, @@ -39,7 +40,7 @@ impl<'a> TimelineView<'a> { } } - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<BarAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> NoteActionResponse { timeline_ui( ui, self.ndb, @@ -68,7 +69,7 @@ fn timeline_ui( img_cache: &mut ImageCache, reversed: bool, textmode: bool, -) -> Option<BarAction> { +) -> NoteActionResponse { //padding(4.0, ui, |ui| ui.heading("Notifications")); /* let font_id = egui::TextStyle::Body.resolve(ui.style()); @@ -83,7 +84,7 @@ fn timeline_ui( error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... - return None; + return NoteActionResponse::default(); }; timeline.selected_view = tabs_ui(ui); @@ -94,7 +95,6 @@ fn timeline_ui( egui::Id::new(("tlscroll", timeline.view_id())) }; - let mut bar_action: Option<BarAction> = None; egui::ScrollArea::vertical() .id_source(scroll_id) .animated(false) @@ -107,71 +107,25 @@ fn timeline_ui( error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... - return 0; + return NoteActionResponse::default(); }; - let view = timeline.current_view(); - let len = view.notes.len(); - let txn = if let Ok(txn) = Transaction::new(ndb) { - txn - } else { - warn!("failed to create transaction"); - return 0; - }; - - view.list - .clone() - .borrow_mut() - .ui_custom_layout(ui, len, |ui, start_index| { - ui.spacing_mut().item_spacing.y = 0.0; - ui.spacing_mut().item_spacing.x = 4.0; - - let ind = if reversed { - len - start_index - 1 - } else { - start_index - }; - - let note_key = timeline.current_view().notes[ind].key; - - let note = if let Ok(note) = ndb.get_note_by_key(&txn, note_key) { - note - } else { - warn!("failed to query note {:?}", note_key); - return 0; - }; - - ui::padding(8.0, ui, |ui| { - let resp = ui::NoteView::new(ndb, note_cache, img_cache, &note) - .note_previews(!textmode) - .selectable_text(false) - .options_button(true) - .show(ui); - - if let Some(ba) = resp.action { - bar_action = Some(ba); - } else if resp.response.clicked() { - debug!("clicked note"); - } - - if let Some(context) = resp.context_selection { - context.process(ui, &note); - } - }); - - ui::hline(ui); - //ui.add(egui::Separator::default().spacing(0.0)); - - 1 - }); - - 1 - }); - - bar_action + let txn = Transaction::new(ndb).expect("failed to create txn"); + TimelineTabView::new( + timeline.current_view(), + reversed, + textmode, + &txn, + ndb, + note_cache, + img_cache, + ) + .show(ui) + }) + .inner } -fn tabs_ui(ui: &mut egui::Ui) -> i32 { +pub fn tabs_ui(ui: &mut egui::Ui) -> i32 { ui.spacing_mut().item_spacing.y = 0.0; let tab_res = egui_tabs::Tabs::new(2) @@ -254,3 +208,90 @@ fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { egui::Rangef::new(min, max) } + +pub struct TimelineTabView<'a> { + tab: &'a TimelineTab, + reversed: bool, + textmode: bool, + txn: &'a Transaction, + ndb: &'a Ndb, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, +} + +impl<'a> TimelineTabView<'a> { + pub fn new( + tab: &'a TimelineTab, + reversed: bool, + textmode: bool, + txn: &'a Transaction, + ndb: &'a Ndb, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + ) -> Self { + Self { + tab, + reversed, + txn, + textmode, + ndb, + note_cache, + img_cache, + } + } + + pub fn show(&mut self, ui: &mut egui::Ui) -> NoteActionResponse { + let mut open_profile = None; + let mut bar_action: Option<BarAction> = None; + let len = self.tab.notes.len(); + + self.tab + .list + .clone() + .borrow_mut() + .ui_custom_layout(ui, len, |ui, start_index| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().item_spacing.x = 4.0; + + let ind = if self.reversed { + len - start_index - 1 + } else { + start_index + }; + + let note_key = self.tab.notes[ind].key; + + let note = if let Ok(note) = self.ndb.get_note_by_key(self.txn, note_key) { + note + } else { + warn!("failed to query note {:?}", note_key); + return 0; + }; + + ui::padding(8.0, ui, |ui| { + let resp = ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, &note) + .note_previews(!self.textmode) + .selectable_text(false) + .options_button(true) + .show(ui); + + bar_action = bar_action.or(resp.action.bar_action); + open_profile = open_profile.or(resp.action.open_profile); + + if let Some(context) = resp.context_selection { + context.process(ui, &note); + } + }); + + ui::hline(ui); + //ui.add(egui::Separator::default().spacing(0.0)); + + 1 + }); + + NoteActionResponse { + open_profile, + bar_action, + } + } +}