notedeck

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

commit 7ba81d0761a8b8f1eaeee7abdaea35b82f645bf5
parent cc5a888b89a3c772ee2cc849652d3c01515e8b78
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 23 Jun 2025 10:51:31 -0700

Merge Threads by kernel

kernelkind (16):
      add `NoteId` hashbrown `Equivalent` impl
      unknowns: use unowned noteid instead of owned
      tmp: upgrade `egui-nav` to use `ReturnType`
      add `ThreadSubs` for managing local & remote subscriptions
      add threads impl
      add overlay conception to `Router`
      add overlay to `RouterAction`
      ui: add `hline_with_width`
      note: refactor to use action composition & reduce nesting
      add pfp bounding box to `NoteResponse`
      add unread note indicator option to `NoteView`
      thread UI
      add preview flag to `NoteAction`
      add `NotesOpenResult`
      integrate new threads conception
      only deserialize first route in each column

Diffstat:
MCargo.lock | 4+++-
MCargo.toml | 2+-
Mcrates/enostr/Cargo.toml | 2++
Mcrates/enostr/src/note.rs | 6++++++
Mcrates/notedeck/src/note/action.rs | 11++++++++++-
Mcrates/notedeck/src/unknowns.rs | 10++++++----
Mcrates/notedeck_chrome/src/chrome.rs | 1+
Mcrates/notedeck_columns/Cargo.toml | 1+
Mcrates/notedeck_columns/src/actionbar.rs | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_columns/src/app.rs | 7++++++-
Mcrates/notedeck_columns/src/multi_subscriber.rs | 264++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_columns/src/nav.rs | 41+++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_columns/src/route.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_columns/src/storage/decks.rs | 28++++++++++++----------------
Mcrates/notedeck_columns/src/timeline/kind.rs | 33---------------------------------
Mcrates/notedeck_columns/src/timeline/mod.rs | 19+------------------
Mcrates/notedeck_columns/src/timeline/route.rs | 83+++++++++++++++++++++++++++++++------------------------------------------------
Acrates/notedeck_columns/src/timeline/thread.rs | 528+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/ui/column/header.rs | 26+++++++++++++++++++++-----
Mcrates/notedeck_columns/src/ui/thread.rs | 412+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/notedeck_ui/src/lib.rs | 6+++++-
Mcrates/notedeck_ui/src/note/contents.rs | 16+++++++++++-----
Mcrates/notedeck_ui/src/note/mod.rs | 474++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
23 files changed, 1875 insertions(+), 416 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1489,7 +1489,7 @@ dependencies = [ [[package]] name = "egui_nav" version = "0.2.0" -source = "git+https://github.com/damus-io/egui-nav?rev=0f0cbdd3184f3ff5fdf69ada08416ffc58a70d7a#0f0cbdd3184f3ff5fdf69ada08416ffc58a70d7a" +source = "git+https://github.com/kernelkind/egui-nav?rev=111de8ac40b5d18df53e9691eb18a50d49cb31d8#111de8ac40b5d18df53e9691eb18a50d49cb31d8" dependencies = [ "egui", "egui_extras", @@ -1554,6 +1554,7 @@ version = "0.3.0" dependencies = [ "bech32", "ewebsock", + "hashbrown", "hex", "mio", "nostr 0.37.0", @@ -3302,6 +3303,7 @@ dependencies = [ "egui_virtual_list", "ehttp", "enostr", + "hashbrown", "hex", "human_format", "image", diff --git a/Cargo.toml b/Cargo.toml @@ -23,7 +23,7 @@ egui = { version = "0.31.1", features = ["serde"] } egui-wgpu = "0.31.1" egui_extras = { version = "0.31.1", features = ["all_loaders"] } egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] } -egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "0f0cbdd3184f3ff5fdf69ada08416ffc58a70d7a" } +egui_nav = { git = "https://github.com/kernelkind/egui-nav", rev = "111de8ac40b5d18df53e9691eb18a50d49cb31d8" } egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b374a8a6658c09c9a4181299734d0" } #egui_virtual_list = "0.6.0" egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" } diff --git a/crates/enostr/Cargo.toml b/crates/enostr/Cargo.toml @@ -20,3 +20,4 @@ url = { workspace = true } mio = { workspace = true } tokio = { workspace = true } tokenator = { workspace = true } +hashbrown = { workspace = true } +\ No newline at end of file diff --git a/crates/enostr/src/note.rs b/crates/enostr/src/note.rs @@ -143,3 +143,9 @@ impl<'de> Deserialize<'de> for NoteId { NoteId::from_hex(&s).map_err(serde::de::Error::custom) } } + +impl hashbrown::Equivalent<NoteId> for &[u8; 32] { + fn equivalent(&self, key: &NoteId) -> bool { + self.as_slice() == key.bytes() + } +} diff --git a/crates/notedeck/src/note/action.rs b/crates/notedeck/src/note/action.rs @@ -18,7 +18,7 @@ pub enum NoteAction { Profile(Pubkey), /// User has clicked a note link - Note(NoteId), + Note { note_id: NoteId, preview: bool }, /// User has selected some context option Context(ContextSelection), @@ -30,6 +30,15 @@ pub enum NoteAction { Media(MediaAction), } +impl NoteAction { + pub fn note(id: NoteId) -> NoteAction { + NoteAction::Note { + note_id: id, + preview: false, + } + } +} + #[derive(Debug, Eq, PartialEq, Clone)] pub enum ZapAction { Send(ZapTargetAmount), diff --git a/crates/notedeck/src/unknowns.rs b/crates/notedeck/src/unknowns.rs @@ -191,7 +191,7 @@ impl UnknownIds { pub fn add_unknown_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, unk_id: &UnknownId) { match unk_id { UnknownId::Pubkey(pk) => self.add_pubkey_if_missing(ndb, txn, pk), - UnknownId::Id(note_id) => self.add_note_id_if_missing(ndb, txn, note_id), + UnknownId::Id(note_id) => self.add_note_id_if_missing(ndb, txn, note_id.bytes()), } } @@ -205,13 +205,15 @@ impl UnknownIds { self.mark_updated(); } - pub fn add_note_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, note_id: &NoteId) { + pub fn add_note_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, note_id: &[u8; 32]) { // we already have this note, skip - if ndb.get_note_by_id(txn, note_id.bytes()).is_ok() { + if ndb.get_note_by_id(txn, note_id).is_ok() { return; } - self.ids.entry(UnknownId::Id(*note_id)).or_default(); + self.ids + .entry(UnknownId::Id(NoteId::new(*note_id))) + .or_default(); self.mark_updated(); } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -625,6 +625,7 @@ fn chrome_handle_app_action( cols, 0, &mut columns.timeline_cache, + &mut columns.threads, ctx.note_cache, ctx.pool, &txn, diff --git a/crates/notedeck_columns/Cargo.toml b/crates/notedeck_columns/Cargo.toml @@ -51,6 +51,7 @@ sha2 = { workspace = true } base64 = { workspace = true } egui-winit = { workspace = true } profiling = { workspace = true } +hashbrown = { workspace = true } human_format = "1.1.0" [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -2,10 +2,15 @@ use crate::{ column::Columns, nav::{RouterAction, RouterType}, route::Route, - timeline::{ThreadSelection, TimelineCache, TimelineKind}, + timeline::{ + thread::{ + selected_has_at_least_n_replies, InsertionResponse, NoteSeenFlags, ThreadNode, Threads, + }, + ThreadSelection, TimelineCache, TimelineKind, + }, }; -use enostr::{Pubkey, RelayPool}; +use enostr::{NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, NoteKey, Transaction}; use notedeck::{ get_wallet_for_mut, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, @@ -18,12 +23,17 @@ pub struct NewNotes { pub notes: Vec<NoteKey>, } +pub enum NotesOpenResult { + Timeline(TimelineOpenResult), + Thread(NewThreadNotes), +} + pub enum TimelineOpenResult { NewNotes(NewNotes), } struct NoteActionResponse { - timeline_res: Option<TimelineOpenResult>, + timeline_res: Option<NotesOpenResult>, router_action: Option<RouterAction>, } @@ -31,8 +41,9 @@ struct NoteActionResponse { #[allow(clippy::too_many_arguments)] fn execute_note_action( action: NoteAction, - ndb: &Ndb, + ndb: &mut Ndb, timeline_cache: &mut TimelineCache, + threads: &mut Threads, note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, @@ -42,6 +53,7 @@ fn execute_note_action( images: &mut Images, router_type: RouterType, ui: &mut egui::Ui, + col: usize, ) -> NoteActionResponse { let mut timeline_res = None; let mut router_action = None; @@ -53,25 +65,34 @@ fn execute_note_action( NoteAction::Profile(pubkey) => { let kind = TimelineKind::Profile(pubkey); router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone()))); - timeline_res = timeline_cache.open(ndb, note_cache, txn, pool, &kind); + timeline_res = timeline_cache + .open(ndb, note_cache, txn, pool, &kind) + .map(NotesOpenResult::Timeline); } - NoteAction::Note(note_id) => 'ex: { + NoteAction::Note { note_id, preview } => 'ex: { let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id) else { tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes())); break 'ex; }; - let kind = TimelineKind::Thread(thread_selection); - router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone()))); - // NOTE!!: you need the note_id to timeline root id thing + timeline_res = threads + .open(ndb, txn, pool, &thread_selection, preview, col) + .map(NotesOpenResult::Thread); + + let route = Route::Thread(thread_selection); - timeline_res = timeline_cache.open(ndb, note_cache, txn, pool, &kind); + router_action = Some(RouterAction::Overlay { + route, + make_new: preview, + }); } NoteAction::Hashtag(htag) => { let kind = TimelineKind::Hashtag(htag.clone()); router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone()))); - timeline_res = timeline_cache.open(ndb, note_cache, txn, pool, &kind); + timeline_res = timeline_cache + .open(ndb, note_cache, txn, pool, &kind) + .map(NotesOpenResult::Timeline); } NoteAction::Quote(note_id) => { router_action = Some(RouterAction::route_to(Route::quote(note_id))); @@ -135,10 +156,11 @@ fn execute_note_action( #[allow(clippy::too_many_arguments)] pub fn execute_and_process_note_action( action: NoteAction, - ndb: &Ndb, + ndb: &mut Ndb, columns: &mut Columns, col: usize, timeline_cache: &mut TimelineCache, + threads: &mut Threads, note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, @@ -163,6 +185,7 @@ pub fn execute_and_process_note_action( action, ndb, timeline_cache, + threads, note_cache, pool, txn, @@ -172,10 +195,18 @@ pub fn execute_and_process_note_action( images, router_type, ui, + col, ); if let Some(br) = resp.timeline_res { - br.process(ndb, note_cache, txn, timeline_cache, unknown_ids); + match br { + NotesOpenResult::Timeline(timeline_open_result) => { + timeline_open_result.process(ndb, note_cache, txn, timeline_cache, unknown_ids); + } + NotesOpenResult::Thread(thread_open_result) => { + thread_open_result.process(threads, ndb, txn, unknown_ids, note_cache); + } + } } resp.router_action @@ -237,7 +268,7 @@ impl NewNotes { unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, ) { - let reversed = matches!(&self.id, TimelineKind::Thread(_)); + let reversed = false; let timeline = if let Some(profile) = timeline_cache.timelines.get_mut(&self.id) { profile @@ -252,3 +283,103 @@ impl NewNotes { } } } + +pub struct NewThreadNotes { + pub selected_note_id: NoteId, + pub notes: Vec<NoteKey>, +} + +impl NewThreadNotes { + pub fn process( + &self, + threads: &mut Threads, + ndb: &Ndb, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + note_cache: &mut NoteCache, + ) { + let Some(node) = threads.threads.get_mut(&self.selected_note_id.bytes()) else { + tracing::error!("Could not find thread node for {:?}", self.selected_note_id); + return; + }; + + process_thread_notes( + &self.notes, + node, + &mut threads.seen_flags, + ndb, + txn, + unknown_ids, + note_cache, + ); + } +} + +pub fn process_thread_notes( + notes: &Vec<NoteKey>, + thread: &mut ThreadNode, + seen_flags: &mut NoteSeenFlags, + ndb: &Ndb, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + note_cache: &mut NoteCache, +) { + if notes.is_empty() { + return; + } + + let mut has_spliced_resp = false; + let mut num_new_notes = 0; + for key in notes { + let note = if let Ok(note) = ndb.get_note_by_key(txn, *key) { + note + } else { + tracing::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; + }; + + // Ensure that unknown ids are captured when inserting notes + UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &note); + + let created_at = note.created_at(); + let note_ref = notedeck::NoteRef { + key: *key, + created_at, + }; + + if thread.replies.contains(&note_ref) { + continue; + } + + let insertion_resp = thread.replies.insert(note_ref); + if let InsertionResponse::Merged(crate::timeline::MergeKind::Spliced) = insertion_resp { + has_spliced_resp = true; + } + + if matches!(insertion_resp, InsertionResponse::Merged(_)) { + num_new_notes += 1; + } + + if !seen_flags.contains(note.id()) { + let cached_note = note_cache.cached_note_or_insert_mut(*key, &note); + + let note_reply = cached_note.reply.borrow(note.tags()); + + let has_reply = if let Some(root) = note_reply.root() { + selected_has_at_least_n_replies(ndb, txn, Some(note.id()), root.id, 1) + } else { + selected_has_at_least_n_replies(ndb, txn, None, note.id(), 1) + }; + + seen_flags.mark_replies(note.id(), has_reply); + } + } + + if has_spliced_resp { + tracing::debug!( + "spliced when inserting {} new notes, resetting virtual list", + num_new_notes + ); + thread.list.reset(); + } +} diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -8,7 +8,7 @@ use crate::{ storage, subscriptions::{SubKind, Subscriptions}, support::Support, - timeline::{self, TimelineCache}, + timeline::{self, thread::Threads, TimelineCache}, ui::{self, DesktopSidePanel}, view_state::ViewState, Result, @@ -45,6 +45,7 @@ pub struct Damus { pub subscriptions: Subscriptions, pub support: Support, pub jobs: JobsCache, + pub threads: Threads, //frame_history: crate::frame_history::FrameHistory, @@ -443,6 +444,8 @@ impl Damus { ctx.accounts.with_fallback(FALLBACK_PUBKEY()); + let threads = Threads::default(); + Self { subscriptions: Subscriptions::default(), since_optimize: parsed_args.since_optimize, @@ -458,6 +461,7 @@ impl Damus { debug, unrecognized_args, jobs, + threads, } } @@ -502,6 +506,7 @@ impl Damus { decks_cache, unrecognized_args: BTreeSet::default(), jobs: JobsCache::default(), + threads: Threads::default(), } } diff --git a/crates/notedeck_columns/src/multi_subscriber.rs b/crates/notedeck_columns/src/multi_subscriber.rs @@ -1,8 +1,12 @@ -use enostr::{Filter, RelayPool}; +use egui_nav::ReturnType; +use enostr::{Filter, NoteId, RelayPool}; +use hashbrown::HashMap; use nostrdb::{Ndb, Subscription}; use tracing::{error, info}; use uuid::Uuid; +use crate::timeline::ThreadSelection; + #[derive(Debug)] pub struct MultiSubscriber { pub filters: Vec<Filter>, @@ -143,3 +147,261 @@ impl MultiSubscriber { } } } + +type RootNoteId = NoteId; + +#[derive(Default)] +pub struct ThreadSubs { + pub remotes: HashMap<RootNoteId, Remote>, + scopes: HashMap<MetaId, Vec<Scope>>, +} + +// column id +type MetaId = usize; + +pub struct Remote { + pub filter: Vec<Filter>, + subid: String, + dependers: usize, +} + +impl std::fmt::Debug for Remote { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Remote") + .field("subid", &self.subid) + .field("dependers", &self.dependers) + .finish() + } +} + +struct Scope { + pub root_id: NoteId, + stack: Vec<Sub>, +} + +pub struct Sub { + pub selected_id: NoteId, + pub sub: Subscription, + pub filter: Vec<Filter>, +} + +impl ThreadSubs { + #[allow(clippy::too_many_arguments)] + pub fn subscribe( + &mut self, + ndb: &mut Ndb, + pool: &mut RelayPool, + meta_id: usize, + id: &ThreadSelection, + local_sub_filter: Vec<Filter>, + new_scope: bool, + remote_sub_filter: impl FnOnce() -> Vec<Filter>, + ) { + let cur_scopes = self.scopes.entry(meta_id).or_default(); + + let new_subs = if new_scope || cur_scopes.is_empty() { + local_sub_new_scope(ndb, id, local_sub_filter, cur_scopes) + } else { + let cur_scope = cur_scopes.last_mut().expect("can't be empty"); + sub_current_scope(ndb, id, local_sub_filter, cur_scope) + }; + + let remote = match self.remotes.raw_entry_mut().from_key(&id.root_id.bytes()) { + hashbrown::hash_map::RawEntryMut::Occupied(entry) => entry.into_mut(), + hashbrown::hash_map::RawEntryMut::Vacant(entry) => { + let (_, res) = entry.insert( + NoteId::new(*id.root_id.bytes()), + sub_remote(pool, remote_sub_filter, id), + ); + + res + } + }; + + remote.dependers = remote.dependers.saturating_add_signed(new_subs); + let num_dependers = remote.dependers; + tracing::info!( + "Sub stats: num remotes: {}, num locals: {}, num remote dependers: {:?}", + self.remotes.len(), + self.scopes.len(), + num_dependers, + ); + } + + pub fn unsubscribe( + &mut self, + ndb: &mut Ndb, + pool: &mut RelayPool, + meta_id: usize, + id: &ThreadSelection, + return_type: ReturnType, + ) { + let Some(scopes) = self.scopes.get_mut(&meta_id) else { + return; + }; + + let Some(remote) = self.remotes.get_mut(&id.root_id.bytes()) else { + tracing::error!("somehow we're unsubscribing but we don't have a remote"); + return; + }; + + match return_type { + ReturnType::Drag => { + if let Some(scope) = scopes.last_mut() { + let Some(cur_sub) = scope.stack.pop() else { + tracing::error!("expected a scope to be left"); + return; + }; + + if cur_sub.selected_id.bytes() != id.selected_or_root() { + tracing::error!("Somehow the current scope's root is not equal to the selected note's root"); + } + + if ndb_unsub(ndb, cur_sub.sub, id) { + remote.dependers = remote.dependers.saturating_sub(1); + } + + if scope.stack.is_empty() { + scopes.pop(); + } + } + } + ReturnType::Click => { + let Some(scope) = scopes.pop() else { + tracing::error!("called unsubscribe but there aren't any scopes left"); + return; + }; + + for sub in scope.stack { + if sub.selected_id.bytes() != id.selected_or_root() { + tracing::error!("Somehow the current scope's root is not equal to the selected note's root"); + } + + if ndb_unsub(ndb, sub.sub, id) { + remote.dependers = remote.dependers.saturating_sub(1); + } + } + } + } + + if scopes.is_empty() { + self.scopes.remove(&meta_id); + } + + let num_dependers = remote.dependers; + + if remote.dependers == 0 { + let remote = self + .remotes + .remove(&id.root_id.bytes()) + .expect("code above should guarentee existence"); + tracing::info!("Remotely unsubscribed: {}", remote.subid); + pool.unsubscribe(remote.subid); + } + + tracing::info!( + "unsub stats: num remotes: {}, num locals: {}, num remote dependers: {:?}", + self.remotes.len(), + self.scopes.len(), + num_dependers, + ); + } + + pub fn get_local(&self, meta_id: usize) -> Option<&Sub> { + self.scopes + .get(&meta_id) + .as_ref() + .and_then(|s| s.last()) + .and_then(|s| s.stack.last()) + } +} + +fn sub_current_scope( + ndb: &mut Ndb, + selection: &ThreadSelection, + local_sub_filter: Vec<Filter>, + cur_scope: &mut Scope, +) -> isize { + let mut new_subs = 0; + + if selection.root_id.bytes() != cur_scope.root_id.bytes() { + tracing::error!( + "Somehow the current scope's root is not equal to the selected note's root" + ); + } + + if let Some(sub) = ndb_sub(ndb, &local_sub_filter, selection) { + cur_scope.stack.push(Sub { + selected_id: NoteId::new(*selection.selected_or_root()), + sub, + filter: local_sub_filter, + }); + new_subs += 1; + } + + new_subs +} + +fn ndb_sub(ndb: &Ndb, filter: &[Filter], id: impl std::fmt::Debug) -> Option<Subscription> { + match ndb.subscribe(filter) { + Ok(s) => Some(s), + Err(e) => { + tracing::info!("Failed to get subscription for {:?}: {e}", id); + None + } + } +} + +fn ndb_unsub(ndb: &mut Ndb, sub: Subscription, id: impl std::fmt::Debug) -> bool { + match ndb.unsubscribe(sub) { + Ok(_) => true, + Err(e) => { + tracing::info!("Failed to unsub {:?}: {e}", id); + false + } + } +} + +fn sub_remote( + pool: &mut RelayPool, + remote_sub_filter: impl FnOnce() -> Vec<Filter>, + id: impl std::fmt::Debug, +) -> Remote { + let subid = Uuid::new_v4().to_string(); + + let filter = remote_sub_filter(); + + let remote = Remote { + filter: filter.clone(), + subid: subid.clone(), + dependers: 0, + }; + + tracing::info!("Remote subscribe for {:?}", id); + + pool.subscribe(subid, filter); + + remote +} + +fn local_sub_new_scope( + ndb: &mut Ndb, + id: &ThreadSelection, + local_sub_filter: Vec<Filter>, + scopes: &mut Vec<Scope>, +) -> isize { + let Some(sub) = ndb_sub(ndb, &local_sub_filter, id) else { + return 0; + }; + + scopes.push(Scope { + root_id: id.root_id.to_note_id(), + stack: vec![Sub { + selected_id: NoteId::new(*id.selected_or_root()), + sub, + filter: local_sub_filter, + }], + }); + + 1 +} diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -8,7 +8,10 @@ use crate::{ profile_state::ProfileState, relay_pool_manager::RelayPoolManager, route::{Route, Router, SingletonRouter}, - timeline::{route::render_timeline_route, TimelineCache}, + timeline::{ + route::{render_thread_route, render_timeline_route}, + TimelineCache, + }, ui::{ self, add_column::render_add_column_routes, @@ -182,7 +185,7 @@ fn process_popup_resp( process_result = process_render_nav_action(app, ctx, ui, col, nav_action); } - if let Some(NavAction::Returned) = action.action { + if let Some(NavAction::Returned(_)) = action.action { let column = app.columns_mut(ctx.accounts).column_mut(col); column.sheet_router.clear(); } else if let Some(NavAction::Navigating) = action.action { @@ -210,7 +213,7 @@ fn process_nav_resp( if let Some(action) = response.action { match action { - NavAction::Returned => { + NavAction::Returned(return_type) => { let r = app .columns_mut(ctx.accounts) .column_mut(col) @@ -223,6 +226,12 @@ fn process_nav_resp( } }; + if let Some(Route::Thread(selection)) = &r { + tracing::info!("Return type: {:?}", return_type); + app.threads + .close(ctx.ndb, ctx.pool, selection, return_type, col); + } + process_result = Some(ProcessNavResult::SwitchOccurred); } @@ -237,7 +246,7 @@ fn process_nav_resp( } NavAction::Dragging => {} - NavAction::Returning => {} + NavAction::Returning(_) => {} NavAction::Resetting => {} NavAction::Navigating => {} } @@ -253,6 +262,10 @@ pub enum RouterAction { /// chrome atm PfpClicked, RouteTo(Route, RouterType), + Overlay { + route: Route, + make_new: bool, + }, } pub enum RouterType { @@ -289,6 +302,14 @@ impl RouterAction { None } }, + RouterAction::Overlay { route, make_new } => { + if make_new { + stack_router.route_to_overlaid_new(route); + } else { + stack_router.route_to_overlaid(route); + } + None + } } } @@ -343,6 +364,7 @@ fn process_render_nav_action( get_active_columns_mut(ctx.accounts, &mut app.decks_cache), col, &mut app.timeline_cache, + &mut app.threads, ctx.note_cache, ctx.pool, &txn, @@ -414,6 +436,17 @@ fn render_nav_body( &mut note_context, &mut app.jobs, ), + Route::Thread(selection) => render_thread_route( + ctx.unknown_ids, + &mut app.threads, + ctx.accounts, + selection, + col, + app.note_options, + ui, + &mut note_context, + &mut app.jobs, + ), Route::Accounts(amr) => { let mut action = render_accounts_route( ui, diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -1,6 +1,9 @@ use enostr::{NoteId, Pubkey}; -use notedeck::{NoteZapTargetOwned, WalletType}; -use std::fmt::{self}; +use notedeck::{NoteZapTargetOwned, RootNoteIdBuf, WalletType}; +use std::{ + fmt::{self}, + ops::Range, +}; use crate::{ accounts::AccountsRoute, @@ -17,6 +20,7 @@ use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; #[derive(Clone, Eq, PartialEq, Debug)] pub enum Route { Timeline(TimelineKind), + Thread(ThreadSelection), Accounts(AccountsRoute), Reply(NoteId), Quote(NoteId), @@ -50,7 +54,7 @@ impl Route { } pub fn thread(thread_selection: ThreadSelection) -> Self { - Route::Timeline(TimelineKind::Thread(thread_selection)) + Route::Thread(thread_selection) } pub fn profile(pubkey: Pubkey) -> Self { @@ -76,6 +80,18 @@ impl Route { pub fn serialize_tokens(&self, writer: &mut TokenWriter) { match self { Route::Timeline(timeline_kind) => timeline_kind.serialize_tokens(writer), + Route::Thread(selection) => { + writer.write_token("thread"); + + if let Some(reply) = selection.selected_note { + writer.write_token("root"); + writer.write_token(&NoteId::new(*selection.root_id.bytes()).hex()); + writer.write_token("reply"); + writer.write_token(&reply.hex()); + } else { + writer.write_token(&NoteId::new(*selection.root_id.bytes()).hex()); + } + } Route::Accounts(routes) => routes.serialize_tokens(writer), Route::AddColumn(routes) => routes.serialize_tokens(writer), Route::Search => writer.write_token("search"), @@ -196,6 +212,31 @@ impl Route { Ok(Route::Search) }) }, + |p| { + p.parse_all(|p| { + p.parse_token("thread")?; + p.parse_token("root")?; + + let root = tokenator::parse_hex_id(p)?; + + p.parse_token("reply")?; + + let selected = tokenator::parse_hex_id(p)?; + + Ok(Route::Thread(ThreadSelection { + root_id: RootNoteIdBuf::new_unsafe(root), + selected_note: Some(NoteId::new(selected)), + })) + }) + }, + |p| { + p.parse_all(|p| { + p.parse_token("thread")?; + Ok(Route::Thread(ThreadSelection::from_root_id( + RootNoteIdBuf::new_unsafe(tokenator::parse_hex_id(p)?), + ))) + }) + }, ], ) } @@ -203,6 +244,7 @@ impl Route { pub fn title(&self) -> ColumnTitle<'_> { match self { Route::Timeline(kind) => kind.to_title(), + Route::Thread(_) => ColumnTitle::simple("Thread"), Route::Reply(_id) => ColumnTitle::simple("Reply"), Route::Quote(_id) => ColumnTitle::simple("Quote"), Route::Relays => ColumnTitle::simple("Relays"), @@ -250,6 +292,9 @@ pub struct Router<R: Clone> { pub returning: bool, pub navigating: bool, replacing: bool, + + // An overlay captures a range of routes where only one will persist when going back, the most recent added + overlay_ranges: Vec<Range<usize>>, } impl<R: Clone> Router<R> { @@ -265,6 +310,7 @@ impl<R: Clone> Router<R> { returning, navigating, replacing, + overlay_ranges: Vec::new(), } } @@ -273,6 +319,16 @@ impl<R: Clone> Router<R> { self.routes.push(route); } + pub fn route_to_overlaid(&mut self, route: R) { + self.route_to(route); + self.set_overlaying(); + } + + pub fn route_to_overlaid_new(&mut self, route: R) { + self.route_to(route); + self.new_overlay(); + } + // Route to R. Then when it is successfully placed, should call `remove_previous_routes` to remove all previous routes pub fn route_to_replaced(&mut self, route: R) { self.navigating = true; @@ -286,6 +342,18 @@ impl<R: Clone> Router<R> { return None; } self.returning = true; + + if let Some(range) = self.overlay_ranges.pop() { + tracing::info!("Going back, found overlay: {:?}", range); + self.remove_overlay(range); + } else { + tracing::info!("Going back, no overlay"); + } + + if self.routes.len() == 1 { + return None; + } + self.prev().cloned() } @@ -294,6 +362,24 @@ impl<R: Clone> Router<R> { if self.routes.len() == 1 { return None; } + + 's: { + let Some(last_range) = self.overlay_ranges.last_mut() else { + break 's; + }; + + if last_range.end != self.routes.len() { + break 's; + } + + if last_range.end - 1 <= last_range.start { + self.overlay_ranges.pop(); + break 's; + } + + last_range.end -= 1; + } + self.returning = false; self.routes.pop() } @@ -309,10 +395,47 @@ impl<R: Clone> Router<R> { self.routes.drain(..num_routes - 1); } + /// Removes all routes in the overlay besides the last + fn remove_overlay(&mut self, overlay_range: Range<usize>) { + let num_routes = self.routes.len(); + if num_routes <= 1 { + return; + } + + if overlay_range.len() <= 1 { + return; + } + + self.routes + .drain(overlay_range.start..overlay_range.end - 1); + } + pub fn is_replacing(&self) -> bool { self.replacing } + fn set_overlaying(&mut self) { + let mut overlaying_active = None; + let mut binding = self.overlay_ranges.last_mut(); + if let Some(range) = &mut binding { + if range.end == self.routes.len() - 1 { + overlaying_active = Some(range); + } + }; + + if let Some(range) = overlaying_active { + range.end = self.routes.len(); + } else { + let new_range = self.routes.len() - 1..self.routes.len(); + self.overlay_ranges.push(new_range); + } + } + + fn new_overlay(&mut self) { + let new_range = self.routes.len() - 1..self.routes.len(); + self.overlay_ranges.push(new_range); + } + pub fn top(&self) -> &R { self.routes.last().expect("routes can't be empty") } @@ -339,9 +462,9 @@ impl fmt::Display for Route { TimelineKind::Generic(_) => write!(f, "Custom"), TimelineKind::Search(_) => write!(f, "Search"), TimelineKind::Hashtag(ht) => write!(f, "Hashtag ({})", ht), - TimelineKind::Thread(_id) => write!(f, "Thread"), TimelineKind::Profile(_id) => write!(f, "Profile"), }, + Route::Thread(_) => write!(f, "Thread"), Route::Reply(_id) => write!(f, "Reply"), Route::Quote(_id) => write!(f, "Quote"), Route::Relays => write!(f, "Relays"), @@ -398,3 +521,30 @@ impl<R: Clone> Default for SingletonRouter<R> { } } } + +#[cfg(test)] +mod tests { + use enostr::NoteId; + use tokenator::{TokenParser, TokenWriter}; + + use crate::{timeline::ThreadSelection, Route}; + use enostr::Pubkey; + use notedeck::RootNoteIdBuf; + + #[test] + fn test_thread_route_serialize() { + let note_id_hex = "1c54e5b0c386425f7e017d9e068ddef8962eb2ce1bb08ed27e24b93411c12e60"; + let note_id = NoteId::from_hex(note_id_hex).unwrap(); + let data_str = format!("thread:{}", note_id_hex); + let data = &data_str.split(":").collect::<Vec<&str>>(); + let mut token_writer = TokenWriter::default(); + let mut parser = TokenParser::new(&data); + let parsed = Route::parse(&mut parser, &Pubkey::new(*note_id.bytes())).unwrap(); + let expected = Route::Thread(ThreadSelection::from_root_id(RootNoteIdBuf::new_unsafe( + *note_id.bytes(), + ))); + parsed.serialize_tokens(&mut token_writer); + assert_eq!(expected, parsed); + assert_eq!(token_writer.str(), data_str); + } +} diff --git a/crates/notedeck_columns/src/storage/decks.rs b/crates/notedeck_columns/src/storage/decks.rs @@ -319,26 +319,22 @@ fn deserialize_columns( ) -> Columns { let mut cols = Columns::new(); for column in columns { - let mut cur_routes = Vec::new(); + let Some(route) = column.first() else { + continue; + }; - for route in column { - let tokens: Vec<&str> = route.split(":").collect(); - let mut parser = TokenParser::new(&tokens); + let tokens: Vec<&str> = route.split(":").collect(); + let mut parser = TokenParser::new(&tokens); - match CleanIntermediaryRoute::parse(&mut parser, deck_user) { - Ok(route_intermediary) => { - if let Some(ir) = route_intermediary.into_intermediary_route(ndb) { - cur_routes.push(ir); - } - } - Err(err) => { - error!("could not turn tokens to RouteIntermediary: {:?}", err); + match CleanIntermediaryRoute::parse(&mut parser, deck_user) { + Ok(route_intermediary) => { + if let Some(ir) = route_intermediary.into_intermediary_route(ndb) { + cols.insert_intermediary_routes(timeline_cache, vec![ir]); } } - } - - if !cur_routes.is_empty() { - cols.insert_intermediary_routes(timeline_cache, cur_routes); + Err(err) => { + error!("could not turn tokens to RouteIntermediary: {:?}", err); + } } } diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs @@ -208,8 +208,6 @@ pub enum TimelineKind { Profile(Pubkey), - Thread(ThreadSelection), - Universe, /// Generic filter, references a hash of a filter @@ -266,7 +264,6 @@ impl Display for TimelineKind { TimelineKind::Profile(_) => f.write_str("Profile"), TimelineKind::Universe => f.write_str("Universe"), TimelineKind::Hashtag(_) => f.write_str("Hashtag"), - TimelineKind::Thread(_) => f.write_str("Thread"), TimelineKind::Search(_) => f.write_str("Search"), } } @@ -282,7 +279,6 @@ impl TimelineKind { TimelineKind::Universe => None, TimelineKind::Generic(_) => None, TimelineKind::Hashtag(_ht) => None, - TimelineKind::Thread(_ht) => None, TimelineKind::Search(query) => query.author(), } } @@ -298,7 +294,6 @@ impl TimelineKind { TimelineKind::Universe => true, TimelineKind::Generic(_) => true, TimelineKind::Hashtag(_ht) => true, - TimelineKind::Thread(_ht) => true, TimelineKind::Search(_q) => true, } } @@ -321,10 +316,6 @@ impl TimelineKind { writer.write_token("profile"); PubkeySource::pubkey(*pk).serialize_tokens(writer); } - TimelineKind::Thread(root_note_id) => { - writer.write_token("thread"); - writer.write_token(&root_note_id.root_id.hex()); - } TimelineKind::Universe => { writer.write_token("universe"); } @@ -378,12 +369,6 @@ impl TimelineKind { parser, &[ |p| { - p.parse_token("thread")?; - Ok(TimelineKind::Thread(ThreadSelection::from_root_id( - RootNoteIdBuf::new_unsafe(tokenator::parse_hex_id(p)?), - ))) - }, - |p| { p.parse_token("universe")?; Ok(TimelineKind::Universe) }, @@ -425,10 +410,6 @@ impl TimelineKind { TimelineKind::Profile(pk) } - pub fn thread(selected_note: ThreadSelection) -> Self { - TimelineKind::Thread(selected_note) - } - pub fn is_notifications(&self) -> bool { matches!(self, TimelineKind::Notifications(_)) } @@ -474,17 +455,6 @@ impl TimelineKind { todo!("implement generic filter lookups") } - TimelineKind::Thread(selection) => FilterState::ready(vec![ - nostrdb::Filter::new() - .kinds([1]) - .event(selection.root_id.bytes()) - .build(), - nostrdb::Filter::new() - .ids([selection.root_id.bytes()]) - .limit(1) - .build(), - ]), - TimelineKind::Profile(pk) => FilterState::ready(vec![Filter::new() .authors([pk.bytes()]) .kinds([1]) @@ -510,8 +480,6 @@ impl TimelineKind { TimelineTab::full_tabs(), )), - TimelineKind::Thread(root_id) => Some(Timeline::thread(root_id)), - TimelineKind::Generic(_filter_id) => { warn!("you can't convert a TimelineKind::Generic to a Timeline"); // TODO: you actually can! just need to look up the filter id @@ -609,7 +577,6 @@ impl TimelineKind { }, TimelineKind::Notifications(_pubkey_source) => ColumnTitle::simple("Notifications"), TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self), - TimelineKind::Thread(_root_id) => ColumnTitle::simple("Thread"), TimelineKind::Universe => ColumnTitle::simple("Universe"), TimelineKind::Generic(_) => ColumnTitle::simple("Custom"), TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.to_string()), diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -21,6 +21,7 @@ use tracing::{debug, error, info, warn}; pub mod cache; pub mod kind; pub mod route; +pub mod thread; pub use cache::TimelineCache; pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind}; @@ -213,24 +214,6 @@ impl Timeline { )) } - pub fn thread(selection: ThreadSelection) -> Self { - let filter = vec![ - nostrdb::Filter::new() - .kinds([1]) - .event(selection.root_id.bytes()) - .build(), - nostrdb::Filter::new() - .ids([selection.root_id.bytes()]) - .limit(1) - .build(), - ]; - Timeline::new( - TimelineKind::Thread(selection), - FilterState::ready(filter), - TimelineTab::only_notes_and_replies(), - ) - } - pub fn last_per_pubkey(list: &Note, list_kind: &ListKind) -> Result<Self> { let kind = 1; let notes_per_pk = 1; diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -1,7 +1,7 @@ use crate::{ nav::RenderNavAction, profile::ProfileAction, - timeline::{TimelineCache, TimelineKind}, + timeline::{thread::Threads, ThreadSelection, TimelineCache, TimelineKind}, ui::{self, ProfileView}, }; @@ -16,7 +16,7 @@ pub fn render_timeline_route( accounts: &mut Accounts, kind: &TimelineKind, col: usize, - mut note_options: NoteOptions, + note_options: NoteOptions, depth: usize, ui: &mut egui::Ui, note_context: &mut NoteContext, @@ -74,30 +74,38 @@ pub fn render_timeline_route( note_action.map(RenderNavAction::NoteAction) } } + } +} - TimelineKind::Thread(id) => { - // don't truncate thread notes for now, since they are - // default truncated everywher eelse - note_options.set_truncate(false); - - // text is selectable in threads - note_options.set_selectable_text(true); +#[allow(clippy::too_many_arguments)] +pub fn render_thread_route( + unknown_ids: &mut UnknownIds, + threads: &mut Threads, + accounts: &mut Accounts, + selection: &ThreadSelection, + col: usize, + mut note_options: NoteOptions, + ui: &mut egui::Ui, + note_context: &mut NoteContext, + jobs: &mut JobsCache, +) -> Option<RenderNavAction> { + // don't truncate thread notes for now, since they are + // default truncated everywher eelse + note_options.set_truncate(false); - ui::ThreadView::new( - timeline_cache, - unknown_ids, - id.selected_or_root(), - note_options, - &accounts.mutefun(), - note_context, - &accounts.get_selected_account().map(|a| (&a.key).into()), - jobs, - ) - .id_source(egui::Id::new(("threadscroll", col))) - .ui(ui) - .map(Into::into) - } - } + ui::ThreadView::new( + threads, + unknown_ids, + selection.selected_or_root(), + note_options, + &accounts.mutefun(), + note_context, + &accounts.get_selected_account().map(|a| (&a.key).into()), + jobs, + ) + .id_source(col) + .ui(ui) + .map(Into::into) } #[allow(clippy::too_many_arguments)] @@ -139,30 +147,3 @@ pub fn render_profile_route( None } } - -#[cfg(test)] -mod tests { - use enostr::NoteId; - use tokenator::{TokenParser, TokenWriter}; - - use crate::timeline::{ThreadSelection, TimelineKind}; - use enostr::Pubkey; - use notedeck::RootNoteIdBuf; - - #[test] - fn test_timeline_route_serialize() { - let note_id_hex = "1c54e5b0c386425f7e017d9e068ddef8962eb2ce1bb08ed27e24b93411c12e60"; - let note_id = NoteId::from_hex(note_id_hex).unwrap(); - let data_str = format!("thread:{}", note_id_hex); - let data = &data_str.split(":").collect::<Vec<&str>>(); - let mut token_writer = TokenWriter::default(); - let mut parser = TokenParser::new(&data); - let parsed = TimelineKind::parse(&mut parser, &Pubkey::new(*note_id.bytes())).unwrap(); - let expected = TimelineKind::Thread(ThreadSelection::from_root_id( - RootNoteIdBuf::new_unsafe(*note_id.bytes()), - )); - parsed.serialize_tokens(&mut token_writer); - assert_eq!(expected, parsed); - assert_eq!(token_writer.str(), data_str); - } -} diff --git a/crates/notedeck_columns/src/timeline/thread.rs b/crates/notedeck_columns/src/timeline/thread.rs @@ -0,0 +1,528 @@ +use std::{ + collections::{BTreeSet, HashSet}, + hash::Hash, +}; + +use egui_nav::ReturnType; +use egui_virtual_list::VirtualList; +use enostr::{NoteId, RelayPool}; +use hashbrown::{hash_map::RawEntryMut, HashMap}; +use nostrdb::{Filter, Ndb, Note, NoteKey, NoteReplyBuf, Transaction}; +use notedeck::{NoteCache, NoteRef, UnknownIds}; + +use crate::{ + actionbar::{process_thread_notes, NewThreadNotes}, + multi_subscriber::ThreadSubs, + timeline::MergeKind, +}; + +use super::ThreadSelection; + +pub struct ThreadNode { + pub replies: HybridSet<NoteRef>, + pub prev: ParentState, + pub have_all_ancestors: bool, + pub list: VirtualList, +} + +#[derive(Clone)] +pub enum ParentState { + Unknown, + None, + Parent(NoteId), +} + +/// Affords: +/// - O(1) contains +/// - O(log n) sorted insertion +pub struct HybridSet<T> { + reversed: bool, + lookup: HashSet<T>, // fast deduplication + ordered: BTreeSet<T>, // sorted iteration +} + +impl<T> Default for HybridSet<T> { + fn default() -> Self { + Self { + reversed: Default::default(), + lookup: Default::default(), + ordered: Default::default(), + } + } +} + +pub enum InsertionResponse { + AlreadyExists, + Merged(MergeKind), +} + +impl<T: Copy + Ord + Eq + Hash> HybridSet<T> { + pub fn insert(&mut self, val: T) -> InsertionResponse { + if !self.lookup.insert(val) { + return InsertionResponse::AlreadyExists; + } + + let front_insertion = match self.ordered.iter().next() { + Some(first) => (val >= *first) == self.reversed, + None => true, + }; + + self.ordered.insert(val); // O(log n) + + InsertionResponse::Merged(if front_insertion { + MergeKind::FrontInsert + } else { + MergeKind::Spliced + }) + } +} + +impl<T: Eq + Hash> HybridSet<T> { + pub fn contains(&self, val: &T) -> bool { + self.lookup.contains(val) // O(1) + } +} + +impl<T> HybridSet<T> { + pub fn iter(&self) -> HybridIter<'_, T> { + HybridIter { + inner: self.ordered.iter(), + reversed: self.reversed, + } + } + + pub fn new(reversed: bool) -> Self { + Self { + reversed, + ..Default::default() + } + } +} + +impl<'a, T> IntoIterator for &'a HybridSet<T> { + type Item = &'a T; + type IntoIter = HybridIter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +pub struct HybridIter<'a, T> { + inner: std::collections::btree_set::Iter<'a, T>, + reversed: bool, +} + +impl<'a, T> Iterator for HybridIter<'a, T> { + type Item = &'a T; + + fn next(&mut self) -> Option<Self::Item> { + if self.reversed { + self.inner.next_back() + } else { + self.inner.next() + } + } +} + +impl ThreadNode { + pub fn new(parent: ParentState) -> Self { + Self { + replies: HybridSet::new(true), + prev: parent, + have_all_ancestors: false, + list: VirtualList::new(), + } + } +} + +#[derive(Default)] +pub struct Threads { + pub threads: HashMap<NoteId, ThreadNode>, + pub subs: ThreadSubs, + + pub seen_flags: NoteSeenFlags, +} + +impl Threads { + /// Opening a thread. + /// Similar to [[super::cache::TimelineCache::open]] + pub fn open( + &mut self, + ndb: &mut Ndb, + txn: &Transaction, + pool: &mut RelayPool, + thread: &ThreadSelection, + new_scope: bool, + col: usize, + ) -> Option<NewThreadNotes> { + tracing::info!("Opening thread: {:?}", thread); + let local_sub_filter = if let Some(selected) = &thread.selected_note { + vec![direct_replies_filter_non_root( + selected.bytes(), + thread.root_id.bytes(), + )] + } else { + vec![direct_replies_filter_root(thread.root_id.bytes())] + }; + + let selected_note_id = thread.selected_or_root(); + self.seen_flags.mark_seen(selected_note_id); + + let filter = match self.threads.raw_entry_mut().from_key(&selected_note_id) { + RawEntryMut::Occupied(_entry) => { + // TODO(kernelkind): reenable this once the panic is fixed + // + // let node = entry.into_mut(); + // if let Some(first) = node.replies.first() { + // &filter::make_filters_since(&local_sub_filter, first.created_at + 1) + // } else { + // &local_sub_filter + // } + &local_sub_filter + } + RawEntryMut::Vacant(entry) => { + let id = NoteId::new(*selected_note_id); + + let node = ThreadNode::new(ParentState::Unknown); + entry.insert(id, node); + + &local_sub_filter + } + }; + + let new_notes = ndb.query(txn, filter, 500).ok().map(|r| { + r.into_iter() + .map(NoteRef::from_query_result) + .collect::<Vec<_>>() + }); + + self.subs + .subscribe(ndb, pool, col, thread, local_sub_filter, new_scope, || { + replies_filter_remote(thread) + }); + + new_notes.map(|notes| NewThreadNotes { + selected_note_id: NoteId::new(*selected_note_id), + notes: notes.into_iter().map(|f| f.key).collect(), + }) + } + + pub fn close( + &mut self, + ndb: &mut Ndb, + pool: &mut RelayPool, + thread: &ThreadSelection, + return_type: ReturnType, + id: usize, + ) { + tracing::info!("Closing thread: {:?}", thread); + self.subs.unsubscribe(ndb, pool, id, thread, return_type); + } + + /// Responsible for making sure the chain and the direct replies are up to date + pub fn update( + &mut self, + selected: &Note<'_>, + note_cache: &mut NoteCache, + ndb: &Ndb, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + col: usize, + ) { + let Some(selected_key) = selected.key() else { + tracing::error!("Selected note did not have a key"); + return; + }; + + let reply = note_cache + .cached_note_or_insert_mut(selected_key, selected) + .reply; + + self.fill_reply_chain_recursive(selected, &reply, note_cache, ndb, txn, unknown_ids); + let node = self + .threads + .get_mut(&selected.id()) + .expect("should be guarenteed to exist from `Self::fill_reply_chain_recursive`"); + + let Some(sub) = self.subs.get_local(col) else { + tracing::error!("Was expecting to find local sub"); + return; + }; + + let keys = ndb.poll_for_notes(sub.sub, 10); + + if keys.is_empty() { + return; + } + + tracing::info!("Got {} new notes", keys.len()); + + process_thread_notes( + &keys, + node, + &mut self.seen_flags, + ndb, + txn, + unknown_ids, + note_cache, + ); + } + + fn fill_reply_chain_recursive( + &mut self, + cur_note: &Note<'_>, + cur_reply: &NoteReplyBuf, + note_cache: &mut NoteCache, + ndb: &Ndb, + txn: &Transaction, + unknown_ids: &mut UnknownIds, + ) -> bool { + let (unknown_parent_state, mut have_all_ancestors) = self + .threads + .get(&cur_note.id()) + .map(|t| (matches!(t.prev, ParentState::Unknown), t.have_all_ancestors)) + .unwrap_or((true, false)); + + if have_all_ancestors { + return true; + } + + let mut new_parent = None; + + let note_reply = cur_reply.borrow(cur_note.tags()); + + let next_link = 's: { + let Some(parent) = note_reply.reply() else { + break 's NextLink::None; + }; + + if unknown_parent_state { + new_parent = Some(ParentState::Parent(NoteId::new(*parent.id))); + } + + let Ok(reply_note) = ndb.get_note_by_id(txn, parent.id) else { + break 's NextLink::Unknown(parent.id); + }; + + let Some(notekey) = reply_note.key() else { + break 's NextLink::Unknown(parent.id); + }; + + NextLink::Next(reply_note, notekey) + }; + + match next_link { + NextLink::Unknown(parent) => { + unknown_ids.add_note_id_if_missing(ndb, txn, parent); + } + NextLink::Next(next_note, note_key) => { + UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &next_note); + + let cached_note = note_cache.cached_note_or_insert_mut(note_key, &next_note); + + let next_reply = cached_note.reply; + if self.fill_reply_chain_recursive( + &next_note, + &next_reply, + note_cache, + ndb, + txn, + unknown_ids, + ) { + have_all_ancestors = true; + } + + if !self.seen_flags.contains(next_note.id()) { + self.seen_flags.mark_replies( + next_note.id(), + selected_has_at_least_n_replies(ndb, txn, None, next_note.id(), 2), + ); + } + } + NextLink::None => { + have_all_ancestors = true; + new_parent = Some(ParentState::None); + } + } + + match self.threads.raw_entry_mut().from_key(&cur_note.id()) { + RawEntryMut::Occupied(entry) => { + let node = entry.into_mut(); + if let Some(parent) = new_parent { + node.prev = parent; + } + + if have_all_ancestors { + node.have_all_ancestors = true; + } + } + RawEntryMut::Vacant(entry) => { + let id = NoteId::new(*cur_note.id()); + let parent = new_parent.unwrap_or(ParentState::Unknown); + let (_, res) = entry.insert(id, ThreadNode::new(parent)); + + if have_all_ancestors { + res.have_all_ancestors = true; + } + } + } + + have_all_ancestors + } +} + +enum NextLink<'a> { + Unknown(&'a [u8; 32]), + Next(Note<'a>, NoteKey), + None, +} + +pub fn selected_has_at_least_n_replies( + ndb: &Ndb, + txn: &Transaction, + selected: Option<&[u8; 32]>, + root: &[u8; 32], + n: u8, +) -> bool { + let filter = if let Some(selected) = selected { + &vec![direct_replies_filter_non_root(selected, root)] + } else { + &vec![direct_replies_filter_root(root)] + }; + + let Ok(res) = ndb.query(txn, filter, n as i32) else { + return false; + }; + + res.len() >= n.into() +} + +fn direct_replies_filter_non_root( + selected_note_id: &[u8; 32], + root_id: &[u8; 32], +) -> nostrdb::Filter { + let tmp_selected = *selected_note_id; + nostrdb::Filter::new() + .kinds([1]) + .custom(move |n: nostrdb::Note<'_>| { + for tag in n.tags() { + if tag.count() < 4 { + continue; + } + + let Some("e") = tag.get_str(0) else { + continue; + }; + + let Some(tagged_id) = tag.get_id(1) else { + continue; + }; + + if *tagged_id != tmp_selected { + // NOTE: if these aren't dereferenced a segfault occurs... + continue; + } + + if let Some(data) = tag.get_str(3) { + if data == "reply" { + return true; + } + } + } + false + }) + .event(root_id) + .build() +} + +/// Custom filter requirements: +/// - Do NOT capture references (e.g. `*root_id`) inside the closure +/// - Instead, copy values outside and capture them with `move` +/// +/// Incorrect: +/// .custom(|_| { *root_id }) // ❌ +/// Also Incorrect: +/// .custom(move |_| { *root_id }) // ❌ +/// Correct: +/// let tmp = *root_id; +/// .custom(move |_| { tmp }) // ✅ +fn direct_replies_filter_root(root_id: &[u8; 32]) -> nostrdb::Filter { + let tmp_root = *root_id; + nostrdb::Filter::new() + .kinds([1]) + .custom(move |n: nostrdb::Note<'_>| { + let mut contains_root = false; + for tag in n.tags() { + if tag.count() < 4 { + continue; + } + + let Some("e") = tag.get_str(0) else { + continue; + }; + + if let Some(s) = tag.get_str(3) { + if s == "reply" { + return false; + } + } + + let Some(tagged_id) = tag.get_id(1) else { + continue; + }; + + if *tagged_id != tmp_root { + continue; + } + + if let Some(s) = tag.get_str(3) { + if s == "root" { + contains_root = true; + } + } + } + + contains_root + }) + .event(root_id) + .build() +} + +fn replies_filter_remote(selection: &ThreadSelection) -> Vec<Filter> { + vec![ + nostrdb::Filter::new() + .kinds([1]) + .event(selection.root_id.bytes()) + .build(), + nostrdb::Filter::new() + .ids([selection.root_id.bytes()]) + .limit(1) + .build(), + ] +} + +/// Represents indicators that there is more content in the note to view +#[derive(Default)] +pub struct NoteSeenFlags { + // true indicates the note has replies AND it has not been read + pub flags: HashMap<NoteId, bool>, +} + +impl NoteSeenFlags { + pub fn mark_seen(&mut self, note_id: &[u8; 32]) { + self.flags.insert(NoteId::new(*note_id), false); + } + + pub fn mark_replies(&mut self, note_id: &[u8; 32], has_replies: bool) { + self.flags.insert(NoteId::new(*note_id), has_replies); + } + + pub fn get(&self, note_id: &[u8; 32]) -> Option<&bool> { + self.flags.get(&note_id) + } + + pub fn contains(&self, note_id: &[u8; 32]) -> bool { + self.flags.contains_key(&note_id) + } +} diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -1,6 +1,7 @@ use crate::column::ColumnsAction; use crate::nav::RenderNavAction; use crate::nav::SwitchingAction; +use crate::timeline::ThreadSelection; use crate::{ column::Columns, route::Route, @@ -437,11 +438,6 @@ impl<'a> NavTitle<'a> { TimelineKind::Profile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), - TimelineKind::Thread(_) => { - // no pfp for threads - None - } - TimelineKind::Search(_sq) => { // TODO: show author pfp if author field set? @@ -467,6 +463,9 @@ impl<'a> NavTitle<'a> { Route::Search => Some(ui.add(ui::side_panel::search_button())), Route::Wallet(_) => None, Route::CustomizeZapAmount(_) => None, + Route::Thread(thread_selection) => { + Some(self.thread_pfp(ui, thread_selection, pfp_size)) + } } } @@ -488,6 +487,23 @@ impl<'a> NavTitle<'a> { } } + fn thread_pfp( + &mut self, + ui: &mut egui::Ui, + selection: &ThreadSelection, + pfp_size: f32, + ) -> egui::Response { + let txn = Transaction::new(self.ndb).unwrap(); + + if let Ok(note) = self.ndb.get_note_by_id(&txn, selection.selected_or_root()) { + if let Some(mut pfp) = self.pubkey_pfp(&txn, note.pubkey(), pfp_size) { + return ui.add(&mut pfp); + } + } + + ui.add(&mut ProfilePic::new(self.img_cache, notedeck::profile::no_pfp_url()).size(pfp_size)) + } + fn title_label_value(title: &str) -> egui::Label { egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())) .selectable(false) diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -1,18 +1,21 @@ +use egui::InnerResponse; +use egui_virtual_list::VirtualList; use enostr::KeypairUnowned; -use nostrdb::Transaction; -use notedeck::{MuteFun, NoteAction, NoteContext, RootNoteId, UnknownIds}; +use nostrdb::{Note, Transaction}; +use notedeck::note::root_note_id_from_selected_id; +use notedeck::{MuteFun, NoteAction, NoteContext, UnknownIds}; use notedeck_ui::jobs::JobsCache; -use notedeck_ui::NoteOptions; -use tracing::error; +use notedeck_ui::note::NoteResponse; +use notedeck_ui::{NoteOptions, NoteView}; -use crate::timeline::{ThreadSelection, TimelineCache, TimelineKind}; -use crate::ui::timeline::TimelineTabView; +use crate::timeline::thread::{NoteSeenFlags, ParentState, Threads}; pub struct ThreadView<'a, 'd> { - timeline_cache: &'a mut TimelineCache, + threads: &'a mut Threads, unknown_ids: &'a mut UnknownIds, selected_note_id: &'a [u8; 32], note_options: NoteOptions, + col: usize, id_source: egui::Id, is_muted: &'a MuteFun, note_context: &'a mut NoteContext<'d>, @@ -23,7 +26,7 @@ pub struct ThreadView<'a, 'd> { impl<'a, 'd> ThreadView<'a, 'd> { #[allow(clippy::too_many_arguments)] pub fn new( - timeline_cache: &'a mut TimelineCache, + threads: &'a mut Threads, unknown_ids: &'a mut UnknownIds, selected_note_id: &'a [u8; 32], note_options: NoteOptions, @@ -34,7 +37,7 @@ impl<'a, 'd> ThreadView<'a, 'd> { ) -> Self { let id_source = egui::Id::new("threadscroll_threadview"); ThreadView { - timeline_cache, + threads, unknown_ids, selected_note_id, note_options, @@ -43,11 +46,13 @@ impl<'a, 'd> ThreadView<'a, 'd> { note_context, cur_acc, jobs, + col: 0, } } - pub fn id_source(mut self, id: egui::Id) -> Self { - self.id_source = id; + pub fn id_source(mut self, col: usize) -> Self { + self.col = col; + self.id_source = egui::Id::new(("threadscroll", col)); self } @@ -60,66 +65,355 @@ impl<'a, 'd> ThreadView<'a, 'd> { .auto_shrink([false, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible); - let offset_id = self.id_source.with("scroll_offset"); + let offset_id = self + .id_source + .with(("scroll_offset", self.selected_note_id)); if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) { scroll_area = scroll_area.vertical_scroll_offset(offset); } - let output = scroll_area.show(ui, |ui| { - let root_id = match RootNoteId::new( - self.note_context.ndb, - self.note_context.note_cache, - &txn, - self.selected_note_id, - ) { - Ok(root_id) => root_id, - - Err(err) => { - ui.label(format!("Error loading thread: {:?}", err)); - return None; + let output = scroll_area.show(ui, |ui| self.notes(ui, &txn)); + + ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y)); + + output.inner + } + + fn notes(&mut self, ui: &mut egui::Ui, txn: &Transaction) -> Option<NoteAction> { + let Ok(cur_note) = self + .note_context + .ndb + .get_note_by_id(txn, self.selected_note_id) + else { + let id = *self.selected_note_id; + tracing::error!("ndb: Did not find note {}", enostr::NoteId::new(id).hex()); + return None; + }; + + self.threads.update( + &cur_note, + self.note_context.note_cache, + self.note_context.ndb, + txn, + self.unknown_ids, + self.col, + ); + + let cur_node = self.threads.threads.get(&self.selected_note_id).unwrap(); + + let full_chain = cur_node.have_all_ancestors; + let mut note_builder = ThreadNoteBuilder::new(cur_note); + + let mut parent_state = cur_node.prev.clone(); + while let ParentState::Parent(id) = parent_state { + if let Ok(note) = self.note_context.ndb.get_note_by_id(txn, id.bytes()) { + note_builder.add_chain(note); + if let Some(res) = self.threads.threads.get(&id.bytes()) { + parent_state = res.prev.clone(); + continue; } - }; - - let thread_timeline = self - .timeline_cache - .notes( - self.note_context.ndb, - self.note_context.note_cache, - &txn, - &TimelineKind::Thread(ThreadSelection::from_root_id(root_id.to_owned())), - ) - .get_ptr(); - - // TODO(jb55): skip poll if ThreadResult is fresh? - - let reversed = true; - // poll for new notes and insert them into our existing notes - if let Err(err) = thread_timeline.poll_notes_into_view( - self.note_context.ndb, - &txn, - self.unknown_ids, - self.note_context.note_cache, - reversed, - ) { - error!("error polling notes into thread timeline: {err}"); } + parent_state = ParentState::Unknown; + } + + for note_ref in &cur_node.replies { + if let Ok(note) = self.note_context.ndb.get_note_by_key(txn, note_ref.key) { + note_builder.add_reply(note); + } + } + + let list = &mut self + .threads + .threads + .get_mut(&self.selected_note_id) + .unwrap() + .list; + + let notes = note_builder.into_notes(&mut self.threads.seen_flags); + + if !full_chain { + // TODO(kernelkind): insert UI denoting we don't have the full chain yet + ui.colored_label(ui.visuals().error_fg_color, "LOADING NOTES"); + } + + let zapping_acc = self + .cur_acc + .as_ref() + .filter(|_| self.note_context.current_account_has_wallet) + .or(self.cur_acc.as_ref()); + + show_notes( + ui, + list, + &notes, + self.note_context, + zapping_acc, + self.note_options, + self.jobs, + txn, + self.is_muted, + ) + } +} + +#[allow(clippy::too_many_arguments)] +fn show_notes( + ui: &mut egui::Ui, + list: &mut VirtualList, + thread_notes: &ThreadNotes, + note_context: &mut NoteContext<'_>, + zapping_acc: Option<&KeypairUnowned<'_>>, + flags: NoteOptions, + jobs: &mut JobsCache, + txn: &Transaction, + is_muted: &MuteFun, +) -> Option<NoteAction> { + let mut action = None; + + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().item_spacing.x = 4.0; + + let selected_note_index = thread_notes.selected_index; + let notes = &thread_notes.notes; + + list.ui_custom_layout(ui, notes.len(), |ui, cur_index| 's: { + let note = &notes[cur_index]; + + // should we mute the thread? we might not have it! + let muted = root_note_id_from_selected_id( + note_context.ndb, + note_context.note_cache, + txn, + note.note.id(), + ) + .ok() + .is_some_and(|root_id| is_muted(&note.note, root_id.bytes())); + + if muted { + break 's 0; + } + + let resp = note.show(note_context, zapping_acc, flags, jobs, ui); + + action = if cur_index == selected_note_index { + resp.action.and_then(strip_note_action) + } else { + resp.action + } + .or(action.take()); + + 1 + }); + + action +} + +fn strip_note_action(action: NoteAction) -> Option<NoteAction> { + if matches!( + action, + NoteAction::Note { + note_id: _, + preview: false, + } + ) { + return None; + } + + Some(action) +} + +struct ThreadNoteBuilder<'a> { + chain: Vec<Note<'a>>, + selected: Note<'a>, + replies: Vec<Note<'a>>, +} + +impl<'a> ThreadNoteBuilder<'a> { + pub fn new(selected: Note<'a>) -> Self { + Self { + chain: Vec::new(), + selected, + replies: Vec::new(), + } + } + + pub fn add_chain(&mut self, note: Note<'a>) { + self.chain.push(note); + } - TimelineTabView::new( - thread_timeline.current_view(), - true, - self.note_options, - &txn, - self.is_muted, - self.note_context, - self.cur_acc, - self.jobs, + pub fn add_reply(&mut self, note: Note<'a>) { + self.replies.push(note); + } + + pub fn into_notes(mut self, seen_flags: &mut NoteSeenFlags) -> ThreadNotes<'a> { + let mut notes = Vec::new(); + + let selected_is_root = self.chain.is_empty(); + let mut cur_is_root = true; + while let Some(note) = self.chain.pop() { + notes.push(ThreadNote { + unread_and_have_replies: *seen_flags.get(note.id()).unwrap_or(&false), + note, + note_type: ThreadNoteType::Chain { root: cur_is_root }, + }); + cur_is_root = false; + } + + let selected_index = notes.len(); + notes.push(ThreadNote { + note: self.selected, + note_type: ThreadNoteType::Selected { + root: selected_is_root, + }, + unread_and_have_replies: false, + }); + + for reply in self.replies { + notes.push(ThreadNote { + unread_and_have_replies: *seen_flags.get(reply.id()).unwrap_or(&false), + note: reply, + note_type: ThreadNoteType::Reply, + }); + } + + ThreadNotes { + notes, + selected_index, + } + } +} + +enum ThreadNoteType { + Chain { root: bool }, + Selected { root: bool }, + Reply, +} + +struct ThreadNotes<'a> { + notes: Vec<ThreadNote<'a>>, + selected_index: usize, +} + +struct ThreadNote<'a> { + pub note: Note<'a>, + note_type: ThreadNoteType, + pub unread_and_have_replies: bool, +} + +impl<'a> ThreadNote<'a> { + fn options(&self, mut cur_options: NoteOptions) -> NoteOptions { + match self.note_type { + ThreadNoteType::Chain { root: _ } => cur_options, + ThreadNoteType::Selected { root: _ } => { + cur_options.set_wide(true); + cur_options + } + ThreadNoteType::Reply => cur_options, + } + } + + fn show( + &self, + note_context: &'a mut NoteContext<'_>, + zapping_acc: Option<&'a KeypairUnowned<'a>>, + flags: NoteOptions, + jobs: &'a mut JobsCache, + ui: &mut egui::Ui, + ) -> NoteResponse { + let inner = notedeck_ui::padding(8.0, ui, |ui| { + NoteView::new( + note_context, + zapping_acc, + &self.note, + self.options(flags), + jobs, ) + .unread_indicator(self.unread_and_have_replies) .show(ui) }); - ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y)); + match self.note_type { + ThreadNoteType::Chain { root } => add_chain_adornment(ui, &inner, root), + ThreadNoteType::Selected { root } => add_selected_adornment(ui, &inner, root), + ThreadNoteType::Reply => notedeck_ui::hline(ui), + } - output.inner + inner.inner } } + +fn add_chain_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) { + let Some(pfp_rect) = note_resp.inner.pfp_rect else { + return; + }; + + let note_rect = note_resp.response.rect; + + let painter = ui.painter_at(note_rect); + + if !root { + paint_line_above_pfp(ui, &painter, &pfp_rect, &note_rect); + } + + // painting line below pfp: + let top_pt = { + let mut top = pfp_rect.center(); + top.y = pfp_rect.bottom(); + top + }; + + let bottom_pt = { + let mut bottom = top_pt; + bottom.y = note_rect.bottom(); + bottom + }; + + painter.line_segment([top_pt, bottom_pt], LINE_STROKE(ui)); + + let hline_min_x = top_pt.x + 6.0; + notedeck_ui::hline_with_width( + ui, + egui::Rangef::new(hline_min_x, ui.available_rect_before_wrap().right()), + ); +} + +fn add_selected_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) { + let Some(pfp_rect) = note_resp.inner.pfp_rect else { + return; + }; + let note_rect = note_resp.response.rect; + let painter = ui.painter_at(note_rect); + + if !root { + paint_line_above_pfp(ui, &painter, &pfp_rect, &note_rect); + } + notedeck_ui::hline(ui); +} + +fn paint_line_above_pfp( + ui: &egui::Ui, + painter: &egui::Painter, + pfp_rect: &egui::Rect, + note_rect: &egui::Rect, +) { + let bottom_pt = { + let mut center = pfp_rect.center(); + center.y = pfp_rect.top(); + center + }; + + let top_pt = { + let mut top = bottom_pt; + top.y = note_rect.top(); + top + }; + + painter.line_segment([bottom_pt, top_pt], LINE_STROKE(ui)); +} + +const LINE_STROKE: fn(&egui::Ui) -> egui::Stroke = |ui: &egui::Ui| { + let mut stroke = ui.style().visuals.widgets.noninteractive.bg_stroke; + stroke.width = 2.0; + stroke +}; diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -46,12 +46,16 @@ pub fn padding<R>( } pub fn hline(ui: &egui::Ui) { + hline_with_width(ui, ui.available_rect_before_wrap().x_range()); +} + +pub fn hline_with_width(ui: &egui::Ui, range: egui::Rangef) { // pixel perfect horizontal line let rect = ui.available_rect_before_wrap(); #[allow(deprecated)] let resize_y = ui.painter().round_to_pixel(rect.top()) - 0.5; let stroke = ui.style().visuals.widgets.noninteractive.bg_stroke; - ui.painter().hline(rect.x_range(), resize_y, stroke); + ui.painter().hline(range, resize_y, stroke); } pub fn show_pointer(ui: &egui::Ui) { diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -270,11 +270,17 @@ pub fn render_note_contents( } }); - let preview_note_action = if let Some((id, _block_str)) = inline_note { - render_note_preview(ui, note_context, cur_acc, txn, id, note_key, options, jobs).action - } else { - None - }; + let preview_note_action = inline_note.and_then(|(id, _)| { + render_note_preview(ui, note_context, cur_acc, txn, id, note_key, options, jobs) + .action + .map(|a| match a { + NoteAction::Note { note_id, .. } => NoteAction::Note { + note_id, + preview: true, + }, + other => other, + }) + }); let mut media_action = None; if !supported_medias.is_empty() && !options.has_textmode() { diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -14,6 +14,7 @@ pub use contents::{render_note_contents, render_note_preview, NoteContents}; pub use context::NoteContextButton; use notedeck::note::MediaAction; use notedeck::note::ZapTargetAmount; +use notedeck::Images; pub use options::NoteOptions; pub use reply_description::reply_desc; @@ -36,11 +37,13 @@ pub struct NoteView<'a, 'd> { framed: bool, flags: NoteOptions, jobs: &'a mut JobsCache, + show_unread_indicator: bool, } pub struct NoteResponse { pub response: egui::Response, pub action: Option<NoteAction>, + pub pfp_rect: Option<egui::Rect>, } impl NoteResponse { @@ -48,6 +51,7 @@ impl NoteResponse { Self { response, action: None, + pfp_rect: None, } } @@ -55,6 +59,11 @@ impl NoteResponse { self.action = action; self } + + pub fn with_pfp(mut self, pfp_rect: egui::Rect) -> Self { + self.pfp_rect = Some(pfp_rect); + self + } } /* @@ -93,6 +102,7 @@ impl<'a, 'd> NoteView<'a, 'd> { flags, framed, jobs, + show_unread_indicator: false, } } @@ -179,6 +189,11 @@ impl<'a, 'd> NoteView<'a, 'd> { self } + pub fn unread_indicator(mut self, show_unread_indicator: bool) -> Self { + self.show_unread_indicator = show_unread_indicator; + self + } + fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { let note_key = self.note.key().expect("todo: implement non-db notes"); let txn = self.note.txn().expect("todo: implement non-db notes"); @@ -234,8 +249,7 @@ impl<'a, 'd> NoteView<'a, 'd> { note_key: NoteKey, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, ui: &mut egui::Ui, - ) -> (egui::Response, Option<MediaAction>) { - let mut action = None; + ) -> PfpResponse { if !self.options().has_wide() { ui.spacing_mut().item_spacing.x = 16.0; } else { @@ -244,107 +258,77 @@ impl<'a, 'd> NoteView<'a, 'd> { let pfp_size = self.options().pfp_size(); - let sense = Sense::click(); - let resp = match profile + match profile .as_ref() .ok() .and_then(|p| p.record().profile()?.picture()) { // these have different lifetimes and types, // so the calls must be separate - Some(pic) => { - let anim_speed = 0.05; - let profile_key = profile.as_ref().unwrap().record().note_key(); - let note_key = note_key.as_u64(); - - let (rect, size, resp) = crate::anim::hover_expand( - ui, - egui::Id::new((profile_key, note_key)), - pfp_size as f32, - NoteView::expand_size() as f32, - anim_speed, - ); - - let mut pfp = ProfilePic::new(self.note_context.img_cache, pic).size(size); - let pfp_resp = ui.put(rect, &mut pfp); - - action = action.or(pfp.action); + Some(pic) => show_actual_pfp( + ui, + self.note_context.img_cache, + pic, + pfp_size, + note_key, + profile, + ), + + None => show_fallback_pfp(ui, self.note_context.img_cache, pfp_size), + } + } - if resp.hovered() || resp.clicked() { - crate::show_pointer(ui); - } + fn show_repost( + &mut self, + ui: &mut egui::Ui, + txn: &Transaction, + note_to_repost: Note<'_>, + ) -> NoteResponse { + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(txn, self.note.pubkey()); - pfp_resp.on_hover_ui_at_pointer(|ui| { + let style = NotedeckTextStyle::Small; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(2.0); + ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); + }); + ui.add_space(6.0); + let resp = ui.add(one_line_display_name_widget( + ui.visuals(), + get_display_name(profile.as_ref().ok()), + style, + )); + if let Ok(rec) = &profile { + resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); - ui.add(ProfilePreview::new( - profile.as_ref().unwrap(), - self.note_context.img_cache, - )); + ui.add(ProfilePreview::new(rec, self.note_context.img_cache)); }); - - resp } - - None => { - // This has to match the expand size from the above case to - // prevent bounciness - let size = (pfp_size + NoteView::expand_size()) as f32; - let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); - - let mut pfp = - ProfilePic::new(self.note_context.img_cache, notedeck::profile::no_pfp_url()) - .size(pfp_size as f32); - let resp = ui.put(rect, &mut pfp).interact(sense); - action = action.or(pfp.action); - - resp - } - }; - (resp, action) + let color = ui.style().visuals.noninteractive().fg_stroke.color; + ui.add_space(4.0); + ui.label( + RichText::new("Reposted") + .color(color) + .text_style(style.text_style()), + ); + }); + NoteView::new( + self.note_context, + self.zapping_acc, + &note_to_repost, + self.flags, + self.jobs, + ) + .show(ui) } pub fn show_impl(&mut self, ui: &mut egui::Ui) -> NoteResponse { let txn = self.note.txn().expect("txn"); if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) { - let profile = self - .note_context - .ndb - .get_profile_by_pubkey(txn, self.note.pubkey()); - - let style = NotedeckTextStyle::Small; - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.add_space(2.0); - ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); - }); - ui.add_space(6.0); - let resp = ui.add(one_line_display_name_widget( - ui.visuals(), - get_display_name(profile.as_ref().ok()), - style, - )); - if let Ok(rec) = &profile { - resp.on_hover_ui_at_pointer(|ui| { - ui.set_max_width(300.0); - ui.add(ProfilePreview::new(rec, self.note_context.img_cache)); - }); - } - let color = ui.style().visuals.noninteractive().fg_stroke.color; - ui.add_space(4.0); - ui.label( - RichText::new("Reposted") - .color(color) - .text_style(style.text_style()), - ); - }); - NoteView::new( - self.note_context, - self.zapping_acc, - &note_to_repost, - self.flags, - self.jobs, - ) - .show(ui) + self.show_repost(ui, txn, note_to_repost) } else { self.show_standard(ui) } @@ -376,16 +360,33 @@ impl<'a, 'd> NoteView<'a, 'd> { note_cache: &mut NoteCache, note: &Note, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, + show_unread_indicator: bool, ) { let note_key = note.key().unwrap(); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - ui.add(Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); + let horiz_resp = ui + .horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.add(Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); - let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); - render_reltime(ui, cached_note, true); - }); + let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); + render_reltime(ui, cached_note, true); + }) + .response; + + if !show_unread_indicator { + return; + } + + let radius = 4.0; + let circle_center = { + let mut center = horiz_resp.rect.right_center(); + center.x += radius + 4.0; + center + }; + + ui.painter() + .circle_filled(circle_center, radius, crate::colors::PINK); } fn wide_ui( @@ -394,63 +395,63 @@ impl<'a, 'd> NoteView<'a, 'd> { txn: &Transaction, note_key: NoteKey, profile: &Result<ProfileRecord, nostrdb::Error>, - ) -> egui::InnerResponse<Option<NoteAction>> { - let mut note_action: Option<NoteAction> = None; - + ) -> egui::InnerResponse<NoteUiResponse> { ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - ui.horizontal(|ui| { - let (pfp_resp, action) = self.pfp(note_key, profile, ui); - if pfp_resp.clicked() { - note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey()))); - } else if let Some(action) = action { - note_action = Some(NoteAction::Media(action)); - }; - - let size = ui.available_size(); - ui.vertical(|ui| { - ui.add_sized( - [size.x, self.options().pfp_size() as f32], - |ui: &mut egui::Ui| { - ui.horizontal_centered(|ui| { - NoteView::note_header( - ui, - self.note_context.note_cache, - self.note, - profile, - ); - }) - .response - }, - ); + let mut note_action: Option<NoteAction> = None; + let pfp_rect = ui + .horizontal(|ui| { + let pfp_resp = self.pfp(note_key, profile, ui); + let pfp_rect = pfp_resp.bounding_rect; + note_action = pfp_resp + .into_action(self.note.pubkey()) + .or(note_action.take()); + + let size = ui.available_size(); + ui.vertical(|ui| 's: { + ui.add_sized( + [size.x, self.options().pfp_size() as f32], + |ui: &mut egui::Ui| { + ui.horizontal_centered(|ui| { + NoteView::note_header( + ui, + self.note_context.note_cache, + self.note, + profile, + self.show_unread_indicator, + ); + }) + .response + }, + ); - let note_reply = self - .note_context - .note_cache - .cached_note_or_insert_mut(note_key, self.note) - .reply - .borrow(self.note.tags()); + let note_reply = self + .note_context + .note_cache + .cached_note_or_insert_mut(note_key, self.note) + .reply + .borrow(self.note.tags()); - if note_reply.reply().is_some() { - let action = ui - .horizontal(|ui| { - reply_desc( - ui, - self.zapping_acc, - txn, - &note_reply, - self.note_context, - self.flags, - self.jobs, - ) - }) - .inner; - - if action.is_some() { - note_action = action; + if note_reply.reply().is_none() { + break 's; } - } - }); - }); + + ui.horizontal(|ui| { + note_action = reply_desc( + ui, + self.zapping_acc, + txn, + &note_reply, + self.note_context, + self.flags, + self.jobs, + ) + .or(note_action.take()); + }); + }); + + pfp_rect + }) + .inner; let mut contents = NoteContents::new( self.note_context, @@ -463,12 +464,10 @@ impl<'a, 'd> NoteView<'a, 'd> { ui.add(&mut contents); - if let Some(action) = contents.action { - note_action = Some(action); - } + note_action = contents.action.or(note_action); if self.options().has_actionbar() { - if let Some(action) = render_note_actionbar( + note_action = render_note_actionbar( ui, self.zapping_acc.as_ref().map(|c| Zapper { zaps: self.note_context.zaps, @@ -479,12 +478,13 @@ impl<'a, 'd> NoteView<'a, 'd> { note_key, ) .inner - { - note_action = Some(action); - } + .or(note_action); } - note_action + NoteUiResponse { + action: note_action, + pfp_rect, + } }) } @@ -494,20 +494,22 @@ impl<'a, 'd> NoteView<'a, 'd> { txn: &Transaction, note_key: NoteKey, profile: &Result<ProfileRecord, nostrdb::Error>, - ) -> egui::InnerResponse<Option<NoteAction>> { - let mut note_action: Option<NoteAction> = None; + ) -> egui::InnerResponse<NoteUiResponse> { // main design ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { - let (pfp_resp, action) = self.pfp(note_key, profile, ui); - if pfp_resp.clicked() { - note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey()))); - } else if let Some(action) = action { - note_action = Some(NoteAction::Media(action)); - }; + let pfp_resp = self.pfp(note_key, profile, ui); + let pfp_rect = pfp_resp.bounding_rect; + let mut note_action: Option<NoteAction> = pfp_resp.into_action(self.note.pubkey()); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - NoteView::note_header(ui, self.note_context.note_cache, self.note, profile); - ui.horizontal(|ui| { + NoteView::note_header( + ui, + self.note_context.note_cache, + self.note, + profile, + self.show_unread_indicator, + ); + ui.horizontal(|ui| 's: { ui.spacing_mut().item_spacing.x = 2.0; let note_reply = self @@ -517,21 +519,20 @@ impl<'a, 'd> NoteView<'a, 'd> { .reply .borrow(self.note.tags()); - if note_reply.reply().is_some() { - let action = reply_desc( - ui, - self.zapping_acc, - txn, - &note_reply, - self.note_context, - self.flags, - self.jobs, - ); - - if action.is_some() { - note_action = action; - } + if note_reply.reply().is_none() { + break 's; } + + note_action = reply_desc( + ui, + self.zapping_acc, + txn, + &note_reply, + self.note_context, + self.flags, + self.jobs, + ) + .or(note_action.take()); }); let mut contents = NoteContents::new( @@ -544,12 +545,10 @@ impl<'a, 'd> NoteView<'a, 'd> { ); ui.add(&mut contents); - if let Some(action) = contents.action { - note_action = Some(action); - } + note_action = contents.action.or(note_action); if self.options().has_actionbar() { - if let Some(action) = render_note_actionbar( + note_action = render_note_actionbar( ui, self.zapping_acc.as_ref().map(|c| Zapper { zaps: self.note_context.zaps, @@ -560,12 +559,13 @@ impl<'a, 'd> NoteView<'a, 'd> { note_key, ) .inner - { - note_action = Some(action); - } + .or(note_action); } - note_action + NoteUiResponse { + action: note_action, + pfp_rect, + } }) .inner }) @@ -591,7 +591,8 @@ impl<'a, 'd> NoteView<'a, 'd> { self.standard_ui(ui, txn, note_key, &profile) }; - let mut note_action = response.inner; + let note_ui_resp = response.inner; + let mut note_action = note_ui_resp.action; if self.options().has_options_button() { let context_pos = { @@ -607,19 +608,22 @@ impl<'a, 'd> NoteView<'a, 'd> { } } - let note_action = - if note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox) { - Some(NoteAction::Note(NoteId::new(*self.note.id()))) - } else { - note_action - }; + note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox) + .then_some(NoteAction::note(NoteId::new(*self.note.id()))) + .or(note_action); - NoteResponse::new(response.response).with_action(note_action) + NoteResponse::new(response.response) + .with_action(note_action) + .with_pfp(note_ui_resp.pfp_rect) } } fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> { - let new_note_id: &[u8; 32] = if note.kind() == 6 { + if note.kind() != 6 { + return None; + } + + let new_note_id: &[u8; 32] = { let mut res = None; for tag in note.tags().iter() { if tag.count() == 0 { @@ -634,14 +638,90 @@ fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option } } res? - } else { - return None; }; let note = ndb.get_note_by_id(txn, new_note_id).ok(); note.filter(|note| note.kind() == 1) } +struct NoteUiResponse { + action: Option<NoteAction>, + pfp_rect: egui::Rect, +} + +struct PfpResponse { + action: Option<MediaAction>, + response: egui::Response, + bounding_rect: egui::Rect, +} + +impl PfpResponse { + fn into_action(self, note_pk: &[u8; 32]) -> Option<NoteAction> { + if self.response.clicked() { + return Some(NoteAction::Profile(Pubkey::new(*note_pk))); + } + + self.action.map(NoteAction::Media) + } +} + +fn show_actual_pfp( + ui: &mut egui::Ui, + images: &mut Images, + pic: &str, + pfp_size: i8, + note_key: NoteKey, + profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, +) -> PfpResponse { + let anim_speed = 0.05; + let profile_key = profile.as_ref().unwrap().record().note_key(); + let note_key = note_key.as_u64(); + + let (rect, size, resp) = crate::anim::hover_expand( + ui, + egui::Id::new((profile_key, note_key)), + pfp_size as f32, + NoteView::expand_size() as f32, + anim_speed, + ); + + let mut pfp = ProfilePic::new(images, pic).size(size); + let pfp_resp = ui.put(rect, &mut pfp); + let action = pfp.action; + + if resp.hovered() || resp.clicked() { + crate::show_pointer(ui); + } + + pfp_resp.on_hover_ui_at_pointer(|ui| { + ui.set_max_width(300.0); + ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images)); + }); + + PfpResponse { + response: resp, + action, + bounding_rect: rect.shrink((rect.width() - size) / 2.0), + } +} + +fn show_fallback_pfp(ui: &mut egui::Ui, images: &mut Images, pfp_size: i8) -> PfpResponse { + let sense = Sense::click(); + // This has to match the expand size from the above case to + // prevent bounciness + let size = (pfp_size + NoteView::expand_size()) as f32; + let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); + + let mut pfp = ProfilePic::new(images, notedeck::profile::no_pfp_url()).size(pfp_size as f32); + let response = ui.put(rect, &mut pfp).interact(sense); + + PfpResponse { + action: pfp.action, + response, + bounding_rect: rect.shrink((rect.width() - size) / 2.0), + } +} + fn note_hitbox_id( note_key: NoteKey, note_options: NoteOptions,