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:
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, ¬e);
+
+ let created_at = note.created_at();
+ let note_ref = notedeck::NoteRef {
+ key: *key,
+ created_at,
+ };
+
+ if thread.replies.contains(¬e_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, ¬e);
+
+ 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(¬e_id)
+ }
+
+ pub fn contains(&self, note_id: &[u8; 32]) -> bool {
+ self.flags.contains_key(¬e_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,
+ ¬es,
+ 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 = ¬es[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(¬e.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, ¬e_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, ¬e_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,
+ ¬e_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,
- ¬e_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,
- ¬e_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,
+ ¬e_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,
- ¬e_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,
+ ¬e_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,