notedeck

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

commit 8c458f8f78ecef905906e2f75d1a63b09deee450
parent 51b4dfd3f33aa3b06ce9ec3370193f06a3050cfe
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 16 Aug 2024 11:51:42 -0700

Merge initial threads

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 3++-
Msrc/actionbar.rs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/app.rs | 229+++++++++++++++++++++++++++++++------------------------------------------------
Msrc/error.rs | 39++++++++++++++++++++++++++++++++++++---
Msrc/filter.rs | 45++++++++++++++++++++++++++++++++++++---------
Msrc/lib.rs | 1+
Msrc/note.rs | 32+++++++++++++++++++++++++++++++-
Asrc/thread.rs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/timeline.rs | 385+++++++++++++++++++++++++++++++++++++------------------------------------------
Msrc/ui/mention.rs | 25++++++++++++++++++++-----
Msrc/ui/mod.rs | 4++++
Msrc/ui/note/contents.rs | 3++-
Msrc/ui/note/mod.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/ui/note/options.rs | 71++++++++++++++++++++++++++++++-----------------------------------------
Msrc/ui/note/post.rs | 1+
Asrc/ui/thread.rs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/timeline.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 1264 insertions(+), 480 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.3.4" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=f2f2ff40d0235c788f1e965375938380f2ee5419#f2f2ff40d0235c788f1e965375938380f2ee5419" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=04e5917b44b0112ecfd0eb93e8a1e2c81fce1d75#04e5917b44b0112ecfd0eb93e8a1e2c81fce1d75" dependencies = [ "bindgen", "cc", diff --git a/Cargo.toml b/Cargo.toml @@ -33,7 +33,8 @@ serde_json = "1.0.89" env_logger = "0.10.0" puffin_egui = { version = "0.27.0", optional = true } puffin = { version = "0.19.0", optional = true } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "f2f2ff40d0235c788f1e965375938380f2ee5419" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "04e5917b44b0112ecfd0eb93e8a1e2c81fce1d75" } +#nostrdb = { path = "/Users/jb55/dev/github/damus-io/nostrdb-rs" } #nostrdb = "0.3.4" hex = "0.4.3" base32 = "0.4.0" diff --git a/src/actionbar.rs b/src/actionbar.rs @@ -1,5 +1,12 @@ -use crate::{route::Route, Damus}; +use crate::{ + note::NoteRef, + route::Route, + thread::{Thread, ThreadResult}, + Damus, +}; use enostr::NoteId; +use nostrdb::Transaction; +use tracing::{info, warn}; #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum BarAction { @@ -7,8 +14,101 @@ pub enum BarAction { OpenThread, } +pub struct NewThreadNotes { + pub root_id: NoteId, + pub notes: Vec<NoteRef>, +} + +pub enum BarResult { + NewThreadNotes(NewThreadNotes), +} + +/// open_thread is called when a note is selected and we need to navigate +/// to a thread It is responsible for managing the subscription and +/// making sure the thread is up to date. In a sense, it's a model for +/// 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, + txn: &Transaction, + timeline: usize, + selected_note: &[u8; 32], +) -> Option<BarResult> { + { + let timeline = &mut app.timelines[timeline]; + timeline + .routes + .push(Route::Thread(NoteId::new(selected_note.to_owned()))); + timeline.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); + + // The thread is stale, let's update it + let (thread, result) = match thread_res { + ThreadResult::Stale(thread) => { + let notes = Thread::new_notes(&thread.view.notes, root_id, txn, &app.ndb); + let br = if notes.is_empty() { + None + } else { + Some(BarResult::new_thread_notes( + notes, + NoteId::new(root_id.to_owned()), + )) + }; + + // + // we can't insert and update the VirtualList now, because we + // are already borrowing it mutably. Let's pass it as a + // result instead + // + // thread.view.insert(&notes); + (thread, br) + } + + ThreadResult::Fresh(thread) => (thread, None), + }; + + // only start a subscription on nav and if we don't have + // an active subscription for this thread. + if thread.subscription().is_none() { + *thread.subscription_mut() = app.ndb.subscribe(Thread::filters(root_id)).ok(); + + match thread.subscription() { + Some(_sub) => { + thread.subscribers += 1; + info!( + "Locally subscribing to thread. {} total active subscriptions, {} on this thread", + app.ndb.subscription_count(), + thread.subscribers, + ); + } + None => warn!( + "Error subscribing locally to selected note '{}''s thread", + hex::encode(selected_note) + ), + } + } else { + thread.subscribers += 1; + info!( + "Re-using existing thread subscription. {} total active subscriptions, {} on this thread", + app.ndb.subscription_count(), + thread.subscribers, + ) + } + + result +} + impl BarAction { - pub fn execute(self, app: &mut Damus, timeline: usize, replying_to: &[u8; 32]) { + pub fn execute( + self, + app: &mut Damus, + timeline: usize, + replying_to: &[u8; 32], + txn: &Transaction, + ) -> Option<BarResult> { match self { BarAction::Reply => { let timeline = &mut app.timelines[timeline]; @@ -16,15 +116,30 @@ impl BarAction { .routes .push(Route::Reply(NoteId::new(replying_to.to_owned()))); timeline.navigating = true; + None } - BarAction::OpenThread => { - let timeline = &mut app.timelines[timeline]; - timeline - .routes - .push(Route::Thread(NoteId::new(replying_to.to_owned()))); - timeline.navigating = true; - } + BarAction::OpenThread => open_thread(app, txn, timeline, replying_to), } } } + +impl BarResult { + pub fn new_thread_notes(notes: Vec<NoteRef>, root_id: NoteId) -> Self { + BarResult::NewThreadNotes(NewThreadNotes::new(notes, root_id)) + } +} + +impl NewThreadNotes { + pub fn new(notes: Vec<NoteRef>, root_id: NoteId) -> Self { + NewThreadNotes { notes, root_id } + } + + /// Simple helper for processing a NewThreadNotes result. It simply + /// inserts/merges the notes into the thread cache + pub fn process(&self, thread: &mut Thread) { + // threads are chronological, ie reversed from reverse-chronological, the default. + let reversed = true; + thread.view.insert(&self.notes, reversed); + } +} diff --git a/src/app.rs b/src/app.rs @@ -1,8 +1,8 @@ use crate::account_manager::AccountManager; +use crate::actionbar::BarResult; use crate::app_creation::setup_cc; use crate::app_style::user_requested_visuals_change; use crate::draft::Drafts; -use crate::error::Error; use crate::frame_history::FrameHistory; use crate::imgcache::ImageCache; use crate::key_storage::KeyStorageType; @@ -10,8 +10,8 @@ use crate::note::NoteRef; use crate::notecache::{CachedNote, NoteCache}; use crate::relay_pool_manager::RelayPoolManager; use crate::route::Route; -use crate::timeline; -use crate::timeline::{MergeKind, Timeline, ViewFilter}; +use crate::thread::{DecrementResult, Threads}; +use crate::timeline::{Timeline, TimelineSource, ViewFilter}; use crate::ui::note::PostAction; use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup}; use crate::ui::{DesktopSidePanel, RelayView, View}; @@ -53,10 +53,11 @@ pub struct Damus { pub timelines: Vec<Timeline>, pub selected_timeline: i32, - pub drafts: Drafts, - pub img_cache: ImageCache, pub ndb: Ndb, + pub drafts: Drafts, + pub threads: Threads, + pub img_cache: ImageCache, pub account_manager: AccountManager, frame_history: crate::frame_history::FrameHistory, @@ -93,27 +94,6 @@ fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) { /// notes locally. One way to determine this is by looking at the current filter /// and seeing what its limit is. If we have less notes than the limit, /// we might want to backfill older notes -fn should_since_optimize(limit: Option<u16>, num_notes: usize) -> bool { - let limit = limit.unwrap_or(enostr::Filter::default_limit()) as usize; - - // rough heuristic for bailing since optimization if we don't have enough notes - limit <= num_notes -} - -fn since_optimize_filter(filter: &mut enostr::Filter, notes: &[NoteRef]) { - // Get the latest entry in the events - if notes.is_empty() { - return; - } - - // get the latest note - let latest = notes[0]; - let since = latest.created_at - 60; - - // update the filters - filter.since = Some(since); -} - fn send_initial_filters(damus: &mut Damus, relay_url: &str) { info!("Sending initial filters to {}", relay_url); let mut c: u32 = 1; @@ -132,8 +112,8 @@ fn send_initial_filters(damus: &mut Damus, relay_url: &str) { } let notes = timeline.notes(ViewFilter::NotesAndReplies); - if should_since_optimize(f.limit, notes.len()) { - since_optimize_filter(f, notes); + if crate::filter::should_since_optimize(f.limit, notes.len()) { + crate::filter::since_optimize_filter(f, notes); } else { warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", f); } @@ -229,7 +209,8 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { let txn = Transaction::new(&damus.ndb)?; let mut unknown_ids: HashSet<UnknownId> = HashSet::new(); for timeline in 0..damus.timelines.len() { - if let Err(err) = poll_notes_for_timeline(damus, &txn, timeline, &mut unknown_ids) { + let src = TimelineSource::column(timeline); + if let Err(err) = src.poll_notes_into_view(damus, &txn, &mut unknown_ids) { error!("{}", err); } } @@ -248,7 +229,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { } #[derive(Hash, Clone, Copy, PartialEq, Eq)] -enum UnknownId<'a> { +pub enum UnknownId<'a> { Pubkey(&'a [u8; 32]), Id(&'a [u8; 32]), } @@ -277,9 +258,9 @@ impl<'a> UnknownId<'a> { /// We return all of this in a HashSet so that we can fetch these from /// remote relays. /// -fn get_unknown_note_ids<'a>( +pub fn get_unknown_note_ids<'a>( ndb: &Ndb, - _cached_note: &CachedNote, + cached_note: &CachedNote, txn: &'a Transaction, note: &Note<'a>, note_key: NoteKey, @@ -292,7 +273,6 @@ fn get_unknown_note_ids<'a>( } // pull notes that notes are replying to - /* TODO: FIX tags lifetime if cached_note.reply.root.is_some() { let note_reply = cached_note.reply.borrow(note.tags()); if let Some(root) = note_reply.root() { @@ -309,7 +289,6 @@ fn get_unknown_note_ids<'a>( } } } - */ let blocks = ndb.get_blocks_by_key(txn, note_key)?; for block in blocks.iter(note) { @@ -360,101 +339,6 @@ fn get_unknown_note_ids<'a>( Ok(()) } -fn poll_notes_for_timeline<'a>( - damus: &mut Damus, - txn: &'a Transaction, - timeline_ind: usize, - ids: &mut HashSet<UnknownId<'a>>, -) -> Result<()> { - let sub = if let Some(sub) = &damus.timelines[timeline_ind].subscription { - sub - } else { - return Err(Error::NoActiveSubscription); - }; - - let new_note_ids = damus.ndb.poll_for_notes(sub.id, 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) = damus.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; - }; - - let cached_note = damus - .note_cache_mut() - .cached_note_or_insert(key, &note) - .clone(); - let _ = get_unknown_note_ids(&damus.ndb, &cached_note, txn, &note, key, ids); - - let created_at = note.created_at(); - new_refs.push((note, NoteRef { key, created_at })); - } - - // ViewFilter::NotesAndReplies - { - let refs: Vec<NoteRef> = new_refs.iter().map(|(_note, nr)| *nr).collect(); - - insert_notes_into_timeline(damus, timeline_ind, ViewFilter::NotesAndReplies, &refs) - } - - // - // 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 = damus.note_cache_mut().cached_note_or_insert(nr.key, note); - - if ViewFilter::filter_notes(cached_note, note) { - filtered_refs.push(*nr); - } - } - - insert_notes_into_timeline(damus, timeline_ind, ViewFilter::Notes, &filtered_refs); - } - - Ok(()) -} - -fn insert_notes_into_timeline( - app: &mut Damus, - timeline_ind: usize, - filter: ViewFilter, - new_refs: &[NoteRef], -) { - let timeline = &mut app.timelines[timeline_ind]; - let num_prev_items = timeline.notes(filter).len(); - let (notes, merge_kind) = timeline::merge_sorted_vecs(timeline.notes(filter), new_refs); - debug!( - "got merge kind {:?} for {:?} on timeline {}", - merge_kind, filter, timeline_ind - ); - - timeline.view_mut(filter).notes = notes; - let new_items = timeline.notes(filter).len() - num_prev_items; - - // TODO: technically items could have been added inbetween - if new_items > 0 { - let mut list = app.timelines[timeline_ind].view(filter).list.borrow_mut(); - - match merge_kind { - // TODO: update egui_virtual_list to support spliced inserts - MergeKind::Spliced => list.reset(), - MergeKind::FrontInsert => list.items_inserted_at_start(new_items), - } - } -} - #[cfg(feature = "profiling")] fn setup_profiling() { puffin::set_scopes_on(true); // tell puffin to collect data @@ -762,6 +646,7 @@ fn parse_args(args: &[String]) -> Args { res } +/* fn determine_key_storage_type() -> KeyStorageType { #[cfg(target_os = "macos")] { @@ -778,6 +663,7 @@ fn determine_key_storage_type() -> KeyStorageType { KeyStorageType::None } } +*/ impl Damus { /// Called once before the first frame. @@ -808,7 +694,7 @@ impl Damus { // TODO: should pull this from settings None, // TODO: use correct KeyStorage mechanism for current OS arch - determine_key_storage_type(), + KeyStorageType::None, ); for key in parsed_args.keys { @@ -843,6 +729,7 @@ impl Damus { Self { pool, is_mobile, + threads: Threads::default(), drafts: Drafts::default(), state: DamusState::Initializing, img_cache: ImageCache::new(imgcache_dir), @@ -872,6 +759,7 @@ impl Damus { config.set_ingester_threads(2); Self { is_mobile, + threads: Threads::default(), drafts: Drafts::default(), state: DamusState::Initializing, pool: RelayPool::new(), @@ -1015,6 +903,53 @@ fn render_panel(ctx: &egui::Context, app: &mut Damus, timeline_ind: usize) { }); } +/// Local thread unsubscribe +fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { + let unsubscribe = { + let txn = Transaction::new(&app.ndb).expect("txn"); + let root_id = crate::note::root_note_id_from_selected_id(app, &txn, id); + + let thread = app.threads.thread_mut(&app.ndb, &txn, root_id).get_ptr(); + let unsub = thread.decrement_sub(); + + if let Ok(DecrementResult::LastSubscriber(_subid)) = unsub { + *thread.subscription_mut() = None; + } + + unsub + }; + + match unsubscribe { + Ok(DecrementResult::LastSubscriber(sub_id)) => { + if let Err(e) = app.ndb.unsubscribe(sub_id) { + error!("failed to unsubscribe from thread: {e}, subid:{sub_id}, {} active subscriptions", app.ndb.subscription_count()); + } else { + info!( + "Unsubscribed from thread subid:{}. {} active subscriptions", + sub_id, + app.ndb.subscription_count() + ); + } + } + + Ok(DecrementResult::ActiveSubscribers) => { + info!( + "Keeping thread subscription. {} active subscriptions.", + app.ndb.subscription_count() + ); + // do nothing + } + + Err(e) => { + // something is wrong! + error!( + "Thread unsubscribe error: {e}. {} active subsciptions.", + app.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; @@ -1027,7 +962,7 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut .show(ui, |ui, nav| match nav.top() { Route::Timeline(_n) => { let app = &mut app_ctx.borrow_mut(); - timeline::timeline_view(ui, app, timeline_ind); + ui::TimelineView::new(app, timeline_ind).ui(ui); None } @@ -1036,11 +971,6 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut None } - Route::Thread(_key) => { - ui.label("thread view"); - None - } - Route::Relays => { let pool = &mut app_ctx.borrow_mut().pool; let manager = RelayPoolManager::new(pool); @@ -1048,6 +978,22 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut None } + Route::Thread(id) => { + let app = &mut app_ctx.borrow_mut(); + let result = ui::ThreadView::new(app, timeline_ind, id.bytes()).ui(ui); + + if let Some(bar_result) = result { + match bar_result { + BarResult::NewThreadNotes(new_notes) => { + let thread = app.threads.thread_expected_mut(new_notes.root_id.bytes()); + new_notes.process(thread); + } + } + } + + None + } + Route::Reply(id) => { let mut app = app_ctx.borrow_mut(); @@ -1076,18 +1022,21 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut } }); + let mut app = app_ctx.borrow_mut(); if let Some(reply_response) = nav_response.inner { if let Some(PostAction::Post(_np)) = reply_response.inner.action { - app_ctx.borrow_mut().timelines[timeline_ind].returning = true; + app.timelines[timeline_ind].returning = true; } } if let Some(NavAction::Returned) = nav_response.action { - let mut app = app_ctx.borrow_mut(); - app.timelines[timeline_ind].routes.pop(); + let popped = app.timelines[timeline_ind].routes.pop(); + if let Some(Route::Thread(id)) = popped { + thread_unsubscribe(&mut app, id.bytes()); + } app.timelines[timeline_ind].returning = false; } else if let Some(NavAction::Navigated) = nav_response.action { - app_ctx.borrow_mut().timelines[timeline_ind].navigating = false; + app.timelines[timeline_ind].navigating = false; } } diff --git a/src/error.rs b/src/error.rs @@ -1,8 +1,41 @@ use std::{fmt, io}; +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +pub enum SubscriptionError { + //#[error("No active subscriptions")] + NoActive, + + /// When a timeline has an unexpected number + /// of active subscriptions. Should only happen if there + /// is a bug in notedeck + //#[error("Unexpected subscription count")] + UnexpectedSubscriptionCount(i32), +} + +impl Error { + pub fn unexpected_sub_count(c: i32) -> Self { + Error::SubscriptionError(SubscriptionError::UnexpectedSubscriptionCount(c)) + } + + pub fn no_active_sub() -> Self { + Error::SubscriptionError(SubscriptionError::NoActive) + } +} + +impl fmt::Display for SubscriptionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoActive => write!(f, "No active subscriptions"), + Self::UnexpectedSubscriptionCount(c) => { + write!(f, "Unexpected subscription count: {}", c) + } + } + } +} + #[derive(Debug)] pub enum Error { - NoActiveSubscription, + SubscriptionError(SubscriptionError), LoadFailed, Io(io::Error), Nostr(enostr::Error), @@ -14,8 +47,8 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::NoActiveSubscription => { - write!(f, "subscription not active in timeline") + Self::SubscriptionError(sub_err) => { + write!(f, "{sub_err}") } Self::LoadFailed => { write!(f, "load failed") diff --git a/src/filter.rs b/src/filter.rs @@ -1,44 +1,71 @@ +use crate::note::NoteRef; + +pub fn should_since_optimize(limit: Option<u16>, num_notes: usize) -> bool { + let limit = limit.unwrap_or(enostr::Filter::default_limit()) as usize; + + // rough heuristic for bailing since optimization if we don't have enough notes + limit <= num_notes +} + +pub fn since_optimize_filter_with(filter: &mut enostr::Filter, notes: &[NoteRef], since_gap: u64) { + // Get the latest entry in the events + if notes.is_empty() { + return; + } + + // get the latest note + let latest = notes[0]; + let since = latest.created_at - since_gap; + + // update the filters + filter.since = Some(since); +} + +pub fn since_optimize_filter(filter: &mut enostr::Filter, notes: &[NoteRef]) { + since_optimize_filter_with(filter, notes, 60); +} + pub fn convert_enostr_filter(filter: &enostr::Filter) -> nostrdb::Filter { let mut nfilter = nostrdb::Filter::new(); if let Some(ref ids) = filter.ids { - nfilter.ids(ids.iter().map(|a| *a.bytes()).collect()); + nfilter = nfilter.ids(ids.iter().map(|a| *a.bytes()).collect()); } if let Some(ref authors) = filter.authors { let authors: Vec<[u8; 32]> = authors.iter().map(|a| *a.bytes()).collect(); - nfilter.authors(authors); + nfilter = nfilter.authors(authors); } if let Some(ref kinds) = filter.kinds { - nfilter.kinds(kinds.clone()); + nfilter = nfilter.kinds(kinds.clone()); } // #e if let Some(ref events) = filter.events { - nfilter.events(events.iter().map(|a| *a.bytes()).collect()); + nfilter = nfilter.events(events.iter().map(|a| *a.bytes()).collect()); } // #p if let Some(ref pubkeys) = filter.pubkeys { - nfilter.pubkeys(pubkeys.iter().map(|a| *a.bytes()).collect()); + nfilter = nfilter.pubkeys(pubkeys.iter().map(|a| *a.bytes()).collect()); } // #t if let Some(ref hashtags) = filter.hashtags { - nfilter.tags(hashtags.clone(), 't'); + nfilter = nfilter.tags(hashtags.clone(), 't'); } if let Some(since) = filter.since { - nfilter.since(since); + nfilter = nfilter.since(since); } if let Some(until) = filter.until { - nfilter.until(until); + nfilter = nfilter.until(until); } if let Some(limit) = filter.limit { - nfilter.limit(limit.into()); + nfilter = nfilter.limit(limit.into()); } nfilter.build() diff --git a/src/lib.rs b/src/lib.rs @@ -27,6 +27,7 @@ pub mod relay_pool_manager; mod result; mod route; mod test_data; +mod thread; mod time; mod timecache; mod timeline; diff --git a/src/note.rs b/src/note.rs @@ -1,4 +1,5 @@ -use nostrdb::{NoteKey, QueryResult}; +use crate::Damus; +use nostrdb::{NoteKey, QueryResult, Transaction}; use std::cmp::Ordering; #[derive(Debug, Eq, PartialEq, Copy, Clone)] @@ -35,3 +36,32 @@ impl PartialOrd for NoteRef { Some(self.cmp(other)) } } + +pub fn root_note_id_from_selected_id<'a>( + app: &mut Damus, + txn: &'a Transaction, + selected_note_id: &'a [u8; 32], +) -> &'a [u8; 32] { + let selected_note_key = if let Ok(key) = app + .ndb + .get_notekey_by_id(txn, selected_note_id) + .map(NoteKey::new) + { + key + } else { + return selected_note_id; + }; + + let note = if let Ok(note) = app.ndb.get_note_by_key(txn, selected_note_key) { + note + } else { + return selected_note_id; + }; + + app.note_cache_mut() + .cached_note_or_insert(selected_note_key, &note) + .reply + .borrow(note.tags()) + .root() + .map_or_else(|| selected_note_id, |nr| nr.id) +} diff --git a/src/thread.rs b/src/thread.rs @@ -0,0 +1,189 @@ +use crate::note::NoteRef; +use crate::timeline::{TimelineTab, ViewFilter}; +use crate::Error; +use nostrdb::{Filter, FilterBuilder, Ndb, Subscription, Transaction}; +use std::cmp::Ordering; +use std::collections::HashMap; +use tracing::{debug, warn}; + +#[derive(Default)] +pub struct Thread { + pub view: TimelineTab, + sub: Option<Subscription>, + pub subscribers: i32, +} + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +pub enum DecrementResult { + LastSubscriber(u64), + ActiveSubscribers, +} + +impl Thread { + pub fn new(notes: Vec<NoteRef>) -> Self { + let mut cap = ((notes.len() as f32) * 1.5) as usize; + if cap == 0 { + cap = 25; + } + let mut view = TimelineTab::new_with_capacity(ViewFilter::NotesAndReplies, cap); + view.notes = notes; + let sub: Option<Subscription> = None; + let subscribers: i32 = 0; + + Thread { + view, + sub, + subscribers, + } + } + + /// Look for new thread notes since our last fetch + pub fn new_notes( + notes: &[NoteRef], + root_id: &[u8; 32], + txn: &Transaction, + ndb: &Ndb, + ) -> Vec<NoteRef> { + if notes.is_empty() { + return vec![]; + } + + let last_note = notes[0]; + let filters = Thread::filters_since(root_id, last_note.created_at + 1); + + if let Ok(results) = ndb.query(txn, filters, 1000) { + debug!("got {} results from thread update", results.len()); + results + .into_iter() + .map(NoteRef::from_query_result) + .collect() + } else { + debug!("got no results from thread update",); + vec![] + } + } + + pub fn decrement_sub(&mut self) -> Result<DecrementResult, Error> { + self.subscribers -= 1; + + match self.subscribers.cmp(&0) { + Ordering::Equal => { + if let Some(sub) = self.subscription() { + Ok(DecrementResult::LastSubscriber(sub.id)) + } else { + Err(Error::no_active_sub()) + } + } + Ordering::Less => Err(Error::unexpected_sub_count(self.subscribers)), + Ordering::Greater => Ok(DecrementResult::ActiveSubscribers), + } + } + + pub fn subscription(&self) -> Option<&Subscription> { + self.sub.as_ref() + } + + pub fn subscription_mut(&mut self) -> &mut Option<Subscription> { + &mut self.sub + } + + fn filters_raw(root: &[u8; 32]) -> Vec<FilterBuilder> { + vec![ + nostrdb::Filter::new().kinds(vec![1]).event(root), + nostrdb::Filter::new().ids(vec![*root]).limit(1), + ] + } + + pub fn filters_since(root: &[u8; 32], since: u64) -> Vec<Filter> { + Self::filters_raw(root) + .into_iter() + .map(|fb| fb.since(since).build()) + .collect() + } + + pub fn filters(root: &[u8; 32]) -> Vec<Filter> { + Self::filters_raw(root) + .into_iter() + .map(|mut fb| fb.build()) + .collect() + } +} + +#[derive(Default)] +pub struct Threads { + /// root id to thread + pub root_id_to_thread: HashMap<[u8; 32], Thread>, +} + +pub enum ThreadResult<'a> { + Fresh(&'a mut Thread), + Stale(&'a mut Thread), +} + +impl<'a> ThreadResult<'a> { + pub fn get_ptr(self) -> &'a mut Thread { + match self { + Self::Fresh(ptr) => ptr, + Self::Stale(ptr) => ptr, + } + } + + pub fn is_stale(&self) -> bool { + match self { + Self::Fresh(_ptr) => false, + Self::Stale(_ptr) => true, + } + } +} + +impl Threads { + pub fn thread_expected_mut(&mut self, root_id: &[u8; 32]) -> &mut Thread { + self.root_id_to_thread + .get_mut(root_id) + .expect("thread_expected_mut used but there was no thread") + } + + pub fn thread_mut<'a>( + &'a mut self, + ndb: &Ndb, + txn: &Transaction, + root_id: &[u8; 32], + ) -> ThreadResult<'a> { + // we can't use the naive hashmap entry API here because lookups + // require a copy, wait until we have a raw entry api. We could + // also use hashbrown? + + if self.root_id_to_thread.contains_key(root_id) { + return ThreadResult::Stale(self.root_id_to_thread.get_mut(root_id).unwrap()); + } + + // we don't have the thread, query for it! + let filters = Thread::filters(root_id); + + let notes = if let Ok(results) = ndb.query(txn, filters, 1000) { + results + .into_iter() + .map(NoteRef::from_query_result) + .collect() + } else { + debug!( + "got no results from thread lookup for {}", + hex::encode(root_id) + ); + vec![] + }; + + if notes.is_empty() { + warn!("thread query returned 0 notes? ") + } else { + debug!("found thread with {} notes", notes.len()); + } + + self.root_id_to_thread + .insert(root_id.to_owned(), Thread::new(notes)); + ThreadResult::Fresh(self.root_id_to_thread.get_mut(root_id).unwrap()) + } + + //fn thread_by_id(&self, ndb: &Ndb, id: &[u8; 32]) -> &mut Thread { + //} +} diff --git a/src/timeline.rs b/src/timeline.rs @@ -1,21 +1,152 @@ -use crate::draft::DraftSource; +use crate::app::{get_unknown_note_ids, UnknownId}; +use crate::error::Error; use crate::note::NoteRef; use crate::notecache::CachedNote; -use crate::ui::note::PostAction; -use crate::{ui, Damus}; +use crate::{Damus, Result}; use crate::route::Route; -use egui::containers::scroll_area::ScrollBarVisibility; -use egui::{Direction, Layout}; -use egui_tabs::TabColor; use egui_virtual_list::VirtualList; use enostr::Filter; use nostrdb::{Note, Subscription, Transaction}; use std::cell::RefCell; +use std::collections::HashSet; use std::rc::Rc; -use tracing::{debug, info, warn}; +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<'b>(self, app: &'b mut Damus, txn: &Transaction) -> Option<&'b Subscription> { + match self { + TimelineSource::Column { ind, .. } => app.timelines[ind].subscription.as_ref(), + 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() + } + } + } + + pub fn poll_notes_into_view( + &self, + app: &mut Damus, + txn: &'a Transaction, + ids: &mut HashSet<UnknownId<'a>>, + ) -> Result<()> { + let sub_id = if let Some(sub_id) = self.sub(app, txn).map(|s| s.id) { + sub_id + } else { + return Err(Error::no_active_sub()); + }; + + // + // TODO(BUG!): poll for these before the txn, otherwise we can hit + // a race condition where we hit the "no note??" expect below. This may + // require some refactoring due to the missing ids logic + // + let new_note_ids = app.ndb.poll_for_notes(sub_id, 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; + }; + + let cached_note = app + .note_cache_mut() + .cached_note_or_insert(key, &note) + .clone(); + let _ = get_unknown_note_ids(&app.ndb, &cached_note, txn, &note, key, ids); + + 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 { @@ -58,19 +189,19 @@ impl ViewFilter { /// A timeline view is a filtered view of notes in a timeline. Two standard views /// are "Notes" and "Notes & Replies". A timeline is associated with a Filter, -/// but a TimelineView is a further filtered view of this Filter that can't +/// but a TimelineTab is a further filtered view of this Filter that can't /// be captured by a Filter itself. #[derive(Default)] -pub struct TimelineView { +pub struct TimelineTab { pub notes: Vec<NoteRef>, pub selection: i32, pub filter: ViewFilter, pub list: Rc<RefCell<VirtualList>>, } -impl TimelineView { +impl TimelineTab { pub fn new(filter: ViewFilter) -> Self { - TimelineView::new_with_capacity(filter, 1000) + TimelineTab::new_with_capacity(filter, 1000) } pub fn new_with_capacity(filter: ViewFilter, cap: usize) -> Self { @@ -80,7 +211,7 @@ impl TimelineView { let list = Rc::new(RefCell::new(list)); let notes: Vec<NoteRef> = Vec::with_capacity(cap); - TimelineView { + TimelineTab { notes, selection, filter, @@ -88,6 +219,35 @@ impl TimelineView { } } + 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 => 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 { @@ -109,7 +269,7 @@ impl TimelineView { pub struct Timeline { pub filter: Vec<Filter>, - pub views: Vec<TimelineView>, + pub views: Vec<TimelineTab>, pub selected_view: i32, pub routes: Vec<Route>, pub navigating: bool, @@ -122,8 +282,8 @@ pub struct Timeline { impl Timeline { pub fn new(filter: Vec<Filter>) -> Self { let subscription: Option<Subscription> = None; - let notes = TimelineView::new(ViewFilter::Notes); - let replies = TimelineView::new(ViewFilter::NotesAndReplies); + 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("Timeline".to_string())]; @@ -141,11 +301,11 @@ impl Timeline { } } - pub fn current_view(&self) -> &TimelineView { + pub fn current_view(&self) -> &TimelineTab { &self.views[self.selected_view as usize] } - pub fn current_view_mut(&mut self) -> &mut TimelineView { + pub fn current_view_mut(&mut self) -> &mut TimelineTab { &mut self.views[self.selected_view as usize] } @@ -153,202 +313,15 @@ impl Timeline { &self.views[view.index()].notes } - pub fn view(&self, view: ViewFilter) -> &TimelineView { + pub fn view(&self, view: ViewFilter) -> &TimelineTab { &self.views[view.index()] } - pub fn view_mut(&mut self, view: ViewFilter) -> &mut TimelineView { + pub fn view_mut(&mut self, view: ViewFilter) -> &mut TimelineTab { &mut self.views[view.index()] } } -fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 { - let font_id = egui::FontId::default(); - let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE)); - galley.rect.width() -} - -fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { - let midpoint = (range.min + range.max) / 2.0; - let half_width = width / 2.0; - - let min = midpoint - half_width; - let max = midpoint + half_width; - - egui::Rangef::new(min, max) -} - -fn tabs_ui(ui: &mut egui::Ui) -> i32 { - ui.spacing_mut().item_spacing.y = 0.0; - - let tab_res = egui_tabs::Tabs::new(2) - .selected(1) - .hover_bg(TabColor::none()) - .selected_fg(TabColor::none()) - .selected_bg(TabColor::none()) - .hover_bg(TabColor::none()) - //.hover_bg(TabColor::custom(egui::Color32::RED)) - .height(32.0) - .layout(Layout::centered_and_justified(Direction::TopDown)) - .show(ui, |ui, state| { - ui.spacing_mut().item_spacing.y = 0.0; - - let ind = state.index(); - - let txt = if ind == 0 { "Notes" } else { "Notes & Replies" }; - - let res = ui.add(egui::Label::new(txt).selectable(false)); - - // underline - if state.is_selected() { - let rect = res.rect; - let underline = - shrink_range_to_width(rect.x_range(), get_label_width(ui, txt) * 1.15); - let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5; - return (underline, underline_y); - } - - (egui::Rangef::new(0.0, 0.0), 0.0) - }); - - //ui.add_space(0.5); - ui::hline(ui); - - let sel = tab_res.selected().unwrap_or_default(); - - let (underline, underline_y) = tab_res.inner()[sel as usize].inner; - let underline_width = underline.span(); - - let tab_anim_id = ui.id().with("tab_anim"); - let tab_anim_size = tab_anim_id.with("size"); - - let stroke = egui::Stroke { - color: ui.visuals().hyperlink_color, - width: 2.0, - }; - - let speed = 0.1f32; - - // animate underline position - let x = ui - .ctx() - .animate_value_with_time(tab_anim_id, underline.min, speed); - - // animate underline width - let w = ui - .ctx() - .animate_value_with_time(tab_anim_size, underline_width, speed); - - let underline = egui::Rangef::new(x, x + w); - - ui.painter().hline(underline, underline_y, stroke); - - sel -} - -pub fn timeline_view(ui: &mut egui::Ui, app: &mut Damus, timeline: usize) { - //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 { - // 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); - } - } - } - } - } - } - } - - app.timelines[timeline].selected_view = tabs_ui(ui); - - // need this for some reason?? - ui.add_space(3.0); - - let scroll_id = egui::Id::new(("tlscroll", app.timelines[timeline].selected_view, timeline)); - 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 len = view.notes.len(); - view.list - .clone() - .borrow_mut() - .ui_custom_layout(ui, len, |ui, start_index| { - ui.spacing_mut().item_spacing.y = 0.0; - ui.spacing_mut().item_spacing.x = 4.0; - - let note_key = app.timelines[timeline].current_view().notes[start_index].key; - - let txn = if let Ok(txn) = Transaction::new(&app.ndb) { - txn - } else { - warn!("failed to create transaction for {:?}", note_key); - return 0; - }; - - let note = if let Ok(note) = app.ndb.get_note_by_key(&txn, note_key) { - note - } else { - warn!("failed to query note {:?}", note_key); - return 0; - }; - - ui::padding(8.0, ui, |ui| { - let textmode = app.textmode; - let resp = ui::NoteView::new(app, &note) - .note_previews(!textmode) - .show(ui); - - if let Some(action) = resp.action { - action.execute(app, timeline, note.id()); - } else if resp.response.clicked() { - debug!("clicked note"); - } - }); - - ui::hline(ui); - //ui.add(egui::Separator::default().spacing(0.0)); - - 1 - }); - }); -} - #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MergeKind { FrontInsert, diff --git a/src/ui/mention.rs b/src/ui/mention.rs @@ -5,13 +5,26 @@ pub struct Mention<'a> { app: &'a mut Damus, txn: &'a Transaction, pk: &'a [u8; 32], + selectable: bool, size: f32, } impl<'a> Mention<'a> { pub fn new(app: &'a mut Damus, txn: &'a Transaction, pk: &'a [u8; 32]) -> Self { let size = 16.0; - Mention { app, txn, pk, size } + let selectable = true; + Mention { + app, + txn, + pk, + selectable, + size, + } + } + + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = selectable; + self } pub fn size(mut self, size: f32) -> Self { @@ -22,7 +35,7 @@ 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) + mention_ui(self.app, self.txn, self.pk, ui, self.size, self.selectable) } } @@ -32,6 +45,7 @@ fn mention_ui( pk: &[u8; 32], ui: &mut egui::Ui, size: f32, + selectable: bool ) -> egui::Response { #[cfg(feature = "profiling")] puffin::profile_function!(); @@ -46,9 +60,10 @@ fn mention_ui( "??".to_string() }; - let resp = ui.add(egui::Label::new( - egui::RichText::new(name).color(colors::PURPLE).size(size), - )); + let resp = ui.add( + egui::Label::new(egui::RichText::new(name).color(colors::PURPLE).size(size)) + .selectable(selectable), + ); if let Some(rec) = profile.as_ref() { resp.on_hover_ui_at_pointer(|ui| { diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -10,6 +10,8 @@ pub mod preview; pub mod profile; pub mod relay; pub mod side_panel; +pub mod thread; +pub mod timeline; pub mod username; pub use account_management::AccountManagementView; @@ -22,6 +24,8 @@ pub use preview::{Preview, PreviewApp, PreviewConfig}; pub use profile::{profile_preview_controller, ProfilePic, ProfilePreview}; pub use relay::RelayView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; +pub use thread::ThreadView; +pub use timeline::TimelineView; pub use username::Username; use egui::Margin; diff --git a/src/ui/note/contents.rs b/src/ui/note/contents.rs @@ -110,6 +110,7 @@ fn render_note_contents( #[cfg(feature = "profiling")] puffin::profile_function!(); + let selectable = options.has_selectable_text(); let images: Vec<String> = vec![]; let mut inline_note: Option<(&[u8; 32], &str)> = None; @@ -173,7 +174,7 @@ fn render_note_contents( BlockType::Text => { #[cfg(feature = "profiling")] puffin::profile_scope!("text contents"); - ui.label(block.as_str()); + ui.add(egui::Label::new(block.as_str()).selectable(selectable)); } _ => { diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs @@ -33,11 +33,17 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: #[cfg(feature = "profiling")] puffin::profile_function!(); - ui.add(Label::new( - RichText::new("replying to") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + let size = 10.0; + let selectable = false; + + ui.add( + Label::new( + RichText::new("replying to") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); let reply = if let Some(reply) = note_reply.reply() { reply @@ -48,55 +54,91 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app: let reply_note = if let Ok(reply_note) = app.ndb.get_note_by_id(txn, reply.id) { reply_note } else { - ui.add(Label::new( - RichText::new("a note") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + Label::new( + RichText::new("a note") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); return; }; 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()).size(10.0)); - ui.add(Label::new( - RichText::new("'s note") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("'s note") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } 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 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()).size(10.0)); - ui.add(Label::new( - RichText::new("'s note") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("'s note") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } else { // replying to bob in alice's thread - ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("in").size(10.0).color(colors::GRAY_SECONDARY), - )); - ui.add(ui::Mention::new(app, txn, root_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("'s thread") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new(RichText::new("in").size(size).color(colors::GRAY_SECONDARY)) + .selectable(selectable), + ); + ui.add( + ui::Mention::new(app, txn, root_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("'s thread") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } } else { - ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0)); - ui.add(Label::new( - RichText::new("in someone's thread") - .size(10.0) - .color(colors::GRAY_SECONDARY), - )); + ui.add( + ui::Mention::new(app, txn, reply_note.pubkey()) + .size(size) + .selectable(selectable), + ); + ui.add( + Label::new( + RichText::new("in someone's thread") + .size(size) + .color(colors::GRAY_SECONDARY), + ) + .selectable(selectable), + ); } } } @@ -127,6 +169,11 @@ impl<'a> NoteView<'a> { self } + pub fn selectable_text(mut self, enable: bool) -> Self { + self.options_mut().set_selectable_text(enable); + self + } + pub fn wide(mut self, enable: bool) -> Self { self.options_mut().set_wide(enable); self @@ -373,33 +420,13 @@ fn render_note_actionbar( note_key: NoteKey, ) -> egui::InnerResponse<Option<BarAction>> { ui.horizontal(|ui| { - let img_data = if ui.style().visuals.dark_mode { - egui::include_image!("../../../assets/icons/reply.png") - } else { - egui::include_image!("../../../assets/icons/reply-dark.png") - }; - - ui.spacing_mut().button_padding = egui::vec2(0.0, 0.0); + let reply_resp = reply_button(ui, note_key); + let thread_resp = thread_button(ui, note_key); - let button_size = 10.0; - let expand_size = 5.0; - let anim_speed = 0.05; - - let (rect, size, resp) = ui::anim::hover_expand( - ui, - ui.id().with(("reply_anim", note_key)), - button_size, - expand_size, - anim_speed, - ); - - // align rect to note contents - let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); - - ui.put(rect, egui::Image::new(img_data).max_width(size)); - - if resp.clicked() { + if reply_resp.clicked() { Some(BarAction::Reply) + } else if thread_resp.clicked() { + Some(BarAction::OpenThread) } else { None } @@ -432,3 +459,45 @@ fn render_reltime( } }) } + +fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { + let img_data = if ui.style().visuals.dark_mode { + egui::include_image!("../../../assets/icons/reply.png") + } else { + egui::include_image!("../../../assets/icons/reply-dark.png") + }; + + let (rect, size, resp) = + ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); + + // align rect to note contents + let expand_size = 5.0; // from hover_expand_small + let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); + + let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size)); + + resp.union(put_resp) +} + +fn thread_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { + let id = ui.id().with(("thread_anim", note_key)); + let size = 8.0; + let expand_size = 5.0; + let anim_speed = 0.05; + + let (rect, size, resp) = ui::anim::hover_expand(ui, id, size, expand_size, anim_speed); + + let color = if ui.style().visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + + ui.painter_at(rect).circle_stroke( + rect.center(), + (size - 1.0) / 2.0, + egui::Stroke::new(1.0, color), + ); + + resp +} diff --git a/src/ui/note/options.rs b/src/ui/note/options.rs @@ -6,21 +6,46 @@ bitflags! { #[repr(transparent)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NoteOptions: u32 { - const actionbar = 0b00000001; - const note_previews = 0b00000010; - const small_pfp = 0b00000100; - const medium_pfp = 0b00001000; - const wide = 0b00010000; + const actionbar = 0b00000001; + const note_previews = 0b00000010; + const small_pfp = 0b00000100; + const medium_pfp = 0b00001000; + const wide = 0b00010000; + const selectable_text = 0b00100000; } } +macro_rules! create_setter { + ($fn_name:ident, $option:ident) => { + #[inline] + pub fn $fn_name(&mut self, enable: bool) { + if enable { + *self |= NoteOptions::$option; + } else { + *self &= !NoteOptions::$option; + } + } + }; +} + impl NoteOptions { + create_setter!(set_small_pfp, small_pfp); + create_setter!(set_medium_pfp, medium_pfp); + create_setter!(set_note_previews, note_previews); + create_setter!(set_selectable_text, selectable_text); + create_setter!(set_actionbar, actionbar); + #[inline] pub fn has_actionbar(self) -> bool { (self & NoteOptions::actionbar) == NoteOptions::actionbar } #[inline] + pub fn has_selectable_text(self) -> bool { + (self & NoteOptions::selectable_text) == NoteOptions::selectable_text + } + + #[inline] pub fn has_note_previews(self) -> bool { (self & NoteOptions::note_previews) == NoteOptions::note_previews } @@ -58,40 +83,4 @@ impl NoteOptions { *self &= !NoteOptions::wide; } } - - #[inline] - pub fn set_small_pfp(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::small_pfp; - } else { - *self &= !NoteOptions::small_pfp; - } - } - - #[inline] - pub fn set_medium_pfp(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::medium_pfp; - } else { - *self &= !NoteOptions::medium_pfp; - } - } - - #[inline] - pub fn set_note_previews(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::note_previews; - } else { - *self &= !NoteOptions::note_previews; - } - } - - #[inline] - pub fn set_actionbar(&mut self, enable: bool) { - if enable { - *self |= NoteOptions::actionbar; - } else { - *self &= !NoteOptions::actionbar; - } - } } diff --git a/src/ui/note/post.rs b/src/ui/note/post.rs @@ -76,6 +76,7 @@ impl<'app, 'd> PostView<'app, 'd> { } let buffer = &mut self.draft_source.draft(&mut self.app.drafts).buffer; + let response = ui.add_sized( ui.available_size(), TextEdit::multiline(buffer) diff --git a/src/ui/thread.rs b/src/ui/thread.rs @@ -0,0 +1,139 @@ +use crate::{actionbar::BarResult, timeline::TimelineSource, ui, Damus}; +use nostrdb::{NoteKey, Transaction}; +use std::collections::HashSet; +use tracing::warn; + +pub struct ThreadView<'a> { + app: &'a mut Damus, + timeline: usize, + selected_note_id: &'a [u8; 32], +} + +impl<'a> ThreadView<'a> { + pub fn new(app: &'a mut Damus, timeline: usize, selected_note_id: &'a [u8; 32]) -> Self { + ThreadView { + app, + timeline, + selected_note_id, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<BarResult> { + let txn = Transaction::new(&self.app.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) + { + key + } else { + // TODO: render 404 ? + return None; + }; + + let scroll_id = egui::Id::new(( + "threadscroll", + self.app.timelines[self.timeline].selected_view, + self.timeline, + selected_note_key, + )); + + ui.label( + egui::RichText::new("Threads ALPHA! It's not done. Things will be broken.") + .color(egui::Color32::RED), + ); + + egui::ScrollArea::vertical() + .id_source(scroll_id) + .animated(false) + .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) { + note + } else { + return; + }; + + let root_id = { + let cached_note = self + .app + .note_cache_mut() + .cached_note_or_insert(selected_note_key, &note); + + cached_note + .reply + .borrow(note.tags()) + .root() + .map_or_else(|| self.selected_note_id, |nr| nr.id) + }; + + // poll for new notes and insert them into our existing notes + { + let mut ids = HashSet::new(); + let _ = TimelineSource::Thread(root_id) + .poll_notes_into_view(self.app, &txn, &mut ids); + // TODO: do something with unknown ids + } + + let (len, list) = { + let thread = self + .app + .threads + .thread_mut(&self.app.ndb, &txn, root_id) + .get_ptr(); + + let len = thread.view.notes.len(); + (len, &mut thread.view.list) + }; + + list.clone() + .borrow_mut() + .ui_custom_layout(ui, len, |ui, start_index| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().item_spacing.x = 4.0; + + let ind = len - 1 - start_index; + let note_key = { + let thread = self + .app + .threads + .thread_mut(&self.app.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) { + note + } else { + warn!("failed to query note {:?}", note_key); + return 0; + }; + + ui::padding(8.0, ui, |ui| { + let textmode = self.app.textmode; + let resp = ui::NoteView::new(self.app, &note) + .note_previews(!textmode) + .show(ui); + + if let Some(action) = resp.action { + let br = action.execute(self.app, self.timeline, note.id(), &txn); + if br.is_some() { + result = br; + } + } + }); + + ui::hline(ui); + //ui.add(egui::Separator::default().spacing(0.0)); + + 1 + }); + }); + + result + } +} diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs @@ -0,0 +1,248 @@ +use crate::{actionbar::BarResult, draft::DraftSource, ui, ui::note::PostAction, Damus}; +use egui::containers::scroll_area::ScrollBarVisibility; +use egui::{Direction, Layout}; +use egui_tabs::TabColor; +use nostrdb::Transaction; +use tracing::{debug, info, warn}; + +pub struct TimelineView<'a> { + app: &'a mut Damus, + reverse: bool, + timeline: usize, +} + +impl<'a> TimelineView<'a> { + pub fn new(app: &'a mut Damus, timeline: usize) -> TimelineView<'a> { + let reverse = false; + TimelineView { + app, + timeline, + reverse, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + timeline_ui(ui, self.app, self.timeline, self.reverse); + } + + pub fn reversed(mut self) -> Self { + self.reverse = true; + self + } +} + +fn timeline_ui(ui: &mut egui::Ui, app: &mut Damus, timeline: usize, reversed: 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); + } + + app.timelines[timeline].selected_view = tabs_ui(ui); + + // need this for some reason?? + ui.add_space(3.0); + + let scroll_id = egui::Id::new(("tlscroll", app.timelines[timeline].selected_view, timeline)); + 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 len = view.notes.len(); + let mut bar_result: Option<BarResult> = None; + let txn = if let Ok(txn) = Transaction::new(&app.ndb) { + txn + } else { + warn!("failed to create transaction"); + return 0; + }; + + view.list + .clone() + .borrow_mut() + .ui_custom_layout(ui, len, |ui, start_index| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().item_spacing.x = 4.0; + + let ind = if reversed { + len - start_index - 1 + } else { + start_index + }; + + let note_key = app.timelines[timeline].current_view().notes[ind].key; + + let note = if let Ok(note) = app.ndb.get_note_by_key(&txn, note_key) { + note + } else { + warn!("failed to query note {:?}", note_key); + return 0; + }; + + ui::padding(8.0, ui, |ui| { + let textmode = app.textmode; + let resp = ui::NoteView::new(app, &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; + } + } else if resp.response.clicked() { + debug!("clicked note"); + } + }); + + ui::hline(ui); + //ui.add(egui::Separator::default().spacing(0.0)); + + 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); + } + } + } + + 1 + }); +} + +fn postbox_view(app: &mut Damus, ui: &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); + } + } + } + } + } + } +} + +fn tabs_ui(ui: &mut egui::Ui) -> i32 { + ui.spacing_mut().item_spacing.y = 0.0; + + let tab_res = egui_tabs::Tabs::new(2) + .selected(1) + .hover_bg(TabColor::none()) + .selected_fg(TabColor::none()) + .selected_bg(TabColor::none()) + .hover_bg(TabColor::none()) + //.hover_bg(TabColor::custom(egui::Color32::RED)) + .height(32.0) + .layout(Layout::centered_and_justified(Direction::TopDown)) + .show(ui, |ui, state| { + ui.spacing_mut().item_spacing.y = 0.0; + + let ind = state.index(); + + let txt = if ind == 0 { "Notes" } else { "Notes & Replies" }; + + let res = ui.add(egui::Label::new(txt).selectable(false)); + + // underline + if state.is_selected() { + let rect = res.rect; + let underline = + shrink_range_to_width(rect.x_range(), get_label_width(ui, txt) * 1.15); + let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5; + return (underline, underline_y); + } + + (egui::Rangef::new(0.0, 0.0), 0.0) + }); + + //ui.add_space(0.5); + ui::hline(ui); + + let sel = tab_res.selected().unwrap_or_default(); + + let (underline, underline_y) = tab_res.inner()[sel as usize].inner; + let underline_width = underline.span(); + + let tab_anim_id = ui.id().with("tab_anim"); + let tab_anim_size = tab_anim_id.with("size"); + + let stroke = egui::Stroke { + color: ui.visuals().hyperlink_color, + width: 2.0, + }; + + let speed = 0.1f32; + + // animate underline position + let x = ui + .ctx() + .animate_value_with_time(tab_anim_id, underline.min, speed); + + // animate underline width + let w = ui + .ctx() + .animate_value_with_time(tab_anim_size, underline_width, speed); + + let underline = egui::Rangef::new(x, x + w); + + ui.painter().hline(underline, underline_y, stroke); + + sel +} + +fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 { + let font_id = egui::FontId::default(); + let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE)); + galley.rect.width() +} + +fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { + let midpoint = (range.min + range.max) / 2.0; + let half_width = width / 2.0; + + let min = midpoint - half_width; + let max = midpoint + half_width; + + egui::Rangef::new(min, max) +}