notedeck

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

commit 00091c508845a3e5a1beec322e9e4de9ac932ad8
parent 4379466d1db434387be5569f61e375059d634df7
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 10 Sep 2024 15:27:54 -0700

Switch to Columns

Also refactor damus app usage to only pass in things that we need in views.

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

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Menostr/src/keypair.rs | 35+++++++++++++++++++++++++++++------
Menostr/src/lib.rs | 2+-
Msrc/account_manager.rs | 8+++++++-
Msrc/actionbar.rs | 56++++++++++++++++++++++++++++++++------------------------
Msrc/app.rs | 467+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/args.rs | 38++++++++++++++++++++------------------
Msrc/column.rs | 274++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/draft.rs | 22++++++++++++++++------
Msrc/note.rs | 14+++++++-------
Msrc/post.rs | 7++++++-
Msrc/subscriptions.rs | 6+++---
Msrc/test_data.rs | 4+++-
Dsrc/timeline.rs | 379-------------------------------------------------------------------------------
Asrc/timeline/kind.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/timeline/mod.rs | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/account_management.rs | 8++++----
Msrc/ui/account_switcher.rs | 10++++------
Msrc/ui/mention.rs | 34+++++++++++++++++++++++++---------
Msrc/ui/mod.rs | 4+++-
Msrc/ui/note/contents.rs | 46++++++++++++++++++++++++++++++----------------
Msrc/ui/note/mod.rs | 131++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/ui/note/options.rs | 7+++++++
Msrc/ui/note/post.rs | 93+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/ui/note/reply.rs | 93++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/ui/profile/profile_preview_controller.rs | 16++++++++--------
Msrc/ui/thread.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/ui/timeline.rs | 186++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/unknowns.rs | 69+++++++++++++++++++++++++++++++++++++++------------------------------
30 files changed, 1595 insertions(+), 1098 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1116,7 +1116,7 @@ dependencies = [ [[package]] name = "egui_nav" version = "0.1.0" -source = "git+https://github.com/damus-io/egui-nav?branch=egui-0.28#a8dd95d2ae2a9a5c5251d47407320ad0eb074953" +source = "git+https://github.com/damus-io/egui-nav?rev=b19742503329a13df660ac8c5a3ada4a25b7cc53#b19742503329a13df660ac8c5a3ada4a25b7cc53" dependencies = [ "egui", "egui_extras", diff --git a/Cargo.toml b/Cargo.toml @@ -18,7 +18,7 @@ eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras", features = ["all_loaders"] } ehttp = "0.2.0" egui_tabs = { git = "https://github.com/damus-io/egui-tabs", branch = "egui-0.28" } -egui_nav = { git = "https://github.com/damus-io/egui-nav", branch = "egui-0.28" } +egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "b19742503329a13df660ac8c5a3ada4a25b7cc53" } egui_virtual_list = { git = "https://github.com/jb55/hello_egui", branch = "egui-0.28", package = "egui_virtual_list" } reqwest = { version = "0.12.4", default-features = false, features = [ "rustls-tls-native-roots" ] } image = { version = "0.25", features = ["jpeg", "png", "webp"] } diff --git a/enostr/src/keypair.rs b/enostr/src/keypair.rs @@ -32,11 +32,11 @@ impl Keypair { } } - pub fn to_full(self) -> Option<FullKeypair> { - if let Some(secret_key) = self.secret_key { - Some(FullKeypair { - pubkey: self.pubkey, - secret_key, + pub fn to_full<'a>(&'a self) -> Option<FilledKeypair<'a>> { + if let Some(secret_key) = &self.secret_key { + Some(FilledKeypair { + pubkey: &self.pubkey, + secret_key: secret_key, }) } else { None @@ -44,17 +44,40 @@ impl Keypair { } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, Clone)] pub struct FullKeypair { pub pubkey: Pubkey, pub secret_key: SecretKey, } +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub struct FilledKeypair<'a> { + pub pubkey: &'a Pubkey, + pub secret_key: &'a SecretKey, +} + +impl<'a> FilledKeypair<'a> { + pub fn new(pubkey: &'a Pubkey, secret_key: &'a SecretKey) -> Self { + FilledKeypair { pubkey, secret_key } + } + + pub fn to_full(&self) -> FullKeypair { + FullKeypair { + pubkey: self.pubkey.to_owned(), + secret_key: self.secret_key.to_owned(), + } + } +} + impl FullKeypair { pub fn new(pubkey: Pubkey, secret_key: SecretKey) -> Self { FullKeypair { pubkey, secret_key } } + pub fn to_filled<'a>(&'a self) -> FilledKeypair<'a> { + FilledKeypair::new(&self.pubkey, &self.secret_key) + } + pub fn generate() -> Self { let mut rng = nostr::secp256k1::rand::rngs::OsRng; let (secret_key, _) = &nostr::SECP256K1.generate_keypair(&mut rng); diff --git a/enostr/src/lib.rs b/enostr/src/lib.rs @@ -11,7 +11,7 @@ pub use client::ClientMessage; pub use error::Error; pub use ewebsock; pub use filter::Filter; -pub use keypair::{FullKeypair, Keypair, SerializableKeypair}; +pub use keypair::{FilledKeypair, FullKeypair, Keypair, SerializableKeypair}; pub use nostr::SecretKey; pub use note::{Note, NoteId}; pub use profile::Profile; diff --git a/src/account_manager.rs b/src/account_manager.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; -use enostr::Keypair; +use enostr::{FilledKeypair, Keypair}; use crate::key_storage::{KeyStorage, KeyStorageResponse, KeyStorageType}; pub use crate::user_account::UserAccount; @@ -88,6 +88,12 @@ impl AccountManager { self.currently_selected_account } + pub fn selected_or_first_nsec(&self) -> Option<FilledKeypair<'_>> { + self.get_selected_account() + .and_then(|kp| kp.to_full()) + .or_else(|| self.accounts.iter().find_map(|a| a.to_full())) + } + pub fn get_selected_account(&self) -> Option<&UserAccount> { if let Some(account_index) = self.currently_selected_account { if let Some(account) = self.get_account(account_index) { diff --git a/src/actionbar.rs b/src/actionbar.rs @@ -1,11 +1,12 @@ use crate::{ + column::Column, note::NoteRef, + notecache::NoteCache, route::Route, - thread::{Thread, ThreadResult}, - Damus, + thread::{Thread, ThreadResult, Threads}, }; -use enostr::NoteId; -use nostrdb::Transaction; +use enostr::{NoteId, RelayPool}; +use nostrdb::{Ndb, Transaction}; use tracing::{error, info}; use uuid::Uuid; @@ -30,26 +31,28 @@ pub enum BarResult { /// the thread view. We don't have a concept of model/view/controller etc /// in egui, but this is the closest thing to that. fn open_thread( - app: &mut Damus, + ndb: &Ndb, txn: &Transaction, - timeline: usize, + column: &mut Column, + note_cache: &mut NoteCache, + pool: &mut RelayPool, + threads: &mut Threads, selected_note: &[u8; 32], ) -> Option<BarResult> { { - let timeline = &mut app.timelines[timeline]; - timeline - .routes + column + .routes_mut() .push(Route::Thread(NoteId::new(selected_note.to_owned()))); - timeline.navigating = true; + column.navigating = true; } - let root_id = crate::note::root_note_id_from_selected_id(app, txn, selected_note); - let thread_res = app.threads.thread_mut(&app.ndb, txn, root_id); + 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, &app.ndb); + let notes = Thread::new_notes(&thread.view.notes, root_id, txn, ndb); let bar_result = if notes.is_empty() { None } else { @@ -76,14 +79,14 @@ fn open_thread( // an active subscription for this thread. if thread.subscription().is_none() { let filters = Thread::filters(root_id); - *thread.subscription_mut() = app.ndb.subscribe(&filters).ok(); + *thread.subscription_mut() = ndb.subscribe(&filters).ok(); if thread.remote_subscription().is_some() { error!("Found active remote subscription when it was not expected"); } else { let subid = Uuid::new_v4().to_string(); *thread.remote_subscription_mut() = Some(subid.clone()); - app.pool.subscribe(subid, filters); + pool.subscribe(subid, filters); } match thread.subscription() { @@ -91,7 +94,7 @@ fn open_thread( thread.subscribers += 1; info!( "Locally/remotely subscribing to thread. {} total active subscriptions, {} on this thread", - app.ndb.subscription_count(), + ndb.subscription_count(), thread.subscribers, ); } @@ -104,7 +107,7 @@ fn open_thread( thread.subscribers += 1; info!( "Re-using existing thread subscription. {} total active subscriptions, {} on this thread", - app.ndb.subscription_count(), + ndb.subscription_count(), thread.subscribers, ) } @@ -113,24 +116,29 @@ fn open_thread( } impl BarAction { + #[allow(clippy::too_many_arguments)] pub fn execute( self, - app: &mut Damus, - timeline: usize, + ndb: &Ndb, + column: &mut Column, + threads: &mut Threads, + note_cache: &mut NoteCache, + pool: &mut RelayPool, replying_to: &[u8; 32], txn: &Transaction, ) -> Option<BarResult> { match self { BarAction::Reply => { - let timeline = &mut app.timelines[timeline]; - timeline - .routes + column + .routes_mut() .push(Route::Reply(NoteId::new(replying_to.to_owned()))); - timeline.navigating = true; + column.navigating = true; None } - BarAction::OpenThread => open_thread(app, txn, timeline, replying_to), + BarAction::OpenThread => { + open_thread(ndb, txn, column, note_cache, pool, threads, replying_to) + } } } } diff --git a/src/app.rs b/src/app.rs @@ -3,7 +3,7 @@ use crate::actionbar::BarResult; use crate::app_creation::setup_cc; use crate::app_style::user_requested_visuals_change; use crate::args::Args; -use crate::column::ColumnKind; +use crate::column::{Column, ColumnKind, Columns}; use crate::draft::Drafts; use crate::error::{Error, FilterError}; use crate::filter::FilterState; @@ -16,7 +16,7 @@ use crate::relay_pool_manager::RelayPoolManager; use crate::route::Route; use crate::subscriptions::{SubKind, Subscriptions}; use crate::thread::{DecrementResult, Threads}; -use crate::timeline::{Timeline, TimelineSource, ViewFilter}; +use crate::timeline::{Timeline, TimelineKind, TimelineSource, ViewFilter}; use crate::ui::note::PostAction; use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup}; use crate::ui::{DesktopSidePanel, RelayView, View}; @@ -24,8 +24,6 @@ use crate::unknowns::UnknownIds; use crate::{filter, Result}; use egui_nav::{Nav, NavAction}; use enostr::{ClientMessage, RelayEvent, RelayMessage, RelayPool}; -use std::cell::RefCell; -use std::rc::Rc; use uuid::Uuid; use egui::{Context, Frame, Style}; @@ -53,15 +51,13 @@ pub struct Damus { /// global navigation for account management popups, etc. pub global_nav: Vec<Route>, - pub timelines: Vec<Timeline>, - pub selected_timeline: i32, - + pub columns: Columns, pub ndb: Ndb, pub unknown_ids: UnknownIds, pub drafts: Drafts, pub threads: Threads, pub img_cache: ImageCache, - pub account_manager: AccountManager, + pub accounts: AccountManager, pub subscriptions: Subscriptions, frame_history: crate::frame_history::FrameHistory, @@ -99,10 +95,15 @@ fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) { } } -fn send_initial_timeline_filter(damus: &mut Damus, timeline: usize, to: &str) { - let can_since_optimize = damus.since_optimize; - - let filter_state = damus.timelines[timeline].filter.clone(); +fn send_initial_timeline_filter( + ndb: &Ndb, + can_since_optimize: bool, + subs: &mut Subscriptions, + pool: &mut RelayPool, + timeline: &mut Timeline, + to: &str, +) { + let filter_state = timeline.filter.clone(); match filter_state { FilterState::Broken(err) => { @@ -131,7 +132,7 @@ fn send_initial_timeline_filter(damus: &mut Damus, timeline: usize, to: &str) { filter = filter.limit_mut(lim); } - let notes = damus.timelines[timeline].notes(ViewFilter::NotesAndReplies); + let notes = timeline.notes(ViewFilter::NotesAndReplies); // Should we since optimize? Not always. For example // if we only have a few notes locally. One way to @@ -148,38 +149,41 @@ fn send_initial_timeline_filter(damus: &mut Damus, timeline: usize, to: &str) { filter }).collect(); - let sub_id = damus.gen_subid(&SubKind::Initial); - damus - .subscriptions() - .insert(sub_id.clone(), SubKind::Initial); + //let sub_id = damus.gen_subid(&SubKind::Initial); + let sub_id = Uuid::new_v4().to_string(); + subs.subs.insert(sub_id.clone(), SubKind::Initial); let cmd = ClientMessage::req(sub_id, new_filters); - damus.pool.send_to(&cmd, to); + pool.send_to(&cmd, to); } // we need some data first FilterState::NeedsRemote(filter) => { - let uid = damus.timelines[timeline].uid; - let sub_kind = SubKind::FetchingContactList(uid); - let sub_id = damus.gen_subid(&sub_kind); - let local_sub = damus.ndb.subscribe(&filter).expect("sub"); + let sub_kind = SubKind::FetchingContactList(timeline.id); + //let sub_id = damus.gen_subid(&sub_kind); + let sub_id = Uuid::new_v4().to_string(); + let local_sub = ndb.subscribe(&filter).expect("sub"); - damus.timelines[timeline].filter = - FilterState::fetching_remote(sub_id.clone(), local_sub); + timeline.filter = FilterState::fetching_remote(sub_id.clone(), local_sub); - damus.subscriptions().insert(sub_id.clone(), sub_kind); + subs.subs.insert(sub_id.clone(), sub_kind); - damus.pool.subscribe(sub_id, filter.to_owned()); + pool.subscribe(sub_id, filter.to_owned()); } } } fn send_initial_filters(damus: &mut Damus, relay_url: &str) { info!("Sending initial filters to {}", relay_url); - let timelines = damus.timelines.len(); - - for i in 0..timelines { - send_initial_timeline_filter(damus, i, relay_url); + for timeline in damus.columns.timelines_mut() { + send_initial_timeline_filter( + &damus.ndb, + damus.since_optimize, + &mut damus.subscriptions, + &mut damus.pool, + timeline, + relay_url, + ); } } @@ -190,7 +194,7 @@ enum ContextAction { fn handle_key_events( input: &egui::InputState, pixels_per_point: f32, - damus: &mut Damus, + columns: &mut Columns, ) -> Option<ContextAction> { let amount = 0.2; @@ -213,16 +217,16 @@ fn handle_key_events( Some(ContextAction::SetPixelsPerPoint(pixels_per_point - amount)); } egui::Key::J => { - damus.select_down(); + columns.select_down(); } egui::Key::K => { - damus.select_up(); + columns.select_up(); } egui::Key::H => { - damus.select_left(); + columns.select_left(); } egui::Key::L => { - damus.select_left(); + columns.select_left(); } _ => {} } @@ -234,7 +238,7 @@ fn handle_key_events( fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { let ppp = ctx.pixels_per_point(); - let res = ctx.input(|i| handle_key_events(i, ppp, damus)); + let res = ctx.input(|i| handle_key_events(i, ppp, &mut damus.columns)); if let Some(action) = res { match action { ContextAction::SetPixelsPerPoint(amt) => { @@ -263,12 +267,27 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { } } - for timeline in 0..damus.timelines.len() { - let src = TimelineSource::column(timeline); + let n_cols = damus.columns.columns().len(); + for col_ind in 0..n_cols { + let timeline = + if let ColumnKind::Timeline(timeline) = damus.columns.column_mut(col_ind).kind_mut() { + timeline + } else { + continue; + }; - if let Ok(true) = is_timeline_ready(damus, timeline) { + if let Ok(true) = + is_timeline_ready(&damus.ndb, &mut damus.pool, &mut damus.note_cache, timeline) + { let txn = Transaction::new(&damus.ndb).expect("txn"); - if let Err(err) = src.poll_notes_into_view(&txn, damus) { + if let Err(err) = TimelineSource::column(timeline.id).poll_notes_into_view( + &txn, + &damus.ndb, + &mut damus.columns, + &mut damus.threads, + &mut damus.unknown_ids, + &mut damus.note_cache, + ) { error!("poll_notes_into_view: {err}"); } } else { @@ -298,8 +317,13 @@ fn unknown_id_send(damus: &mut Damus) { /// Our timelines may require additional data before it is functional. For /// example, when we have to fetch a contact list before we do the actual /// following list query. -fn is_timeline_ready(damus: &mut Damus, timeline: usize) -> Result<bool> { - let sub = match &damus.timelines[timeline].filter { +fn is_timeline_ready( + ndb: &Ndb, + pool: &mut RelayPool, + note_cache: &mut NoteCache, + timeline: &mut Timeline, +) -> Result<bool> { + let sub = match &timeline.filter { FilterState::GotRemote(sub) => *sub, FilterState::Ready(_f) => return Ok(true), _ => return Ok(false), @@ -307,9 +331,12 @@ fn is_timeline_ready(damus: &mut Damus, timeline: usize) -> Result<bool> { // We got at least one eose for our filter request. Let's see // if nostrdb is done processing it yet. - let res = damus.ndb.poll_for_notes(sub, 1); + let res = ndb.poll_for_notes(sub, 1); if res.is_empty() { - debug!("check_timeline_filter_state: no notes found (yet?) for timeline {timeline}"); + debug!( + "check_timeline_filter_state: no notes found (yet?) for timeline {:?}", + timeline + ); return Ok(false); } @@ -318,8 +345,8 @@ fn is_timeline_ready(damus: &mut Damus, timeline: usize) -> Result<bool> { let note_key = res[0]; let filter = { - let txn = Transaction::new(&damus.ndb).expect("txn"); - let note = damus.ndb.get_note_by_key(&txn, note_key).expect("note"); + let txn = Transaction::new(ndb).expect("txn"); + let note = ndb.get_note_by_key(&txn, note_key).expect("note"); filter::filter_from_tags(&note).map(|f| f.into_follow_filter()) }; @@ -327,23 +354,24 @@ fn is_timeline_ready(damus: &mut Damus, timeline: usize) -> Result<bool> { match filter { Err(Error::Filter(e)) => { error!("got broken when building filter {e}"); - damus.timelines[timeline].filter = FilterState::broken(e); + timeline.filter = FilterState::broken(e); } Err(err) => { error!("got broken when building filter {err}"); - damus.timelines[timeline].filter = FilterState::broken(FilterError::EmptyContactList); + timeline.filter = FilterState::broken(FilterError::EmptyContactList); return Err(err); } Ok(filter) => { // we just switched to the ready state, we should send initial // queries and setup the local subscription info!("Found contact list! Setting up local and remote contact list query"); - setup_initial_timeline(damus, timeline, &filter).expect("setup init"); - damus.timelines[timeline].filter = FilterState::ready(filter.clone()); + setup_initial_timeline(ndb, timeline, note_cache, &filter).expect("setup init"); + timeline.filter = FilterState::ready(filter.clone()); - let ck = &damus.timelines[timeline].kind; - let subid = damus.gen_subid(&SubKind::Column(ck.clone())); - damus.pool.subscribe(subid, filter) + //let ck = &timeline.kind; + //let subid = damus.gen_subid(&SubKind::Column(ck.clone())); + let subid = Uuid::new_v4().to_string(); + pool.subscribe(subid, filter) } } @@ -355,18 +383,23 @@ fn setup_profiling() { puffin::set_scopes_on(true); // tell puffin to collect data } -fn setup_initial_timeline(damus: &mut Damus, timeline: usize, filters: &[Filter]) -> Result<()> { - damus.timelines[timeline].subscription = Some(damus.ndb.subscribe(filters)?); - let txn = Transaction::new(&damus.ndb)?; +fn setup_initial_timeline( + ndb: &Ndb, + timeline: &mut Timeline, + note_cache: &mut NoteCache, + filters: &[Filter], +) -> Result<()> { + timeline.subscription = Some(ndb.subscribe(filters)?); + let txn = Transaction::new(ndb)?; debug!( "querying nostrdb sub {:?} {:?}", - damus.timelines[timeline].subscription, damus.timelines[timeline].filter + timeline.subscription, timeline.filter ); let lim = filters[0].limit().unwrap_or(crate::filter::default_limit()) as i32; - let results = damus.ndb.query(&txn, filters, lim)?; + let results = ndb.query(&txn, filters, lim)?; let filters = { - let views = &damus.timelines[timeline].views; + let views = &timeline.views; let filters: Vec<fn(&CachedNote, &Note) -> bool> = views.iter().map(|v| v.filter.filter()).collect(); filters @@ -375,12 +408,10 @@ fn setup_initial_timeline(damus: &mut Damus, timeline: usize, filters: &[Filter] for result in results { for (view, filter) in filters.iter().enumerate() { if filter( - damus - .note_cache_mut() - .cached_note_or_insert_mut(result.note_key, &result.note), + note_cache.cached_note_or_insert_mut(result.note_key, &result.note), &result.note, ) { - damus.timelines[timeline].views[view].notes.push(NoteRef { + timeline.views[view].notes.push(NoteRef { key: result.note_key, created_at: result.note.created_at(), }) @@ -391,12 +422,16 @@ fn setup_initial_timeline(damus: &mut Damus, timeline: usize, filters: &[Filter] Ok(()) } -fn setup_initial_nostrdb_subs(damus: &mut Damus) -> Result<()> { - let timelines = damus.timelines.len(); - for i in 0..timelines { - let filter = damus.timelines[i].filter.clone(); - match filter { - FilterState::Ready(filters) => setup_initial_timeline(damus, i, &filters)?, +fn setup_initial_nostrdb_subs( + ndb: &Ndb, + note_cache: &mut NoteCache, + columns: &mut Columns, +) -> Result<()> { + for timeline in columns.timelines_mut() { + match &timeline.filter { + FilterState::Ready(filters) => { + { setup_initial_timeline(ndb, timeline, note_cache, &filters.clone()) }? + } FilterState::Broken(err) => { error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}") @@ -427,7 +462,8 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { damus .subscriptions() .insert("unknownids".to_string(), SubKind::OneShot); - setup_initial_nostrdb_subs(damus).expect("home subscription failed"); + setup_initial_nostrdb_subs(&damus.ndb, &mut damus.note_cache, &mut damus.columns) + .expect("home subscription failed"); } if let Err(err) = try_process_event(damus, ctx) { @@ -458,12 +494,18 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { }; match *sub_kind { - SubKind::Column(_) => { - // eose on column? whatevs + SubKind::Timeline(_) => { + // eose on timeline? whatevs } SubKind::Initial => { let txn = Transaction::new(&damus.ndb)?; - UnknownIds::update(&txn, damus); + UnknownIds::update( + &txn, + &mut damus.unknown_ids, + &damus.columns, + &damus.ndb, + &mut damus.note_cache, + ); // this is possible if this is the first time if damus.unknown_ids.ready_to_send() { unknown_id_send(damus); @@ -477,8 +519,8 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { } SubKind::FetchingContactList(timeline_uid) => { - let timeline_ind = if let Some(i) = damus.find_timeline(timeline_uid) { - i + let timeline = if let Some(tl) = damus.columns.find_timeline_mut(timeline_uid) { + tl } else { error!( "timeline uid:{} not found for FetchingContactList", @@ -487,35 +529,25 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { return Ok(()); }; - let local_sub = if let FilterState::FetchingRemote(unisub) = - &damus.timelines[timeline_ind].filter - { + // If this request was fetching a contact list, our filter + // state should be "FetchingRemote". We look at the local + // subscription for that filter state and get the subscription id + let local_sub = if let FilterState::FetchingRemote(unisub) = &timeline.filter { unisub.local } else { // TODO: we could have multiple contact list results, we need // to check to see if this one is newer and use that instead warn!( "Expected timeline to have FetchingRemote state but was {:?}", - damus.timelines[timeline_ind].filter + timeline.filter ); return Ok(()); }; - damus.timelines[timeline_ind].filter = FilterState::got_remote(local_sub); - - /* - // see if we're fast enough to catch a processed contact list - let note_keys = damus.ndb.poll_for_notes(local_sub, 1); - if !note_keys.is_empty() { - debug!("fast! caught contact list from {relay_url} right away"); - let txn = Transaction::new(&damus.ndb)?; - let note_key = note_keys[0]; - let nr = damus.ndb.get_note_by_key(&txn, note_key)?; - let filter = filter::filter_from_tags(&nr)?.into_follow_filter(); - setup_initial_timeline(damus, timeline, &filter) - damus.timelines[timeline_ind].filter = FilterState::ready(filter); - } - */ + // We take the subscription id and pass it to the new state of + // "GotRemote". This will let future frames know that it can try + // to look for the contact list in nostrdb. + timeline.filter = FilterState::got_remote(local_sub); } } @@ -593,7 +625,7 @@ impl Damus { let mut config = Config::new(); config.set_ingester_threads(4); - let mut account_manager = AccountManager::new( + let mut accounts = AccountManager::new( // TODO: should pull this from settings None, // TODO: use correct KeyStorage mechanism for current OS arch @@ -602,12 +634,12 @@ impl Damus { for key in parsed_args.keys { info!("adding account: {}", key.pubkey); - account_manager.add_account(key); + accounts.add_account(key); } // TODO: pull currently selected account from settings - if account_manager.num_accounts() > 0 { - account_manager.select_account(0); + if accounts.num_accounts() > 0 { + accounts.select_account(0); } // setup relays if we have them @@ -629,27 +661,27 @@ impl Damus { pool }; - let account = account_manager + let account = accounts .get_selected_account() .as_ref() .map(|a| a.pubkey.bytes()); let ndb = Ndb::new(&dbpath, &config).expect("ndb"); - let mut timelines: Vec<Timeline> = Vec::with_capacity(parsed_args.columns.len()); + let mut columns: Vec<Column> = Vec::with_capacity(parsed_args.columns.len()); for col in parsed_args.columns { if let Some(timeline) = col.into_timeline(&ndb, account) { - timelines.push(timeline); + columns.push(Column::timeline(timeline)); } } let debug = parsed_args.debug; - if timelines.is_empty() { + if columns.is_empty() { let filter = Filter::from_json(include_str!("../queries/timeline.json")).unwrap(); - timelines.push(Timeline::new( - ColumnKind::Generic, + columns.push(Column::timeline(Timeline::new( + TimelineKind::Generic, FilterState::ready(vec![filter]), - )); + ))); } Self { @@ -663,11 +695,10 @@ impl Damus { state: DamusState::Initializing, img_cache: ImageCache::new(imgcache_dir.into()), note_cache: NoteCache::default(), - selected_timeline: 0, - timelines, + columns: Columns::new(columns), textmode: parsed_args.textmode, ndb, - account_manager, + accounts, frame_history: FrameHistory::default(), show_account_switcher: false, show_global_popup: false, @@ -684,12 +715,12 @@ impl Damus { } pub fn mock<P: AsRef<Path>>(data_path: P) -> Self { - let mut timelines: Vec<Timeline> = vec![]; + let mut columns: Vec<Column> = vec![]; let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); - timelines.push(Timeline::new( - ColumnKind::Universe, + columns.push(Column::timeline(Timeline::new( + TimelineKind::Universe, FilterState::ready(vec![filter]), - )); + ))); let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir()); let _ = std::fs::create_dir_all(imgcache_dir.clone()); @@ -708,11 +739,10 @@ impl Damus { pool: RelayPool::new(), img_cache: ImageCache::new(imgcache_dir), note_cache: NoteCache::default(), - selected_timeline: 0, - timelines, + columns: Columns::new(columns), textmode: false, ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), - account_manager: AccountManager::new(None, KeyStorageType::None), + accounts: AccountManager::new(None, KeyStorageType::None), frame_history: FrameHistory::default(), show_account_switcher: false, show_global_popup: true, @@ -720,16 +750,6 @@ impl Damus { } } - pub fn find_timeline(&self, uid: u32) -> Option<usize> { - for (i, timeline) in self.timelines.iter().enumerate() { - if timeline.uid == uid { - return Some(i); - } - } - - None - } - pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> { &mut self.subscriptions.subs } @@ -741,32 +761,6 @@ impl Damus { pub fn note_cache(&self) -> &NoteCache { &self.note_cache } - - 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; - } } /* @@ -797,7 +791,7 @@ fn top_panel(ctx: &egui::Context) -> egui::TopBottomPanel { .show_separator_line(false) } -fn render_panel(ctx: &egui::Context, app: &mut Damus, timeline_ind: usize) { +fn render_panel(ctx: &egui::Context, app: &mut Damus) { top_panel(ctx).show(ctx, |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { ui.visuals_mut().button_frame = false; @@ -843,26 +837,34 @@ fn render_panel(ctx: &egui::Context, app: &mut Damus, timeline_ind: usize) { app.frame_history.mean_frame_time() * 1e3 )); - if !app.timelines.is_empty() { + /* + if !app.timelines().count().is_empty() { ui.weak(format!( "{} notes", - &app.timelines[timeline_ind] + &app.timelines() .notes(ViewFilter::NotesAndReplies) .len() )); } + */ } }); }); } /// Local thread unsubscribe -fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { +fn thread_unsubscribe( + ndb: &Ndb, + threads: &mut Threads, + pool: &mut RelayPool, + note_cache: &mut NoteCache, + id: &[u8; 32], +) { let (unsubscribe, remote_subid) = { - let txn = Transaction::new(&app.ndb).expect("txn"); - let root_id = crate::note::root_note_id_from_selected_id(app, &txn, id); + 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 = app.threads.thread_mut(&app.ndb, &txn, root_id).get_ptr(); + let thread = threads.thread_mut(ndb, &txn, root_id).get_ptr(); let unsub = thread.decrement_sub(); let mut remote_subid: Option<String> = None; @@ -877,30 +879,30 @@ fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { match unsubscribe { Ok(DecrementResult::LastSubscriber(sub)) => { - if let Err(e) = app.ndb.unsubscribe(sub) { + if let Err(e) = ndb.unsubscribe(sub) { error!( "failed to unsubscribe from thread: {e}, subid:{}, {} active subscriptions", sub.id(), - app.ndb.subscription_count() + ndb.subscription_count() ); } else { info!( "Unsubscribed from thread subid:{}. {} active subscriptions", sub.id(), - app.ndb.subscription_count() + ndb.subscription_count() ); } // unsub from remote if let Some(subid) = remote_subid { - app.pool.unsubscribe(subid); + pool.unsubscribe(subid); } } Ok(DecrementResult::ActiveSubscribers) => { info!( "Keeping thread subscription. {} active subscriptions.", - app.ndb.subscription_count() + ndb.subscription_count() ); // do nothing } @@ -909,25 +911,49 @@ fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { // something is wrong! error!( "Thread unsubscribe error: {e}. {} active subsciptions.", - app.ndb.subscription_count() + ndb.subscription_count() ); } } } -fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut egui::Ui) { - let navigating = app.timelines[timeline_ind].navigating; - let returning = app.timelines[timeline_ind].returning; - let app_ctx = Rc::new(RefCell::new(app)); +fn render_nav(show_postbox: bool, col: usize, app: &mut Damus, ui: &mut egui::Ui) { + let navigating = app.columns.column(col).navigating; + let returning = app.columns.column(col).returning; - let nav_response = Nav::new(routes) + let nav_response = Nav::new(app.columns.column(col).routes().to_vec()) .navigating(navigating) .returning(returning) .title(false) - .show(ui, |ui, nav| match nav.top() { + .show_mut(ui, |ui, nav| match nav.top() { Route::Timeline(_n) => { - let app = &mut app_ctx.borrow_mut(); - ui::TimelineView::new(app, timeline_ind).ui(ui); + let column = app.columns.column_mut(col); + if column.kind().timeline().is_some() { + if show_postbox { + if let Some(kp) = app.accounts.selected_or_first_nsec() { + ui::timeline::postbox_view( + &app.ndb, + kp, + &mut app.pool, + &mut app.drafts, + &mut app.img_cache, + ui, + ); + } + } + ui::TimelineView::new( + &app.ndb, + column, + &mut app.note_cache, + &mut app.img_cache, + &mut app.threads, + &mut app.pool, + app.textmode, + ) + .ui(ui); + } else { + ui.label("no timeline for this column?"); + } None } @@ -937,15 +963,25 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut } Route::Relays => { - let pool = &mut app_ctx.borrow_mut().pool; - let manager = RelayPoolManager::new(pool); + let manager = RelayPoolManager::new(&mut app.pool); RelayView::new(manager).ui(ui); None } Route::Thread(id) => { - let app = &mut app_ctx.borrow_mut(); - let result = ui::ThreadView::new(app, timeline_ind, id.bytes()).ui(ui); + let result = ui::ThreadView::new( + col, + &mut app.columns, + &mut app.threads, + &app.ndb, + &mut app.note_cache, + &mut app.img_cache, + &mut app.unknown_ids, + &mut app.pool, + app.textmode, + id.bytes(), + ) + .ui(ui); if let Some(bar_result) = result { match bar_result { @@ -960,8 +996,6 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut } Route::Reply(id) => { - let mut app = app_ctx.borrow_mut(); - let txn = if let Ok(txn) = Transaction::new(&app.ndb) { txn } else { @@ -976,32 +1010,55 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut return None; }; - let id = egui::Id::new(("post", timeline_ind, note.key().unwrap())); - let response = egui::ScrollArea::vertical().show(ui, |ui| { - ui::PostReplyView::new(&mut app, &note) + let id = egui::Id::new(( + "post", + app.columns.column(col).view_id(), + note.key().unwrap(), + )); + + if let Some(poster) = app.accounts.selected_or_first_nsec() { + let response = egui::ScrollArea::vertical().show(ui, |ui| { + ui::PostReplyView::new( + &app.ndb, + poster, + &mut app.pool, + &mut app.drafts, + &mut app.note_cache, + &mut app.img_cache, + &note, + ) .id_source(id) .show(ui) - }); + }); - Some(response) + Some(response) + } else { + None + } } }); - let mut app = app_ctx.borrow_mut(); + let column = app.columns.column_mut(col); if let Some(reply_response) = nav_response.inner { if let Some(PostAction::Post(_np)) = reply_response.inner.action { - app.timelines[timeline_ind].returning = true; + column.returning = true; } } if let Some(NavAction::Returned) = nav_response.action { - let popped = app.timelines[timeline_ind].routes.pop(); + let popped = column.routes_mut().pop(); if let Some(Route::Thread(id)) = popped { - thread_unsubscribe(&mut app, id.bytes()); + thread_unsubscribe( + &app.ndb, + &mut app.threads, + &mut app.pool, + &mut app.note_cache, + id.bytes(), + ); } - app.timelines[timeline_ind].returning = false; + column.returning = false; } else if let Some(NavAction::Navigated) = nav_response.action { - app.timelines[timeline_ind].navigating = false; + column.navigating = false; } } @@ -1014,7 +1071,9 @@ fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { //let routes = app.timelines[0].routes.clone(); main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| { - render_nav(app.timelines[0].routes.clone(), 0, app, ui); + if !app.columns.columns().is_empty() { + render_nav(false, 0, app, ui); + } }); } @@ -1033,12 +1092,12 @@ fn main_panel(style: &Style, narrow: bool) -> egui::CentralPanel { } fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { - render_panel(ctx, app, 0); + render_panel(ctx, app); #[cfg(feature = "profiling")] puffin::profile_function!(); let screen_size = ctx.screen_rect().width(); - let calc_panel_width = (screen_size / app.timelines.len() as f32) - 30.0; + let calc_panel_width = (screen_size / app.columns.columns().len() as f32) - 30.0; let min_width = 320.0; let need_scroll = calc_panel_width < min_width; let panel_sizes = if need_scroll { @@ -1053,18 +1112,18 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { DesktopGlobalPopup::show(app.global_nav.clone(), app, ui); if need_scroll { egui::ScrollArea::horizontal().show(ui, |ui| { - timelines_view(ui, panel_sizes, app, app.timelines.len()); + timelines_view(ui, panel_sizes, app, app.columns.columns().len()); }); } else { - timelines_view(ui, panel_sizes, app, app.timelines.len()); + timelines_view(ui, panel_sizes, app, app.columns.columns().len()); } }); } -fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, timelines: usize) { +fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usize) { StripBuilder::new(ui) .size(Size::exact(40.0)) - .sizes(sizes, timelines) + .sizes(sizes, columns) .clip(true) .horizontal(|mut strip| { strip.cell(|ui| { @@ -1085,15 +1144,17 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, timelines: us ); }); - for timeline_ind in 0..timelines { + let n_cols = app.columns.columns().len(); + let mut first = true; + for column_ind in 0..n_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - render_nav( - app.timelines[timeline_ind].routes.clone(), - timeline_ind, - app, - ui, - ); + let show_postbox = + first && app.columns.column(column_ind).kind().timeline().is_some(); + if show_postbox { + first = false + } + render_nav(show_postbox, column_ind, app, ui); // vertical line ui.painter().vline( diff --git a/src/args.rs b/src/args.rs @@ -1,6 +1,5 @@ -use crate::column::{ColumnKind, PubkeySource}; use crate::filter::FilterState; -use crate::timeline::Timeline; +use crate::timeline::{PubkeySource, Timeline, TimelineKind}; use enostr::{Filter, Keypair, Pubkey, SecretKey}; use nostrdb::Ndb; use tracing::{debug, error, info}; @@ -137,22 +136,24 @@ impl Args { if let Some(rest) = column_name.strip_prefix("contacts:") { if let Ok(pubkey) = Pubkey::parse(rest) { info!("contact column for user {}", pubkey.hex()); - res.columns.push(ArgColumn::Column(ColumnKind::contact_list( - PubkeySource::Explicit(pubkey), - ))) + res.columns + .push(ArgColumn::Timeline(TimelineKind::contact_list( + PubkeySource::Explicit(pubkey), + ))) } else { error!("error parsing contacts pubkey {}", rest); continue; } } else if column_name == "contacts" { - res.columns.push(ArgColumn::Column(ColumnKind::contact_list( - PubkeySource::DeckAuthor, - ))) + res.columns + .push(ArgColumn::Timeline(TimelineKind::contact_list( + PubkeySource::DeckAuthor, + ))) } else if let Some(notif_pk_str) = column_name.strip_prefix("notifications:") { if let Ok(pubkey) = Pubkey::parse(notif_pk_str) { info!("got notifications column for user {}", pubkey.hex()); res.columns - .push(ArgColumn::Column(ColumnKind::notifications( + .push(ArgColumn::Timeline(TimelineKind::notifications( PubkeySource::Explicit(pubkey), ))) } else { @@ -162,21 +163,22 @@ impl Args { } else if column_name == "notifications" { debug!("got notification column for default user"); res.columns - .push(ArgColumn::Column(ColumnKind::notifications( + .push(ArgColumn::Timeline(TimelineKind::notifications( PubkeySource::DeckAuthor, ))) } else if column_name == "profile" { debug!("got profile column for default user"); - res.columns.push(ArgColumn::Column(ColumnKind::profile( + res.columns.push(ArgColumn::Timeline(TimelineKind::profile( PubkeySource::DeckAuthor, ))) } else if column_name == "universe" { debug!("got universe column"); - res.columns.push(ArgColumn::Column(ColumnKind::Universe)) + res.columns + .push(ArgColumn::Timeline(TimelineKind::Universe)) } else if let Some(profile_pk_str) = column_name.strip_prefix("profile:") { if let Ok(pubkey) = Pubkey::parse(profile_pk_str) { info!("got profile column for user {}", pubkey.hex()); - res.columns.push(ArgColumn::Column(ColumnKind::profile( + res.columns.push(ArgColumn::Timeline(TimelineKind::profile( PubkeySource::Explicit(pubkey), ))) } else { @@ -214,9 +216,9 @@ impl Args { } if res.columns.is_empty() { - let ck = ColumnKind::contact_list(PubkeySource::DeckAuthor); + let ck = TimelineKind::contact_list(PubkeySource::DeckAuthor); info!("No columns set, setting up defaults: {:?}", ck); - res.columns.push(ArgColumn::Column(ck)); + res.columns.push(ArgColumn::Timeline(ck)); } res @@ -226,7 +228,7 @@ impl Args { /// A way to define columns from the commandline. Can be column kinds or /// generic queries pub enum ArgColumn { - Column(ColumnKind), + Timeline(TimelineKind), Generic(Vec<Filter>), } @@ -234,10 +236,10 @@ impl ArgColumn { pub fn into_timeline(self, ndb: &Ndb, user: Option<&[u8; 32]>) -> Option<Timeline> { match self { ArgColumn::Generic(filters) => Some(Timeline::new( - ColumnKind::Generic, + TimelineKind::Generic, FilterState::ready(filters), )), - ArgColumn::Column(ck) => ck.into_timeline(ndb, user), + ArgColumn::Timeline(tk) => tk.into_timeline(ndb, user), } } } diff --git a/src/column.rs b/src/column.rs @@ -1,152 +1,170 @@ -use crate::error::FilterError; -use crate::filter; -use crate::filter::FilterState; -use crate::{timeline::Timeline, Error}; -use enostr::Pubkey; -use nostrdb::{Filter, Ndb, Transaction}; -use std::fmt::Display; -use tracing::{error, warn}; - -#[derive(Clone, Debug)] -pub enum PubkeySource { - Explicit(Pubkey), - DeckAuthor, -} +use crate::route::Route; +use crate::timeline::{Timeline, TimelineId}; +use std::iter::Iterator; +use tracing::warn; + +pub struct Column { + kind: ColumnKind, + routes: Vec<Route>, -#[derive(Debug, Clone)] -pub enum ListKind { - Contact(PubkeySource), + pub navigating: bool, + pub returning: bool, } -/// -/// What kind of column is it? -/// - Follow List -/// - Notifications -/// - DM -/// - filter -/// - ... etc -#[derive(Debug, Clone)] -pub enum ColumnKind { - List(ListKind), +impl Column { + pub fn timeline(timeline: Timeline) -> Self { + let routes = vec![Route::Timeline(format!("{}", &timeline.kind))]; + let kind = ColumnKind::Timeline(timeline); + Column::new(kind, routes) + } - Notifications(PubkeySource), + pub fn kind(&self) -> &ColumnKind { + &self.kind + } - Profile(PubkeySource), + pub fn kind_mut(&mut self) -> &mut ColumnKind { + &mut self.kind + } - Universe, + pub fn view_id(&self) -> egui::Id { + self.kind.view_id() + } - /// Generic filter - Generic, + pub fn routes(&self) -> &[Route] { + &self.routes + } + + pub fn routes_mut(&mut self) -> &mut Vec<Route> { + &mut self.routes + } + + pub fn new(kind: ColumnKind, routes: Vec<Route>) -> Self { + let navigating = false; + let returning = false; + Column { + kind, + routes, + navigating, + returning, + } + } } -impl Display for ColumnKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ColumnKind::List(ListKind::Contact(_src)) => f.write_str("Contacts"), - ColumnKind::Generic => f.write_str("Timeline"), - ColumnKind::Notifications(_) => f.write_str("Notifications"), - ColumnKind::Profile(_) => f.write_str("Profile"), - ColumnKind::Universe => f.write_str("Universe"), +pub struct Columns { + columns: Vec<Column>, + + /// The selected column for key navigation + selected: i32, +} + +impl Columns { + pub fn columns_mut(&mut self) -> &mut Vec<Column> { + &mut self.columns + } + + pub fn column(&self, ind: usize) -> &Column { + &self.columns()[ind] + } + + pub fn columns(&self) -> &[Column] { + &self.columns + } + + pub fn new(columns: Vec<Column>) -> Self { + let selected = -1; + Columns { columns, selected } + } + + pub fn selected(&mut self) -> &mut Column { + &mut self.columns[self.selected as usize] + } + + pub fn timelines_mut(&mut self) -> impl Iterator<Item = &mut Timeline> { + self.columns + .iter_mut() + .filter_map(|c| c.kind_mut().timeline_mut()) + } + + pub fn timelines(&self) -> impl Iterator<Item = &Timeline> { + self.columns.iter().filter_map(|c| c.kind().timeline()) + } + + pub fn find_timeline_mut(&mut self, id: TimelineId) -> Option<&mut Timeline> { + self.timelines_mut().find(|tl| tl.id == id) + } + + pub fn find_timeline(&self, id: TimelineId) -> Option<&Timeline> { + self.timelines().find(|tl| tl.id == id) + } + + pub fn column_mut(&mut self, ind: usize) -> &mut Column { + &mut self.columns[ind] + } + + pub fn select_down(&mut self) { + self.selected().kind_mut().select_down(); + } + + pub fn select_up(&mut self) { + self.selected().kind_mut().select_up(); + } + + pub fn select_left(&mut self) { + if self.selected - 1 < 0 { + return; + } + self.selected -= 1; + } + + pub fn select_right(&mut self) { + if self.selected + 1 >= self.columns.len() as i32 { + return; } + self.selected += 1; } } +/// What type of column is it? +#[derive(Debug)] +pub enum ColumnKind { + Timeline(Timeline), + + ManageAccount, +} + impl ColumnKind { - pub fn contact_list(pk: PubkeySource) -> Self { - ColumnKind::List(ListKind::Contact(pk)) + pub fn timeline_mut(&mut self) -> Option<&mut Timeline> { + match self { + ColumnKind::Timeline(tl) => Some(tl), + _ => None, + } + } + + pub fn timeline(&self) -> Option<&Timeline> { + match self { + ColumnKind::Timeline(tl) => Some(tl), + _ => None, + } } - pub fn profile(pk: PubkeySource) -> Self { - ColumnKind::Profile(pk) + pub fn view_id(&self) -> egui::Id { + match self { + ColumnKind::Timeline(timeline) => timeline.view_id(), + ColumnKind::ManageAccount => egui::Id::new("manage_account"), + } } - pub fn notifications(pk: PubkeySource) -> Self { - ColumnKind::Notifications(pk) + pub fn select_down(&mut self) { + match self { + ColumnKind::Timeline(tl) => tl.current_view_mut().select_down(), + ColumnKind::ManageAccount => warn!("todo: manage account select_down"), + } } - pub fn into_timeline(self, ndb: &Ndb, default_user: Option<&[u8; 32]>) -> Option<Timeline> { + pub fn select_up(&mut self) { match self { - ColumnKind::Universe => Some(Timeline::new( - ColumnKind::Universe, - FilterState::ready(vec![Filter::new() - .kinds([1]) - .limit(filter::default_limit()) - .build()]), - )), - - ColumnKind::Generic => { - warn!("you can't convert a ColumnKind::Generic to a Timeline"); - None - } - - ColumnKind::Profile(pk_src) => { - let pk = match &pk_src { - PubkeySource::DeckAuthor => default_user?, - PubkeySource::Explicit(pk) => pk.bytes(), - }; - - let filter = Filter::new() - .authors([pk]) - .kinds([1]) - .limit(filter::default_limit()) - .build(); - - Some(Timeline::new( - ColumnKind::profile(pk_src), - FilterState::ready(vec![filter]), - )) - } - - ColumnKind::Notifications(pk_src) => { - let pk = match &pk_src { - PubkeySource::DeckAuthor => default_user?, - PubkeySource::Explicit(pk) => pk.bytes(), - }; - - let notifications_filter = Filter::new() - .pubkeys([pk]) - .kinds([1]) - .limit(filter::default_limit()) - .build(); - - Some(Timeline::new( - ColumnKind::notifications(pk_src), - FilterState::ready(vec![notifications_filter]), - )) - } - - ColumnKind::List(ListKind::Contact(pk_src)) => { - let pk = match &pk_src { - PubkeySource::DeckAuthor => default_user?, - PubkeySource::Explicit(pk) => pk.bytes(), - }; - - let contact_filter = Filter::new().authors([pk]).kinds([3]).limit(1).build(); - - let txn = Transaction::new(ndb).expect("txn"); - let results = ndb - .query(&txn, &[contact_filter.clone()], 1) - .expect("contact query failed?"); - - if results.is_empty() { - return Some(Timeline::new( - ColumnKind::contact_list(pk_src), - FilterState::needs_remote(vec![contact_filter.clone()]), - )); - } - - match Timeline::contact_list(&results[0].note) { - Err(Error::Filter(FilterError::EmptyContactList)) => Some(Timeline::new( - ColumnKind::contact_list(pk_src), - FilterState::needs_remote(vec![contact_filter]), - )), - Err(e) => { - error!("Unexpected error: {e}"); - None - } - Ok(tl) => Some(tl), - } - } + ColumnKind::Timeline(tl) => tl.current_view_mut().select_down(), + ColumnKind::ManageAccount => warn!("todo: manage account select_down"), } } } diff --git a/src/draft.rs b/src/draft.rs @@ -7,16 +7,21 @@ pub struct Draft { #[derive(Default)] pub struct Drafts { - pub replies: HashMap<[u8; 32], Draft>, - pub compose: Draft, + replies: HashMap<[u8; 32], Draft>, + compose: Draft, } impl Drafts { - pub fn clear(&mut self, source: DraftSource) { - source.draft(self).buffer = "".to_string(); + pub fn compose_mut(&mut self) -> &mut Draft { + &mut self.compose + } + + pub fn reply_mut(&mut self, id: &[u8; 32]) -> &mut Draft { + self.replies.entry(*id).or_default() } } +/* pub enum DraftSource<'a> { Compose, Reply(&'a [u8; 32]), // note id @@ -25,14 +30,19 @@ pub enum DraftSource<'a> { impl<'a> DraftSource<'a> { pub fn draft(&self, drafts: &'a mut Drafts) -> &'a mut Draft { match self { - DraftSource::Compose => &mut drafts.compose, - DraftSource::Reply(id) => drafts.replies.entry(**id).or_default(), + DraftSource::Compose => drafts.compose_mut(), + DraftSource::Reply(id) => drafts.reply_mut(id), } } } +*/ impl Draft { pub fn new() -> Self { Draft::default() } + + pub fn clear(&mut self) { + self.buffer = "".to_string(); + } } diff --git a/src/note.rs b/src/note.rs @@ -1,5 +1,5 @@ -use crate::Damus; -use nostrdb::{NoteKey, QueryResult, Transaction}; +use crate::notecache::NoteCache; +use nostrdb::{Ndb, NoteKey, QueryResult, Transaction}; use std::cmp::Ordering; #[derive(Debug, Eq, PartialEq, Copy, Clone)] @@ -38,12 +38,12 @@ impl PartialOrd for NoteRef { } pub fn root_note_id_from_selected_id<'a>( - app: &mut Damus, + ndb: &Ndb, + note_cache: &mut NoteCache, txn: &'a Transaction, selected_note_id: &'a [u8; 32], ) -> &'a [u8; 32] { - let selected_note_key = if let Ok(key) = app - .ndb + let selected_note_key = if let Ok(key) = ndb .get_notekey_by_id(txn, selected_note_id) .map(NoteKey::new) { @@ -52,13 +52,13 @@ pub fn root_note_id_from_selected_id<'a>( return selected_note_id; }; - let note = if let Ok(note) = app.ndb.get_note_by_key(txn, selected_note_key) { + let note = if let Ok(note) = ndb.get_note_by_key(txn, selected_note_key) { note } else { return selected_note_id; }; - app.note_cache_mut() + note_cache .cached_note_or_insert(selected_note_key, &note) .reply .borrow(note.tags()) diff --git a/src/post.rs b/src/post.rs @@ -1,12 +1,17 @@ +use enostr::FullKeypair; use nostrdb::{Note, NoteBuilder, NoteReply}; use std::collections::HashSet; pub struct NewPost { pub content: String, - pub account: usize, + pub account: FullKeypair, } impl NewPost { + pub fn new(content: String, account: FullKeypair) -> Self { + NewPost { content, account } + } + pub fn to_note(&self, seckey: &[u8; 32]) -> Note { NoteBuilder::new() .kind(1) diff --git a/src/subscriptions.rs b/src/subscriptions.rs @@ -1,4 +1,4 @@ -use crate::column::ColumnKind; +use crate::timeline::{TimelineId, TimelineKind}; use std::collections::HashMap; #[derive(Debug, Clone)] @@ -10,12 +10,12 @@ pub enum SubKind { /// One shot requests, we can just close after we receive EOSE OneShot, - Column(ColumnKind), + Timeline(TimelineKind), /// We are fetching a contact list so that we can use it for our follows /// Filter. // TODO: generalize this to any list? - FetchingContactList(u32), + FetchingContactList(TimelineId), } /// Subscriptions that need to be tracked at various stages. Sometimes we diff --git a/src/test_data.rs b/src/test_data.rs @@ -55,6 +55,7 @@ const TEST_PROFILE_DATA: [u8; 448] = [ 0x0c, 0x00, 0x24, 0x00, 0x04, 0x00, 0x0c, 0x00, 0x1c, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, ]; +/* const TEST_PUBKEY: [u8; 32] = [ 0x32, 0xe1, 0x82, 0x76, 0x35, 0x45, 0x0e, 0xbb, 0x3c, 0x5a, 0x7d, 0x12, 0xc1, 0xf8, 0xe7, 0xb2, 0xb5, 0x14, 0x43, 0x9a, 0xc1, 0x0a, 0x67, 0xee, 0xf3, 0xd9, 0xfd, 0x9c, 0x5c, 0x68, 0xe2, 0x45, @@ -63,6 +64,7 @@ const TEST_PUBKEY: [u8; 32] = [ pub fn test_pubkey() -> &'static [u8; 32] { &TEST_PUBKEY } +*/ pub fn test_profile_record() -> ProfileRecord<'static> { ProfileRecord::new_owned(&TEST_PROFILE_DATA).unwrap() @@ -99,7 +101,7 @@ pub fn test_app() -> Damus { let accounts = get_test_accounts(); for account in accounts { - app.account_manager.add_account(account); + app.accounts.add_account(account); } app diff --git a/src/timeline.rs b/src/timeline.rs @@ -1,379 +0,0 @@ -use crate::column::{ColumnKind, PubkeySource}; -use crate::error::Error; -use crate::note::NoteRef; -use crate::notecache::CachedNote; -use crate::unknowns::UnknownIds; -use crate::{filter, filter::FilterState}; -use crate::{Damus, Result}; -use std::sync::atomic::{AtomicU32, Ordering}; - -use crate::route::Route; - -use egui_virtual_list::VirtualList; -use enostr::Pubkey; -use nostrdb::{Note, Subscription, Transaction}; -use std::cell::RefCell; -use std::rc::Rc; - -use tracing::{debug, error}; - -#[derive(Debug, Copy, Clone)] -pub enum TimelineSource<'a> { - Column { ind: usize }, - Thread(&'a [u8; 32]), -} - -impl<'a> TimelineSource<'a> { - pub fn column(ind: usize) -> Self { - TimelineSource::Column { ind } - } - - pub fn view<'b>( - self, - app: &'b mut Damus, - txn: &Transaction, - filter: ViewFilter, - ) -> &'b mut TimelineTab { - match self { - TimelineSource::Column { ind, .. } => app.timelines[ind].view_mut(filter), - TimelineSource::Thread(root_id) => { - // TODO: replace all this with the raw entry api eventually - - let thread = if app.threads.root_id_to_thread.contains_key(root_id) { - app.threads.thread_expected_mut(root_id) - } else { - app.threads.thread_mut(&app.ndb, txn, root_id).get_ptr() - }; - - &mut thread.view - } - } - } - - pub fn sub(self, app: &mut Damus, txn: &Transaction) -> Option<Subscription> { - match self { - TimelineSource::Column { ind, .. } => app.timelines[ind].subscription, - TimelineSource::Thread(root_id) => { - // TODO: replace all this with the raw entry api eventually - - let thread = if app.threads.root_id_to_thread.contains_key(root_id) { - app.threads.thread_expected_mut(root_id) - } else { - app.threads.thread_mut(&app.ndb, txn, root_id).get_ptr() - }; - - thread.subscription() - } - } - } - - /// Check local subscriptions for new notes and insert them into - /// timelines (threads, columns) - pub fn poll_notes_into_view(&self, txn: &Transaction, app: &mut Damus) -> Result<()> { - let sub = if let Some(sub) = self.sub(app, txn) { - sub - } else { - return Err(Error::no_active_sub()); - }; - - let new_note_ids = app.ndb.poll_for_notes(sub, 100); - 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) = app.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; - }; - - UnknownIds::update_from_note(txn, app, &note); - - let created_at = note.created_at(); - new_refs.push((note, NoteRef { 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; - - // ViewFilter::NotesAndReplies - { - let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect(); - - let reversed = false; - self.view(app, txn, ViewFilter::NotesAndReplies) - .insert(&refs, reversed); - } - - // - // 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 - { - let mut filtered_refs = Vec::with_capacity(new_refs.len()); - for (note, nr) in &new_refs { - let cached_note = app.note_cache_mut().cached_note_or_insert(nr.key, note); - - if ViewFilter::filter_notes(cached_note, note) { - filtered_refs.push(*nr); - } - } - - self.view(app, txn, ViewFilter::Notes) - .insert(&filtered_refs, reversed); - } - - Ok(()) - } -} - -#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] -pub enum ViewFilter { - Notes, - - #[default] - NotesAndReplies, -} - -impl ViewFilter { - pub fn name(&self) -> &'static str { - match self { - ViewFilter::Notes => "Notes", - ViewFilter::NotesAndReplies => "Notes & Replies", - } - } - - pub fn index(&self) -> usize { - match self { - ViewFilter::Notes => 0, - ViewFilter::NotesAndReplies => 1, - } - } - - pub fn filter_notes(cache: &CachedNote, note: &Note) -> bool { - !cache.reply.borrow(note.tags()).is_reply() - } - - fn identity(_cache: &CachedNote, _note: &Note) -> bool { - true - } - - pub fn filter(&self) -> fn(&CachedNote, &Note) -> bool { - match self { - ViewFilter::Notes => ViewFilter::filter_notes, - ViewFilter::NotesAndReplies => ViewFilter::identity, - } - } -} - -/// 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 TimelineTab is a further filtered view of this Filter that can't -/// be captured by a Filter itself. -#[derive(Default)] -pub struct TimelineTab { - pub notes: Vec<NoteRef>, - pub selection: i32, - pub filter: ViewFilter, - pub list: Rc<RefCell<VirtualList>>, -} - -impl TimelineTab { - pub fn new(filter: ViewFilter) -> Self { - TimelineTab::new_with_capacity(filter, 1000) - } - - pub fn new_with_capacity(filter: ViewFilter, cap: usize) -> Self { - let selection = 0i32; - let mut list = VirtualList::new(); - list.hide_on_resize(None); - list.over_scan(1000.0); - let list = Rc::new(RefCell::new(list)); - let notes: Vec<NoteRef> = Vec::with_capacity(cap); - - TimelineTab { - notes, - selection, - filter, - list, - } - } - - pub fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) { - if new_refs.is_empty() { - return; - } - 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 { - list.items_inserted_at_start(new_items); - } - } - } - } - } - - pub fn select_down(&mut self) { - debug!("select_down {}", self.selection + 1); - if self.selection + 1 > self.notes.len() as i32 { - return; - } - - self.selection += 1; - } - - pub fn select_up(&mut self) { - debug!("select_up {}", self.selection - 1); - if self.selection - 1 < 0 { - return; - } - - self.selection -= 1; - } -} - -/// A column in a deck. Holds navigation state, loaded notes, column kind, etc. -pub struct Timeline { - pub uid: u32, - pub kind: ColumnKind, - // We may not have the filter loaded yet, so let's make it an option so - // that codepaths have to explicitly handle it - pub filter: FilterState, - pub views: Vec<TimelineTab>, - pub selected_view: i32, - pub routes: Vec<Route>, - pub navigating: bool, - pub returning: bool, - - /// Our nostrdb subscription - pub subscription: Option<Subscription>, -} - -impl Timeline { - /// Create a timeline from a contact list - pub fn contact_list(contact_list: &Note) -> Result<Self> { - let filter = filter::filter_from_tags(contact_list)?.into_follow_filter(); - let pk_src = PubkeySource::Explicit(Pubkey::new(*contact_list.pubkey())); - - Ok(Timeline::new( - ColumnKind::contact_list(pk_src), - FilterState::ready(filter), - )) - } - - pub fn new(kind: ColumnKind, filter: FilterState) -> Self { - // global unique id for all new timelines - static UIDS: AtomicU32 = AtomicU32::new(0); - - let subscription: Option<Subscription> = None; - let notes = TimelineTab::new(ViewFilter::Notes); - let replies = TimelineTab::new(ViewFilter::NotesAndReplies); - let views = vec![notes, replies]; - let selected_view = 0; - let routes = vec![Route::Timeline(format!("{}", kind))]; - let navigating = false; - let returning = false; - let uid = UIDS.fetch_add(1, Ordering::Relaxed); - - Timeline { - uid, - kind, - navigating, - returning, - filter, - views, - subscription, - selected_view, - routes, - } - } - - pub fn current_view(&self) -> &TimelineTab { - &self.views[self.selected_view as usize] - } - - pub fn current_view_mut(&mut self) -> &mut TimelineTab { - &mut self.views[self.selected_view as usize] - } - - pub fn notes(&self, view: ViewFilter) -> &[NoteRef] { - &self.views[view.index()].notes - } - - pub fn view(&self, view: ViewFilter) -> &TimelineTab { - &self.views[view.index()] - } - - pub fn view_mut(&mut self, view: ViewFilter) -> &mut TimelineTab { - &mut self.views[view.index()] - } -} - -pub enum MergeKind { - FrontInsert, - Spliced, -} - -pub fn merge_sorted_vecs<T: Ord + Copy>(vec1: &[T], vec2: &[T]) -> (Vec<T>, MergeKind) { - let mut merged = Vec::with_capacity(vec1.len() + vec2.len()); - let mut i = 0; - let mut j = 0; - let mut result: Option<MergeKind> = None; - - while i < vec1.len() && j < vec2.len() { - if vec1[i] <= vec2[j] { - if result.is_none() && j < vec2.len() { - // if we're pushing from our large list and still have - // some left in vec2, then this is a splice - result = Some(MergeKind::Spliced); - } - merged.push(vec1[i]); - i += 1; - } else { - merged.push(vec2[j]); - j += 1; - } - } - - // Append any remaining elements from either vector - if i < vec1.len() { - merged.extend_from_slice(&vec1[i..]); - } - if j < vec2.len() { - merged.extend_from_slice(&vec2[j..]); - } - - (merged, result.unwrap_or(MergeKind::FrontInsert)) -} diff --git a/src/timeline/kind.rs b/src/timeline/kind.rs @@ -0,0 +1,153 @@ +use crate::error::{Error, FilterError}; +use crate::filter; +use crate::filter::FilterState; +use crate::timeline::Timeline; +use enostr::{Filter, Pubkey}; +use nostrdb::{Ndb, Transaction}; +use std::fmt::Display; +use tracing::{error, warn}; + +#[derive(Clone, Debug)] +pub enum PubkeySource { + Explicit(Pubkey), + DeckAuthor, +} + +#[derive(Debug, Clone)] +pub enum ListKind { + Contact(PubkeySource), +} + +/// +/// What kind of timeline is it? +/// - Follow List +/// - Notifications +/// - DM +/// - filter +/// - ... etc +/// +#[derive(Debug, Clone)] +pub enum TimelineKind { + List(ListKind), + + Notifications(PubkeySource), + + Profile(PubkeySource), + + Universe, + + /// Generic filter + Generic, +} + +impl Display for TimelineKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimelineKind::List(ListKind::Contact(_src)) => f.write_str("Contacts"), + TimelineKind::Generic => f.write_str("Timeline"), + TimelineKind::Notifications(_) => f.write_str("Notifications"), + TimelineKind::Profile(_) => f.write_str("Profile"), + TimelineKind::Universe => f.write_str("Universe"), + } + } +} + +impl TimelineKind { + pub fn contact_list(pk: PubkeySource) -> Self { + TimelineKind::List(ListKind::Contact(pk)) + } + + pub fn profile(pk: PubkeySource) -> Self { + TimelineKind::Profile(pk) + } + + pub fn notifications(pk: PubkeySource) -> Self { + TimelineKind::Notifications(pk) + } + + pub fn into_timeline(self, ndb: &Ndb, default_user: Option<&[u8; 32]>) -> Option<Timeline> { + match self { + TimelineKind::Universe => Some(Timeline::new( + TimelineKind::Universe, + FilterState::ready(vec![Filter::new() + .kinds([1]) + .limit(filter::default_limit()) + .build()]), + )), + + TimelineKind::Generic => { + warn!("you can't convert a TimelineKind::Generic to a Timeline"); + None + } + + TimelineKind::Profile(pk_src) => { + let pk = match &pk_src { + PubkeySource::DeckAuthor => default_user?, + PubkeySource::Explicit(pk) => pk.bytes(), + }; + + let filter = Filter::new() + .authors([pk]) + .kinds([1]) + .limit(filter::default_limit()) + .build(); + + Some(Timeline::new( + TimelineKind::profile(pk_src), + FilterState::ready(vec![filter]), + )) + } + + TimelineKind::Notifications(pk_src) => { + let pk = match &pk_src { + PubkeySource::DeckAuthor => default_user?, + PubkeySource::Explicit(pk) => pk.bytes(), + }; + + let notifications_filter = Filter::new() + .pubkeys([pk]) + .kinds([1]) + .limit(crate::filter::default_limit()) + .build(); + + Some(Timeline::new( + TimelineKind::notifications(pk_src), + FilterState::ready(vec![notifications_filter]), + )) + } + + TimelineKind::List(ListKind::Contact(pk_src)) => { + let pk = match &pk_src { + PubkeySource::DeckAuthor => default_user?, + PubkeySource::Explicit(pk) => pk.bytes(), + }; + + let contact_filter = Filter::new().authors([pk]).kinds([3]).limit(1).build(); + + let txn = Transaction::new(ndb).expect("txn"); + let results = ndb + .query(&txn, &[contact_filter.clone()], 1) + .expect("contact query failed?"); + + if results.is_empty() { + return Some(Timeline::new( + TimelineKind::contact_list(pk_src), + FilterState::needs_remote(vec![contact_filter.clone()]), + )); + } + + match Timeline::contact_list(&results[0].note) { + Err(Error::Filter(FilterError::EmptyContactList)) => Some(Timeline::new( + TimelineKind::contact_list(pk_src), + FilterState::needs_remote(vec![contact_filter]), + )), + Err(e) => { + error!("Unexpected error: {e}"); + None + } + Ok(tl) => Some(tl), + } + } + } + } +} diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs @@ -0,0 +1,419 @@ +use crate::column::Columns; +use crate::error::Error; +use crate::note::NoteRef; +use crate::notecache::{CachedNote, NoteCache}; +use crate::thread::Threads; +use crate::unknowns::UnknownIds; +use crate::Result; +use crate::{filter, filter::FilterState}; +use std::fmt; +use std::sync::atomic::{AtomicU32, Ordering}; + +use egui_virtual_list::VirtualList; +use enostr::Pubkey; +use nostrdb::{Ndb, Note, Subscription, Transaction}; +use std::cell::RefCell; +use std::hash::Hash; +use std::rc::Rc; + +use tracing::{debug, error}; + +mod kind; + +pub use kind::{PubkeySource, TimelineKind}; + +#[derive(Debug, Hash, Copy, Clone, Eq, PartialEq)] +pub struct TimelineId(u32); + +impl TimelineId { + pub fn new(id: u32) -> Self { + TimelineId(id) + } +} + +impl fmt::Display for TimelineId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "TimelineId({})", self.0) + } +} + +#[derive(Debug, Copy, Clone)] +pub enum TimelineSource<'a> { + Column(TimelineId), + Thread(&'a [u8; 32]), +} + +impl<'a> TimelineSource<'a> { + pub fn column(id: TimelineId) -> Self { + TimelineSource::Column(id) + } + + pub fn view<'b>( + self, + ndb: &Ndb, + columns: &'b mut Columns, + threads: &'b mut Threads, + txn: &Transaction, + filter: ViewFilter, + ) -> &'b mut TimelineTab { + match self { + TimelineSource::Column(tid) => columns + .find_timeline_mut(tid) + .expect("timeline") + .view_mut(filter), + + TimelineSource::Thread(root_id) => { + // TODO: replace all this with the raw entry api eventually + + let thread = if threads.root_id_to_thread.contains_key(root_id) { + threads.thread_expected_mut(root_id) + } else { + threads.thread_mut(ndb, txn, root_id).get_ptr() + }; + + &mut thread.view + } + } + } + + fn sub( + self, + ndb: &Ndb, + columns: &Columns, + txn: &Transaction, + threads: &mut Threads, + ) -> Option<Subscription> { + match self { + TimelineSource::Column(tid) => columns.find_timeline(tid).expect("thread").subscription, + TimelineSource::Thread(root_id) => { + // TODO: replace all this with the raw entry api eventually + + let thread = if threads.root_id_to_thread.contains_key(root_id) { + threads.thread_expected_mut(root_id) + } else { + threads.thread_mut(ndb, txn, root_id).get_ptr() + }; + + thread.subscription() + } + } + } + + /// Check local subscriptions for new notes and insert them into + /// timelines (threads, columns) + pub fn poll_notes_into_view( + &self, + txn: &Transaction, + ndb: &Ndb, + columns: &mut Columns, + threads: &mut Threads, + unknown_ids: &mut UnknownIds, + note_cache: &mut NoteCache, + ) -> Result<()> { + let sub = if let Some(sub) = self.sub(ndb, columns, txn, threads) { + sub + } else { + return Err(Error::no_active_sub()); + }; + + let new_note_ids = ndb.poll_for_notes(sub, 100); + 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) { + 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; + }; + + 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 })); + } + + // 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; + + // ViewFilter::NotesAndReplies + { + let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect(); + + let reversed = false; + self.view(ndb, columns, threads, txn, ViewFilter::NotesAndReplies) + .insert(&refs, reversed); + } + + // + // 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 + { + 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); + } + } + + self.view(ndb, columns, threads, txn, ViewFilter::Notes) + .insert(&filtered_refs, reversed); + } + + Ok(()) + } +} + +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +pub enum ViewFilter { + Notes, + + #[default] + NotesAndReplies, +} + +impl ViewFilter { + pub fn name(&self) -> &'static str { + match self { + ViewFilter::Notes => "Notes", + ViewFilter::NotesAndReplies => "Notes & Replies", + } + } + + pub fn index(&self) -> usize { + match self { + ViewFilter::Notes => 0, + ViewFilter::NotesAndReplies => 1, + } + } + + pub fn filter_notes(cache: &CachedNote, note: &Note) -> bool { + !cache.reply.borrow(note.tags()).is_reply() + } + + fn identity(_cache: &CachedNote, _note: &Note) -> bool { + true + } + + pub fn filter(&self) -> fn(&CachedNote, &Note) -> bool { + match self { + ViewFilter::Notes => ViewFilter::filter_notes, + ViewFilter::NotesAndReplies => ViewFilter::identity, + } + } +} + +/// 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 TimelineTab is a further filtered view of this Filter that can't +/// be captured by a Filter itself. +#[derive(Default, Debug)] +pub struct TimelineTab { + pub notes: Vec<NoteRef>, + pub selection: i32, + pub filter: ViewFilter, + pub list: Rc<RefCell<VirtualList>>, +} + +impl TimelineTab { + pub fn new(filter: ViewFilter) -> Self { + TimelineTab::new_with_capacity(filter, 1000) + } + + pub fn new_with_capacity(filter: ViewFilter, cap: usize) -> Self { + let selection = 0i32; + let mut list = VirtualList::new(); + list.hide_on_resize(None); + list.over_scan(1000.0); + let list = Rc::new(RefCell::new(list)); + let notes: Vec<NoteRef> = Vec::with_capacity(cap); + + TimelineTab { + notes, + selection, + filter, + list, + } + } + + pub fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) { + if new_refs.is_empty() { + return; + } + 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 { + list.items_inserted_at_start(new_items); + } + } + } + } + } + + pub fn select_down(&mut self) { + debug!("select_down {}", self.selection + 1); + if self.selection + 1 > self.notes.len() as i32 { + return; + } + + self.selection += 1; + } + + pub fn select_up(&mut self) { + debug!("select_up {}", self.selection - 1); + if self.selection - 1 < 0 { + return; + } + + self.selection -= 1; + } +} + +/// A column in a deck. Holds navigation state, loaded notes, column kind, etc. +#[derive(Debug)] +pub struct Timeline { + pub id: TimelineId, + pub kind: TimelineKind, + // We may not have the filter loaded yet, so let's make it an option so + // that codepaths have to explicitly handle it + pub filter: FilterState, + pub views: Vec<TimelineTab>, + pub selected_view: i32, + + /// Our nostrdb subscription + pub subscription: Option<Subscription>, +} + +impl Timeline { + /// Create a timeline from a contact list + pub fn contact_list(contact_list: &Note) -> Result<Self> { + let filter = filter::filter_from_tags(contact_list)?.into_follow_filter(); + let pk_src = PubkeySource::Explicit(Pubkey::new(*contact_list.pubkey())); + + Ok(Timeline::new( + TimelineKind::contact_list(pk_src), + FilterState::ready(filter), + )) + } + + pub fn make_view_id(id: TimelineId, selected_view: i32) -> egui::Id { + egui::Id::new((id, selected_view)) + } + + pub fn view_id(&self) -> egui::Id { + Timeline::make_view_id(self.id, self.selected_view) + } + + pub fn new(kind: TimelineKind, filter: FilterState) -> Self { + // global unique id for all new timelines + static UIDS: AtomicU32 = AtomicU32::new(0); + + let subscription: Option<Subscription> = None; + let notes = TimelineTab::new(ViewFilter::Notes); + let replies = TimelineTab::new(ViewFilter::NotesAndReplies); + let views = vec![notes, replies]; + let selected_view = 0; + let id = TimelineId::new(UIDS.fetch_add(1, Ordering::Relaxed)); + + Timeline { + id, + kind, + filter, + views, + subscription, + selected_view, + } + } + + pub fn current_view(&self) -> &TimelineTab { + &self.views[self.selected_view as usize] + } + + pub fn current_view_mut(&mut self) -> &mut TimelineTab { + &mut self.views[self.selected_view as usize] + } + + pub fn notes(&self, view: ViewFilter) -> &[NoteRef] { + &self.views[view.index()].notes + } + + pub fn view(&self, view: ViewFilter) -> &TimelineTab { + &self.views[view.index()] + } + + pub fn view_mut(&mut self, view: ViewFilter) -> &mut TimelineTab { + &mut self.views[view.index()] + } +} + +pub enum MergeKind { + FrontInsert, + Spliced, +} + +pub fn merge_sorted_vecs<T: Ord + Copy>(vec1: &[T], vec2: &[T]) -> (Vec<T>, MergeKind) { + let mut merged = Vec::with_capacity(vec1.len() + vec2.len()); + let mut i = 0; + let mut j = 0; + let mut result: Option<MergeKind> = None; + + while i < vec1.len() && j < vec2.len() { + if vec1[i] <= vec2[j] { + if result.is_none() && j < vec2.len() { + // if we're pushing from our large list and still have + // some left in vec2, then this is a splice + result = Some(MergeKind::Spliced); + } + merged.push(vec1[i]); + i += 1; + } else { + merged.push(vec2[j]); + j += 1; + } + } + + // Append any remaining elements from either vector + if i < vec1.len() { + merged.extend_from_slice(&vec1[i..]); + } + if j < vec2.len() { + merged.extend_from_slice(&vec2[j..]); + } + + (merged, result.unwrap_or(MergeKind::FrontInsert)) +} diff --git a/src/ui/account_management.rs b/src/ui/account_management.rs @@ -40,7 +40,7 @@ impl AccountManagementView { let maybe_remove = profile_preview_controller::set_profile_previews(app, ui, account_card_ui()); - Self::maybe_remove_accounts(&mut app.account_manager, maybe_remove); + Self::maybe_remove_accounts(&mut app.accounts, maybe_remove); } fn show_accounts_mobile(app: &mut Damus, ui: &mut egui::Ui) { @@ -56,7 +56,7 @@ impl AccountManagementView { ); // remove all account indicies user requested - Self::maybe_remove_accounts(&mut app.account_manager, maybe_remove); + Self::maybe_remove_accounts(&mut app.accounts, maybe_remove); }, ); } @@ -95,8 +95,8 @@ impl AccountManagementView { // Layout::right_to_left(egui::Align::Center), // |ui| { // if ui.add(logout_all_button()).clicked() { - // for index in (0..self.account_manager.num_accounts()).rev() { - // self.account_manager.remove_account(index); + // for index in (0..self.accounts.num_accounts()).rev() { + // self.accounts.remove_account(index); // } // } // }, diff --git a/src/ui/account_switcher.rs b/src/ui/account_switcher.rs @@ -49,12 +49,10 @@ impl AccountSelectionWidget { fn perform_action(app: &mut Damus, action: AccountSelectAction) { match action { - AccountSelectAction::RemoveAccount { _index } => { - app.account_manager.remove_account(_index) - } + AccountSelectAction::RemoveAccount { _index } => app.accounts.remove_account(_index), AccountSelectAction::SelectAccount { _index } => { app.show_account_switcher = false; - app.account_manager.select_account(_index); + app.accounts.select_account(_index); } AccountSelectAction::OpenAccountManagement => { app.show_account_switcher = false; @@ -66,7 +64,7 @@ impl AccountSelectionWidget { fn show(app: &mut Damus, ui: &mut egui::Ui) -> (AccountSelectResponse, egui::Response) { let mut res = AccountSelectResponse::default(); - let mut selected_index = app.account_manager.get_selected_account_index(); + let mut selected_index = app.accounts.get_selected_account_index(); let response = Frame::none() .outer_margin(8.0) @@ -83,7 +81,7 @@ impl AccountSelectionWidget { ui.add(add_account_button()); if let Some(_index) = selected_index { - if let Some(account) = app.account_manager.get_account(_index) { + if let Some(account) = app.accounts.get_account(_index) { ui.add_space(8.0); if Self::handle_sign_out(&app.ndb, ui, account) { res.action = Some(AccountSelectAction::RemoveAccount { _index }) diff --git a/src/ui/mention.rs b/src/ui/mention.rs @@ -1,8 +1,9 @@ -use crate::{colors, ui, Damus}; -use nostrdb::Transaction; +use crate::{colors, imgcache::ImageCache, ui}; +use nostrdb::{Ndb, Transaction}; pub struct Mention<'a> { - app: &'a mut Damus, + ndb: &'a Ndb, + img_cache: &'a mut ImageCache, txn: &'a Transaction, pk: &'a [u8; 32], selectable: bool, @@ -10,11 +11,17 @@ pub struct Mention<'a> { } impl<'a> Mention<'a> { - pub fn new(app: &'a mut Damus, txn: &'a Transaction, pk: &'a [u8; 32]) -> Self { + pub fn new( + ndb: &'a Ndb, + img_cache: &'a mut ImageCache, + txn: &'a Transaction, + pk: &'a [u8; 32], + ) -> Self { let size = 16.0; let selectable = true; Mention { - app, + ndb, + img_cache, txn, pk, selectable, @@ -35,12 +42,21 @@ impl<'a> Mention<'a> { impl<'a> egui::Widget for Mention<'a> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - mention_ui(self.app, self.txn, self.pk, ui, self.size, self.selectable) + mention_ui( + self.ndb, + self.img_cache, + self.txn, + self.pk, + ui, + self.size, + self.selectable, + ) } } fn mention_ui( - app: &mut Damus, + ndb: &Ndb, + img_cache: &mut ImageCache, txn: &Transaction, pk: &[u8; 32], ui: &mut egui::Ui, @@ -51,7 +67,7 @@ fn mention_ui( puffin::profile_function!(); ui.horizontal(|ui| { - let profile = app.ndb.get_profile_by_pubkey(txn, pk).ok(); + let profile = ndb.get_profile_by_pubkey(txn, pk).ok(); let name: String = if let Some(name) = profile.as_ref().and_then(crate::profile::get_profile_name) { @@ -68,7 +84,7 @@ fn mention_ui( if let Some(rec) = profile.as_ref() { resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); - ui.add(ui::ProfilePreview::new(rec, &mut app.img_cache)); + ui.add(ui::ProfilePreview::new(rec, img_cache)); }); } }) diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -31,7 +31,9 @@ pub use username::Username; use egui::Margin; /// This is kind of like the Widget trait but is meant for larger top-level -/// views that are typically stateful. The Widget trait forces us to add mutable +/// views that are typically stateful. +/// +/// The Widget trait forces us to add mutable /// implementations at the type level, which screws us when generating Previews /// for a Widget. I would have just Widget instead of making this Trait otherwise. /// diff --git a/src/ui/note/contents.rs b/src/ui/note/contents.rs @@ -1,14 +1,17 @@ use crate::images::ImageType; use crate::imgcache::ImageCache; +use crate::notecache::NoteCache; use crate::ui::note::NoteOptions; use crate::ui::ProfilePic; -use crate::{colors, ui, Damus}; +use crate::{colors, ui}; use egui::{Color32, Hyperlink, Image, RichText}; -use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; +use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; use tracing::warn; pub struct NoteContents<'a> { - damus: &'a mut Damus, + ndb: &'a Ndb, + img_cache: &'a mut ImageCache, + note_cache: &'a mut NoteCache, txn: &'a Transaction, note: &'a Note<'a>, note_key: NoteKey, @@ -17,14 +20,18 @@ pub struct NoteContents<'a> { impl<'a> NoteContents<'a> { pub fn new( - damus: &'a mut Damus, + ndb: &'a Ndb, + img_cache: &'a mut ImageCache, + note_cache: &'a mut NoteCache, txn: &'a Transaction, note: &'a Note, note_key: NoteKey, options: ui::note::NoteOptions, ) -> Self { NoteContents { - damus, + ndb, + img_cache, + note_cache, txn, note, note_key, @@ -37,7 +44,9 @@ impl egui::Widget for NoteContents<'_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { render_note_contents( ui, - self.damus, + self.ndb, + self.img_cache, + self.note_cache, self.txn, self.note, self.note_key, @@ -51,7 +60,9 @@ impl egui::Widget for NoteContents<'_> { /// notes are references within a note fn render_note_preview( ui: &mut egui::Ui, - app: &mut Damus, + ndb: &Ndb, + note_cache: &mut NoteCache, + img_cache: &mut ImageCache, txn: &Transaction, id: &[u8; 32], _id_str: &str, @@ -59,7 +70,7 @@ fn render_note_preview( #[cfg(feature = "profiling")] puffin::profile_function!(); - let note = if let Ok(note) = app.ndb.get_note_by_id(txn, id) { + let note = if let Ok(note) = ndb.get_note_by_id(txn, id) { // TODO: support other preview kinds if note.kind() == 1 { note @@ -92,7 +103,7 @@ fn render_note_preview( ui.visuals().noninteractive().bg_stroke.color, )) .show(ui, |ui| { - ui::NoteView::new(app, &note) + ui::NoteView::new(ndb, note_cache, img_cache, &note) .actionbar(false) .small_pfp(true) .wide(true) @@ -102,9 +113,12 @@ fn render_note_preview( .response } +#[allow(clippy::too_many_arguments)] fn render_note_contents( ui: &mut egui::Ui, - damus: &mut Damus, + ndb: &Ndb, + img_cache: &mut ImageCache, + note_cache: &mut NoteCache, txn: &Transaction, note: &Note, note_key: NoteKey, @@ -118,7 +132,7 @@ fn render_note_contents( let mut inline_note: Option<(&[u8; 32], &str)> = None; let resp = ui.horizontal_wrapped(|ui| { - let blocks = if let Ok(blocks) = damus.ndb.get_blocks_by_key(txn, note_key) { + let blocks = if let Ok(blocks) = ndb.get_blocks_by_key(txn, note_key) { blocks } else { warn!("missing note content blocks? '{}'", note.content()); @@ -132,11 +146,11 @@ fn render_note_contents( match block.blocktype() { BlockType::MentionBech32 => match block.as_mention().unwrap() { Mention::Profile(profile) => { - ui.add(ui::Mention::new(damus, txn, profile.pubkey())); + ui.add(ui::Mention::new(ndb, img_cache, txn, profile.pubkey())); } Mention::Pubkey(npub) => { - ui.add(ui::Mention::new(damus, txn, npub.pubkey())); + ui.add(ui::Mention::new(ndb, img_cache, txn, npub.pubkey())); } Mention::Note(note) if options.has_note_previews() => { @@ -186,13 +200,13 @@ fn render_note_contents( }); if let Some((id, block_str)) = inline_note { - render_note_preview(ui, damus, txn, id, block_str); + render_note_preview(ui, ndb, note_cache, img_cache, txn, id, block_str); } - if !images.is_empty() && !damus.textmode { + if !images.is_empty() && !options.has_textmode() { ui.add_space(2.0); let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); - image_carousel(ui, &mut damus.img_cache, images, carousel_id); + image_carousel(ui, img_cache, images, carousel_id); ui.add_space(2.0); } diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs @@ -8,12 +8,21 @@ pub use options::NoteOptions; pub use post::{PostAction, PostResponse, PostView}; pub use reply::PostReplyView; -use crate::{actionbar::BarAction, colors, notecache::CachedNote, ui, ui::View, Damus}; +use crate::{ + actionbar::BarAction, + colors, + imgcache::ImageCache, + notecache::{CachedNote, NoteCache}, + ui, + ui::View, +}; use egui::{Label, RichText, Sense}; -use nostrdb::{Note, NoteKey, NoteReply, Transaction}; +use nostrdb::{Ndb, Note, NoteKey, NoteReply, Transaction}; pub struct NoteView<'a> { - app: &'a mut Damus, + ndb: &'a Ndb, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, note: &'a nostrdb::Note<'a>, flags: NoteOptions, } @@ -29,7 +38,13 @@ impl<'a> View for NoteView<'a> { } } -fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: &mut Damus) { +fn reply_desc( + ui: &mut egui::Ui, + txn: &Transaction, + note_reply: &NoteReply, + ndb: &Ndb, + img_cache: &mut ImageCache, +) { #[cfg(feature = "profiling")] puffin::profile_function!(); @@ -51,7 +66,7 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: return; }; - let reply_note = if let Ok(reply_note) = app.ndb.get_note_by_id(txn, reply.id) { + let reply_note = if let Ok(reply_note) = ndb.get_note_by_id(txn, reply.id) { reply_note } else { ui.add( @@ -68,7 +83,7 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: if note_reply.is_reply_to_root() { // We're replying to the root, let's show this ui.add( - ui::Mention::new(app, txn, reply_note.pubkey()) + ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable), ); @@ -83,11 +98,11 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: } else if let Some(root) = note_reply.root() { // replying to another post in a thread, not the root - if let Ok(root_note) = app.ndb.get_note_by_id(txn, root.id) { + if let Ok(root_note) = ndb.get_note_by_id(txn, root.id) { if root_note.pubkey() == reply_note.pubkey() { // simply "replying to bob's note" when replying to bob in his thread ui.add( - ui::Mention::new(app, txn, reply_note.pubkey()) + ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable), ); @@ -103,7 +118,7 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: // replying to bob in alice's thread ui.add( - ui::Mention::new(app, txn, reply_note.pubkey()) + ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable), ); @@ -112,7 +127,7 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: .selectable(selectable), ); ui.add( - ui::Mention::new(app, txn, root_note.pubkey()) + ui::Mention::new(ndb, img_cache, txn, root_note.pubkey()) .size(size) .selectable(selectable), ); @@ -127,7 +142,7 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: } } else { ui.add( - ui::Mention::new(app, txn, reply_note.pubkey()) + ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable), ); @@ -144,9 +159,25 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: } impl<'a> NoteView<'a> { - pub fn new(app: &'a mut Damus, note: &'a nostrdb::Note<'a>) -> Self { + pub fn new( + ndb: &'a Ndb, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + note: &'a nostrdb::Note<'a>, + ) -> Self { let flags = NoteOptions::actionbar | NoteOptions::note_previews; - Self { app, note, flags } + Self { + ndb, + note_cache, + img_cache, + note, + flags, + } + } + + pub fn textmode(mut self, enable: bool) -> Self { + self.options_mut().set_textmode(enable); + self } pub fn actionbar(mut self, enable: bool) -> Self { @@ -192,14 +223,13 @@ impl<'a> NoteView<'a> { let txn = self.note.txn().expect("todo: implement non-db notes"); ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { - let profile = self.app.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); + let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); //ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let cached_note = self - .app - .note_cache_mut() + .note_cache .cached_note_or_insert_mut(note_key, self.note); let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); @@ -218,7 +248,13 @@ impl<'a> NoteView<'a> { }); ui.add(NoteContents::new( - self.app, txn, self.note, note_key, self.flags, + self.ndb, + self.img_cache, + self.note_cache, + txn, + self.note, + note_key, + self.flags, )); //}); }) @@ -255,33 +291,26 @@ impl<'a> NoteView<'a> { let profile_key = profile.as_ref().unwrap().record().note_key(); let note_key = note_key.as_u64(); - if ui::is_narrow(ui.ctx()) { - ui.add(ui::ProfilePic::new(&mut self.app.img_cache, pic)); - } else { - let (rect, size, _resp) = ui::anim::hover_expand( - ui, - egui::Id::new((profile_key, note_key)), - pfp_size, - ui::NoteView::expand_size(), - anim_speed, - ); - - ui.put( - rect, - ui::ProfilePic::new(&mut self.app.img_cache, pic).size(size), - ) + let (rect, size, _resp) = ui::anim::hover_expand( + ui, + egui::Id::new((profile_key, note_key)), + pfp_size, + ui::NoteView::expand_size(), + anim_speed, + ); + + ui.put(rect, ui::ProfilePic::new(self.img_cache, pic).size(size)) .on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); ui.add(ui::ProfilePreview::new( profile.as_ref().unwrap(), - &mut self.app.img_cache, + self.img_cache, )); }); - } } None => { ui.add( - ui::ProfilePic::new(&mut self.app.img_cache, ui::ProfilePic::no_pfp_url()) + ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()) .size(pfp_size), ); } @@ -289,7 +318,7 @@ impl<'a> NoteView<'a> { } pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { - if self.app.textmode { + if self.options().has_textmode() { NoteResponse { response: self.textmode_ui(ui), action: None, @@ -301,7 +330,7 @@ impl<'a> NoteView<'a> { fn note_header( ui: &mut egui::Ui, - app: &mut Damus, + note_cache: &mut NoteCache, note: &Note, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, ) -> egui::Response { @@ -311,9 +340,7 @@ impl<'a> NoteView<'a> { ui.spacing_mut().item_spacing.x = 2.0; ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); - let cached_note = app - .note_cache_mut() - .cached_note_or_insert_mut(note_key, note); + let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); render_reltime(ui, cached_note, true); }) .response @@ -325,7 +352,7 @@ impl<'a> NoteView<'a> { 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 profile = self.app.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); + let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); // wide design let response = if self.options().has_wide() { @@ -336,28 +363,29 @@ impl<'a> NoteView<'a> { ui.vertical(|ui| { ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| { ui.horizontal_centered(|ui| { - NoteView::note_header(ui, self.app, self.note, &profile); + NoteView::note_header(ui, self.note_cache, self.note, &profile); }) .response }); let note_reply = self - .app - .note_cache_mut() + .note_cache .cached_note_or_insert_mut(note_key, self.note) .reply .borrow(self.note.tags()); if note_reply.reply().is_some() { ui.horizontal(|ui| { - reply_desc(ui, txn, &note_reply, self.app); + reply_desc(ui, txn, &note_reply, self.ndb, self.img_cache); }); } }); }); let resp = ui.add(NoteContents::new( - self.app, + self.ndb, + self.img_cache, + self.note_cache, txn, self.note, note_key, @@ -375,25 +403,26 @@ impl<'a> NoteView<'a> { self.pfp(note_key, &profile, ui); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - NoteView::note_header(ui, self.app, self.note, &profile); + NoteView::note_header(ui, self.note_cache, self.note, &profile); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let note_reply = self - .app - .note_cache_mut() + .note_cache .cached_note_or_insert_mut(note_key, self.note) .reply .borrow(self.note.tags()); if note_reply.reply().is_some() { - reply_desc(ui, txn, &note_reply, self.app); + reply_desc(ui, txn, &note_reply, self.ndb, self.img_cache); } }); ui.add(NoteContents::new( - self.app, + self.ndb, + self.img_cache, + self.note_cache, txn, self.note, note_key, diff --git a/src/ui/note/options.rs b/src/ui/note/options.rs @@ -12,6 +12,7 @@ bitflags! { const medium_pfp = 0b00001000; const wide = 0b00010000; const selectable_text = 0b00100000; + const textmode = 0b01000000; } } @@ -33,6 +34,7 @@ impl NoteOptions { create_setter!(set_medium_pfp, medium_pfp); create_setter!(set_note_previews, note_previews); create_setter!(set_selectable_text, selectable_text); + create_setter!(set_textmode, textmode); create_setter!(set_actionbar, actionbar); #[inline] @@ -46,6 +48,11 @@ impl NoteOptions { } #[inline] + pub fn has_textmode(self) -> bool { + (self & NoteOptions::textmode) == NoteOptions::textmode + } + + #[inline] pub fn has_note_previews(self) -> bool { (self & NoteOptions::note_previews) == NoteOptions::note_previews } diff --git a/src/ui/note/post.rs b/src/ui/note/post.rs @@ -1,16 +1,17 @@ -use crate::app::Damus; -use crate::draft::{Draft, DraftSource}; +use crate::draft::Draft; +use crate::imgcache::ImageCache; use crate::post::NewPost; use crate::ui; use crate::ui::{Preview, PreviewConfig, View}; use egui::widgets::text_edit::TextEdit; -use nostrdb::Transaction; - -pub struct PostView<'app, 'd> { - app: &'app mut Damus, - /// account index - poster: usize, - draft_source: DraftSource<'d>, +use enostr::{FilledKeypair, FullKeypair}; +use nostrdb::{Config, Ndb, Transaction}; + +pub struct PostView<'a> { + ndb: &'a Ndb, + draft: &'a mut Draft, + img_cache: &'a mut ImageCache, + poster: FilledKeypair<'a>, id_source: Option<egui::Id>, } @@ -23,14 +24,20 @@ pub struct PostResponse { pub edit_response: egui::Response, } -impl<'app, 'd> PostView<'app, 'd> { - pub fn new(app: &'app mut Damus, draft_source: DraftSource<'d>, poster: usize) -> Self { +impl<'a> PostView<'a> { + pub fn new( + ndb: &'a Ndb, + draft: &'a mut Draft, + img_cache: &'a mut ImageCache, + poster: FilledKeypair<'a>, + ) -> Self { let id_source: Option<egui::Id> = None; PostView { - id_source, - app, + ndb, + draft, + img_cache, poster, - draft_source, + id_source, } } @@ -39,47 +46,30 @@ impl<'app, 'd> PostView<'app, 'd> { self } - fn draft(&mut self) -> &mut Draft { - self.draft_source.draft(&mut self.app.drafts) - } - fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response { ui.spacing_mut().item_spacing.x = 12.0; let pfp_size = 24.0; - let poster_pubkey = self - .app - .account_manager - .get_account(self.poster) - .map(|acc| acc.pubkey.bytes()) - .unwrap_or(crate::test_data::test_pubkey()); - // TODO: refactor pfp control to do all of this for us let poster_pfp = self - .app .ndb - .get_profile_by_pubkey(txn, poster_pubkey) + .get_profile_by_pubkey(txn, self.poster.pubkey.bytes()) .as_ref() .ok() - .and_then(|p| { - Some(ui::ProfilePic::from_profile(&mut self.app.img_cache, p)?.size(pfp_size)) - }); + .and_then(|p| Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size))); if let Some(pfp) = poster_pfp { ui.add(pfp); } else { ui.add( - ui::ProfilePic::new(&mut self.app.img_cache, ui::ProfilePic::no_pfp_url()) - .size(pfp_size), + ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size), ); } - let buffer = &mut self.draft_source.draft(&mut self.app.drafts).buffer; - let response = ui.add_sized( ui.available_size(), - TextEdit::multiline(buffer) + TextEdit::multiline(&mut self.draft.buffer) .hint_text(egui::RichText::new("Write a banger note here...").weak()) .frame(false), ); @@ -144,10 +134,10 @@ impl<'app, 'd> PostView<'app, 'd> { .add_sized([91.0, 32.0], egui::Button::new("Post now")) .clicked() { - Some(PostAction::Post(NewPost { - content: self.draft().buffer.clone(), - account: self.poster, - })) + Some(PostAction::Post(NewPost::new( + self.draft.buffer.clone(), + self.poster.to_full(), + ))) } else { None } @@ -167,28 +157,41 @@ impl<'app, 'd> PostView<'app, 'd> { mod preview { use super::*; - use crate::test_data; pub struct PostPreview { - app: Damus, + ndb: Ndb, + img_cache: ImageCache, + draft: Draft, + poster: FullKeypair, } impl PostPreview { fn new() -> Self { + let ndb = Ndb::new(".", &Config::new()).expect("ndb"); + PostPreview { - app: test_data::test_app(), + ndb, + img_cache: ImageCache::new(".".into()), + draft: Draft::new(), + poster: FullKeypair::generate(), } } } impl View for PostPreview { fn ui(&mut self, ui: &mut egui::Ui) { - let txn = Transaction::new(&self.app.ndb).unwrap(); - PostView::new(&mut self.app, DraftSource::Compose, 0).ui(&txn, ui); + let txn = Transaction::new(&self.ndb).expect("txn"); + PostView::new( + &self.ndb, + &mut self.draft, + &mut self.img_cache, + self.poster.to_filled(), + ) + .ui(&txn, ui); } } - impl<'app, 'p> Preview for PostView<'app, 'p> { + impl<'a> Preview for PostView<'a> { type Prev = PostPreview; fn preview(_cfg: PreviewConfig) -> Self::Prev { diff --git a/src/ui/note/reply.rs b/src/ui/note/reply.rs @@ -1,21 +1,43 @@ -use crate::draft::DraftSource; +use crate::draft::Drafts; +use crate::imgcache::ImageCache; +use crate::notecache::NoteCache; +use crate::ui; use crate::ui::note::{PostAction, PostResponse}; -use crate::{ui, Damus}; +use enostr::{FilledKeypair, RelayPool}; +use nostrdb::Ndb; use tracing::info; pub struct PostReplyView<'a> { - app: &'a mut Damus, - id_source: Option<egui::Id>, + ndb: &'a Ndb, + poster: FilledKeypair<'a>, + pool: &'a mut RelayPool, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + drafts: &'a mut Drafts, note: &'a nostrdb::Note<'a>, + id_source: Option<egui::Id>, } impl<'a> PostReplyView<'a> { - pub fn new(app: &'a mut Damus, note: &'a nostrdb::Note<'a>) -> Self { + pub fn new( + ndb: &'a Ndb, + poster: FilledKeypair<'a>, + pool: &'a mut RelayPool, + drafts: &'a mut Drafts, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + note: &'a nostrdb::Note<'a>, + ) -> Self { let id_source: Option<egui::Id> = None; PostReplyView { - app, - id_source, + ndb, + poster, + pool, + drafts, note, + note_cache, + img_cache, + id_source, } } @@ -46,7 +68,7 @@ impl<'a> PostReplyView<'a> { egui::Frame::none() .outer_margin(egui::Margin::same(note_offset)) .show(ui, |ui| { - ui::NoteView::new(self.app, self.note) + ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, self.note) .actionbar(false) .medium_pfp(true) .show(ui); @@ -54,43 +76,26 @@ impl<'a> PostReplyView<'a> { let id = self.id(); let replying_to = self.note.id(); - let draft_source = DraftSource::Reply(replying_to); - let poster = self - .app - .account_manager - .get_selected_account_index() - .unwrap_or(0); let rect_before_post = ui.min_rect(); - let post_response = ui::PostView::new(self.app, draft_source, poster) - .id_source(id) - .ui(self.note.txn().unwrap(), ui); - - if self - .app - .account_manager - .get_selected_account() - .map_or(false, |a| a.secret_key.is_some()) - { - if let Some(action) = &post_response.action { - match action { - PostAction::Post(np) => { - let seckey = self - .app - .account_manager - .get_account(poster) - .unwrap() - .secret_key - .as_ref() - .unwrap() - .to_secret_bytes(); - - let note = np.to_reply(&seckey, self.note); - - let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap()); - info!("sending {}", raw_msg); - self.app.pool.send(&enostr::ClientMessage::raw(raw_msg)); - self.app.drafts.clear(DraftSource::Reply(replying_to)); - } + + let post_response = { + let draft = self.drafts.reply_mut(replying_to); + ui::PostView::new(self.ndb, draft, self.img_cache, self.poster) + .id_source(id) + .ui(self.note.txn().unwrap(), ui) + }; + + if let Some(action) = &post_response.action { + match action { + PostAction::Post(np) => { + let seckey = self.poster.secret_key.to_secret_bytes(); + + let note = np.to_reply(&seckey, self.note); + + let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap()); + info!("sending {}", raw_msg); + self.pool.send(&enostr::ClientMessage::raw(raw_msg)); + self.drafts.reply_mut(replying_to).clear(); } } } diff --git a/src/ui/profile/profile_preview_controller.rs b/src/ui/profile/profile_preview_controller.rs @@ -33,8 +33,8 @@ pub fn set_profile_previews( return None; }; - for i in 0..app.account_manager.num_accounts() { - let account = if let Some(account) = app.account_manager.get_account(i) { + for i in 0..app.accounts.num_accounts() { + let account = if let Some(account) = app.accounts.get_account(i) { account } else { continue; @@ -47,7 +47,7 @@ pub fn set_profile_previews( let preview = SimpleProfilePreview::new(profile.as_ref(), &mut app.img_cache); - let is_selected = if let Some(selected) = app.account_manager.get_selected_account_index() { + let is_selected = if let Some(selected) = app.accounts.get_selected_account_index() { i == selected } else { false @@ -66,7 +66,7 @@ pub fn set_profile_previews( } to_remove.as_mut().unwrap().push(i); } - ProfilePreviewOp::SwitchTo => app.account_manager.select_account(i), + ProfilePreviewOp::SwitchTo => app.accounts.select_account(i), } } @@ -92,8 +92,8 @@ pub fn view_profile_previews( return None; }; - for i in 0..app.account_manager.num_accounts() { - let account = if let Some(account) = app.account_manager.get_account(i) { + for i in 0..app.accounts.num_accounts() { + let account = if let Some(account) = app.accounts.get_account(i) { account } else { continue; @@ -106,7 +106,7 @@ pub fn view_profile_previews( let preview = SimpleProfilePreview::new(profile.as_ref(), &mut app.img_cache); - let is_selected = if let Some(selected) = app.account_manager.get_selected_account_index() { + let is_selected = if let Some(selected) = app.accounts.get_selected_account_index() { i == selected } else { false @@ -136,7 +136,7 @@ pub fn show_with_selected_pfp( ui: &mut egui::Ui, ui_element: fn(ui: &mut egui::Ui, pfp: ProfilePic) -> egui::Response, ) -> Option<egui::Response> { - let selected_account = app.account_manager.get_selected_account(); + let selected_account = app.accounts.get_selected_account(); if let Some(selected_account) = selected_account { if let Ok(txn) = Transaction::new(&app.ndb) { let profile = app diff --git a/src/ui/thread.rs b/src/ui/thread.rs @@ -1,28 +1,57 @@ -use crate::{actionbar::BarResult, timeline::TimelineSource, ui, Damus}; -use nostrdb::{NoteKey, Transaction}; +use crate::{ + actionbar::BarResult, column::Columns, imgcache::ImageCache, notecache::NoteCache, + thread::Threads, timeline::TimelineSource, ui, unknowns::UnknownIds, +}; +use enostr::RelayPool; +use nostrdb::{Ndb, NoteKey, Transaction}; use tracing::{error, warn}; pub struct ThreadView<'a> { - app: &'a mut Damus, - timeline: usize, + column: usize, + columns: &'a mut Columns, + threads: &'a mut Threads, + ndb: &'a Ndb, + pool: &'a mut RelayPool, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + unknown_ids: &'a mut UnknownIds, selected_note_id: &'a [u8; 32], + textmode: bool, } impl<'a> ThreadView<'a> { - pub fn new(app: &'a mut Damus, timeline: usize, selected_note_id: &'a [u8; 32]) -> Self { + #[allow(clippy::too_many_arguments)] + pub fn new( + column: usize, + columns: &'a mut Columns, + threads: &'a mut Threads, + ndb: &'a Ndb, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + unknown_ids: &'a mut UnknownIds, + pool: &'a mut RelayPool, + textmode: bool, + selected_note_id: &'a [u8; 32], + ) -> Self { ThreadView { - app, - timeline, + column, + columns, + threads, + ndb, + note_cache, + img_cache, + textmode, selected_note_id, + unknown_ids, + pool, } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<BarResult> { - let txn = Transaction::new(&self.app.ndb).expect("txn"); + let txn = Transaction::new(self.ndb).expect("txn"); let mut result: Option<BarResult> = None; let selected_note_key = if let Ok(key) = self - .app .ndb .get_notekey_by_id(&txn, self.selected_note_id) .map(NoteKey::new) @@ -33,12 +62,13 @@ impl<'a> ThreadView<'a> { return None; }; - let scroll_id = egui::Id::new(( - "threadscroll", - self.app.timelines[self.timeline].selected_view, - self.timeline, - selected_note_key, - )); + let scroll_id = { + egui::Id::new(( + "threadscroll", + self.columns.column(self.column).view_id(), + selected_note_key, + )) + }; ui.label( egui::RichText::new("Threads ALPHA! It's not done. Things will be broken.") @@ -51,7 +81,7 @@ 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.app.ndb.get_note_by_key(&txn, selected_note_key) { + let note = if let Ok(note) = self.ndb.get_note_by_key(&txn, selected_note_key) { note } else { return; @@ -59,8 +89,7 @@ impl<'a> ThreadView<'a> { let root_id = { let cached_note = self - .app - .note_cache_mut() + .note_cache .cached_note_or_insert(selected_note_key, &note); cached_note @@ -71,17 +100,19 @@ impl<'a> ThreadView<'a> { }; // poll for new notes and insert them into our existing notes - if let Err(e) = TimelineSource::Thread(root_id).poll_notes_into_view(&txn, self.app) - { + if let Err(e) = TimelineSource::Thread(root_id).poll_notes_into_view( + &txn, + self.ndb, + self.columns, + self.threads, + self.unknown_ids, + self.note_cache, + ) { error!("Thread::poll_notes_into_view: {e}"); } let (len, list) = { - let thread = self - .app - .threads - .thread_mut(&self.app.ndb, &txn, root_id) - .get_ptr(); + let thread = self.threads.thread_mut(self.ndb, &txn, root_id).get_ptr(); let len = thread.view.notes.len(); (len, &mut thread.view.list) @@ -95,15 +126,11 @@ impl<'a> ThreadView<'a> { let ind = len - 1 - start_index; let note_key = { - let thread = self - .app - .threads - .thread_mut(&self.app.ndb, &txn, root_id) - .get_ptr(); + let thread = self.threads.thread_mut(self.ndb, &txn, root_id).get_ptr(); thread.view.notes[ind].key }; - let note = if let Ok(note) = self.app.ndb.get_note_by_key(&txn, note_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); @@ -111,13 +138,22 @@ impl<'a> ThreadView<'a> { }; ui::padding(8.0, ui, |ui| { - let textmode = self.app.textmode; - let resp = ui::NoteView::new(self.app, &note) - .note_previews(!textmode) - .show(ui); + let resp = + ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, &note) + .note_previews(!self.textmode) + .textmode(self.textmode) + .show(ui); if let Some(action) = resp.action { - let br = action.execute(self.app, self.timeline, note.id(), &txn); + let br = action.execute( + self.ndb, + self.columns.column_mut(self.column), + self.threads, + self.note_cache, + self.pool, + note.id(), + &txn, + ); if br.is_some() { result = br; } diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs @@ -1,28 +1,67 @@ -use crate::{actionbar::BarResult, draft::DraftSource, ui, ui::note::PostAction, Damus}; +use crate::{ + actionbar::BarAction, + actionbar::BarResult, + column::{Column, ColumnKind}, + draft::Drafts, + imgcache::ImageCache, + notecache::NoteCache, + thread::Threads, + ui, + ui::note::PostAction, +}; use egui::containers::scroll_area::ScrollBarVisibility; use egui::{Direction, Layout}; use egui_tabs::TabColor; -use nostrdb::Transaction; +use enostr::{FilledKeypair, RelayPool}; +use nostrdb::{Ndb, Note, Transaction}; use tracing::{debug, info, warn}; pub struct TimelineView<'a> { - app: &'a mut Damus, + ndb: &'a Ndb, + column: &'a mut Column, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + threads: &'a mut Threads, + pool: &'a mut RelayPool, + textmode: bool, reverse: bool, - timeline: usize, } impl<'a> TimelineView<'a> { - pub fn new(app: &'a mut Damus, timeline: usize) -> TimelineView<'a> { + pub fn new( + ndb: &'a Ndb, + column: &'a mut Column, + note_cache: &'a mut NoteCache, + img_cache: &'a mut ImageCache, + threads: &'a mut Threads, + pool: &'a mut RelayPool, + textmode: bool, + ) -> TimelineView<'a> { let reverse = false; TimelineView { - app, - timeline, + ndb, + column, + note_cache, + img_cache, + threads, + pool, reverse, + textmode, } } pub fn ui(&mut self, ui: &mut egui::Ui) { - timeline_ui(ui, self.app, self.timeline, self.reverse); + timeline_ui( + ui, + self.ndb, + self.column, + self.note_cache, + self.img_cache, + self.threads, + self.pool, + self.reverse, + self.textmode, + ); } pub fn reversed(mut self) -> Self { @@ -31,39 +70,61 @@ impl<'a> TimelineView<'a> { } } -fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bool) { +#[allow(clippy::too_many_arguments)] +fn timeline_ui( + ui: &mut egui::Ui, + ndb: &Ndb, + column: &mut Column, + note_cache: &mut NoteCache, + img_cache: &mut ImageCache, + threads: &mut Threads, + pool: &mut RelayPool, + reversed: bool, + textmode: bool, +) { //padding(4.0, ui, |ui| ui.heading("Notifications")); /* let font_id = egui::TextStyle::Body.resolve(ui.style()); let row_height = ui.fonts(|f| f.row_height(&font_id)) + ui.spacing().item_spacing.y; + */ - if timeline == 0 { - postbox_view(app, ui); - } + { + let timeline = if let ColumnKind::Timeline(timeline) = column.kind_mut() { + timeline + } else { + return; + }; - app.timelines[timeline].selected_view = tabs_ui(ui); + timeline.selected_view = tabs_ui(ui); - // need this for some reason?? - ui.add_space(3.0); + // need this for some reason?? + ui.add_space(3.0); + } - let scroll_id = egui::Id::new(("tlscroll", app.timelines[timeline].selected_view, timeline)); + let scroll_id = egui::Id::new(("tlscroll", column.view_id())); egui::ScrollArea::vertical() .id_source(scroll_id) .animated(false) .auto_shrink([false, false]) .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible) .show(ui, |ui| { - let view = app.timelines[timeline].current_view(); + let timeline = if let ColumnKind::Timeline(timeline) = column.kind_mut() { + timeline + } else { + return 0; + }; + + let view = timeline.current_view(); let len = view.notes.len(); - let mut bar_result: Option<BarResult> = None; - let txn = if let Ok(txn) = Transaction::new(&app.ndb) { + let txn = if let Ok(txn) = Transaction::new(ndb) { txn } else { warn!("failed to create transaction"); return 0; }; + let mut bar_action: Option<(BarAction, Note)> = None; view.list .clone() .borrow_mut() @@ -77,9 +138,9 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo start_index }; - let note_key = app.timelines[timeline].current_view().notes[ind].key; + let note_key = timeline.current_view().notes[ind].key; - let note = if let Ok(note) = app.ndb.get_note_by_key(&txn, note_key) { + let note = if let Ok(note) = ndb.get_note_by_key(&txn, note_key) { note } else { warn!("failed to query note {:?}", note_key); @@ -87,17 +148,13 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo }; ui::padding(8.0, ui, |ui| { - let textmode = app.textmode; - let resp = ui::NoteView::new(app, &note) + let resp = ui::NoteView::new(ndb, note_cache, img_cache, &note) .note_previews(!textmode) .selectable_text(false) .show(ui); - if let Some(action) = resp.action { - let br = action.execute(app, timeline, note.id(), &txn); - if br.is_some() { - bar_result = br; - } + if let Some(ba) = resp.action { + bar_action = Some((ba, note)); } else if resp.response.clicked() { debug!("clicked note"); } @@ -109,15 +166,19 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo 1 }); - if let Some(br) = bar_result { - match br { - // update the thread for next render if we have new notes - BarResult::NewThreadNotes(new_notes) => { - let thread = app - .threads - .thread_mut(&app.ndb, &txn, new_notes.root_id.bytes()) - .get_ptr(); - new_notes.process(thread); + // handle any actions from the virtual list + if let Some((action, note)) = bar_action { + if let Some(br) = + action.execute(ndb, column, threads, note_cache, pool, note.id(), &txn) + { + match br { + // 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()) + .get_ptr(); + new_notes.process(thread); + } } } } @@ -126,38 +187,27 @@ fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: bo }); } -fn postbox_view(app: &mut Damus, ui: &mut egui::Ui) { +pub fn postbox_view<'a>( + ndb: &'a Ndb, + key: FilledKeypair<'a>, + pool: &'a mut RelayPool, + drafts: &'a mut Drafts, + img_cache: &'a mut ImageCache, + ui: &'a mut egui::Ui, +) { // show a postbox in the first timeline - - if let Some(account) = app.account_manager.get_selected_account_index() { - if app - .account_manager - .get_selected_account() - .map_or(false, |a| a.secret_key.is_some()) - { - if let Ok(txn) = Transaction::new(&app.ndb) { - let response = ui::PostView::new(app, DraftSource::Compose, account).ui(&txn, ui); - - if let Some(action) = response.action { - match action { - PostAction::Post(np) => { - let seckey = app - .account_manager - .get_account(account) - .unwrap() - .secret_key - .as_ref() - .unwrap() - .to_secret_bytes(); - - let note = np.to_note(&seckey); - let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap()); - info!("sending {}", raw_msg); - app.pool.send(&enostr::ClientMessage::raw(raw_msg)); - app.drafts.clear(DraftSource::Compose); - } - } - } + let txn = Transaction::new(ndb).expect("txn"); + let response = ui::PostView::new(ndb, drafts.compose_mut(), img_cache, key).ui(&txn, ui); + + if let Some(action) = response.action { + match action { + PostAction::Post(np) => { + let seckey = key.secret_key.to_secret_bytes(); + let note = np.to_note(&seckey); + let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap()); + info!("sending {}", raw_msg); + pool.send(&enostr::ClientMessage::raw(raw_msg)); + drafts.compose_mut().clear(); } } } diff --git a/src/unknowns.rs b/src/unknowns.rs @@ -1,6 +1,7 @@ -use crate::notecache::CachedNote; +use crate::column::Columns; +use crate::notecache::{CachedNote, NoteCache}; use crate::timeline::ViewFilter; -use crate::{Damus, Result}; +use crate::Result; use enostr::{Filter, NoteId, Pubkey}; use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; use std::collections::HashSet; @@ -63,37 +64,45 @@ impl UnknownIds { self.last_updated = Some(now); } - pub fn update_from_note(txn: &Transaction, app: &mut Damus, note: &Note) -> bool { - let before = app.unknown_ids.ids().len(); + pub fn update_from_note( + txn: &Transaction, + ndb: &Ndb, + unknown_ids: &mut UnknownIds, + note_cache: &mut NoteCache, + note: &Note, + ) -> bool { + let before = unknown_ids.ids().len(); let key = note.key().expect("note key"); - let cached_note = app - .note_cache_mut() - .cached_note_or_insert(key, note) - .clone(); - if let Err(e) = - get_unknown_note_ids(&app.ndb, &cached_note, txn, note, app.unknown_ids.ids_mut()) - { + //let cached_note = note_cache.cached_note_or_insert(key, note).clone(); + let cached_note = note_cache.cached_note_or_insert(key, note); + if let Err(e) = get_unknown_note_ids(ndb, cached_note, txn, note, unknown_ids.ids_mut()) { error!("UnknownIds::update_from_note {e}"); } - let after = app.unknown_ids.ids().len(); + let after = unknown_ids.ids().len(); if before != after { - app.unknown_ids.mark_updated(); + unknown_ids.mark_updated(); true } else { false } } - pub fn update(txn: &Transaction, app: &mut Damus) -> bool { - let before = app.unknown_ids.ids().len(); - if let Err(e) = get_unknown_ids(txn, app) { + pub fn update( + txn: &Transaction, + unknown_ids: &mut UnknownIds, + columns: &Columns, + ndb: &Ndb, + note_cache: &mut NoteCache, + ) -> bool { + let before = unknown_ids.ids().len(); + if let Err(e) = get_unknown_ids(txn, unknown_ids, columns, ndb, note_cache) { error!("UnknownIds::update {e}"); } - let after = app.unknown_ids.ids().len(); + let after = unknown_ids.ids().len(); if before != after { - app.unknown_ids.mark_updated(); + unknown_ids.mark_updated(); true } else { false @@ -211,17 +220,23 @@ pub fn get_unknown_note_ids<'a>( Ok(()) } -fn get_unknown_ids(txn: &Transaction, damus: &mut Damus) -> Result<()> { +fn get_unknown_ids( + txn: &Transaction, + unknown_ids: &mut UnknownIds, + columns: &Columns, + ndb: &Ndb, + note_cache: &mut NoteCache, +) -> Result<()> { #[cfg(feature = "profiling")] puffin::profile_function!(); let mut new_cached_notes: Vec<(NoteKey, CachedNote)> = vec![]; - for timeline in &damus.timelines { + for timeline in columns.timelines() { for noteref in timeline.notes(ViewFilter::NotesAndReplies) { - let note = damus.ndb.get_note_by_key(txn, noteref.key)?; + let note = ndb.get_note_by_key(txn, noteref.key)?; let note_key = note.key().unwrap(); - let cached_note = damus.note_cache().cached_note(noteref.key); + let cached_note = note_cache.cached_note(noteref.key); let cached_note = if let Some(cn) = cached_note { cn.clone() } else { @@ -230,20 +245,14 @@ fn get_unknown_ids(txn: &Transaction, damus: &mut Damus) -> Result<()> { new_cached_note }; - let _ = get_unknown_note_ids( - &damus.ndb, - &cached_note, - txn, - &note, - damus.unknown_ids.ids_mut(), - ); + let _ = get_unknown_note_ids(ndb, &cached_note, txn, &note, unknown_ids.ids_mut()); } } // This is mainly done to avoid the double mutable borrow that would happen // if we tried to update the note_cache mutably in the loop above for (note_key, note) in new_cached_notes { - damus.note_cache_mut().cache_mut().insert(note_key, note); + note_cache.cache_mut().insert(note_key, note); } Ok(())