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
This commit is contained in:
William Casarin
2025-06-23 10:51:31 -07:00
23 changed files with 1877 additions and 419 deletions

4
Cargo.lock generated
View File

@@ -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",

View File

@@ -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" }

View File

@@ -20,3 +20,4 @@ url = { workspace = true }
mio = { workspace = true }
tokio = { workspace = true }
tokenator = { workspace = true }
hashbrown = { workspace = true }

View File

@@ -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()
}
}

View File

@@ -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),

View File

@@ -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();
}
}

View File

@@ -625,6 +625,7 @@ fn chrome_handle_app_action(
cols,
0,
&mut columns.timeline_cache,
&mut columns.threads,
ctx.note_cache,
ctx.pool,
&txn,

View File

@@ -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]

View File

@@ -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);
timeline_res = timeline_cache.open(ndb, note_cache, txn, pool, &kind);
let route = Route::Thread(thread_selection);
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();
}
}

View File

@@ -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(),
}
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
@@ -377,12 +368,6 @@ impl TimelineKind {
TokenParser::alt(
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()),

View File

@@ -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;

View File

@@ -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,32 +74,40 @@ 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);
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)
}
}
}
#[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(
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)]
pub fn render_profile_route(
pubkey: &Pubkey,
@@ -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);
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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 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}");
}
TimelineTabView::new(
thread_timeline.current_view(),
true,
self.note_options,
&txn,
self.is_muted,
self.note_context,
self.cur_acc,
self.jobs,
)
.show(ui)
});
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;
}
}
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);
}
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)
});
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),
}
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
};

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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();
Some(pic) => show_actual_pfp(
ui,
self.note_context.img_cache,
pic,
pfp_size,
note_key,
profile,
),
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,
);
None => show_fallback_pfp(ui, self.note_context.img_cache, pfp_size),
}
}
let mut pfp = ProfilePic::new(self.note_context.img_cache, pic).size(size);
let pfp_resp = ui.put(rect, &mut pfp);
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());
action = action.or(pfp.action);
if resp.hovered() || resp.clicked() {
crate::show_pointer(ui);
}
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 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| {
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 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,